/ src / cli / structuredIO.ts
structuredIO.ts
  1  import { feature } from 'bun:bundle'
  2  import type {
  3    ElicitResult,
  4    JSONRPCMessage,
  5  } from '@modelcontextprotocol/sdk/types.js'
  6  import { randomUUID } from 'crypto'
  7  import type { AssistantMessage } from 'src//types/message.js'
  8  import type {
  9    HookInput,
 10    HookJSONOutput,
 11    PermissionUpdate,
 12    SDKMessage,
 13    SDKUserMessage,
 14  } from 'src/entrypoints/agentSdkTypes.js'
 15  import { SDKControlElicitationResponseSchema } from 'src/entrypoints/sdk/controlSchemas.js'
 16  import type {
 17    SDKControlRequest,
 18    SDKControlResponse,
 19    StdinMessage,
 20    StdoutMessage,
 21  } from 'src/entrypoints/sdk/controlTypes.js'
 22  import type { CanUseToolFn } from 'src/hooks/useCanUseTool.js'
 23  import type { Tool, ToolUseContext } from 'src/Tool.js'
 24  import { type HookCallback, hookJSONOutputSchema } from 'src/types/hooks.js'
 25  import { logForDebugging } from 'src/utils/debug.js'
 26  import { logForDiagnosticsNoPII } from 'src/utils/diagLogs.js'
 27  import { AbortError } from 'src/utils/errors.js'
 28  import {
 29    type Output as PermissionToolOutput,
 30    permissionPromptToolResultToPermissionDecision,
 31    outputSchema as permissionToolOutputSchema,
 32  } from 'src/utils/permissions/PermissionPromptToolResultSchema.js'
 33  import type {
 34    PermissionDecision,
 35    PermissionDecisionReason,
 36  } from 'src/utils/permissions/PermissionResult.js'
 37  import { hasPermissionsToUseTool } from 'src/utils/permissions/permissions.js'
 38  import { writeToStdout } from 'src/utils/process.js'
 39  import { jsonStringify } from 'src/utils/slowOperations.js'
 40  import { z } from 'zod/v4'
 41  import { notifyCommandLifecycle } from '../utils/commandLifecycle.js'
 42  import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js'
 43  import { executePermissionRequestHooks } from '../utils/hooks.js'
 44  import {
 45    applyPermissionUpdates,
 46    persistPermissionUpdates,
 47  } from '../utils/permissions/PermissionUpdate.js'
 48  import {
 49    notifySessionStateChanged,
 50    type RequiresActionDetails,
 51    type SessionExternalMetadata,
 52  } from '../utils/sessionState.js'
 53  import { jsonParse } from '../utils/slowOperations.js'
 54  import { Stream } from '../utils/stream.js'
 55  import { ndjsonSafeStringify } from './ndjsonSafeStringify.js'
 56  
 57  /**
 58   * Synthetic tool name used when forwarding sandbox network permission
 59   * requests via the can_use_tool control_request protocol. SDK hosts
 60   * see this as a normal tool permission prompt.
 61   */
 62  export const SANDBOX_NETWORK_ACCESS_TOOL_NAME = 'SandboxNetworkAccess'
 63  
 64  function serializeDecisionReason(
 65    reason: PermissionDecisionReason | undefined,
 66  ): string | undefined {
 67    if (!reason) {
 68      return undefined
 69    }
 70  
 71    if (
 72      (feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) &&
 73      reason.type === 'classifier'
 74    ) {
 75      return reason.reason
 76    }
 77    switch (reason.type) {
 78      case 'rule':
 79      case 'mode':
 80      case 'subcommandResults':
 81      case 'permissionPromptTool':
 82        return undefined
 83      case 'hook':
 84      case 'asyncAgent':
 85      case 'sandboxOverride':
 86      case 'workingDir':
 87      case 'safetyCheck':
 88      case 'other':
 89        return reason.reason
 90    }
 91  }
 92  
 93  function buildRequiresActionDetails(
 94    tool: Tool,
 95    input: Record<string, unknown>,
 96    toolUseID: string,
 97    requestId: string,
 98  ): RequiresActionDetails {
 99    // Per-tool summary methods may throw on malformed input; permission
100    // handling must not break because of a bad description.
101    let description: string
102    try {
103      description =
104        tool.getActivityDescription?.(input) ??
105        tool.getToolUseSummary?.(input) ??
106        tool.userFacingName(input)
107    } catch {
108      description = tool.name
109    }
110    return {
111      tool_name: tool.name,
112      action_description: description,
113      tool_use_id: toolUseID,
114      request_id: requestId,
115      input,
116    }
117  }
118  
119  type PendingRequest<T> = {
120    resolve: (result: T) => void
121    reject: (error: unknown) => void
122    schema?: z.Schema
123    request: SDKControlRequest
124  }
125  
126  /**
127   * Provides a structured way to read and write SDK messages from stdio,
128   * capturing the SDK protocol.
129   */
130  // Maximum number of resolved tool_use IDs to track. Once exceeded, the oldest
131  // entry is evicted. This bounds memory in very long sessions while keeping
132  // enough history to catch duplicate control_response deliveries.
133  const MAX_RESOLVED_TOOL_USE_IDS = 1000
134  
135  export class StructuredIO {
136    readonly structuredInput: AsyncGenerator<StdinMessage | SDKMessage>
137    private readonly pendingRequests = new Map<string, PendingRequest<unknown>>()
138  
139    // CCR external_metadata read back on worker start; null when the
140    // transport doesn't restore. Assigned by RemoteIO.
141    restoredWorkerState: Promise<SessionExternalMetadata | null> =
142      Promise.resolve(null)
143  
144    private inputClosed = false
145    private unexpectedResponseCallback?: (
146      response: SDKControlResponse,
147    ) => Promise<void>
148  
149    // Tracks tool_use IDs that have been resolved through the normal permission
150    // flow (or aborted by a hook). When a duplicate control_response arrives
151    // after the original was already handled, this Set prevents the orphan
152    // handler from re-processing it — which would push duplicate assistant
153    // messages into mutableMessages and cause a 400 "tool_use ids must be unique"
154    // error from the API.
155    private readonly resolvedToolUseIds = new Set<string>()
156    private prependedLines: string[] = []
157    private onControlRequestSent?: (request: SDKControlRequest) => void
158    private onControlRequestResolved?: (requestId: string) => void
159  
160    // sendRequest() and print.ts both enqueue here; the drain loop is the
161    // only writer. Prevents control_request from overtaking queued stream_events.
162    readonly outbound = new Stream<StdoutMessage>()
163  
164    constructor(
165      private readonly input: AsyncIterable<string>,
166      private readonly replayUserMessages?: boolean,
167    ) {
168      this.input = input
169      this.structuredInput = this.read()
170    }
171  
172    /**
173     * Records a tool_use ID as resolved so that late/duplicate control_response
174     * messages for the same tool are ignored by the orphan handler.
175     */
176    private trackResolvedToolUseId(request: SDKControlRequest): void {
177      if (request.request.subtype === 'can_use_tool') {
178        this.resolvedToolUseIds.add(request.request.tool_use_id)
179        if (this.resolvedToolUseIds.size > MAX_RESOLVED_TOOL_USE_IDS) {
180          // Evict the oldest entry (Sets iterate in insertion order)
181          const first = this.resolvedToolUseIds.values().next().value
182          if (first !== undefined) {
183            this.resolvedToolUseIds.delete(first)
184          }
185        }
186      }
187    }
188  
189    /** Flush pending internal events. No-op for non-remote IO. Overridden by RemoteIO. */
190    flushInternalEvents(): Promise<void> {
191      return Promise.resolve()
192    }
193  
194    /** Internal-event queue depth. Overridden by RemoteIO; zero otherwise. */
195    get internalEventsPending(): number {
196      return 0
197    }
198  
199    /**
200     * Queue a user turn to be yielded before the next message from this.input.
201     * Works before iteration starts and mid-stream — read() re-checks
202     * prependedLines between each yielded message.
203     */
204    prependUserMessage(content: string): void {
205      this.prependedLines.push(
206        jsonStringify({
207          type: 'user',
208          session_id: '',
209          message: { role: 'user', content },
210          parent_tool_use_id: null,
211        } satisfies SDKUserMessage) + '\n',
212      )
213    }
214  
215    private async *read() {
216      let content = ''
217  
218      // Called once before for-await (an empty this.input otherwise skips the
219      // loop body entirely), then again per block. prependedLines re-check is
220      // inside the while so a prepend pushed between two messages in the SAME
221      // block still lands first.
222      const splitAndProcess = async function* (this: StructuredIO) {
223        for (;;) {
224          if (this.prependedLines.length > 0) {
225            content = this.prependedLines.join('') + content
226            this.prependedLines = []
227          }
228          const newline = content.indexOf('\n')
229          if (newline === -1) break
230          const line = content.slice(0, newline)
231          content = content.slice(newline + 1)
232          const message = await this.processLine(line)
233          if (message) {
234            logForDiagnosticsNoPII('info', 'cli_stdin_message_parsed', {
235              type: message.type,
236            })
237            yield message
238          }
239        }
240      }.bind(this)
241  
242      yield* splitAndProcess()
243  
244      for await (const block of this.input) {
245        content += block
246        yield* splitAndProcess()
247      }
248      if (content) {
249        const message = await this.processLine(content)
250        if (message) {
251          yield message
252        }
253      }
254      this.inputClosed = true
255      for (const request of this.pendingRequests.values()) {
256        // Reject all pending requests if the input stream
257        request.reject(
258          new Error('Tool permission stream closed before response received'),
259        )
260      }
261    }
262  
263    getPendingPermissionRequests() {
264      return Array.from(this.pendingRequests.values())
265        .map(entry => entry.request)
266        .filter(pr => pr.request.subtype === 'can_use_tool')
267    }
268  
269    setUnexpectedResponseCallback(
270      callback: (response: SDKControlResponse) => Promise<void>,
271    ): void {
272      this.unexpectedResponseCallback = callback
273    }
274  
275    /**
276     * Inject a control_response message to resolve a pending permission request.
277     * Used by the bridge to feed permission responses from claude.ai into the
278     * SDK permission flow.
279     *
280     * Also sends a control_cancel_request to the SDK consumer so its canUseTool
281     * callback is aborted via the signal — otherwise the callback hangs.
282     */
283    injectControlResponse(response: SDKControlResponse): void {
284      const requestId = response.response?.request_id
285      if (!requestId) return
286      const request = this.pendingRequests.get(requestId)
287      if (!request) return
288      this.trackResolvedToolUseId(request.request)
289      this.pendingRequests.delete(requestId)
290      // Cancel the SDK consumer's canUseTool callback — the bridge won.
291      void this.write({
292        type: 'control_cancel_request',
293        request_id: requestId,
294      })
295      if (response.response.subtype === 'error') {
296        request.reject(new Error(response.response.error))
297      } else {
298        const result = response.response.response
299        if (request.schema) {
300          try {
301            request.resolve(request.schema.parse(result))
302          } catch (error) {
303            request.reject(error)
304          }
305        } else {
306          request.resolve({})
307        }
308      }
309    }
310  
311    /**
312     * Register a callback invoked whenever a can_use_tool control_request
313     * is written to stdout. Used by the bridge to forward permission
314     * requests to claude.ai.
315     */
316    setOnControlRequestSent(
317      callback: ((request: SDKControlRequest) => void) | undefined,
318    ): void {
319      this.onControlRequestSent = callback
320    }
321  
322    /**
323     * Register a callback invoked when a can_use_tool control_response arrives
324     * from the SDK consumer (via stdin). Used by the bridge to cancel the
325     * stale permission prompt on claude.ai when the SDK consumer wins the race.
326     */
327    setOnControlRequestResolved(
328      callback: ((requestId: string) => void) | undefined,
329    ): void {
330      this.onControlRequestResolved = callback
331    }
332  
333    private async processLine(
334      line: string,
335    ): Promise<StdinMessage | SDKMessage | undefined> {
336      // Skip empty lines (e.g. from double newlines in piped stdin)
337      if (!line) {
338        return undefined
339      }
340      try {
341        const message = normalizeControlMessageKeys(jsonParse(line)) as
342          | StdinMessage
343          | SDKMessage
344        if (message.type === 'keep_alive') {
345          // Silently ignore keep-alive messages
346          return undefined
347        }
348        if (message.type === 'update_environment_variables') {
349          // Apply environment variable updates directly to process.env.
350          // Used by bridge session runner for auth token refresh
351          // (CLAUDE_CODE_SESSION_ACCESS_TOKEN) which must be readable
352          // by the REPL process itself, not just child Bash commands.
353          const keys = Object.keys(message.variables)
354          for (const [key, value] of Object.entries(message.variables)) {
355            process.env[key] = value
356          }
357          logForDebugging(
358            `[structuredIO] applied update_environment_variables: ${keys.join(', ')}`,
359          )
360          return undefined
361        }
362        if (message.type === 'control_response') {
363          // Close lifecycle for every control_response, including duplicates
364          // and orphans — orphans don't yield to print.ts's main loop, so this
365          // is the only path that sees them. uuid is server-injected into the
366          // payload.
367          const uuid =
368            'uuid' in message && typeof message.uuid === 'string'
369              ? message.uuid
370              : undefined
371          if (uuid) {
372            notifyCommandLifecycle(uuid, 'completed')
373          }
374          const request = this.pendingRequests.get(message.response.request_id)
375          if (!request) {
376            // Check if this tool_use was already resolved through the normal
377            // permission flow. Duplicate control_response deliveries (e.g. from
378            // WebSocket reconnects) arrive after the original was handled, and
379            // re-processing them would push duplicate assistant messages into
380            // the conversation, causing API 400 errors.
381            const responsePayload =
382              message.response.subtype === 'success'
383                ? message.response.response
384                : undefined
385            const toolUseID = responsePayload?.toolUseID
386            if (
387              typeof toolUseID === 'string' &&
388              this.resolvedToolUseIds.has(toolUseID)
389            ) {
390              logForDebugging(
391                `Ignoring duplicate control_response for already-resolved toolUseID=${toolUseID} request_id=${message.response.request_id}`,
392              )
393              return undefined
394            }
395            if (this.unexpectedResponseCallback) {
396              await this.unexpectedResponseCallback(message)
397            }
398            return undefined // Ignore responses for requests we don't know about
399          }
400          this.trackResolvedToolUseId(request.request)
401          this.pendingRequests.delete(message.response.request_id)
402          // Notify the bridge when the SDK consumer resolves a can_use_tool
403          // request, so it can cancel the stale permission prompt on claude.ai.
404          if (
405            request.request.request.subtype === 'can_use_tool' &&
406            this.onControlRequestResolved
407          ) {
408            this.onControlRequestResolved(message.response.request_id)
409          }
410  
411          if (message.response.subtype === 'error') {
412            request.reject(new Error(message.response.error))
413            return undefined
414          }
415          const result = message.response.response
416          if (request.schema) {
417            try {
418              request.resolve(request.schema.parse(result))
419            } catch (error) {
420              request.reject(error)
421            }
422          } else {
423            request.resolve({})
424          }
425          // Propagate control responses when replay is enabled
426          if (this.replayUserMessages) {
427            return message
428          }
429          return undefined
430        }
431        if (
432          message.type !== 'user' &&
433          message.type !== 'control_request' &&
434          message.type !== 'assistant' &&
435          message.type !== 'system'
436        ) {
437          logForDebugging(`Ignoring unknown message type: ${message.type}`, {
438            level: 'warn',
439          })
440          return undefined
441        }
442        if (message.type === 'control_request') {
443          if (!message.request) {
444            exitWithMessage(`Error: Missing request on control_request`)
445          }
446          return message
447        }
448        if (message.type === 'assistant' || message.type === 'system') {
449          return message
450        }
451        if (message.message.role !== 'user') {
452          exitWithMessage(
453            `Error: Expected message role 'user', got '${message.message.role}'`,
454          )
455        }
456        return message
457      } catch (error) {
458        // biome-ignore lint/suspicious/noConsole:: intentional console output
459        console.error(`Error parsing streaming input line: ${line}: ${error}`)
460        // eslint-disable-next-line custom-rules/no-process-exit
461        process.exit(1)
462      }
463    }
464  
465    async write(message: StdoutMessage): Promise<void> {
466      writeToStdout(ndjsonSafeStringify(message) + '\n')
467    }
468  
469    private async sendRequest<Response>(
470      request: SDKControlRequest['request'],
471      schema: z.Schema,
472      signal?: AbortSignal,
473      requestId: string = randomUUID(),
474    ): Promise<Response> {
475      const message: SDKControlRequest = {
476        type: 'control_request',
477        request_id: requestId,
478        request,
479      }
480      if (this.inputClosed) {
481        throw new Error('Stream closed')
482      }
483      if (signal?.aborted) {
484        throw new Error('Request aborted')
485      }
486      this.outbound.enqueue(message)
487      if (request.subtype === 'can_use_tool' && this.onControlRequestSent) {
488        this.onControlRequestSent(message)
489      }
490      const aborted = () => {
491        this.outbound.enqueue({
492          type: 'control_cancel_request',
493          request_id: requestId,
494        })
495        // Immediately reject the outstanding promise, without
496        // waiting for the host to acknowledge the cancellation.
497        const request = this.pendingRequests.get(requestId)
498        if (request) {
499          // Track the tool_use ID as resolved before rejecting, so that a
500          // late response from the host is ignored by the orphan handler.
501          this.trackResolvedToolUseId(request.request)
502          request.reject(new AbortError())
503        }
504      }
505      if (signal) {
506        signal.addEventListener('abort', aborted, {
507          once: true,
508        })
509      }
510      try {
511        return await new Promise<Response>((resolve, reject) => {
512          this.pendingRequests.set(requestId, {
513            request: {
514              type: 'control_request',
515              request_id: requestId,
516              request,
517            },
518            resolve: result => {
519              resolve(result as Response)
520            },
521            reject,
522            schema,
523          })
524        })
525      } finally {
526        if (signal) {
527          signal.removeEventListener('abort', aborted)
528        }
529        this.pendingRequests.delete(requestId)
530      }
531    }
532  
533    createCanUseTool(
534      onPermissionPrompt?: (details: RequiresActionDetails) => void,
535    ): CanUseToolFn {
536      return async (
537        tool: Tool,
538        input: { [key: string]: unknown },
539        toolUseContext: ToolUseContext,
540        assistantMessage: AssistantMessage,
541        toolUseID: string,
542        forceDecision?: PermissionDecision,
543      ): Promise<PermissionDecision> => {
544        const mainPermissionResult =
545          forceDecision ??
546          (await hasPermissionsToUseTool(
547            tool,
548            input,
549            toolUseContext,
550            assistantMessage,
551            toolUseID,
552          ))
553        // If the tool is allowed or denied, return the result
554        if (
555          mainPermissionResult.behavior === 'allow' ||
556          mainPermissionResult.behavior === 'deny'
557        ) {
558          return mainPermissionResult
559        }
560  
561        // Run PermissionRequest hooks in parallel with the SDK permission
562        // prompt.  In the terminal CLI, hooks race against the interactive
563        // prompt so that e.g. a hook with --delay 20 doesn't block the UI.
564        // We need the same behavior here: the SDK host (VS Code, etc.) shows
565        // its permission dialog immediately while hooks run in the background.
566        // Whichever resolves first wins; the loser is cancelled/ignored.
567  
568        // AbortController used to cancel the SDK request if a hook decides first
569        const hookAbortController = new AbortController()
570        const parentSignal = toolUseContext.abortController.signal
571        // Forward parent abort to our local controller
572        const onParentAbort = () => hookAbortController.abort()
573        parentSignal.addEventListener('abort', onParentAbort, { once: true })
574  
575        try {
576          // Start the hook evaluation (runs in background)
577          const hookPromise = executePermissionRequestHooksForSDK(
578            tool.name,
579            toolUseID,
580            input,
581            toolUseContext,
582            mainPermissionResult.suggestions,
583          ).then(decision => ({ source: 'hook' as const, decision }))
584  
585          // Start the SDK permission prompt immediately (don't wait for hooks)
586          const requestId = randomUUID()
587          onPermissionPrompt?.(
588            buildRequiresActionDetails(tool, input, toolUseID, requestId),
589          )
590          const sdkPromise = this.sendRequest<PermissionToolOutput>(
591            {
592              subtype: 'can_use_tool',
593              tool_name: tool.name,
594              input,
595              permission_suggestions: mainPermissionResult.suggestions,
596              blocked_path: mainPermissionResult.blockedPath,
597              decision_reason: serializeDecisionReason(
598                mainPermissionResult.decisionReason,
599              ),
600              tool_use_id: toolUseID,
601              agent_id: toolUseContext.agentId,
602            },
603            permissionToolOutputSchema(),
604            hookAbortController.signal,
605            requestId,
606          ).then(result => ({ source: 'sdk' as const, result }))
607  
608          // Race: hook completion vs SDK prompt response.
609          // The hook promise always resolves (never rejects), returning
610          // undefined if no hook made a decision.
611          const winner = await Promise.race([hookPromise, sdkPromise])
612  
613          if (winner.source === 'hook') {
614            if (winner.decision) {
615              // Hook decided — abort the pending SDK request.
616              // Suppress the expected AbortError rejection from sdkPromise.
617              sdkPromise.catch(() => {})
618              hookAbortController.abort()
619              return winner.decision
620            }
621            // Hook passed through (no decision) — wait for the SDK prompt
622            const sdkResult = await sdkPromise
623            return permissionPromptToolResultToPermissionDecision(
624              sdkResult.result,
625              tool,
626              input,
627              toolUseContext,
628            )
629          }
630  
631          // SDK prompt responded first — use its result (hook still running
632          // in background but its result will be ignored)
633          return permissionPromptToolResultToPermissionDecision(
634            winner.result,
635            tool,
636            input,
637            toolUseContext,
638          )
639        } catch (error) {
640          return permissionPromptToolResultToPermissionDecision(
641            {
642              behavior: 'deny',
643              message: `Tool permission request failed: ${error}`,
644              toolUseID,
645            },
646            tool,
647            input,
648            toolUseContext,
649          )
650        } finally {
651          // Only transition back to 'running' if no other permission prompts
652          // are pending (concurrent tool execution can have multiple in-flight).
653          if (this.getPendingPermissionRequests().length === 0) {
654            notifySessionStateChanged('running')
655          }
656          parentSignal.removeEventListener('abort', onParentAbort)
657        }
658      }
659    }
660  
661    createHookCallback(callbackId: string, timeout?: number): HookCallback {
662      return {
663        type: 'callback',
664        timeout,
665        callback: async (
666          input: HookInput,
667          toolUseID: string | null,
668          abort: AbortSignal | undefined,
669        ): Promise<HookJSONOutput> => {
670          try {
671            const result = await this.sendRequest<HookJSONOutput>(
672              {
673                subtype: 'hook_callback',
674                callback_id: callbackId,
675                input,
676                tool_use_id: toolUseID || undefined,
677              },
678              hookJSONOutputSchema(),
679              abort,
680            )
681            return result
682          } catch (error) {
683            // biome-ignore lint/suspicious/noConsole:: intentional console output
684            console.error(`Error in hook callback ${callbackId}:`, error)
685            return {}
686          }
687        },
688      }
689    }
690  
691    /**
692     * Sends an elicitation request to the SDK consumer and returns the response.
693     */
694    async handleElicitation(
695      serverName: string,
696      message: string,
697      requestedSchema?: Record<string, unknown>,
698      signal?: AbortSignal,
699      mode?: 'form' | 'url',
700      url?: string,
701      elicitationId?: string,
702    ): Promise<ElicitResult> {
703      try {
704        const result = await this.sendRequest<ElicitResult>(
705          {
706            subtype: 'elicitation',
707            mcp_server_name: serverName,
708            message,
709            mode,
710            url,
711            elicitation_id: elicitationId,
712            requested_schema: requestedSchema,
713          },
714          SDKControlElicitationResponseSchema(),
715          signal,
716        )
717        return result
718      } catch {
719        return { action: 'cancel' as const }
720      }
721    }
722  
723    /**
724     * Creates a SandboxAskCallback that forwards sandbox network permission
725     * requests to the SDK host as can_use_tool control_requests.
726     *
727     * This piggybacks on the existing can_use_tool protocol with a synthetic
728     * tool name so that SDK hosts (VS Code, CCR, etc.) can prompt the user
729     * for network access without requiring a new protocol subtype.
730     */
731    createSandboxAskCallback(): (hostPattern: {
732      host: string
733      port?: number
734    }) => Promise<boolean> {
735      return async (hostPattern): Promise<boolean> => {
736        try {
737          const result = await this.sendRequest<PermissionToolOutput>(
738            {
739              subtype: 'can_use_tool',
740              tool_name: SANDBOX_NETWORK_ACCESS_TOOL_NAME,
741              input: { host: hostPattern.host },
742              tool_use_id: randomUUID(),
743              description: `Allow network connection to ${hostPattern.host}?`,
744            },
745            permissionToolOutputSchema(),
746          )
747          return result.behavior === 'allow'
748        } catch {
749          // If the request fails (stream closed, abort, etc.), deny the connection
750          return false
751        }
752      }
753    }
754  
755    /**
756     * Sends an MCP message to an SDK server and waits for the response
757     */
758    async sendMcpMessage(
759      serverName: string,
760      message: JSONRPCMessage,
761    ): Promise<JSONRPCMessage> {
762      const response = await this.sendRequest<{ mcp_response: JSONRPCMessage }>(
763        {
764          subtype: 'mcp_message',
765          server_name: serverName,
766          message,
767        },
768        z.object({
769          mcp_response: z.any() as z.Schema<JSONRPCMessage>,
770        }),
771      )
772      return response.mcp_response
773    }
774  }
775  
776  function exitWithMessage(message: string): never {
777    // biome-ignore lint/suspicious/noConsole:: intentional console output
778    console.error(message)
779    // eslint-disable-next-line custom-rules/no-process-exit
780    process.exit(1)
781  }
782  
783  /**
784   * Execute PermissionRequest hooks and return a decision if one is made.
785   * Returns undefined if no hook made a decision.
786   */
787  async function executePermissionRequestHooksForSDK(
788    toolName: string,
789    toolUseID: string,
790    input: Record<string, unknown>,
791    toolUseContext: ToolUseContext,
792    suggestions: PermissionUpdate[] | undefined,
793  ): Promise<PermissionDecision | undefined> {
794    const appState = toolUseContext.getAppState()
795    const permissionMode = appState.toolPermissionContext.mode
796  
797    // Iterate directly over the generator instead of using `all`
798    const hookGenerator = executePermissionRequestHooks(
799      toolName,
800      toolUseID,
801      input,
802      toolUseContext,
803      permissionMode,
804      suggestions,
805      toolUseContext.abortController.signal,
806    )
807  
808    for await (const hookResult of hookGenerator) {
809      if (
810        hookResult.permissionRequestResult &&
811        (hookResult.permissionRequestResult.behavior === 'allow' ||
812          hookResult.permissionRequestResult.behavior === 'deny')
813      ) {
814        const decision = hookResult.permissionRequestResult
815        if (decision.behavior === 'allow') {
816          const finalInput = decision.updatedInput || input
817  
818          // Apply permission updates if provided by hook ("always allow")
819          const permissionUpdates = decision.updatedPermissions ?? []
820          if (permissionUpdates.length > 0) {
821            persistPermissionUpdates(permissionUpdates)
822            const currentAppState = toolUseContext.getAppState()
823            const updatedContext = applyPermissionUpdates(
824              currentAppState.toolPermissionContext,
825              permissionUpdates,
826            )
827            // Update permission context via setAppState
828            toolUseContext.setAppState(prev => {
829              if (prev.toolPermissionContext === updatedContext) return prev
830              return { ...prev, toolPermissionContext: updatedContext }
831            })
832          }
833  
834          return {
835            behavior: 'allow',
836            updatedInput: finalInput,
837            userModified: false,
838            decisionReason: {
839              type: 'hook',
840              hookName: 'PermissionRequest',
841            },
842          }
843        } else {
844          // Hook denied the permission
845          return {
846            behavior: 'deny',
847            message:
848              decision.message || 'Permission denied by PermissionRequest hook',
849            decisionReason: {
850              type: 'hook',
851              hookName: 'PermissionRequest',
852            },
853          }
854        }
855      }
856    }
857  
858    return undefined
859  }