1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts";
3import {
4 dynamicToolTitle,
5 formatReadToolLabel,
6 formatSearchToolLabel,
7} from "@t3tools/shared/toolActivity";
8import { field, text } from "../unknownField.ts";
9import { readWorkflowAgentAnswers } from "../workflowAgentAnswers.ts";
10import {
11 CLAUDE_WORKFLOW_TASK_TYPE,
12 hasClaudeWorkflowSnapshot,
13 mergeClaudeWorkflowProgress,
14 parseClaudeWorkflowRunHandles,
15} from "./claudeWorkflowProgress.ts";
16
17import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
18import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts";
19import {
20 type CanUseTool,
21 forkSession as forkClaudeSession,
22 type EffortLevel,
23 type ForkSessionOptions,
24 type ForkSessionResult,
25 getSubagentMessages,
26 query,
27 type Options as ClaudeQueryOptions,
28 type PermissionMode,
29 type PermissionResult,
30 type PermissionUpdate,
31 type Query as ClaudeQuery,
32 type Settings as ClaudeSdkSettings,
33 type SpawnOptions as ClaudeSpawnOptions,
34 type SpawnedProcess as ClaudeSpawnedProcess,
35 type SDKAssistantMessage,
36 type SDKAPIRetryMessage,
37 type SDKMessage,
38 type SDKRateLimitInfo,
39 type SDKResultMessage,
40 type SDKUserMessage,
41} from "@anthropic-ai/claude-agent-sdk";
42import type {
43 AskUserQuestionInput,
44 WebSearchOutput,
45} from "@anthropic-ai/claude-agent-sdk/sdk-tools";
46// @effect-diagnostics-next-line nodeBuiltinImport:off -- the SDK takes a Node ChildProcess for the CLI.
47import * as NodeChildProcess from "node:child_process";
48import { parseCliArgs } from "@t3tools/shared/cliArgs";
49import { AgentScope } from "@t3tools/shared/AgentScope";
50import * as HostProcess from "@t3tools/shared/HostProcess";
51import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model";
52import {
53 CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
54 formatClaudeResumeCompactionQuestion,
55} from "@t3tools/shared/claudeCompaction";
56import {
57 type ChatAttachment,
58 ClaudeSettings,
59 defaultInstanceIdForDriver,
60 type ModelSelection,
61 type OrchestrationV2ConversationMessage,
62 type OrchestrationV2ExecutionNode,
63 type OrchestrationV2ProviderCapabilities,
64 type OrchestrationV2ProviderFailure,
65 OrchestrationV2ProviderGoal,
66 type OrchestrationV2PlanArtifact,
67 type OrchestrationV2PlanStep,
68 type OrchestrationV2PendingBackgroundTask,
69 type OrchestrationV2ProviderRetry,
70 type OrchestrationV2ProviderSession,
71 type OrchestrationV2ProviderThread,
72 type OrchestrationV2ProviderTurn,
73 type OrchestrationV2RuntimeRequest,
74 type OrchestrationV2UserInputQuestion,
75 type OrchestrationV2Subagent,
76 type OrchestrationV2AppThread,
77 type OrchestrationV2SubagentWorkflow,
78 type OrchestrationV2WorkflowRunHandles,
79 type OrchestrationV2TurnItem,
80 type OrchestrationV2WebSearchResult,
81 type ProviderApprovalDecision,
82 ProviderDriverKind,
83 type ProviderInstanceId,
84 type ProviderRequestKind,
85 type ProviderUserInputAnswers,
86 type ProviderThreadId,
87 type RunId,
88 type ThreadId,
89} from "@t3tools/contracts";
90
91import * as Cause from "effect/Cause";
92import * as Context from "effect/Context";
93import * as Crypto from "effect/Crypto";
94import * as Hex from "effect/encoding/Hex";
95import * as DateTime from "effect/DateTime";
96import * as Deferred from "effect/Deferred";
97import * as Effect from "effect/Effect";
98import * as Equal from "effect/Equal";
99import * as Exit from "effect/Exit";
100import * as FileSystem from "effect/FileSystem";
101import * as Layer from "effect/Layer";
102import * as Option from "effect/Option";
103import * as Path from "effect/Path";
104import * as Queue from "effect/Queue";
105import * as Ref from "effect/Ref";
106import * as Schema from "effect/Schema";
107import * as Semaphore from "effect/Semaphore";
108import * as Stream from "effect/Stream";
109
110import { resolveAttachmentPath } from "../../attachmentStore.ts";
111import { resolveClaudeSdkExecutablePath } from "../../provider/Drivers/ClaudeExecutable.ts";
112import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts";
113import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts";
114import {
115 compileClaudeModelSelection,
116 type CompiledClaudeModelSelection,
117} from "../../claudeModelOptions.ts";
118import * as ServerConfig from "../../config.ts";
119import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
120import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
121import {
122 claudeSignedOutMessage,
123 makeClaudeEnvironment,
124} from "../../provider/Drivers/ClaudeHome.ts";
125import {
126 BUNDLED_CLAUDE_MODEL_CATALOG,
127 resolveClaudeCatalogContextWindowTokens,
128 type ClaudeModelCatalog,
129 getClaudeCatalogModelCapabilities,
130 resolveClaudeModelSlug,
131 scopeClaudeModelCatalog,
132} from "../../provider/ClaudeModelCatalog.ts";
133import {
134 boundProviderEventForLogging,
135 shouldPersistProviderEvent,
136} from "../../provider/EventNdjsonLogger.ts";
137import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
138import {
139 claudeRateLimitEventToUpdate,
140 type ClaudeScopedLimitNames,
141} from "../../provider/claudeUsageLimits.ts";
142import type { ManagedServerProvider } from "@t3tools/provider-core/server/snapshot";
143import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
144import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions";
145import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
146import {
147 mcpToolPresentation,
148 normalizeMcpText,
149} from "@t3tools/provider-core/server/mcpToolPresentation";
150import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
151import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
152import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
153import {
154 makeProviderFailure,
155 makeProviderRetryTurnItem,
156} from "@t3tools/provider-core/server/failure";
157import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
158import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt";
159import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
160import {
161 ProviderAdapterDriverCreateError,
162 type ProviderAdapterDriver,
163 type ProviderAdapterDriverCreateInput,
164} from "@t3tools/provider-core/server/adapterDriver";
165import {
166 type BackgroundWorkReport,
167 backgroundWorkNotification,
168} from "@t3tools/provider-core/server/notification";
169import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
170import {
171 makeSubagentChildThread,
172 makeSubagentConversationArtifacts,
173 subagentThreadTitle,
174} from "@t3tools/provider-core/server/subagentProjection";
175
176export const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent");
177export const CLAUDE_AGENT_SDK_QUERY_PROTOCOL = "claude-agent-sdk.query" as const;
178
179function claudeContextWindow(modelSelection: ModelSelection): number | null {
180 return (
181 resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, modelSelection) ?? null
182 );
183}
184
185interface ClaudeCacheCreation {
186 readonly ephemeral_1h_input_tokens: number;
187 readonly ephemeral_5m_input_tokens: number;
188}
189
190// Claude Code writes 1h cache entries on a subscription within its limits and
191// 5m entries otherwise (API key, Bedrock, Vertex, extra usage). A response
192// that wrote nothing leaves the TTL unknown.
193function claudePromptCacheTtlMs(
194 cacheCreation: ClaudeCacheCreation | null | undefined,
195): number | undefined {
196 if (!cacheCreation) return undefined;
197 if (cacheCreation.ephemeral_1h_input_tokens > 0) return 60 * 60_000;
198 if (cacheCreation.ephemeral_5m_input_tokens > 0) return 5 * 60_000;
199 return undefined;
200}
201
202export function claudeProviderTurnTokenUsage(
203 usage: {
204 readonly input_tokens: number;
205 readonly cache_creation_input_tokens?: number | null;
206 readonly cache_read_input_tokens?: number | null;
207 readonly cache_creation?: ClaudeCacheCreation | null;
208 readonly output_tokens: number;
209 },
210 modelSelection: ModelSelection,
211 updatedAt: string,
212) {
213 const inputTokens =
214 usage.input_tokens +
215 (usage.cache_creation_input_tokens ?? 0) +
216 (usage.cache_read_input_tokens ?? 0);
217 const outputTokens = usage.output_tokens;
218 const promptCacheTtlMs = claudePromptCacheTtlMs(usage.cache_creation);
219 return {
220 usedTokens: inputTokens + outputTokens,
221 maxTokens: claudeContextWindow(modelSelection),
222 inputTokens,
223 cachedInputTokens: usage.cache_read_input_tokens ?? 0,
224 outputTokens,
225 reasoningOutputTokens: 0,
226 ...(promptCacheTtlMs === undefined ? {} : { promptCacheTtlMs }),
227 updatedAt,
228 };
229}
230export const CLAUDE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CLAUDE_PROVIDER);
231const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({});
232// Entry extensions the Claude SDK runs through node instead of executing.
233const CLAUDE_SCRIPT_EXTENSIONS = [".js", ".mjs", ".tsx", ".ts", ".jsx"];
234// The SDK puts no deadline on control requests. The CLI answers
235// mcp_set_servers once each server connected or hit its own 30 s timeout.
236const CLAUDE_MCP_REGISTRATION_TIMEOUT = "90 seconds";
237
238/**
239 * Starts the Claude CLI as the leader of its own process group. When the CLI
240 * exits it stops the MCP servers it started, but not their children: a
241 * plugin's `bun run start` wrapper dies and its `bun server.ts` keeps running,
242 * writing to a pipe nobody reads (#3089). Those stay in the CLI's group, so
243 * the rest of the group gets SIGKILL once the CLI exits for any reason.
244 * Commands the agent runs through its Bash tool start their own groups, so
245 * background jobs it launched are not part of this.
246 *
247 * The SDK reads stderr only from its own spawn, where it redacts credentials
248 * before adding the tail to exit errors. Here stderr is dropped instead, so
249 * exit errors carry only the code or signal.
250 */
251function spawnClaudeProcessGroup(options: ClaudeSpawnOptions): ClaudeSpawnedProcess {
252 const child = NodeChildProcess.spawn(options.command, options.args, {
253 cwd: options.cwd,
254 env: options.env,
255 signal: options.signal,
256 stdio: ["pipe", "pipe", "ignore"],
257 detached: true,
258 });
259 child.once("exit", () => {
260 try {
261 signalProcessGroup(child.pid ?? 0, "SIGKILL");
262 } catch {
263 // The group is already empty.
264 }
265 });
266 return child;
267}
268
269export const ClaudeProviderCapabilitiesV2 = {
270 sessions: {
271 supportsMultipleProviderThreadsPerSession: false,
272 supportsModelSwitchInSession: true,
273 supportsProviderSwitchingViaHandoff: true,
274 supportsRuntimeModeSwitchInSession: false,
275 pendingRequestsSurviveRestart: false,
276 },
277 threads: {
278 canCreateEmptyThread: true,
279 canReadThreadSnapshot: false,
280 canRollbackThread: true,
281 canForkThread: true,
282 canForkFromTurn: true,
283 canForkFromSubagentThread: false,
284 exposesNativeThreadId: true,
285 },
286 turns: {
287 exposesNativeTurnId: false,
288 emitsTurnStarted: true,
289 emitsTurnCompleted: true,
290 supportsInterrupt: true,
291 supportsActiveSteering: true,
292 activeSteeringInterruptsTools: true,
293 supportsSteeringByInterruptRestart: false,
294 supportsQueuedMessages: true,
295 terminalStatusQuality: "strong",
296 },
297 streaming: {
298 streamsAssistantText: true,
299 streamsReasoning: true,
300 streamsToolOutput: false,
301 streamsPlanText: false,
302 emitsMessageCompleted: true,
303 },
304 tools: {
305 exposesToolItemIds: true,
306 emitsToolStarted: true,
307 emitsToolCompleted: true,
308 emitsToolOutput: true,
309 supportsMcpTools: true,
310 supportsDynamicToolCallbacks: true,
311 },
312 approvals: {
313 supportsCommandApproval: true,
314 supportsFileReadApproval: true,
315 supportsFileChangeApproval: true,
316 supportsApplyPatchApproval: false,
317 approvalsHaveNativeRequestIds: true,
318 approvalCallbacksAreLiveOnly: true,
319 approvalsCanOriginateFromSubagents: false,
320 },
321 planning: {
322 emitsPlanUpdated: true,
323 emitsTodoList: true,
324 emitsProposedPlan: true,
325 supportsStructuredQuestions: true,
326 planDeltasHaveItemIds: false,
327 },
328 subagents: {
329 supportsSubagents: true,
330 exposesSubagentThreadIds: false,
331 emitsSubagentLifecycle: true,
332 canWaitForSubagents: false,
333 canCloseSubagents: false,
334 canForkSubagentThread: false,
335 },
336 context: {
337 acceptsSystemContext: true,
338 acceptsDeveloperContext: true,
339 acceptsSyntheticUserContext: true,
340 canGenerateSummaries: true,
341 canConsumeHandoffSummaries: true,
342 supportsDeltaHandoff: true,
343 supportsFullThreadHandoff: true,
344 maxRecommendedHandoffChars: null,
345 },
346 checkpointing: {
347 appCanCheckpointFilesystem: true,
348 supportsNestedCheckpointScopes: true,
349 providerCanRollbackConversation: true,
350 providerRollbackReturnsSnapshot: true,
351 providerCanReadConversationSnapshot: false,
352 },
353 identity: {
354 nativeThreadIds: "strong",
355 nativeTurnIds: "weak",
356 nativeItemIds: "strong",
357 nativeRequestIds: "strong",
358 },
359 runtimePolicy: {
360 enforcement: "native",
361 },
362} satisfies OrchestrationV2ProviderCapabilities;
363
364const CLAUDE_CODE_PRESET_TOOLS = {
365 type: "preset",
366 preset: "claude_code",
367} satisfies NonNullable<ClaudeQueryOptions["tools"]>;
368
369export type ClaudeAgentSdkQueryToolList = ReadonlyArray<string>;
370export interface ClaudeAgentSdkQueryPresetTools {
371 readonly type: "preset";
372 readonly preset: "claude_code";
373}
374export type ClaudeAgentSdkQueryTools = ClaudeAgentSdkQueryToolList | ClaudeAgentSdkQueryPresetTools;
375
376export const CLAUDE_READ_ONLY_ALLOWED_TOOLS = ["Read", "Glob", "Grep"] as const;
377
378function claudeAgentSdkQueryToolsForSdk(
379 tools: ClaudeAgentSdkQueryTools,
380): NonNullable<ClaudeQueryOptions["tools"]> {
381 if (isClaudeAgentSdkQueryToolList(tools)) {
382 return [...tools];
383 }
384 return { type: tools.type, preset: tools.preset };
385}
386
387function isClaudeAgentSdkQueryToolList(
388 tools: ClaudeAgentSdkQueryTools,
389): tools is ClaudeAgentSdkQueryToolList {
390 return Array.isArray(tools);
391}
392
393type ClaudeAgentSdkThreadIdentity =
394 | {
395 readonly sessionId: string;
396 readonly resume?: never;
397 }
398 | {
399 readonly sessionId?: never;
400 readonly resume: string;
401 };
402
403export type ClaudeAgentSdkQueryOptions = Omit<
404 ClaudeQueryOptions,
405 "maxTurns" | "model" | "permissionMode" | "resume" | "sessionId" | "tools"
406> & {
407 readonly model: string;
408 readonly tools: NonNullable<ClaudeQueryOptions["tools"]>;
409 readonly permissionMode: NonNullable<ClaudeQueryOptions["permissionMode"]>;
410} & ClaudeAgentSdkThreadIdentity;
411
412export interface ClaudeAgentSdkQueryOpenInput {
413 readonly options: ClaudeAgentSdkQueryOptions;
414 readonly threadId: ThreadId;
415 readonly providerSessionId: OrchestrationV2ProviderSession["id"];
416}
417
418export interface ClaudeAgentSdkQuerySession {
419 readonly messages: Stream.Stream<SDKMessage, ClaudeAgentSdkQueryRunnerError>;
420 readonly offer: (message: SDKUserMessage) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
421 readonly setModel: (model: string) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
422 readonly applyFlagSettings: (
423 settings: ClaudeFlagSettings,
424 ) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
425 readonly setPermissionMode: (
426 mode: PermissionMode,
427 ) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
428 readonly stopTask: (taskId: string) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
429 readonly interrupt: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
430 readonly close: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
431}
432
433type ClaudeQueryStreamExit = Exit.Exit<void, ClaudeAgentSdkQueryRunnerError>;
434
435export class ClaudeAgentSdkQueryRunnerError extends Schema.TaggedError<ClaudeAgentSdkQueryRunnerError>()(
436 "ClaudeAgentSdkQueryRunnerError",
437 {
438 method: Schema.String,
439 cause: Schema.Defect(),
440 },
441) {
442 override get message(): string {
443 return "Claude Agent SDK query failed.";
444 }
445}
446
447export class ClaudeBackgroundWorkBlocksQueryReplacementError extends Schema.TaggedError<ClaudeBackgroundWorkBlocksQueryReplacementError>()(
448 "ClaudeBackgroundWorkBlocksQueryReplacementError",
449 {},
450) {
451 override get message(): string {
452 return "Claude is still running background agents or commands, and this model or setting change would end them. Wait for them to finish, or press Stop, then send the message again.";
453 }
454}
455
456export interface ClaudeAgentSdkQueryRunnerShape {
457 readonly allocateSessionId: Effect.Effect<string, ClaudeAgentSdkQueryRunnerError>;
458 readonly open: (
459 input: ClaudeAgentSdkQueryOpenInput,
460 ) => Effect.Effect<ClaudeAgentSdkQuerySession, ClaudeAgentSdkQueryRunnerError>;
461 readonly forkSession: (
462 input: ClaudeAgentSdkSessionForkInput,
463 ) => Effect.Effect<ForkSessionResult, ClaudeAgentSdkQueryRunnerError>;
464 /** The tool call that launched a subagent, read from the CLI's session storage. */
465 readonly subagentLaunchToolUseId: (
466 input: ClaudeAgentSdkSubagentLookupInput,
467 ) => Effect.Effect<string | null, ClaudeAgentSdkQueryRunnerError>;
468 readonly assertComplete: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
469}
470
471export class ClaudeAgentSdkQueryRunner extends Context.Service<
472 ClaudeAgentSdkQueryRunner,
473 ClaudeAgentSdkQueryRunnerShape
474>()("t3/orchestration-v2/Adapters/ClaudeAdapterV2/ClaudeAgentSdkQueryRunner") {}
475
476export interface ClaudeAgentSdkSessionForkInput {
477 readonly sessionId: string;
478 readonly options: ForkSessionOptions;
479 readonly threadId: ThreadId;
480 readonly providerSessionId: OrchestrationV2ProviderSession["id"];
481}
482
483export interface ClaudeAgentSdkSubagentLookupInput {
484 readonly sessionId: string;
485 readonly agentId: string;
486 readonly dir: string | null;
487 readonly threadId: ThreadId;
488 readonly providerSessionId: OrchestrationV2ProviderSession["id"];
489}
490
491const isClaudeAgentSdkQueryRunnerError = Schema.is(ClaudeAgentSdkQueryRunnerError);
492const isProviderAdapterRuntimeRequestResponseError = Schema.is(
493 ProviderAdapter.ProviderAdapterRuntimeRequestResponseError,
494);
495const isProviderAdapterRollbackThreadError = Schema.is(
496 ProviderAdapter.ProviderAdapterRollbackThreadError,
497);
498
499function queryRunnerError(cause: unknown, method: string): ClaudeAgentSdkQueryRunnerError {
500 return isClaudeAgentSdkQueryRunnerError(cause)
501 ? cause
502 : new ClaudeAgentSdkQueryRunnerError({ cause, method });
503}
504
505function closeClaudeQuery(queryRuntime: ClaudeQuery) {
506 return Effect.try({
507 try: () => queryRuntime.close(),
508 catch: (cause) => queryRunnerError(cause, "close"),
509 });
510}
511
512// Iterate the Query itself, not query[Symbol.asyncIterator]() (the raw
513// sdkMessages generator). The raw generator's return() queues behind the
514// in-flight read of the next CLI message and never settles while the CLI is
515// idle, deadlocking stream interruption (and with it, session scope close).
516// Query.return() runs cleanup() first, which closes the transport and
517// unblocks that read.
518export function claudeQueryMessages(queryRuntime: ClaudeQuery): AsyncIterable<SDKMessage, void> {
519 return { [Symbol.asyncIterator]: () => queryRuntime };
520}
521
522export interface ClaudeAgentSdkLoggedQueryOptions {
523 readonly model: ClaudeAgentSdkQueryOptions["model"];
524 readonly tools: ClaudeAgentSdkQueryOptions["tools"];
525 readonly permissionMode: ClaudeAgentSdkQueryOptions["permissionMode"];
526 readonly sessionId?: string;
527 readonly resume?: string;
528 readonly resumeSessionAt?: ClaudeAgentSdkQueryOptions["resumeSessionAt"];
529 readonly cwd?: ClaudeAgentSdkQueryOptions["cwd"];
530 readonly allowedTools?: ClaudeAgentSdkQueryOptions["allowedTools"];
531 readonly disallowedTools?: ClaudeAgentSdkQueryOptions["disallowedTools"];
532 readonly settings?: ClaudeAgentSdkQueryOptions["settings"];
533 readonly effort?: ClaudeAgentSdkQueryOptions["effort"];
534 readonly includePartialMessages?: true;
535 readonly pathToClaudeCodeExecutable?: ClaudeAgentSdkQueryOptions["pathToClaudeCodeExecutable"];
536 readonly hasExtraArgs?: true;
537 readonly allowDangerouslySkipPermissions?: true;
538 readonly hasCanUseTool?: true;
539 readonly hasEnvironment?: true;
540 readonly hasMcpServers?: true;
541}
542
543export type ClaudeAgentSdkProtocolLogEvent =
544 | {
545 readonly direction: "outgoing";
546 readonly stage: "decoded";
547 readonly payload: {
548 readonly type: "query.open";
549 readonly options: ClaudeAgentSdkLoggedQueryOptions;
550 };
551 }
552 | {
553 readonly direction: "outgoing";
554 readonly stage: "decoded";
555 readonly payload: {
556 readonly type: "prompt.offer";
557 readonly message: SDKUserMessage;
558 };
559 }
560 | {
561 readonly direction: "outgoing";
562 readonly stage: "decoded";
563 readonly payload: {
564 readonly type: "query.set_model";
565 readonly model: string;
566 };
567 }
568 | {
569 readonly direction: "outgoing";
570 readonly stage: "decoded";
571 readonly payload: {
572 readonly type: "query.apply_flag_settings";
573 readonly settings: ClaudeFlagSettings;
574 };
575 }
576 | {
577 readonly direction: "outgoing";
578 readonly stage: "decoded";
579 readonly payload: {
580 readonly type: "query.set_permission_mode";
581 readonly mode: PermissionMode;
582 };
583 }
584 | {
585 readonly direction: "outgoing";
586 readonly stage: "decoded";
587 readonly payload: {
588 readonly type: "query.stop_task";
589 readonly taskId: string;
590 };
591 }
592 | {
593 readonly direction: "outgoing";
594 readonly stage: "decoded";
595 readonly payload: {
596 readonly type: "query.interrupt";
597 };
598 }
599 | {
600 readonly direction: "outgoing";
601 readonly stage: "decoded";
602 readonly payload: {
603 readonly type: "query.close";
604 };
605 }
606 | {
607 readonly direction: "outgoing";
608 readonly stage: "decoded";
609 readonly payload: {
610 readonly type: "session.fork";
611 readonly sessionId: string;
612 readonly options: ForkSessionOptions;
613 };
614 }
615 | {
616 readonly direction: "incoming";
617 readonly stage: "decoded";
618 readonly payload: {
619 readonly type: "session.forked";
620 readonly sessionId: string;
621 };
622 }
623 | {
624 readonly direction: "outgoing";
625 readonly stage: "decoded";
626 readonly payload: {
627 readonly type: "subagent.lookup";
628 readonly sessionId: string;
629 readonly agentId: string;
630 };
631 }
632 | {
633 readonly direction: "incoming";
634 readonly stage: "decoded";
635 readonly payload: {
636 readonly type: "subagent.found";
637 readonly toolUseId: string | null;
638 };
639 }
640 | {
641 readonly direction: "incoming";
642 readonly stage: "decoded";
643 readonly payload: SDKMessage;
644 };
645
646export type ClaudeAgentSdkProtocolLogger = (
647 event: ClaudeAgentSdkProtocolLogEvent,
648) => Effect.Effect<void>;
649
650export function loggedClaudeQueryOptions(
651 options: ClaudeAgentSdkQueryOptions,
652): ClaudeAgentSdkLoggedQueryOptions {
653 return {
654 model: options.model,
655 tools: options.tools,
656 permissionMode: options.permissionMode,
657 ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
658 ...(options.resume === undefined ? {} : { resume: options.resume }),
659 ...(options.resumeSessionAt === undefined ? {} : { resumeSessionAt: options.resumeSessionAt }),
660 ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
661 ...(options.allowedTools === undefined ? {} : { allowedTools: options.allowedTools }),
662 ...(options.disallowedTools === undefined ? {} : { disallowedTools: options.disallowedTools }),
663 ...(options.settings === undefined ? {} : { settings: options.settings }),
664 ...(options.effort === undefined ? {} : { effort: options.effort }),
665 ...(options.includePartialMessages === true ? { includePartialMessages: true } : {}),
666 ...(options.pathToClaudeCodeExecutable === undefined
667 ? {}
668 : { pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable }),
669 ...(options.extraArgs === undefined ? {} : { hasExtraArgs: true }),
670 ...(options.allowDangerouslySkipPermissions === true
671 ? { allowDangerouslySkipPermissions: true }
672 : {}),
673 ...(options.canUseTool === undefined ? {} : { hasCanUseTool: true }),
674 ...(options.env === undefined ? {} : { hasEnvironment: true }),
675 ...(options.mcpServers === undefined ? {} : { hasMcpServers: true }),
676 };
677}
678
679export function makeClaudeAgentSdkProtocolLogger(input: {
680 readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined;
681 readonly threadId: ThreadId;
682 readonly providerSessionId: OrchestrationV2ProviderSession["id"];
683}): ClaudeAgentSdkProtocolLogger | undefined {
684 const { nativeEventLogger } = input;
685 if (nativeEventLogger === undefined) {
686 return undefined;
687 }
688
689 return (event) => {
690 if (!shouldPersistProviderEvent("native", event)) return Effect.void;
691 return nativeEventLogger
692 .write(
693 {
694 provider: CLAUDE_PROVIDER,
695 protocol: CLAUDE_AGENT_SDK_QUERY_PROTOCOL,
696 kind: "protocol",
697 providerSessionId: input.providerSessionId,
698 event: boundProviderEventForLogging(event),
699 },
700 input.threadId,
701 )
702 .pipe(Effect.ignore);
703 };
704}
705
706export const layerQueryRunner: Layer.Layer<
707 ClaudeAgentSdkQueryRunner,
708 never,
709 Crypto.Crypto | ProviderEventLoggers.ProviderEventLoggers
710> = Layer.effect(
711 ClaudeAgentSdkQueryRunner,
712 Effect.gen(function* () {
713 const crypto = yield* Crypto.Crypto;
714 const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers;
715 const agentScope = yield* AgentScope;
716 const platform = yield* HostProcess.Platform;
717
718 return ClaudeAgentSdkQueryRunner.of({
719 allocateSessionId: crypto
720 .randomUUIDv4()
721 .pipe(Effect.mapError((cause) => queryRunnerError(cause, "allocateSessionId"))),
722 open: Effect.fn("ClaudeAgentSdkQueryRunner.open")(function* (
723 input: ClaudeAgentSdkQueryOpenInput,
724 ) {
725 const protocolLogger = makeClaudeAgentSdkProtocolLogger({
726 nativeEventLogger,
727 threadId: input.threadId,
728 providerSessionId: input.providerSessionId,
729 });
730 const logProtocolEvent = (event: ClaudeAgentSdkProtocolLogEvent) =>
731 protocolLogger === undefined ? Effect.void : protocolLogger(event);
732 const promptQueue = yield* Queue.unbounded<SDKUserMessage>();
733 const prompt = Stream.fromQueue(promptQueue).pipe(
734 Stream.catchCause((cause) =>
735 Cause.hasInterruptsOnly(cause) ? Stream.empty : Stream.failCause(cause),
736 ),
737 Stream.toAsyncIterable,
738 );
739 // The SDK would pass `mcpServers` as an inline `--mcp-config` argument,
740 // which every local user can read. They go over the CLI's stdin control
741 // channel instead, so header credentials stay out of its argv and out
742 // of the environment every command the agent runs inherits.
743 const { mcpServers, ...queryOptions } = input.options;
744 // The SDK spawns a native binary as `path ...executableArgs ...sdkArgs`,
745 // which lets the agent scope wrapper go in front of the CLI. The SDK
746 // runs a script entry through node instead, so those launch unwrapped.
747 const binaryPath = queryOptions.pathToClaudeCodeExecutable;
748 const launch =
749 binaryPath === undefined ||
750 CLAUDE_SCRIPT_EXTENSIONS.some((ext) => binaryPath.endsWith(ext))
751 ? undefined
752 : yield* agentScope.wrap({
753 command: binaryPath,
754 args: [],
755 name: "claude",
756 threadId: input.threadId,
757 env: queryOptions.env,
758 });
759 const options =
760 launch === undefined || launch.args.length === 0
761 ? queryOptions
762 : {
763 ...queryOptions,
764 pathToClaudeCodeExecutable: launch.command,
765 executableArgs: [...launch.args, ...(queryOptions.executableArgs ?? [])],
766 };
767 // A spawner the caller passed keeps its process. Windows has no process
768 // groups, so there the SDK spawns the CLI itself.
769 const spawnClaudeCodeProcess =
770 options.spawnClaudeCodeProcess ??
771 (platform === "win32" ? undefined : spawnClaudeProcessGroup);
772 // The CLI is running once query() returns, so it is closed if anything
773 // before the session is handed out fails or is interrupted.
774 const queryRuntime = yield* Effect.uninterruptibleMask((restore) =>
775 Effect.try({
776 try: () =>
777 query({
778 prompt,
779 options:
780 spawnClaudeCodeProcess === undefined
781 ? options
782 : { ...options, spawnClaudeCodeProcess },
783 }),
784 catch: (cause) => queryRunnerError(cause, "query"),
785 }).pipe(
786 Effect.tap((queryRuntime) =>
787 restore(
788 Effect.gen(function* () {
789 if (mcpServers !== undefined) {
790 // A rejected request or a server that failed to connect
791 // leaves the turn without those tools, as `--mcp-config`
792 // did. Error texts and the CLI's mcp_status response can
793 // echo the headers, so only server names are logged.
794 const failed = yield* Effect.tryPromise(() =>
795 queryRuntime.setMcpServers(mcpServers),
796 ).pipe(
797 Effect.map((result) => Object.keys(result.errors)),
798 Effect.orElseSucceed(() => Object.keys(mcpServers)),
799 Effect.timeoutOrElse({
800 duration: CLAUDE_MCP_REGISTRATION_TIMEOUT,
801 orElse: () =>
802 Effect.fail(
803 queryRunnerError(
804 "Claude Code did not answer the MCP server registration.",
805 "setMcpServers",
806 ),
807 ),
808 }),
809 );
810 if (failed.length > 0) {
811 yield* Effect.logWarning("orchestration-v2.claude-mcp-servers-unavailable", {
812 threadId: input.threadId,
813 servers: failed,
814 });
815 }
816 }
817 yield* logProtocolEvent({
818 direction: "outgoing",
819 stage: "decoded",
820 payload: {
821 type: "query.open",
822 options: loggedClaudeQueryOptions(input.options),
823 },
824 });
825 }),
826 ).pipe(
827 Effect.onError(() =>
828 Queue.shutdown(promptQueue).pipe(
829 Effect.andThen(closeClaudeQuery(queryRuntime)),
830 Effect.ignore,
831 ),
832 ),
833 ),
834 ),
835 ),
836 );
837
838 return {
839 messages: Stream.fromAsyncIterable(claudeQueryMessages(queryRuntime), (cause) =>
840 queryRunnerError(cause, "fromAsyncIterable"),
841 ).pipe(
842 Stream.tap((message) =>
843 logProtocolEvent({
844 direction: "incoming",
845 stage: "decoded",
846 payload: message,
847 }),
848 ),
849 ),
850 offer: (message) =>
851 Queue.offer(promptQueue, message).pipe(
852 Effect.asVoid,
853 Effect.tap(() =>
854 logProtocolEvent({
855 direction: "outgoing",
856 stage: "decoded",
857 payload: {
858 type: "prompt.offer",
859 message,
860 },
861 }),
862 ),
863 ),
864 setModel: (model) =>
865 Effect.tryPromise({
866 try: () => queryRuntime.setModel(model),
867 catch: (cause) => queryRunnerError(cause, "setModel"),
868 }).pipe(
869 Effect.tap(() =>
870 logProtocolEvent({
871 direction: "outgoing",
872 stage: "decoded",
873 payload: {
874 type: "query.set_model",
875 model,
876 },
877 }),
878 ),
879 ),
880 applyFlagSettings: (settings) =>
881 Effect.tryPromise({
882 try: () => queryRuntime.applyFlagSettings(settings),
883 catch: (cause) => queryRunnerError(cause, "applyFlagSettings"),
884 }).pipe(
885 Effect.tap(() =>
886 logProtocolEvent({
887 direction: "outgoing",
888 stage: "decoded",
889 payload: {
890 type: "query.apply_flag_settings",
891 settings,
892 },
893 }),
894 ),
895 ),
896 setPermissionMode: (mode) =>
897 Effect.tryPromise({
898 try: () => queryRuntime.setPermissionMode(mode),
899 catch: (cause) => queryRunnerError(cause, "setPermissionMode"),
900 }).pipe(
901 Effect.tap(() =>
902 logProtocolEvent({
903 direction: "outgoing",
904 stage: "decoded",
905 payload: {
906 type: "query.set_permission_mode",
907 mode,
908 },
909 }),
910 ),
911 ),
912 stopTask: (taskId) =>
913 Effect.tryPromise({
914 try: () => queryRuntime.stopTask(taskId),
915 catch: (cause) => queryRunnerError(cause, "stopTask"),
916 }).pipe(
917 Effect.tap(() =>
918 logProtocolEvent({
919 direction: "outgoing",
920 stage: "decoded",
921 payload: { type: "query.stop_task", taskId },
922 }),
923 ),
924 ),
925 interrupt: Effect.tryPromise({
926 try: () => queryRuntime.interrupt(),
927 catch: (cause) => queryRunnerError(cause, "interrupt"),
928 }).pipe(
929 Effect.tap(() =>
930 logProtocolEvent({
931 direction: "outgoing",
932 stage: "decoded",
933 payload: {
934 type: "query.interrupt",
935 },
936 }),
937 ),
938 ),
939 close: Queue.shutdown(promptQueue).pipe(
940 Effect.andThen(closeClaudeQuery(queryRuntime)),
941 Effect.tap(() =>
942 logProtocolEvent({
943 direction: "outgoing",
944 stage: "decoded",
945 payload: {
946 type: "query.close",
947 },
948 }),
949 ),
950 ),
951 } satisfies ClaudeAgentSdkQuerySession;
952 }),
953 forkSession: Effect.fn("ClaudeAgentSdkQueryRunner.forkSession")(function* (
954 input: ClaudeAgentSdkSessionForkInput,
955 ) {
956 const protocolLogger = makeClaudeAgentSdkProtocolLogger({
957 nativeEventLogger,
958 threadId: input.threadId,
959 providerSessionId: input.providerSessionId,
960 });
961 const logProtocolEvent = (event: ClaudeAgentSdkProtocolLogEvent) =>
962 protocolLogger === undefined ? Effect.void : protocolLogger(event);
963 yield* logProtocolEvent({
964 direction: "outgoing",
965 stage: "decoded",
966 payload: {
967 type: "session.fork",
968 sessionId: input.sessionId,
969 options: input.options,
970 },
971 });
972 const result = yield* Effect.tryPromise({
973 try: () => forkClaudeSession(input.sessionId, input.options),
974 catch: (cause) => queryRunnerError(cause, "forkSession"),
975 });
976 yield* logProtocolEvent({
977 direction: "incoming",
978 stage: "decoded",
979 payload: {
980 type: "session.forked",
981 sessionId: result.sessionId,
982 },
983 });
984 return result;
985 }),
986 subagentLaunchToolUseId: Effect.fn("ClaudeAgentSdkQueryRunner.subagentLaunchToolUseId")(
987 function* (input: ClaudeAgentSdkSubagentLookupInput) {
988 const protocolLogger = makeClaudeAgentSdkProtocolLogger({
989 nativeEventLogger,
990 threadId: input.threadId,
991 providerSessionId: input.providerSessionId,
992 });
993 const logProtocolEvent = (event: ClaudeAgentSdkProtocolLogEvent) =>
994 protocolLogger === undefined ? Effect.void : protocolLogger(event);
995 yield* logProtocolEvent({
996 direction: "outgoing",
997 stage: "decoded",
998 payload: {
999 type: "subagent.lookup",
1000 sessionId: input.sessionId,
1001 agentId: input.agentId,
1002 },
1003 });
1004 // The CLI stamps every message of a subagent's transcript with the
1005 // tool call that launched it; one message is enough.
1006 const messages = yield* Effect.tryPromise({
1007 try: () =>
1008 getSubagentMessages(input.sessionId, input.agentId, {
1009 ...(input.dir === null ? {} : { dir: input.dir }),
1010 limit: 1,
1011 }),
1012 catch: (cause) => queryRunnerError(cause, "getSubagentMessages"),
1013 });
1014 const toolUseId = messages[0]?.parent_tool_use_id ?? null;
1015 yield* logProtocolEvent({
1016 direction: "incoming",
1017 stage: "decoded",
1018 payload: { type: "subagent.found", toolUseId },
1019 });
1020 return toolUseId;
1021 },
1022 ),
1023 assertComplete: Effect.void,
1024 });
1025 }),
1026);
1027
1028type ClaudeFlagSettings = Parameters<ClaudeQuery["applyFlagSettings"]>[0];
1029
1030/**
1031 * Thinking is a launch flag (`--thinking`), so only a new process can change
1032 * it. Clearing an explicit effort also needs one: the live reset goes to the
1033 * model default, while a new process reads the user's settings files.
1034 */
1035function canApplyClaudeSelectionLive(
1036 live: CompiledClaudeModelSelection,
1037 next: CompiledClaudeModelSelection,
1038): boolean {
1039 return (
1040 live.settings.alwaysThinkingEnabled === next.settings.alwaysThinkingEnabled &&
1041 (next.effort !== undefined || live.effort === undefined)
1042 );
1043}
1044
1045/**
1046 * Flag settings that move a live process to `selection`. `null` clears a key
1047 * a new process for `selection` would not set.
1048 */
1049function claudeLiveSelectionFlagSettings(
1050 selection: CompiledClaudeModelSelection,
1051): ClaudeFlagSettings {
1052 return {
1053 ...(selection.effort === undefined ? {} : { effortLevel: selection.effort as EffortLevel }),
1054 fastMode: selection.settings.fastMode ?? null,
1055 ultracode: selection.settings.ultracode ?? null,
1056 };
1057}
1058
1059export function makeClaudeQueryOptions(input: {
1060 readonly modelSelection: ModelSelection;
1061 readonly nativeThreadId: string;
1062 readonly resume: boolean;
1063 readonly resumeSessionAt?: string;
1064 readonly cwd: string | null;
1065 /**
1066 * The attachments dir grant lets the agent Read/copy pasted images at the
1067 * paths appended to the turn text, without an approval prompt. It is a leaf
1068 * directory holding only attachment files; siblings like secrets/ and
1069 * state.sqlite stay ungranted.
1070 */
1071 readonly attachmentsDir?: string;
1072 readonly settings?: ClaudeSettings;
1073 readonly sdkSettings?: string | ClaudeSdkSettings;
1074 readonly environment?: NodeJS.ProcessEnv;
1075 readonly mcpServers?: ClaudeQueryOptions["mcpServers"];
1076 readonly tools?: ClaudeAgentSdkQueryTools;
1077 readonly allowedTools?: ReadonlyArray<string>;
1078 readonly disallowedTools?: ReadonlyArray<string>;
1079 readonly permissionMode?: PermissionMode;
1080 readonly canUseTool?: CanUseTool;
1081 readonly onUserDialog?: ClaudeQueryOptions["onUserDialog"];
1082 readonly supportedDialogKinds?: ClaudeQueryOptions["supportedDialogKinds"];
1083 readonly allowDangerouslySkipPermissions?: boolean;
1084}): ClaudeAgentSdkQueryOptions {
1085 const compiledSelection = compileClaudeModelSelection(input.modelSelection);
1086 const {
1087 "permission-mode": launchArgPermissionMode,
1088 "dangerously-skip-permissions": launchArgSkipPermissions,
1089 ...extraArgs
1090 } = input.settings === undefined ? {} : parseCliArgs(input.settings.launchArgs).flags;
1091 const requestThinkingSummaries =
1092 compiledSelection.settings.alwaysThinkingEnabled !== false &&
1093 extraArgs["thinking-display"] !== "omitted";
1094 if (requestThinkingSummaries && extraArgs["thinking-display"] === undefined) {
1095 extraArgs["thinking-display"] = "summarized";
1096 }
1097 const threadIdentity: ClaudeAgentSdkThreadIdentity = input.resume
1098 ? { resume: input.nativeThreadId }
1099 : { sessionId: input.nativeThreadId };
1100 const selectedTools = input.tools ?? CLAUDE_CODE_PRESET_TOOLS;
1101 const selectionSettings =
1102 Object.keys(compiledSelection.settings).length === 0
1103 ? undefined
1104 : (compiledSelection.settings as ClaudeSdkSettings);
1105 const querySettings =
1106 selectionSettings === undefined
1107 ? input.sdkSettings
1108 : typeof input.sdkSettings === "object" && input.sdkSettings !== null
1109 ? ({ ...input.sdkSettings, ...selectionSettings } as ClaudeSdkSettings)
1110 : selectionSettings;
1111 const effectiveQuerySettings =
1112 input.settings?.autoCompactWindow === undefined || input.settings.autoCompactWindow.length === 0
1113 ? querySettings
1114 : ({
1115 ...(typeof querySettings === "object" && querySettings !== null ? querySettings : {}),
1116 autoCompactWindow: Number(input.settings.autoCompactWindow),
1117 } as ClaudeSdkSettings);
1118 const options: ClaudeAgentSdkQueryOptions = {
1119 model: compiledSelection.apiModelId,
1120 tools: claudeAgentSdkQueryToolsForSdk(selectedTools),
1121 permissionMode:
1122 (launchArgPermissionMode as PermissionMode | null | undefined) ??
1123 (launchArgSkipPermissions === null || launchArgSkipPermissions === "true"
1124 ? "bypassPermissions"
1125 : (input.permissionMode ?? "default")),
1126 includePartialMessages: true,
1127 ...(compiledSelection.effort === undefined
1128 ? {}
1129 : {
1130 effort: compiledSelection.effort as NonNullable<ClaudeQueryOptions["effort"]>,
1131 }),
1132 ...threadIdentity,
1133 ...(input.resumeSessionAt === undefined ? {} : { resumeSessionAt: input.resumeSessionAt }),
1134 ...(input.allowedTools === undefined ? {} : { allowedTools: [...input.allowedTools] }),
1135 ...(input.disallowedTools === undefined ? {} : { disallowedTools: [...input.disallowedTools] }),
1136 ...(input.canUseTool === undefined ? {} : { canUseTool: input.canUseTool }),
1137 ...(input.allowDangerouslySkipPermissions === true
1138 ? { allowDangerouslySkipPermissions: true }
1139 : {}),
1140 ...(requestThinkingSummaries
1141 ? {
1142 thinking: { type: "adaptive" as const, display: "summarized" as const },
1143 settings:
1144 typeof effectiveQuerySettings === "string"
1145 ? effectiveQuerySettings
1146 : {
1147 ...effectiveQuerySettings,
1148 showThinkingSummaries: true,
1149 },
1150 }
1151 : effectiveQuerySettings === undefined
1152 ? {}
1153 : { settings: effectiveQuerySettings }),
1154 ...(input.onUserDialog === undefined ? {} : { onUserDialog: input.onUserDialog }),
1155 ...(input.supportedDialogKinds === undefined
1156 ? {}
1157 : { supportedDialogKinds: input.supportedDialogKinds }),
1158 ...(input.settings?.binaryPath
1159 ? { pathToClaudeCodeExecutable: input.settings.binaryPath }
1160 : {}),
1161 ...(input.environment === undefined ? {} : { env: input.environment }),
1162 ...(input.mcpServers === undefined ? {} : { mcpServers: input.mcpServers }),
1163 systemPrompt: {
1164 type: "preset" as const,
1165 preset: "claude_code" as const,
1166 append:
1167 buildRuntimeInstructions({ harness: "Claude Code" }) +
1168 (input.mcpServers === undefined ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS),
1169 },
1170 ...(Object.keys(extraArgs).length === 0 ? {} : { extraArgs }),
1171 };
1172 const additionalDirectories = [
1173 ...(input.cwd === null ? [] : [input.cwd]),
1174 ...(input.attachmentsDir === undefined ? [] : [input.attachmentsDir]),
1175 ];
1176 const withDirectories =
1177 additionalDirectories.length === 0 ? options : { ...options, additionalDirectories };
1178 return input.cwd === null ? withDirectories : { ...withDirectories, cwd: input.cwd };
1179}
1180
1181export const CLAUDE_T3_MCP_TOOL_WILDCARD = "mcp__t3-code__*";
1182
1183// Must stay in sync with the Tool.Readonly annotations on OrchestratorToolkit;
1184// ClaudeAdapterV2.test.ts cross-checks this list against the toolkit.
1185export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray<string> = [
1186 "mcp__t3-code__orchestrator_capabilities",
1187 "mcp__t3-code__list_scheduled_tasks",
1188 "mcp__t3-code__t3_thread_list",
1189 "mcp__t3-code__t3_thread_wait",
1190 "mcp__t3-code__t3_pending_request_list",
1191 "mcp__t3-code__t3_pending_request_read",
1192 "mcp__t3-code__t3_thread_configuration",
1193 "mcp__t3-code__t3_thread_transfers",
1194 "mcp__t3-code__t3_worktree_status",
1195 "mcp__t3-code__t3_worktree_list",
1196 "mcp__t3-code__t3_project_list",
1197 "mcp__t3-code__t3_project_read",
1198 "mcp__t3-code__t3_thread_search",
1199 "mcp__t3-code__t3_preview_list",
1200 "mcp__t3-code__t3_environment_read",
1201 "mcp__t3-code__t3_queue_list",
1202 "mcp__t3-code__t3_queue_read",
1203 "mcp__t3-code__html_preview",
1204 "mcp__t3-code__html_render",
1205];
1206
1207// Claude Code aborts an HTTP MCP call after 60 s ("The operation timed out.")
1208// unless the server config sets `timeout`. T3's wait tools (t3_thread_wait,
1209// delegate_task mode=wait) legitimately block for up to an hour
1210// (MAX_WAIT_TIMEOUT_MS in OrchestratorMcpService), so the budget sits just
1211// above that and the server's own wait timeout is what ends a long call.
1212export const CLAUDE_T3_MCP_TOOL_TIMEOUT_MS = 65 * 60 * 1_000;
1213
1214// The SDK's `allowedTools` only pre-approves tool calls; availability is the
1215// separate `tools` option. Attaching the t3-code MCP server therefore always
1216// pre-approves its tools (headless modes like `dontAsk` deny anything that is
1217// not pre-approved), but read-only sandboxes pre-approve only the annotated
1218// read-only orchestrator tools so a read-only session cannot silently spawn
1219// threads or scheduled tasks.
1220//
1221// The header carries the credential itself: the query runner sends
1222// `mcpServers` over the CLI's stdin, never its argv or environment.
1223
1224export function claudeMcpQueryOverrides(input: {
1225 readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined;
1226 readonly readOnlySandbox: boolean;
1227 readonly allowedTools?: ReadonlyArray<string>;
1228}): {
1229 readonly allowedTools?: ReadonlyArray<string>;
1230 readonly mcpServers?: ClaudeQueryOptions["mcpServers"];
1231} {
1232 const session = input.mcpSession;
1233 if (session === undefined) {
1234 return input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools };
1235 }
1236 const mcpAllowedTools = input.readOnlySandbox
1237 ? CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS
1238 : [CLAUDE_T3_MCP_TOOL_WILDCARD];
1239 return {
1240 allowedTools: Array.from(new Set([...(input.allowedTools ?? []), ...mcpAllowedTools])),
1241 mcpServers: {
1242 "t3-code": {
1243 type: "http",
1244 url: session.endpoint,
1245 headers: {
1246 Authorization: session.authorizationHeader,
1247 },
1248 timeout: CLAUDE_T3_MCP_TOOL_TIMEOUT_MS,
1249 },
1250 },
1251 };
1252}
1253
1254function providerSession(input: {
1255 readonly providerSessionId: OrchestrationV2ProviderSession["id"];
1256 readonly providerInstanceId: ProviderInstanceId;
1257 readonly cwd: string | null;
1258 readonly model: string;
1259 readonly now: DateTime.Utc;
1260}): OrchestrationV2ProviderSession {
1261 return {
1262 id: input.providerSessionId,
1263 driver: CLAUDE_PROVIDER,
1264 providerInstanceId: input.providerInstanceId,
1265 status: "ready",
1266 cwd: input.cwd ?? process.cwd(),
1267 model: input.model,
1268 capabilities: ClaudeProviderCapabilitiesV2,
1269 createdAt: input.now,
1270 updatedAt: input.now,
1271 lastError: null,
1272 };
1273}
1274
1275function textFromClaudeContent(content: SDKAssistantMessage["message"]["content"]): string {
1276 return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
1277}
1278
1279// Claude 5.1 and later return the text written ahead of a tool call as a
1280// thinking block, at most one per call, instead of a text block. Summarized
1281// display makes it look like reasoning, so only the second of two thinking
1282// blocks right before a tool call is treated as that text.
1283function claudeWritesUpdatesAsThinking(model: string): boolean {
1284 const match = /claude-[a-z]+-(\d+)(?:-(\d{1,2}))?(?!\d)/u.exec(model);
1285 if (match === null) return false;
1286 const major = Number(match[1]);
1287 return major > 5 || (major === 5 && Number(match[2] ?? 0) >= 1);
1288}
1289
1290// In SDK mode Claude reports `/goal` only through the transcript (its
1291// `active_goal` event is remote-only): synthetic command output names the
1292// goal, and each unmet evaluator check returns as Stop hook feedback.
1293const CLAUDE_GOAL_SET_PREFIX = "Goal set: ";
1294const CLAUDE_GOAL_ACTIVE = /^Goal active: ([\s\S]+?) \((?:not yet evaluated|(\d+) turns?)\)/u;
1295
1296/**
1297 * The goal after one root SDK frame, or undefined when the frame says nothing
1298 * about it. Completion has no frame of its own; see finalizeActiveTurn.
1299 */
1300function nextClaudeGoal(
1301 current: OrchestrationV2ProviderGoal | null,
1302 message: SDKMessage,
1303): OrchestrationV2ProviderGoal | null | undefined {
1304 if (message.type === "assistant") {
1305 if (message.parent_tool_use_id !== null || message.message.model !== "<synthetic>") {
1306 return undefined;
1307 }
1308 const text = textFromClaudeContent(message.message.content).trim();
1309 if (text.startsWith(CLAUDE_GOAL_SET_PREFIX)) {
1310 const objective = text.slice(CLAUDE_GOAL_SET_PREFIX.length).trim();
1311 return objective.length === 0 ? undefined : { objective, status: "active", checks: 0 };
1312 }
1313 if (text.startsWith("Goal cleared: ") || text.startsWith("No goal set")) return null;
1314 const active = CLAUDE_GOAL_ACTIVE.exec(text);
1315 if (active?.[1] === undefined) return undefined;
1316 return {
1317 ...(current?.objective === active[1] ? current : {}),
1318 objective: active[1],
1319 status: "active",
1320 checks: Number(active[2] ?? 0),
1321 };
1322 }
1323 if (
1324 message.type !== "user" ||
1325 message.parent_tool_use_id !== null ||
1326 message.isSynthetic !== true ||
1327 current === null
1328 ) {
1329 return undefined;
1330 }
1331 const prefix = `Stop hook feedback:\n[${current.objective}]: `;
1332 const content = message.message.content;
1333 const text =
1334 typeof content === "string"
1335 ? content
1336 : content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
1337 if (!text.startsWith(prefix)) return undefined;
1338 return {
1339 ...current,
1340 status: "active",
1341 checks: (current.checks ?? 0) + 1,
1342 lastCheck: text.slice(prefix.length).trim(),
1343 };
1344}
1345
1346const providerGoalsEqual = Schema.toEquivalence(Schema.NullOr(OrchestrationV2ProviderGoal));
1347
1348/** Compares a subagent result with its routed text regardless of block joins. */
1349function normalizeClaudeResultText(text: string): string {
1350 return text.replace(/\s+/g, " ").trim();
1351}
1352
1353function assistantTextFromSdkMessage(
1354 message: SDKMessage,
1355): { readonly nativeItemId: string; readonly text: string } | null {
1356 if (message.type !== "assistant") {
1357 return null;
1358 }
1359 return {
1360 nativeItemId: message.uuid,
1361 text: textFromClaudeContent(message.message.content),
1362 };
1363}
1364
1365function resultTextFromSdkMessage(
1366 message: SDKMessage,
1367): { readonly nativeItemId: string; readonly text: string } | null {
1368 if (message.type !== "result" || message.subtype !== "success") {
1369 return null;
1370 }
1371 return {
1372 nativeItemId: message.uuid,
1373 text: message.result,
1374 };
1375}
1376
1377function makeProviderThread(input: {
1378 readonly idAllocator: IdAllocator.IdAllocatorV2["Service"];
1379 readonly providerInstanceId: ProviderInstanceId;
1380 readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"];
1381 readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"];
1382 readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"];
1383 readonly nativeThreadId: string;
1384 readonly forkedFrom?: NonNullable<OrchestrationV2ProviderThread["forkedFrom"]>;
1385 readonly now: DateTime.Utc;
1386}): OrchestrationV2ProviderThread {
1387 return {
1388 id: input.idAllocator.derive.providerThread({
1389 driver: CLAUDE_PROVIDER,
1390 nativeThreadId: input.nativeThreadId,
1391 }),
1392 driver: CLAUDE_PROVIDER,
1393 providerInstanceId: input.providerInstanceId,
1394 providerSessionId: input.providerSessionId,
1395 appThreadId: input.appThreadId,
1396 ownerNodeId: input.ownerNodeId ?? null,
1397 nativeThreadRef: {
1398 driver: CLAUDE_PROVIDER,
1399 nativeId: input.nativeThreadId,
1400 strength: "strong",
1401 },
1402 nativeConversationHeadRef: null,
1403 status: "idle",
1404 firstRunOrdinal: null,
1405 lastRunOrdinal: null,
1406 handoffIds: [],
1407 forkedFrom: input.forkedFrom ?? null,
1408 createdAt: input.now,
1409 updatedAt: input.now,
1410 };
1411}
1412
1413const getNativeThreadId = Effect.fnUntraced(function* (
1414 providerThread: OrchestrationV2ProviderThread,
1415) {
1416 const nativeThreadId = providerThread.nativeThreadRef?.nativeId;
1417 if (nativeThreadId === undefined || nativeThreadId === null) {
1418 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
1419 driver: CLAUDE_PROVIDER,
1420 detail: `Provider thread ${providerThread.id} is missing a native Claude session id.`,
1421 });
1422 }
1423 return nativeThreadId;
1424});
1425
1426const isSyntheticClaudeTurnId = (nativeTurnId: string): boolean => nativeTurnId.startsWith("turn:");
1427
1428const isTerminalProviderTurn = (turn: OrchestrationV2ProviderTurn): boolean =>
1429 turn.status === "completed" ||
1430 turn.status === "interrupted" ||
1431 turn.status === "failed" ||
1432 turn.status === "cancelled";
1433
1434const getNativeConversationHeadId = Effect.fnUntraced(function* (
1435 providerThread: OrchestrationV2ProviderThread,
1436) {
1437 const nativeHeadRef = providerThread.nativeConversationHeadRef;
1438 if (nativeHeadRef === null) {
1439 return undefined;
1440 }
1441 if (nativeHeadRef.driver !== CLAUDE_PROVIDER) {
1442 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
1443 driver: CLAUDE_PROVIDER,
1444 detail: `Provider thread ${providerThread.id} has a non-Claude native conversation head reference.`,
1445 });
1446 }
1447 if (nativeHeadRef.nativeId === null) {
1448 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
1449 driver: CLAUDE_PROVIDER,
1450 detail: `Provider thread ${providerThread.id} has a Claude native conversation head reference without a native id.`,
1451 });
1452 }
1453 return nativeHeadRef.nativeId;
1454});
1455
1456const resolveClaudeForkUpToMessageId = Effect.fn("ClaudeAdapterV2.resolveForkUpToMessageId")(
1457 function* (input: ProviderAdapter.ProviderAdapterV2ForkThreadInput) {
1458 if (input.providerTurnId === undefined || input.sourceProviderTurns === undefined) {
1459 return undefined;
1460 }
1461
1462 const sourceTurns = input.sourceProviderTurns
1463 .filter((turn) => turn.providerThreadId === input.sourceProviderThread.id)
1464 .toSorted((left, right) => left.ordinal - right.ordinal);
1465 const boundaryIndex = sourceTurns.findIndex((turn) => turn.id === input.providerTurnId);
1466 if (boundaryIndex < 0) {
1467 return yield* new ProviderAdapter.ProviderAdapterForkThreadError({
1468 driver: CLAUDE_PROVIDER,
1469 providerThreadId: input.sourceProviderThread.id,
1470 cause: `Cannot fork Claude thread from provider turn ${input.providerTurnId}: source turn was not found in provider thread ${input.sourceProviderThread.id}.`,
1471 });
1472 }
1473
1474 const boundaryNativeId = sourceTurns[boundaryIndex]?.nativeTurnRef?.nativeId;
1475 if (
1476 boundaryNativeId !== undefined &&
1477 boundaryNativeId !== null &&
1478 !isSyntheticClaudeTurnId(boundaryNativeId)
1479 ) {
1480 return boundaryNativeId;
1481 }
1482
1483 const terminalTurnsAfterBoundary = sourceTurns
1484 .slice(boundaryIndex + 1)
1485 .filter(isTerminalProviderTurn);
1486 if (terminalTurnsAfterBoundary.length === 0) {
1487 return undefined;
1488 }
1489
1490 return yield* new ProviderAdapter.ProviderAdapterForkThreadError({
1491 driver: CLAUDE_PROVIDER,
1492 providerThreadId: input.sourceProviderThread.id,
1493 cause: `Cannot fork Claude thread from prior provider turn ${input.providerTurnId}: no SDK assistant message cursor was recorded for that turn.`,
1494 });
1495 },
1496);
1497
1498const resolveClaudeRollbackResumeSessionAt = Effect.fn(
1499 "ClaudeAdapterV2.resolveRollbackResumeSessionAt",
1500)(function* (input: ProviderAdapter.ProviderAdapterV2RollbackThreadInput) {
1501 switch (input.target.type) {
1502 case "thread_start":
1503 return null;
1504 case "provider_turn": {
1505 const target = input.target;
1506 if (target.providerTurn.providerThreadId !== input.providerThread.id) {
1507 return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({
1508 driver: CLAUDE_PROVIDER,
1509 providerThreadId: input.providerThread.id,
1510 cause: `Cannot roll back Claude thread ${input.providerThread.id} to provider turn ${target.providerTurn.id}: target turn belongs to provider thread ${target.providerTurn.providerThreadId}.`,
1511 });
1512 }
1513
1514 const nativeTurnRef = target.providerTurn.nativeTurnRef;
1515 if (
1516 nativeTurnRef !== null &&
1517 nativeTurnRef.driver === CLAUDE_PROVIDER &&
1518 nativeTurnRef.nativeId !== null &&
1519 !isSyntheticClaudeTurnId(nativeTurnRef.nativeId)
1520 ) {
1521 return nativeTurnRef.nativeId;
1522 }
1523
1524 const providerTurnsAfterTarget = input.providerThreadTurns.filter(
1525 (turn) => turn.ordinal > target.providerTurn.ordinal && isTerminalProviderTurn(turn),
1526 );
1527 if (providerTurnsAfterTarget.length === 0) {
1528 return null;
1529 }
1530
1531 return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({
1532 driver: CLAUDE_PROVIDER,
1533 providerThreadId: input.providerThread.id,
1534 cause: `Cannot roll back Claude thread ${input.providerThread.id} to provider turn ${target.providerTurn.id}: no SDK assistant message cursor was recorded for that turn.`,
1535 });
1536 }
1537 }
1538});
1539
1540type ClaudeUserContent = SDKUserMessage["message"]["content"];
1541type ClaudeUserContentBlock = Exclude<ClaudeUserContent, string>[number];
1542
1543const SUPPORTED_CLAUDE_IMAGE_MIME_TYPES = [
1544 "image/gif",
1545 "image/jpeg",
1546 "image/png",
1547 "image/webp",
1548] as const;
1549type SupportedClaudeImageMimeType = (typeof SUPPORTED_CLAUDE_IMAGE_MIME_TYPES)[number];
1550const supportedClaudeImageMimeTypes = new Set<string>(SUPPORTED_CLAUDE_IMAGE_MIME_TYPES);
1551
1552function isSupportedClaudeImageMimeType(
1553 mimeType: string,
1554): mimeType is SupportedClaudeImageMimeType {
1555 return supportedClaudeImageMimeTypes.has(mimeType);
1556}
1557
1558export function makeClaudeUserMessage(input: {
1559 readonly text: string;
1560 readonly priority?: SDKUserMessage["priority"];
1561 readonly skillNames?: ReadonlySet<string>;
1562 // Claude echoes it as user_message_uuid on the turn that answers it.
1563 readonly uuid?: SDKUserMessage["uuid"];
1564}): SDKUserMessage {
1565 // Claude Code expands a skill only from the LAST text block, and only when
1566 // `/name` is its first character. A `$skill` chip anywhere in the prompt is
1567 // therefore split into [leading text, "/name trailing text"] so the CLI
1568 // runs it natively and the prose around it survives. See ClaudeSkillDispatch.
1569 const dispatch =
1570 input.skillNames === undefined
1571 ? undefined
1572 : planClaudeSkillDispatch(input.text, input.skillNames);
1573 return {
1574 type: "user",
1575 message: {
1576 role: "user",
1577 content: dispatch
1578 ? [
1579 ...(dispatch.leadingText === undefined
1580 ? []
1581 : [{ type: "text" as const, text: dispatch.leadingText }]),
1582 { type: "text" as const, text: dispatch.commandText },
1583 ]
1584 : input.text,
1585 },
1586 parent_tool_use_id: null,
1587 ...(input.priority === undefined ? {} : { priority: input.priority }),
1588 ...(input.uuid === undefined ? {} : { uuid: input.uuid }),
1589 };
1590}
1591
1592const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: {
1593 readonly text: string;
1594 readonly attachments: ReadonlyArray<ChatAttachment>;
1595 readonly priority?: SDKUserMessage["priority"];
1596 readonly uuid?: SDKUserMessage["uuid"];
1597 readonly attachmentsDir: string;
1598 readonly fileSystem: FileSystem.FileSystem;
1599 readonly skillNames?: ReadonlySet<string>;
1600}) {
1601 if (input.attachments.length === 0) {
1602 return makeClaudeUserMessage({
1603 text: input.text,
1604 ...(input.skillNames === undefined ? {} : { skillNames: input.skillNames }),
1605 ...(input.priority === undefined ? {} : { priority: input.priority }),
1606 ...(input.uuid === undefined ? {} : { uuid: input.uuid }),
1607 });
1608 }
1609
1610 // The model's tools cannot dereference inlined pixels. Appending the
1611 // on-disk path is what lets a turn like "include this screenshot in the
1612 // PR" copy the actual file (the query grants attachmentsDir for reads).
1613 const textWithAttachmentPaths = providerMessageTextWithAttachmentPaths({
1614 text: input.text,
1615 attachments: input.attachments,
1616 resolveAttachmentPath: (attachment) =>
1617 resolveAttachmentPath({ attachmentsDir: input.attachmentsDir, attachment }),
1618 });
1619
1620 const dispatch =
1621 input.skillNames === undefined
1622 ? undefined
1623 : planClaudeSkillDispatch(textWithAttachmentPaths, input.skillNames);
1624 const content: Array<ClaudeUserContentBlock> = [];
1625 if (dispatch?.leadingText !== undefined) {
1626 content.push({ type: "text", text: dispatch.leadingText });
1627 }
1628
1629 for (const attachment of input.attachments) {
1630 if (attachment.type === "file") {
1631 continue;
1632 }
1633 if (!isSupportedClaudeImageMimeType(attachment.mimeType)) {
1634 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
1635 driver: CLAUDE_PROVIDER,
1636 detail: `Unsupported Claude image attachment type '${attachment.mimeType}'`,
1637 });
1638 }
1639
1640 const attachmentPath = resolveAttachmentPath({
1641 attachmentsDir: input.attachmentsDir,
1642 attachment,
1643 });
1644 if (attachmentPath === null) {
1645 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
1646 driver: CLAUDE_PROVIDER,
1647 detail: `Invalid attachment id '${attachment.id}'`,
1648 });
1649 }
1650
1651 const bytes = yield* input.fileSystem.readFile(attachmentPath).pipe(
1652 Effect.mapError(
1653 (cause) =>
1654 new ProviderAdapter.ProviderAdapterProtocolError({
1655 driver: CLAUDE_PROVIDER,
1656 detail: `Failed to read attachment '${attachment.id}'`,
1657 payload: cause,
1658 }),
1659 ),
1660 );
1661 content.push({
1662 type: "image",
1663 source: {
1664 type: "base64",
1665 media_type: attachment.mimeType,
1666 data: Buffer.from(bytes).toString("base64"),
1667 },
1668 });
1669 }
1670
1671 // Images go before the command block: a text block after them still
1672 // expands, a command block followed by an image does not.
1673 if (dispatch) {
1674 content.push({ type: "text", text: dispatch.commandText });
1675 } else if (textWithAttachmentPaths.length > 0) {
1676 content.push({ type: "text", text: textWithAttachmentPaths });
1677 }
1678
1679 return {
1680 type: "user",
1681 message: {
1682 role: "user",
1683 content,
1684 },
1685 parent_tool_use_id: null,
1686 ...(input.priority === undefined ? {} : { priority: input.priority }),
1687 ...(input.uuid === undefined ? {} : { uuid: input.uuid }),
1688 } satisfies SDKUserMessage;
1689});
1690
1691const sha256Hex = Effect.fnUntraced(function* (crypto: Crypto.Crypto, value: string) {
1692 return Hex.encode(
1693 yield* crypto.digest("SHA-256", new TextEncoder().encode(value)).pipe(Effect.orDie),
1694 );
1695});
1696
1697export function isClaudePromptUuid(value: string): value is NonNullable<SDKUserMessage["uuid"]> {
1698 return /^[^-]+-[^-]+-[^-]+-[^-]+-[^-]+$/.test(value);
1699}
1700
1701type ClaudeAssistantContentBlock = SDKAssistantMessage["message"]["content"][number];
1702type ClaudeToolUseContentBlock = Extract<
1703 ClaudeAssistantContentBlock,
1704 {
1705 readonly id: string;
1706 readonly name: string;
1707 readonly input: unknown;
1708 }
1709>;
1710type ClaudeAssistantToolResultContentBlock = Extract<
1711 ClaudeAssistantContentBlock,
1712 {
1713 readonly tool_use_id: string;
1714 }
1715>;
1716type ClaudeUserToolResultContentBlock = Extract<
1717 ClaudeUserContentBlock,
1718 {
1719 readonly tool_use_id: string;
1720 }
1721>;
1722type ClaudeToolResultContentBlock =
1723 | ClaudeAssistantToolResultContentBlock
1724 | ClaudeUserToolResultContentBlock;
1725type ClaudeTypedToolResultContentBlock = Exclude<
1726 ClaudeToolResultContentBlock,
1727 { readonly type: "mcp_tool_result" | "tool_result" }
1728>;
1729type ClaudeTypedToolResultContent = ClaudeTypedToolResultContentBlock["content"];
1730type ClaudeToolResultOutput =
1731 | Extract<ClaudeToolResultContentBlock, { readonly type: "tool_result" }>["content"]
1732 | Extract<ClaudeToolResultContentBlock, { readonly type: "mcp_tool_result" }>["content"]
1733 | ClaudeTypedToolResultContent;
1734
1735function assertNever(value: never): never {
1736 throw new Error(`Unhandled Claude SDK variant: ${jsonStringifyForTool(value)}`);
1737}
1738
1739const ClaudeRuntimeSandboxPolicyKind = Schema.Struct({
1740 type: Schema.Literals(["dangerFullAccess", "externalSandbox", "readOnly", "workspaceWrite"]),
1741});
1742type ClaudeRuntimeSandboxPolicy = typeof ClaudeRuntimeSandboxPolicyKind.Type;
1743type ClaudeRuntimeSandboxPolicyKindName = ClaudeRuntimeSandboxPolicy["type"];
1744const isClaudeRuntimeSandboxPolicyKind = Schema.is(ClaudeRuntimeSandboxPolicyKind);
1745
1746const ClaudeRuntimeReadOnlyFullAccessSandboxPolicy = Schema.Struct({
1747 type: Schema.Literal("readOnly"),
1748 access: Schema.Struct({
1749 type: Schema.Literal("fullAccess"),
1750 }),
1751});
1752const isClaudeRuntimeReadOnlyFullAccessSandboxPolicy = Schema.is(
1753 ClaudeRuntimeReadOnlyFullAccessSandboxPolicy,
1754);
1755
1756function sandboxPolicyKindForClaudeRuntimePolicy(
1757 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
1758): ClaudeRuntimeSandboxPolicyKindName | undefined {
1759 return runtimePolicy.sandboxPolicy !== undefined &&
1760 isClaudeRuntimeSandboxPolicyKind(runtimePolicy.sandboxPolicy)
1761 ? runtimePolicy.sandboxPolicy.type
1762 : undefined;
1763}
1764
1765function readOnlyPolicyAllowsGlobalReads(
1766 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
1767): boolean {
1768 return (
1769 runtimePolicy.sandboxPolicy !== undefined &&
1770 isClaudeRuntimeReadOnlyFullAccessSandboxPolicy(runtimePolicy.sandboxPolicy)
1771 );
1772}
1773
1774function permissionModeForClaudeRuntimePolicy(
1775 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
1776): PermissionMode {
1777 if (runtimePolicy.interactionMode === "plan") {
1778 return "plan";
1779 }
1780 if (runtimePolicy.approvalPolicy === "never") {
1781 switch (sandboxPolicyKindForClaudeRuntimePolicy(runtimePolicy)) {
1782 case "readOnly":
1783 return "dontAsk";
1784 case "dangerFullAccess":
1785 case "externalSandbox":
1786 return "bypassPermissions";
1787 case "workspaceWrite":
1788 case undefined:
1789 return runtimePolicy.runtimeMode === "approval-required"
1790 ? "dontAsk"
1791 : runtimePolicy.runtimeMode === "auto-accept-edits"
1792 ? "acceptEdits"
1793 : "bypassPermissions";
1794 }
1795 }
1796 if (runtimePolicy.approvalPolicy !== undefined && runtimePolicy.approvalPolicy !== "never") {
1797 return "default";
1798 }
1799
1800 switch (sandboxPolicyKindForClaudeRuntimePolicy(runtimePolicy)) {
1801 case "readOnly":
1802 return "dontAsk";
1803 case "dangerFullAccess":
1804 return runtimePolicy.runtimeMode === "approval-required" ? "default" : "bypassPermissions";
1805 case "externalSandbox":
1806 case "workspaceWrite":
1807 case undefined:
1808 break;
1809 }
1810
1811 switch (runtimePolicy.runtimeMode) {
1812 case "approval-required":
1813 return "default";
1814 case "auto-accept-edits":
1815 return "acceptEdits";
1816 case "auto":
1817 return "auto";
1818 case "full-access":
1819 return "bypassPermissions";
1820 }
1821}
1822
1823export interface ClaudeRuntimeQueryPolicy {
1824 readonly permissionMode: PermissionMode;
1825 readonly tools?: ClaudeAgentSdkQueryTools;
1826 readonly allowedTools?: ReadonlyArray<string>;
1827 readonly allowDangerouslySkipPermissions?: true;
1828 readonly installPermissionCallback: boolean;
1829}
1830
1831export function claudeRuntimeQueryPolicyForRuntimePolicy(
1832 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
1833): ClaudeRuntimeQueryPolicy {
1834 const permissionMode = permissionModeForClaudeRuntimePolicy(runtimePolicy);
1835 const readOnlyTools =
1836 sandboxPolicyKindForClaudeRuntimePolicy(runtimePolicy) === "readOnly"
1837 ? CLAUDE_READ_ONLY_ALLOWED_TOOLS
1838 : undefined;
1839 const allowedTools =
1840 readOnlyTools !== undefined && readOnlyPolicyAllowsGlobalReads(runtimePolicy)
1841 ? readOnlyTools
1842 : undefined;
1843 // acceptEdits approves edits before the callback runs; everything else it
1844 // leaves to the callback, which must ask rather than allow.
1845 const installPermissionCallback =
1846 runtimePolicy.approvalPolicy === undefined
1847 ? runtimePolicy.runtimeMode === "approval-required" ||
1848 runtimePolicy.runtimeMode === "auto-accept-edits"
1849 : runtimePolicy.approvalPolicy !== "never";
1850
1851 if (permissionMode === "plan") {
1852 return {
1853 permissionMode,
1854 ...(readOnlyTools === undefined ? {} : { tools: readOnlyTools }),
1855 ...(allowedTools === undefined ? {} : { allowedTools }),
1856 installPermissionCallback,
1857 };
1858 }
1859
1860 return {
1861 permissionMode,
1862 ...(readOnlyTools === undefined ? {} : { tools: readOnlyTools }),
1863 ...(allowedTools === undefined ? {} : { allowedTools }),
1864 ...(permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}),
1865 installPermissionCallback,
1866 };
1867}
1868
1869function shouldInstallClaudePermissionCallback(policy: ClaudeRuntimeQueryPolicy): boolean {
1870 return policy.installPermissionCallback;
1871}
1872
1873// Whether a tool's permission callback must ask the user before answering.
1874function requiresClaudeApproval(context: ActiveClaudeTurnContext): boolean {
1875 return shouldInstallClaudePermissionCallback(
1876 claudeRuntimeQueryPolicyForRuntimePolicy(context.input.runtimePolicy),
1877 );
1878}
1879
1880function claudeRuntimeQueryPolicyKey(policy: ClaudeRuntimeQueryPolicy): string {
1881 return JSON.stringify({
1882 permissionMode: policy.permissionMode,
1883 tools: policy.tools,
1884 allowedTools: policy.allowedTools,
1885 allowDangerouslySkipPermissions: policy.allowDangerouslySkipPermissions,
1886 installPermissionCallback: policy.installPermissionCallback,
1887 });
1888}
1889
1890// Live-query reuse must key on the effective allowlist (including the
1891// MCP-derived pre-approvals), not just the runtime policy: policies that
1892// share a policy key can still differ in MCP pre-approvals.
1893export function claudeEffectiveQueryPolicyKey(
1894 queryPolicy: ClaudeRuntimeQueryPolicy,
1895 mcpOverrides: {
1896 readonly allowedTools?: ReadonlyArray<string>;
1897 readonly mcpServers?: ClaudeQueryOptions["mcpServers"];
1898 },
1899): string {
1900 return JSON.stringify({
1901 runtimePolicy: claudeRuntimeQueryPolicyKey({
1902 ...queryPolicy,
1903 ...(mcpOverrides.allowedTools === undefined
1904 ? {}
1905 : { allowedTools: mcpOverrides.allowedTools }),
1906 }),
1907 mcpServers: mcpOverrides.mcpServers,
1908 });
1909}
1910
1911type ClaudeToolItemType = Extract<
1912 OrchestrationV2TurnItem["type"],
1913 "command_execution" | "file_change" | "dynamic_tool" | "web_search"
1914>;
1915
1916interface ClaudeToolClassification {
1917 readonly known: boolean;
1918 readonly normalizedName: string;
1919 readonly itemType: ClaudeToolItemType;
1920 readonly requestKind: ProviderRequestKind;
1921}
1922
1923function normalizedClaudeToolName(toolName: string): string {
1924 return toolName.toLowerCase().replaceAll(/[\s_-]/g, "");
1925}
1926
1927const CLAUDE_KNOWN_TOOL_CLASSIFICATIONS: Record<
1928 string,
1929 {
1930 readonly itemType: ClaudeToolItemType;
1931 readonly requestKind: ProviderRequestKind;
1932 }
1933> = {
1934 agent: { itemType: "dynamic_tool", requestKind: "command" },
1935 bash: { itemType: "command_execution", requestKind: "command" },
1936 edit: { itemType: "file_change", requestKind: "file-change" },
1937 glob: { itemType: "dynamic_tool", requestKind: "file-read" },
1938 grep: { itemType: "dynamic_tool", requestKind: "file-read" },
1939 ls: { itemType: "dynamic_tool", requestKind: "file-read" },
1940 monitor: { itemType: "dynamic_tool", requestKind: "command" },
1941 multiedit: { itemType: "file_change", requestKind: "file-change" },
1942 notebookedit: { itemType: "file_change", requestKind: "file-change" },
1943 read: { itemType: "dynamic_tool", requestKind: "file-read" },
1944 sendmessage: { itemType: "dynamic_tool", requestKind: "command" },
1945 task: { itemType: "dynamic_tool", requestKind: "command" },
1946 taskstop: { itemType: "dynamic_tool", requestKind: "command" },
1947 todowrite: { itemType: "dynamic_tool", requestKind: "command" },
1948 toolsearch: { itemType: "dynamic_tool", requestKind: "command" },
1949 webfetch: { itemType: "web_search", requestKind: "command" },
1950 websearch: { itemType: "web_search", requestKind: "command" },
1951 write: { itemType: "file_change", requestKind: "file-change" },
1952};
1953
1954export function classifyClaudeNativeTool(toolName: string): ClaudeToolClassification {
1955 const normalizedName = normalizedClaudeToolName(toolName);
1956 const known = CLAUDE_KNOWN_TOOL_CLASSIFICATIONS[normalizedName];
1957 return known === undefined
1958 ? {
1959 known: false,
1960 normalizedName,
1961 itemType: "dynamic_tool",
1962 requestKind: "command",
1963 }
1964 : {
1965 known: true,
1966 normalizedName,
1967 ...known,
1968 };
1969}
1970
1971function providerRequestKindFromClaudeTool(toolName: string): ProviderRequestKind {
1972 return classifyClaudeNativeTool(toolName).requestKind;
1973}
1974
1975function isClaudeWebSearchOutput(output: unknown): output is WebSearchOutput {
1976 return (
1977 typeof output === "object" &&
1978 output !== null &&
1979 typeof Reflect.get(output, "query") === "string" &&
1980 Array.isArray(Reflect.get(output, "results")) &&
1981 typeof Reflect.get(output, "durationSeconds") === "number"
1982 );
1983}
1984
1985const ClaudeNativeToolInputRecord = Schema.Record(Schema.String, Schema.Unknown);
1986type ClaudeNativeToolInputRecord = typeof ClaudeNativeToolInputRecord.Type;
1987const isClaudeNativeToolInputRecord = Schema.is(ClaudeNativeToolInputRecord);
1988
1989type ClaudeNativeToolInput =
1990 | {
1991 readonly type: "record";
1992 readonly value: ClaudeNativeToolInputRecord;
1993 }
1994 | {
1995 readonly type: "non_record";
1996 readonly value: unknown;
1997 };
1998
1999const EMPTY_CLAUDE_NATIVE_TOOL_INPUT = {
2000 type: "record",
2001 value: {},
2002} satisfies ClaudeNativeToolInput;
2003
2004function claudeNativeToolInputFromUnknown(input: unknown): ClaudeNativeToolInput {
2005 return isClaudeNativeToolInputRecord(input)
2006 ? { type: "record", value: input }
2007 : { type: "non_record", value: input };
2008}
2009
2010function claudeNativeToolInputFromRecord(input: Record<string, unknown>): ClaudeNativeToolInput {
2011 return { type: "record", value: input };
2012}
2013
2014function claudeNativeToolInputValue(input: ClaudeNativeToolInput): unknown {
2015 return input.value;
2016}
2017
2018function inputRecordValue(input: ClaudeNativeToolInput, key: string): unknown {
2019 return input.type === "record" ? input.value[key] : undefined;
2020}
2021
2022function firstStringInputField(
2023 input: ClaudeNativeToolInput,
2024 keys: ReadonlyArray<string>,
2025): string | undefined {
2026 for (const key of keys) {
2027 const value = inputRecordValue(input, key);
2028 if (typeof value === "string" && value.trim().length > 0) {
2029 return value.trim();
2030 }
2031 }
2032 return undefined;
2033}
2034
2035function jsonStringifyForTool(value: unknown): string {
2036 if (typeof value === "string") {
2037 return value;
2038 }
2039 return JSON.stringify(value) ?? String(value);
2040}
2041
2042function commandInputFromClaudeTool(toolName: string, input: ClaudeNativeToolInput): string {
2043 return (
2044 firstStringInputField(input, ["command", "cmd", "script"]) ??
2045 `${toolName}: ${jsonStringifyForTool(claudeNativeToolInputValue(input))}`
2046 );
2047}
2048
2049// Opaque non-subagent background work admitted onto the Waiting roster, by
2050// Claude SDK `task_type`, and the kind the roster names it by. Subagents
2051// project through the normal subagent lifecycle and must not be
2052// double-counted when background_tasks_changed includes them.
2053const CLAUDE_OPAQUE_BACKGROUND_TASK_KINDS: ReadonlyMap<
2054 string,
2055 Exclude<OrchestrationV2PendingBackgroundTask["kind"], "subagent">
2056> = new Map([["local_bash", "command"]]);
2057
2058function isClaudeOpaqueBackgroundTaskType(taskType: string | null | undefined): boolean {
2059 return typeof taskType === "string" && CLAUDE_OPAQUE_BACKGROUND_TASK_KINDS.has(taskType);
2060}
2061
2062function claudePendingBackgroundTask(input: {
2063 readonly taskId: string;
2064 readonly taskType: string | null;
2065 // Claude runs a Monitor as a local_bash task, so only the Monitor tool call
2066 // that started it tells it apart from a background Bash command.
2067 readonly startedByMonitor: boolean;
2068 readonly description: string | undefined;
2069}): OrchestrationV2PendingBackgroundTask {
2070 return {
2071 taskId: input.taskId,
2072 kind: input.startedByMonitor
2073 ? "monitor"
2074 : ((input.taskType === null
2075 ? undefined
2076 : CLAUDE_OPAQUE_BACKGROUND_TASK_KINDS.get(input.taskType)) ?? "background_task"),
2077 ...(input.description !== undefined && input.description.trim().length > 0
2078 ? { description: input.description }
2079 : {}),
2080 };
2081}
2082
2083function claudeTaskTypeFromSdkMessage(message: SDKMessage): string | null {
2084 if (typeof message !== "object" || message === null) {
2085 return null;
2086 }
2087 const taskType = Reflect.get(message, "task_type");
2088 return typeof taskType === "string" ? taskType : null;
2089}
2090
2091function isClaudeNonSubagentTask(message: SDKMessage): boolean {
2092 return isClaudeOpaqueBackgroundTaskType(claudeTaskTypeFromSdkMessage(message));
2093}
2094
2095function isClaudeBackgroundTasksChangedMessage(message: SDKMessage): boolean {
2096 return (
2097 message.type === "system" &&
2098 // Undeclared SDK subtype: full roster snapshot of live background tasks.
2099 (message.subtype as string) === "background_tasks_changed"
2100 );
2101}
2102
2103// Claude opens every turn it runs with a root `init` frame. Outside a T3 turn
2104// that turn is a wake, and `init` comes 20-110 ms after the notification that
2105// caused it but seconds before its first output (model thinking time).
2106function isClaudeTurnStartMessage(message: SDKMessage): boolean {
2107 return message.type === "system" && message.subtype === "init";
2108}
2109
2110function claudePendingBackgroundTasksFromRoster(
2111 roster: ReadonlyMap<string, OrchestrationV2PendingBackgroundTask>,
2112): ReadonlyArray<OrchestrationV2PendingBackgroundTask> {
2113 return Array.from(roster.values());
2114}
2115
2116function parseClaudeBackgroundTaskEntry(
2117 entry: unknown,
2118 monitorTasks: ReadonlyMap<string, unknown>,
2119): OrchestrationV2PendingBackgroundTask | null {
2120 if (entry === null || typeof entry !== "object") {
2121 return null;
2122 }
2123 const taskId = Reflect.get(entry, "task_id");
2124 if (typeof taskId !== "string" || taskId.length === 0) {
2125 return null;
2126 }
2127 const rawTaskType = Reflect.get(entry, "task_type");
2128 const taskType = typeof rawTaskType === "string" ? rawTaskType : null;
2129 // Mirror the incremental path: only opaque non-subagent types currently
2130 // supported for Waiting. Subagent/agent entries stay on the subagent path.
2131 if (!isClaudeOpaqueBackgroundTaskType(taskType)) {
2132 return null;
2133 }
2134 const description = Reflect.get(entry, "description");
2135 return claudePendingBackgroundTask({
2136 taskId,
2137 taskType,
2138 startedByMonitor: monitorTasks.has(taskId),
2139 description: typeof description === "string" ? description : undefined,
2140 });
2141}
2142
2143function fileNameFromClaudeTool(toolName: string, input: ClaudeNativeToolInput): string {
2144 return (
2145 firstStringInputField(input, ["file_path", "path", "filename", "fileName"]) ??
2146 `${toolName} result`
2147 );
2148}
2149
2150type ClaudeNativeToolOutput =
2151 | {
2152 readonly type: "none";
2153 }
2154 | {
2155 readonly type: "content_block";
2156 readonly value: ClaudeToolResultOutput;
2157 }
2158 | {
2159 readonly type: "structured_tool_use_result";
2160 readonly value: unknown;
2161 readonly fallbackValue?: ClaudeToolResultOutput;
2162 };
2163
2164const NO_CLAUDE_NATIVE_TOOL_OUTPUT = { type: "none" } satisfies ClaudeNativeToolOutput;
2165
2166function claudeNativeToolOutputFromToolResult(
2167 toolResult: ClaudeToolResultContentBlock,
2168): ClaudeNativeToolOutput {
2169 const value = outputFromClaudeToolResult(toolResult);
2170 return value === undefined ? NO_CLAUDE_NATIVE_TOOL_OUTPUT : { type: "content_block", value };
2171}
2172
2173function claudeNativeToolOutputFromStructuredResult(input: {
2174 readonly structuredOutput: unknown;
2175 readonly fallbackValue?: ClaudeToolResultOutput;
2176}): ClaudeNativeToolOutput {
2177 return {
2178 type: "structured_tool_use_result",
2179 value: input.structuredOutput,
2180 ...(input.fallbackValue === undefined ? {} : { fallbackValue: input.fallbackValue }),
2181 };
2182}
2183
2184function claudeNativeToolOutputValue(output: ClaudeNativeToolOutput): unknown | undefined {
2185 switch (output.type) {
2186 case "none":
2187 return undefined;
2188 case "content_block":
2189 case "structured_tool_use_result":
2190 return output.value;
2191 default:
2192 return assertNever(output);
2193 }
2194}
2195
2196function claudeNativeToolOutputText(output: ClaudeNativeToolOutput): string {
2197 const value = claudeNativeToolOutputValue(output);
2198 return typeof value === "string" ? value : value === undefined ? "" : jsonStringifyForTool(value);
2199}
2200
2201/**
2202 * Bash results arrive as `{ stdout, stderr, interrupted, ... }`; keep only the
2203 * text. A background run has empty streams, so keep its acknowledgement instead.
2204 */
2205function claudeCommandOutputText(output: ClaudeNativeToolOutput): string {
2206 const value = claudeNativeToolOutputValue(output);
2207 if (typeof value === "object" && value !== null) {
2208 const stdout = Reflect.get(value, "stdout");
2209 const stderr = Reflect.get(value, "stderr");
2210 if (typeof stdout === "string" || typeof stderr === "string") {
2211 const text = [stdout, stderr]
2212 .filter((part): part is string => typeof part === "string" && part.trim().length > 0)
2213 .join("\n");
2214 if (text.length > 0) return text;
2215 return output.type === "structured_tool_use_result" && output.fallbackValue !== undefined
2216 ? claudeSubagentResultText({ type: "content_block", value: output.fallbackValue })
2217 : "";
2218 }
2219 }
2220 return claudeNativeToolOutputText(output);
2221}
2222
2223function claudeSubagentResultText(output: ClaudeNativeToolOutput): string {
2224 const value = claudeNativeToolOutputValue(output);
2225 const content = Array.isArray(value)
2226 ? value
2227 : typeof value === "object" && value !== null && "content" in value
2228 ? value.content
2229 : undefined;
2230 if (Array.isArray(content)) {
2231 const text = content
2232 .flatMap((part) =>
2233 typeof part === "object" &&
2234 part !== null &&
2235 "type" in part &&
2236 part.type === "text" &&
2237 "text" in part &&
2238 typeof part.text === "string"
2239 ? [part.text]
2240 : [],
2241 )
2242 .join("\n");
2243 if (text.length > 0) {
2244 return text;
2245 }
2246 }
2247 return claudeNativeToolOutputText(output);
2248}
2249
2250function isClaudeSubagentAsyncLaunchAck(output: ClaudeNativeToolOutput): boolean {
2251 const value = claudeNativeToolOutputValue(output);
2252 if (typeof value === "object" && value !== null) {
2253 if ("isAsync" in value && value.isAsync === true) {
2254 return true;
2255 }
2256 if ("status" in value && value.status === "async_launched") {
2257 return true;
2258 }
2259 }
2260 return claudeSubagentResultText(output).startsWith("Async agent launched successfully.");
2261}
2262
2263const WEB_FETCH_SNIPPET_MAX_CHARS = 8_000;
2264
2265function webSearchPatternsFromClaudeTool(input: {
2266 readonly toolInput: ClaudeNativeToolInput;
2267 readonly output: ClaudeNativeToolOutput;
2268}): ReadonlyArray<string> {
2269 const output = claudeNativeToolOutputValue(input.output);
2270 const pattern =
2271 firstStringInputField(input.toolInput, ["query", "url", "pattern"]) ??
2272 (isClaudeWebSearchOutput(output) ? output.query : undefined);
2273 return pattern === undefined || pattern.trim().length === 0 ? [] : [pattern];
2274}
2275
2276function webSearchResultsFromClaudeOutput(
2277 output: ClaudeNativeToolOutput,
2278): ReadonlyArray<OrchestrationV2WebSearchResult> {
2279 const value = claudeNativeToolOutputValue(output);
2280 if (!isClaudeWebSearchOutput(value)) {
2281 return [];
2282 }
2283
2284 return value.results.flatMap((result) => {
2285 if (typeof result === "string") {
2286 return [];
2287 }
2288 return result.content.map((content) => ({
2289 title: content.title,
2290 url: content.url,
2291 }));
2292 });
2293}
2294
2295function summarizeClaudeToolRequest(toolName: string, input: ClaudeNativeToolInput): string {
2296 const command = firstStringInputField(input, ["command", "cmd", "script"]);
2297 if (command !== undefined) {
2298 return `${toolName}: ${command.slice(0, 400)}`;
2299 }
2300 const path = firstStringInputField(input, ["file_path", "path", "filename", "fileName"]);
2301 if (path !== undefined) {
2302 return `${toolName}: ${path.slice(0, 400)}`;
2303 }
2304 const serialized = jsonStringifyForTool(claudeNativeToolInputValue(input));
2305 return serialized.length <= 400
2306 ? `${toolName}: ${serialized}`
2307 : `${toolName}: ${serialized.slice(0, 397)}...`;
2308}
2309
2310function outputFromClaudeToolResult(
2311 toolResult: ClaudeToolResultContentBlock,
2312): ClaudeToolResultOutput | undefined {
2313 switch (toolResult.type) {
2314 case "tool_result":
2315 return toolResult.content;
2316 case "mcp_tool_result":
2317 return toolResult.content;
2318 case "bash_code_execution_tool_result":
2319 case "code_execution_tool_result":
2320 case "advisor_tool_result":
2321 case "text_editor_code_execution_tool_result":
2322 case "tool_search_tool_result":
2323 case "web_fetch_tool_result":
2324 case "web_search_tool_result":
2325 return toolResult.content;
2326 default:
2327 return assertNever(toolResult);
2328 }
2329}
2330
2331function isClaudeTypedToolResultErrorContent(content: ClaudeTypedToolResultContent): boolean {
2332 if (Array.isArray(content)) {
2333 return false;
2334 }
2335
2336 switch (content.type) {
2337 case "bash_code_execution_tool_result_error":
2338 case "code_execution_tool_result_error":
2339 case "text_editor_code_execution_tool_result_error":
2340 case "tool_search_tool_result_error":
2341 case "web_fetch_tool_result_error":
2342 case "web_search_tool_result_error":
2343 return true;
2344 default:
2345 return false;
2346 }
2347}
2348
2349function isClaudeToolResultError(toolResult: ClaudeToolResultContentBlock): boolean {
2350 switch (toolResult.type) {
2351 case "tool_result":
2352 return toolResult.is_error === true;
2353 case "mcp_tool_result":
2354 return toolResult.is_error;
2355 case "bash_code_execution_tool_result":
2356 case "code_execution_tool_result":
2357 case "advisor_tool_result":
2358 case "text_editor_code_execution_tool_result":
2359 case "tool_search_tool_result":
2360 case "web_fetch_tool_result":
2361 case "web_search_tool_result":
2362 return isClaudeTypedToolResultErrorContent(toolResult.content);
2363 default:
2364 return assertNever(toolResult);
2365 }
2366}
2367
2368function toolNameFromClaudeToolResult(toolResult: ClaudeToolResultContentBlock): string {
2369 switch (toolResult.type) {
2370 case "bash_code_execution_tool_result":
2371 return "bash_code_execution";
2372 case "code_execution_tool_result":
2373 return "code_execution";
2374 case "advisor_tool_result":
2375 return "advisor";
2376 case "mcp_tool_result":
2377 return "mcp_tool";
2378 case "text_editor_code_execution_tool_result":
2379 return "text_editor_code_execution";
2380 case "tool_result":
2381 return "tool";
2382 case "tool_search_tool_result":
2383 return "tool_search";
2384 case "web_fetch_tool_result":
2385 return "web_fetch";
2386 case "web_search_tool_result":
2387 return "web_search";
2388 default:
2389 return assertNever(toolResult);
2390 }
2391}
2392
2393function isClaudeAssistantToolResultContentBlock(
2394 part: ClaudeAssistantContentBlock,
2395): part is ClaudeAssistantToolResultContentBlock {
2396 return "tool_use_id" in part && typeof part.tool_use_id === "string";
2397}
2398
2399function isClaudeUserToolResultContentBlock(
2400 part: ClaudeUserContentBlock,
2401): part is ClaudeUserToolResultContentBlock {
2402 return "tool_use_id" in part && typeof part.tool_use_id === "string";
2403}
2404
2405function isClaudeToolUseContentBlock(
2406 part: ClaudeAssistantContentBlock,
2407): part is ClaudeToolUseContentBlock {
2408 return (
2409 "id" in part &&
2410 typeof part.id === "string" &&
2411 "name" in part &&
2412 typeof part.name === "string" &&
2413 "input" in part
2414 );
2415}
2416
2417function claudeToolUseBlocksFromAssistantMessage(
2418 message: SDKMessage,
2419): ReadonlyArray<ClaudeToolUseContentBlock> {
2420 if (message.type !== "assistant") {
2421 return [];
2422 }
2423 return message.message.content.filter(isClaudeToolUseContentBlock);
2424}
2425
2426type ClaudeToolPresentation = ReturnType<typeof mcpToolPresentation>;
2427
2428function claudeToolPresentationsFromAssistantMessage(
2429 message: SDKMessage,
2430): ReadonlyMap<string, ClaudeToolPresentation> {
2431 const presentations = new Map<string, ClaudeToolPresentation>();
2432 const meta = message.type === "assistant" ? Reflect.get(message, "tool_use_meta") : undefined;
2433 if (!Array.isArray(meta)) return presentations;
2434 const toolNames = new Map(
2435 claudeToolUseBlocksFromAssistantMessage(message).map((tool) => [
2436 tool.id,
2437 tool.name.replace(/^mcp__claude_ai_/u, "mcp__"),
2438 ]),
2439 );
2440 for (const entry of meta) {
2441 if (typeof entry !== "object" || entry === null) continue;
2442 const id = normalizeMcpText(Reflect.get(entry, "id"), 512);
2443 if (id === undefined) continue;
2444 presentations.set(
2445 id,
2446 mcpToolPresentation({
2447 toolName: toolNames.get(id),
2448 title: Reflect.get(entry, "display_name"),
2449 serverDisplayName: Reflect.get(entry, "server_display_name"),
2450 iconUrl: Reflect.get(entry, "icon_url"),
2451 }),
2452 );
2453 }
2454 return presentations;
2455}
2456
2457function claudeToolResultBlocksFromAssistantMessage(
2458 message: SDKMessage,
2459): ReadonlyArray<ClaudeToolResultContentBlock> {
2460 if (message.type !== "assistant") {
2461 return [];
2462 }
2463 return message.message.content.filter(isClaudeAssistantToolResultContentBlock);
2464}
2465
2466function claudeToolResultBlocksFromUserMessage(
2467 message: SDKMessage,
2468): ReadonlyArray<ClaudeToolResultContentBlock> {
2469 if (message.type !== "user" || typeof message.message.content === "string") {
2470 return [];
2471 }
2472 return message.message.content.filter(isClaudeUserToolResultContentBlock);
2473}
2474
2475function claudeToolResultEntriesFromMessage(message: SDKMessage): ReadonlyArray<{
2476 readonly toolResult: ClaudeToolResultContentBlock;
2477 readonly output: ClaudeNativeToolOutput;
2478}> {
2479 const assistantResults = claudeToolResultBlocksFromAssistantMessage(message).map(
2480 (toolResult) => ({ toolResult, output: claudeNativeToolOutputFromToolResult(toolResult) }),
2481 );
2482 const userResults = claudeToolResultBlocksFromUserMessage(message);
2483 const structuredOutput =
2484 message.type === "user" && userResults.length === 1 ? message.tool_use_result : undefined;
2485 return [
2486 ...assistantResults,
2487 ...userResults.map((toolResult) => ({
2488 toolResult,
2489 output:
2490 structuredOutput === undefined
2491 ? claudeNativeToolOutputFromToolResult(toolResult)
2492 : claudeNativeToolOutputFromStructuredResult({
2493 structuredOutput,
2494 fallbackValue: outputFromClaudeToolResult(toolResult),
2495 }),
2496 })),
2497 ];
2498}
2499
2500// These wire fields are not yet declared by the SDK's SDKUserMessage type.
2501const ClaudeToolResultMetadata = Schema.Struct({
2502 tool_result_meta: Schema.Array(
2503 Schema.Struct({
2504 id: Schema.String,
2505 non_execution_kind: Schema.optional(Schema.NullOr(Schema.String)),
2506 }),
2507 ),
2508});
2509const isClaudeToolResultMetadata = Schema.is(ClaudeToolResultMetadata);
2510
2511function claudeToolNonExecutionKind(message: SDKMessage, toolUseId: string) {
2512 return isClaudeToolResultMetadata(message)
2513 ? (message.tool_result_meta.find((meta) => meta.id === toolUseId)?.non_execution_kind ??
2514 undefined)
2515 : undefined;
2516}
2517
2518function parentToolUseIdFromSdkMessage(message: SDKMessage): string | null {
2519 return message.type === "assistant" || message.type === "user"
2520 ? message.parent_tool_use_id
2521 : null;
2522}
2523
2524export function permissionResultFromDecision(input: {
2525 readonly toolName: string;
2526 readonly decision: ProviderApprovalDecision;
2527 readonly toolInput: Record<string, unknown>;
2528 readonly toolUseID: string;
2529 readonly suggestions?: Parameters<CanUseTool>[2]["suggestions"];
2530}): PermissionResult {
2531 if (input.decision === "accept" || input.decision === "acceptForSession") {
2532 return {
2533 behavior: "allow",
2534 updatedInput: input.toolInput,
2535 toolUseID: input.toolUseID,
2536 decisionClassification:
2537 input.decision === "acceptForSession" ? "user_permanent" : "user_temporary",
2538 ...(input.decision === "acceptForSession"
2539 ? {
2540 updatedPermissions: toSessionPermissionUpdates(input.toolName, input.suggestions),
2541 }
2542 : {}),
2543 };
2544 }
2545
2546 return {
2547 behavior: "deny",
2548 message:
2549 input.decision === "cancel"
2550 ? "User cancelled tool execution."
2551 : "User declined tool execution.",
2552 toolUseID: input.toolUseID,
2553 decisionClassification: "user_reject",
2554 ...(input.decision === "cancel" ? { interrupt: true } : {}),
2555 };
2556}
2557
2558function toSessionPermissionUpdates(
2559 toolName: string,
2560 suggestions: ReadonlyArray<PermissionUpdate> | undefined,
2561): Array<PermissionUpdate> {
2562 const updates = (suggestions ?? []).map((suggestion): PermissionUpdate => ({
2563 ...suggestion,
2564 destination: "session",
2565 }));
2566 if (updates.length > 0) {
2567 return updates;
2568 }
2569 return [
2570 {
2571 type: "addRules",
2572 rules: [{ toolName }],
2573 behavior: "allow",
2574 destination: "session",
2575 },
2576 ];
2577}
2578
2579export const awaitClaudeApprovalDecision = Effect.fn("awaitClaudeApprovalDecision")(function* (
2580 decision: Deferred.Deferred<ProviderApprovalDecision>,
2581 signal: AbortSignal,
2582) {
2583 const cancellation = Effect.callback<ProviderApprovalDecision>((resume) => {
2584 const abort = () => resume(Effect.succeed("cancel"));
2585 signal.addEventListener("abort", abort, { once: true });
2586 if (signal.aborted) {
2587 abort();
2588 }
2589 return Effect.sync(() => signal.removeEventListener("abort", abort));
2590 });
2591 return yield* Effect.raceFirst(Deferred.await(decision), cancellation);
2592});
2593
2594const awaitClaudeUserInputAnswers = Effect.fn("awaitClaudeUserInputAnswers")(function* (
2595 answers: Deferred.Deferred<ProviderUserInputAnswers>,
2596 signal: AbortSignal,
2597) {
2598 const cancellation = Effect.callback<ProviderUserInputAnswers>((resume) => {
2599 const abort = () => resume(Effect.succeed({}));
2600 signal.addEventListener("abort", abort, { once: true });
2601 if (signal.aborted) abort();
2602 return Effect.sync(() => signal.removeEventListener("abort", abort));
2603 });
2604 return yield* Effect.raceFirst(Deferred.await(answers), cancellation);
2605});
2606
2607/**
2608 * First user-facing error from a non-success result. "[ede_diagnostic] ..."
2609 * entries are CLI-internal telemetry (the CLI hides them from its own UI too),
2610 * so they must never become the error banner (#5557).
2611 */
2612function resultUserFacingError(result: SDKResultMessage): string | undefined {
2613 const errors = "errors" in result && Array.isArray(result.errors) ? result.errors : [];
2614 if (result.subtype === "success" && !result.is_error) {
2615 return undefined;
2616 }
2617 return errors.find(
2618 (error): error is string => typeof error === "string" && !error.startsWith("[ede_diagnostic]"),
2619 );
2620}
2621
2622function terminalResultError(
2623 reason: SDKResultMessage["terminal_reason"],
2624 failureHint?: string,
2625): string | undefined {
2626 switch (reason) {
2627 case "api_error":
2628 return failureHint ?? "Claude gave up after repeated API errors.";
2629 case "malformed_tool_use_exhausted":
2630 return "Claude gave up after repeated malformed tool calls.";
2631 case "budget_exhausted":
2632 return "Claude stopped: the turn's token budget was exhausted.";
2633 case "structured_output_retry_exhausted":
2634 return "Claude could not produce the requested structured output.";
2635 case "tool_deferred_unavailable":
2636 return "Claude could not resume a deferred tool call: the tool is no longer available.";
2637 case "turn_setup_failed":
2638 return "Claude could not start the turn.";
2639 case "blocking_limit":
2640 return "Claude stopped: a usage limit blocked the request.";
2641 case "rapid_refill_breaker":
2642 return "Claude stopped: the context refilled too quickly after compaction.";
2643 case "prompt_too_long":
2644 return "Claude stopped: the prompt exceeds the model's context window.";
2645 case "image_error":
2646 return "Claude stopped: an image in the conversation could not be processed.";
2647 case "model_error":
2648 return "Claude stopped: the model returned an error.";
2649 default:
2650 return undefined;
2651 }
2652}
2653
2654function isOverloadedResult(result: SDKResultMessage): boolean {
2655 return result.subtype === "success" && result.api_error_status === 529;
2656}
2657
2658function terminalStatusFromResult(
2659 message: SDKResultMessage,
2660 failureHint?: string,
2661): Extract<
2662 OrchestrationV2ProviderTurn["status"],
2663 "completed" | "interrupted" | "failed" | "cancelled"
2664> {
2665 // The CLI can label an abort as success with is_error=false. Its explicit
2666 // terminal reason takes precedence over that envelope.
2667 if (
2668 message.terminal_reason === "aborted_tools" ||
2669 message.terminal_reason === "aborted_streaming"
2670 ) {
2671 return "interrupted";
2672 }
2673 if (message.subtype === "success") {
2674 // The SDK reports API-level failures (401 auth, 529 overloaded, …) as
2675 // subtype "success" with is_error set; the turn produced no real work.
2676 return isOverloadedResult(message) ||
2677 message.api_error_status === 429 ||
2678 terminalResultError(message.terminal_reason, failureHint) !== undefined ||
2679 (message.is_error && failureHint !== undefined)
2680 ? "failed"
2681 : "completed";
2682 }
2683 const errorText = message.errors.join("\n").toLowerCase();
2684 if (errorText.includes("interrupt")) {
2685 return "interrupted";
2686 }
2687 if (errorText.includes("cancel")) {
2688 return "cancelled";
2689 }
2690 return "failed";
2691}
2692
2693function isClaudeActiveSteeringAbortResult(message: SDKResultMessage): boolean {
2694 return (
2695 message.terminal_reason === "aborted_streaming" || message.terminal_reason === "aborted_tools"
2696 );
2697}
2698
2699function isClaudeProviderContinuationTurn(
2700 input: ProviderAdapter.ProviderAdapterV2TurnInput,
2701): boolean {
2702 return input.message.createdBy === "agent" && input.message.creationSource === "provider";
2703}
2704
2705// Root turn output whose owner (the offered prompt or a queued wake turn)
2706// the prompt echo decides. Subagent frames, lifecycle frames, and anything
2707// else not produced by the root model turn pass straight through.
2708function isClaudePromptEchoGatedFrame(message: SDKMessage): boolean {
2709 switch (message.type) {
2710 case "assistant":
2711 case "stream_event":
2712 case "user":
2713 return message.parent_tool_use_id === null;
2714 case "result":
2715 return true;
2716 default:
2717 return false;
2718 }
2719}
2720
2721// The tool_use a non-root frame belongs to: a subagent frame's parent, or a
2722// task lifecycle frame's tool_use_id.
2723function claudeFrameToolUseIds(message: SDKMessage): ReadonlyArray<string> {
2724 const ids: Array<string> = [];
2725 const parent = Reflect.get(message, "parent_tool_use_id");
2726 if (typeof parent === "string") {
2727 ids.push(parent);
2728 }
2729 if (message.type === "system") {
2730 const toolUseId = Reflect.get(message, "tool_use_id");
2731 if (typeof toolUseId === "string") {
2732 ids.push(toolUseId);
2733 }
2734 }
2735 return ids;
2736}
2737
2738function claudeEchoedPromptUuids(message: SDKMessage): ReadonlyArray<string> {
2739 const uuids = Reflect.get(message, "user_message_uuids");
2740 if (Array.isArray(uuids)) {
2741 return uuids.filter((uuid): uuid is string => typeof uuid === "string");
2742 }
2743 const uuid = Reflect.get(message, "user_message_uuid");
2744 return typeof uuid === "string" ? [uuid] : [];
2745}
2746
2747// The prompt a command_lifecycle frame (queued, started, completed)
2748// acknowledges. A CLI that sends them also echoes that uuid on the result of
2749// the turn answering the prompt; one that does not keeps the old path.
2750function claudePromptLifecycle(
2751 message: SDKMessage,
2752): { readonly uuid: string; readonly state: string } | null {
2753 const type: unknown = Reflect.get(message, "type");
2754 if (type !== "command_lifecycle") return null;
2755 const uuid: unknown = Reflect.get(message, "command_uuid");
2756 const state: unknown = Reflect.get(message, "state");
2757 return typeof uuid === "string" && typeof state === "string" ? { uuid, state } : null;
2758}
2759
2760// A result that answers a turn other than the pending prompt's. An echo
2761// names the prompts its turn consumed. Without one, only a process known to
2762// echo proves the turn foreign by its non-human origin; on a CLI that never
2763// echoes, that result can be the prompt turn's only result.
2764function isClaudeResultForOtherTurn(input: {
2765 readonly message: SDKResultMessage;
2766 readonly promptUuid: string;
2767 readonly promptEchoMode: ClaudeLiveQueryContext["promptEchoMode"];
2768}): boolean {
2769 const echoed = claudeEchoedPromptUuids(input.message);
2770 if (echoed.length > 0) return !echoed.includes(input.promptUuid);
2771 return (
2772 input.promptEchoMode !== "unknown" &&
2773 input.message.origin !== undefined &&
2774 input.message.origin.kind !== "human"
2775 );
2776}
2777
2778function isClaudeTaskNotificationOriginResult(message: SDKMessage): message is SDKResultMessage & {
2779 readonly origin: Extract<
2780 NonNullable<SDKResultMessage["origin"]>,
2781 { readonly kind: "task-notification" }
2782 >;
2783} {
2784 return message.type === "result" && message.origin?.kind === "task-notification";
2785}
2786
2787function providerFailureFromResult(
2788 message: SDKResultMessage,
2789 completedAt: DateTime.Utc,
2790 failureHint?: string,
2791 usageLimited = false,
2792): OrchestrationV2ProviderFailure | null {
2793 // shortcut: accept 9router's wrapped 429 cooldown; prefer native reset metadata when available.
2794 const gatewayLimit =
2795 failureHint === undefined &&
2796 message.subtype === "success" &&
2797 message.is_error &&
2798 message.terminal_reason === "api_error" &&
2799 message.api_error_status === 503
2800 ? /^API Error: 503 \[[^\]\r\n]+\] \[429\]: \{"type":"error","error":\{"type":"rate_limit_error",[^\r\n]*\} \(reset after (?:(\d+)h ?)?(?:(\d+)m ?)?(?:(\d+)s)?\)/.exec(
2801 message.result,
2802 )
2803 : null;
2804 if (gatewayLimit) {
2805 const seconds =
2806 Number(gatewayLimit[1] ?? 0) * 3_600 +
2807 Number(gatewayLimit[2] ?? 0) * 60 +
2808 Number(gatewayLimit[3] ?? 0);
2809 const resetMs = DateTime.toEpochMillis(completedAt) + seconds * 1_000;
2810 if (seconds > 0 && resetMs < 8.64e15) {
2811 return makeProviderFailure({
2812 class: "usage_limit",
2813 message: "Claude API rate limit reached. Try again later.",
2814 code: "api_error_429",
2815 retryable: true,
2816 resetAt: DateTime.formatIso(DateTime.makeUnsafe(resetMs)),
2817 });
2818 }
2819 }
2820 const failureClass =
2821 message.terminal_reason === "blocking_limit" ||
2822 (message.subtype === "success" && message.api_error_status === 429) ||
2823 usageLimited
2824 ? "usage_limit"
2825 : "provider_error";
2826 const listedError = resultUserFacingError(message);
2827 const structuredError = isOverloadedResult(message)
2828 ? "Claude API is overloaded (529). Try again shortly."
2829 : message.subtype === "success" && message.api_error_status === 429
2830 ? "Claude API rate limit reached. Try again later."
2831 : terminalResultError(message.terminal_reason, failureHint);
2832 if (message.subtype !== "success") {
2833 return makeProviderFailure({
2834 message: listedError ?? structuredError ?? message.errors.join("\n"),
2835 code: message.subtype,
2836 class: failureClass,
2837 });
2838 }
2839 if (!message.is_error && structuredError === undefined) {
2840 return null;
2841 }
2842 const apiErrorStatus = message.api_error_status ?? null;
2843 return makeProviderFailure({
2844 message: listedError ?? structuredError ?? failureHint ?? message.result,
2845 code:
2846 apiErrorStatus === null
2847 ? (message.terminal_reason ?? "sdk_result_error")
2848 : `api_error_${apiErrorStatus}`,
2849 class: failureClass,
2850 retryable: apiErrorStatus === 429 || apiErrorStatus === 529 ? true : null,
2851 });
2852}
2853
2854function providerFailureFromApiRetry(message: SDKAPIRetryMessage): OrchestrationV2ProviderFailure {
2855 const errorName = message.error.replaceAll("_", " ");
2856 return makeProviderFailure({
2857 message: `Claude API ${errorName}.`,
2858 code:
2859 message.error_status === null
2860 ? message.error
2861 : `api_error_${Math.trunc(message.error_status)}`,
2862 class:
2863 message.error_status === 429
2864 ? "usage_limit"
2865 : message.error_status === null
2866 ? "transport_error"
2867 : "provider_error",
2868 retryable: true,
2869 });
2870}
2871
2872function buildAssistantArtifacts(input: {
2873 readonly idAllocator: IdAllocator.IdAllocatorV2["Service"];
2874 readonly turnInput: ProviderAdapter.ProviderAdapterV2TurnInput;
2875 readonly providerTurnId: OrchestrationV2ProviderTurn["id"];
2876 readonly nativeItemId: string;
2877 readonly text: string;
2878 readonly ordinal: number;
2879 readonly startedAt: DateTime.Utc;
2880 readonly completedAt: DateTime.Utc;
2881}): {
2882 readonly node: OrchestrationV2ExecutionNode;
2883 readonly message: OrchestrationV2ConversationMessage;
2884 readonly turnItem: OrchestrationV2TurnItem;
2885} {
2886 const nodeId = input.idAllocator.derive.nodeFromProviderItem({
2887 driver: CLAUDE_PROVIDER,
2888 nativeItemId: input.nativeItemId,
2889 });
2890 const messageId = input.idAllocator.derive.messageFromProviderItem({
2891 driver: CLAUDE_PROVIDER,
2892 nativeItemId: input.nativeItemId,
2893 });
2894 const turnItemId = input.idAllocator.derive.turnItemFromProviderItem({
2895 driver: CLAUDE_PROVIDER,
2896 nativeItemId: input.nativeItemId,
2897 });
2898 const nativeItemRef = {
2899 driver: CLAUDE_PROVIDER,
2900 nativeId: input.nativeItemId,
2901 strength: "strong" as const,
2902 };
2903
2904 return {
2905 node: {
2906 id: nodeId,
2907 threadId: input.turnInput.threadId,
2908 runId: input.turnInput.runId,
2909 parentNodeId: input.turnInput.rootNodeId,
2910 rootNodeId: input.turnInput.rootNodeId,
2911 kind: "assistant_message",
2912 status: "completed",
2913 countsForRun: false,
2914 providerThreadId: input.turnInput.providerThread.id,
2915 providerTurnId: input.providerTurnId,
2916 nativeItemRef,
2917 runtimeRequestId: null,
2918 checkpointScopeId: null,
2919 startedAt: input.startedAt,
2920 completedAt: input.completedAt,
2921 },
2922 message: {
2923 createdBy: "agent",
2924 creationSource: "provider",
2925 id: messageId,
2926 threadId: input.turnInput.threadId,
2927 runId: input.turnInput.runId,
2928 nodeId,
2929 role: "assistant",
2930 text: input.text,
2931 attachments: [],
2932 streaming: false,
2933 createdAt: input.completedAt,
2934 updatedAt: input.completedAt,
2935 },
2936 turnItem: {
2937 id: turnItemId,
2938 threadId: input.turnInput.threadId,
2939 runId: input.turnInput.runId,
2940 nodeId,
2941 providerThreadId: input.turnInput.providerThread.id,
2942 providerTurnId: input.providerTurnId,
2943 nativeItemRef,
2944 parentItemId: null,
2945 ordinal: input.ordinal,
2946 status: "completed",
2947 title: null,
2948 startedAt: input.startedAt,
2949 completedAt: input.completedAt,
2950 updatedAt: input.completedAt,
2951 type: "assistant_message",
2952 messageId,
2953 text: input.text,
2954 streaming: false,
2955 },
2956 };
2957}
2958
2959const CLAUDE_USAGE_LIMIT_WINDOWS = {
2960 five_hour: "5-hour",
2961 seven_day: "7-day",
2962 seven_day_opus: "7-day Opus",
2963 seven_day_sonnet: "7-day Sonnet",
2964 seven_day_overage_included: "7-day model",
2965 overage: "overage",
2966} satisfies Record<NonNullable<SDKRateLimitInfo["rateLimitType"]>, string>;
2967
2968/** Beyond this the reset time is not credible, so the row ships without a wait. */
2969const CLAUDE_USAGE_LIMIT_MAX_WAIT_MS = 30 * 24 * 60 * 60 * 1000;
2970
2971/**
2972 * `resetsAt` is epoch seconds. The row states the remaining wait rather than a
2973 * wall-clock time: this renders on the server, while the row is read on clients
2974 * that may sit in another timezone and locale, and that carry their own
2975 * timestamp preference. A wait reads the same everywhere.
2976 */
2977function describeClaudeUsageLimit(
2978 info: SDKRateLimitInfo,
2979 nowMs: number,
2980 names: ClaudeScopedLimitNames,
2981): string {
2982 const label =
2983 info.rateLimitType === "seven_day_overage_included" && names.overageIncluded
2984 ? `7-day ${names.overageIncluded}`
2985 : info.rateLimitType
2986 ? CLAUDE_USAGE_LIMIT_WINDOWS[info.rateLimitType]
2987 : undefined;
2988 const resetsAtMs = info.resetsAt === undefined ? undefined : info.resetsAt * 1000;
2989 const waitMs =
2990 resetsAtMs === undefined || !Number.isFinite(nowMs) ? undefined : resetsAtMs - nowMs;
2991 const wait =
2992 waitMs !== undefined && waitMs > 0 && waitMs <= CLAUDE_USAGE_LIMIT_MAX_WAIT_MS
2993 ? formatClaudeUsageLimitWait(waitMs)
2994 : undefined;
2995 return `Claude usage limit reached. This turn is paused until the ${
2996 label ? `${label} ` : ""
2997 }limit resets${wait ? ` in ${wait}` : ""}.`;
2998}
2999
3000function formatClaudeUsageLimitWait(waitMs: number): string {
3001 const totalMinutes = Math.ceil(waitMs / 60_000);
3002 const hours = Math.floor(totalMinutes / 60);
3003 const minutes = totalMinutes % 60;
3004 if (hours === 0) return `${totalMinutes}m`;
3005 return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
3006}
3007
3008interface ActiveClaudeTurnContext {
3009 readonly input: ProviderAdapter.ProviderAdapterV2TurnInput;
3010 readonly nativeThreadId: string;
3011 readonly nativeTurnId: string;
3012 nativeMessageCursor: string | null;
3013 readonly providerTurnId: OrchestrationV2ProviderTurn["id"];
3014 readonly providerTurnOrdinal: number;
3015 readonly startedAt: DateTime.Utc;
3016 // Item ordinals allocated in this turn. Later turns never look items up
3017 // here: a subagent resumed from another turn keeps its ordinal on the
3018 // session subagent registry.
3019 readonly itemOrdinals: Map<string, number>;
3020 readonly assistant: {
3021 fallbackText: string;
3022 fallbackNativeItemId: string;
3023 emittedNativeItemIds: Set<string>;
3024 };
3025 readonly reasoning: {
3026 messageId: string | null;
3027 readonly streamBlocks: Map<number, string>;
3028 readonly nextBlockIndex: Map<string, number>;
3029 readonly snapshotBlockIndex: Map<string, number>;
3030 readonly snapshots: Set<string>;
3031 // The latest snapshot block, when it was non-empty thinking.
3032 lastThinking: {
3033 readonly messageId: string;
3034 readonly itemId: string;
3035 readonly text: string;
3036 readonly followsThinking: boolean;
3037 } | null;
3038 readonly blocks: Map<
3039 string,
3040 { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string }
3041 >;
3042 };
3043 readonly toolCalls: Map<string, ActiveClaudeToolCall>;
3044 readonly ignoredTaskIds: Set<string>;
3045 readonly announcedUsageLimits: Set<string>;
3046 authenticationFailureMessage: string | undefined;
3047 readonly rejectedRateLimitTypes: Set<string>;
3048 readonly rateLimitResetTimes: Map<string, string | null>;
3049 latestAssistantRateLimited: boolean;
3050 // Usage of the results that steers ended, oldest first. The final result's
3051 // usage covers only its own segment, so the turn bills these as well.
3052 readonly steeredSegmentUsage: Array<SDKResultMessage["usage"]>;
3053 readonly subagentsByTaskId: Map<string, ActiveClaudeSubagent>;
3054 readonly subagentsByToolUseId: Map<string, ActiveClaudeSubagent>;
3055 readonly subagentNodesByTaskId: Map<string, OrchestrationV2ExecutionNode["id"]>;
3056 // Set on turns that offered a prompt. Claude runs a wake turn it queued
3057 // for background work before the next prompt's turn, and only the
3058 // prompt's turn echoes this uuid (see handleSdkMessage).
3059 readonly promptUuid: string | null;
3060 promptEcho: "pending" | "confirmed";
3061 // Claude acknowledged starting the prompt (command_lifecycle "started").
3062 promptStarted: boolean;
3063 // Root frames seen before the echo; held only when the CLI echoes early.
3064 gatedFramesBeforeEcho: number;
3065 readonly heldRootFrames: Array<SDKMessage>;
3066}
3067
3068interface ActiveClaudeProviderRetry {
3069 readonly retry: OrchestrationV2ProviderRetry;
3070 readonly failure: OrchestrationV2ProviderFailure;
3071 readonly startedAt: DateTime.Utc;
3072 readonly itemOrdinal: number;
3073}
3074
3075interface ActiveClaudeSubagent {
3076 task: OrchestrationV2Subagent;
3077 // Local parent-turn cleanup can end the card without a native task outcome.
3078 readonly confirmedOutcome: ProviderAdapter.ProviderAdapterV2SubagentOutcome | undefined;
3079 // The launch run's root node. task.parentNodeId is that same node, or the
3080 // owning subagent's node for a subagent another subagent started.
3081 readonly rootNodeId: OrchestrationV2ExecutionNode["id"];
3082 readonly childThreadId: ThreadId;
3083 /**
3084 * The child thread object itself, not just its id: workflow members are
3085 * parented to it long after the launching turn has settled, and building a
3086 * child thread needs the whole parent.
3087 */
3088 childThread: OrchestrationV2AppThread | null;
3089 readonly childRootNodeId: OrchestrationV2ExecutionNode["id"];
3090 readonly turnItemId: OrchestrationV2TurnItem["id"];
3091 readonly turnItemOrdinal: number;
3092 // The tool call that started the current run: the Agent launch, then each
3093 // SendMessage that resumes the subagent. A new one means a new prompt.
3094 readonly runToolUseId: string | null;
3095 // The API effort the subagent runs at: its Agent call's, else its owner's,
3096 // else the session's. Undefined when none is sent and the model default applies.
3097 readonly effort: string | undefined;
3098 nextChildItemOrdinal: number;
3099 resultItemOrdinal: number | null;
3100 // The subagent's latest assistant message routed into its child thread,
3101 // accumulated across the per-content-block snapshots that share one native
3102 // message id. A completion result equal to it is already on screen.
3103 lastAssistantText: string | null;
3104 lastAssistantMessageId: string | null;
3105}
3106
3107interface ClaudeLiveQueryContext {
3108 readonly nativeThreadId: string;
3109 readonly query: ClaudeAgentSdkQuerySession;
3110 readonly queryPolicyKey: string;
3111 // The selection this process runs now. Live changes update it.
3112 selection: CompiledClaudeModelSelection;
3113 readonly closed: Deferred.Deferred<void, never>;
3114 // Retained for process-exit cleanup even when its last turn failed.
3115 lastTurn: ActiveClaudeTurnContext | null;
3116 // Whether this CLI process echoes a prompt's uuid on the first frame of
3117 // the turn answering it ("early") or only on its result. Learned from the
3118 // first prompt turn. "acknowledged": the CLI confirmed it took a prompt's
3119 // uuid before any echo, so it echoes, but a resume's own turns can still
3120 // run ahead of that prompt.
3121 promptEchoMode: "unknown" | "acknowledged" | "early" | "result_only";
3122 // The mode this process was opened in, and the mode the CLI last reported
3123 // (init and status frames). Claude changes the latter itself through
3124 // EnterPlanMode.
3125 readonly openedPermissionMode: PermissionMode;
3126 permissionMode: PermissionMode;
3127 // Stop, rollback or fork is closing this process; its work is ending.
3128 stopping: boolean;
3129 // Registry entries still running when this process opened. Their process
3130 // is gone and never reports their end; any later task_started replaces the
3131 // entry, so an entry still in this set runs nowhere.
3132 readonly subagentsFromEarlierProcesses: ReadonlySet<ActiveClaudeSubagent>;
3133}
3134
3135interface ActiveClaudeToolCall {
3136 readonly nativeItemId: string;
3137 readonly toolName: string;
3138 readonly classification: ClaudeToolClassification;
3139 readonly input: ClaudeNativeToolInput;
3140 readonly threadId: ThreadId;
3141 readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null;
3142 readonly rootNodeId: OrchestrationV2ExecutionNode["id"];
3143 readonly parentNodeId: OrchestrationV2ExecutionNode["id"];
3144 readonly ordinal: number;
3145 readonly startedAt: DateTime.Utc;
3146 readonly presentation?: ClaudeToolPresentation;
3147}
3148
3149// What is known about a subagent before its task_started registers it,
3150// keyed by the tool_use_id that launches it.
3151interface PendingClaudeSubagentLaunch {
3152 readonly model?: string;
3153 readonly effort?: string;
3154 // parent_tool_use_id of the subagent whose own Agent call launches this one.
3155 readonly ownerToolUseId?: string;
3156}
3157
3158/**
3159 * Agent calls name aliases ("opus") and replies name dated ids; both resolve
3160 * to the catalog slug. A subagent's thread then keeps one model name across
3161 * its launch, its replies and a resume after a restart, so a later report of
3162 * the same model never reads as a model change that drops its effort. The
3163 * catalog is the instance's, so a custom model keeps its own name.
3164 */
3165function canonicalClaudeSubagentModel(catalog: ClaudeModelCatalog, model: string): string {
3166 return resolveClaudeModelSlug(catalog, model);
3167}
3168
3169/**
3170 * The selection a subagent's thread shows. An effort its model does not offer
3171 * is not sent for it, so it is left out (a nested subagent still inherits it).
3172 */
3173function claudeSubagentModelSelection(
3174 catalog: ClaudeModelCatalog,
3175 instanceId: ModelSelection["instanceId"],
3176 model: string,
3177 effort: string | undefined,
3178): ModelSelection {
3179 const descriptor = getClaudeCatalogModelCapabilities(catalog, model).optionDescriptors?.find(
3180 (candidate) => candidate.id === "effort",
3181 );
3182 const offered =
3183 effort !== undefined &&
3184 descriptor?.type === "select" &&
3185 descriptor.options.some((option) => option.id === effort);
3186 return { instanceId, model, ...(offered ? { options: [{ id: "effort", value: effort }] } : {}) };
3187}
3188
3189const PENDING_CLAUDE_SUBAGENT_CAP = 64;
3190// Per-subagent bound on frames held while waiting for task_started.
3191const PENDING_CLAUDE_SUBAGENT_FRAME_CAP = 256;
3192
3193function rememberPendingClaudeSubagentLaunch(
3194 pending: Map<string, PendingClaudeSubagentLaunch>,
3195 toolUseId: string,
3196 launch: PendingClaudeSubagentLaunch,
3197): void {
3198 pending.set(toolUseId, { ...pending.get(toolUseId), ...launch });
3199 if (pending.size <= PENDING_CLAUDE_SUBAGENT_CAP) {
3200 return;
3201 }
3202 const oldest = pending.keys().next();
3203 if (!oldest.done) {
3204 pending.delete(oldest.value);
3205 }
3206}
3207
3208/**
3209 * Agent calls carry model and effort overrides even when the SDK omits child
3210 * assistant snapshots. A subagent's own Agent call arrives only in its
3211 * snapshot, so the owner recorded here is all that links the subagent it
3212 * starts back to it.
3213 */
3214function rememberClaudeSubagentLaunch(
3215 catalog: ClaudeModelCatalog,
3216 context: ActiveClaudeTurnContext,
3217 pending: Map<string, PendingClaudeSubagentLaunch>,
3218 toolUseId: string,
3219 input: ClaudeNativeToolInput,
3220 ownerToolUseId: string | null,
3221): void {
3222 if (context.subagentsByToolUseId.has(toolUseId)) return;
3223 const requested = firstStringInputField(input, ["model"]);
3224 // "inherit" is the caller's model: the session's here, an owner's once
3225 // task_started resolves it.
3226 const model =
3227 requested !== "inherit"
3228 ? requested && canonicalClaudeSubagentModel(catalog, requested)
3229 : ownerToolUseId === null
3230 ? context.input.modelSelection.model
3231 : undefined;
3232 const effort = firstStringInputField(input, ["effort"]);
3233 // A model already known (a snapshot's, or an earlier sighting of this
3234 // call) wins over the requested one.
3235 const launch: PendingClaudeSubagentLaunch = {
3236 ...(model === undefined || pending.get(toolUseId)?.model !== undefined ? {} : { model }),
3237 ...(effort === undefined ? {} : { effort }),
3238 ...(ownerToolUseId === null ? {} : { ownerToolUseId }),
3239 };
3240 if (
3241 launch.model !== undefined ||
3242 launch.effort !== undefined ||
3243 launch.ownerToolUseId !== undefined
3244 ) {
3245 rememberPendingClaudeSubagentLaunch(pending, toolUseId, launch);
3246 }
3247}
3248
3249type PendingClaudeRuntimeRequest =
3250 | {
3251 readonly type: "approval";
3252 readonly requestId: OrchestrationV2RuntimeRequest["id"];
3253 readonly requestKind: ProviderRequestKind;
3254 readonly decision: Deferred.Deferred<ProviderApprovalDecision, never>;
3255 }
3256 | {
3257 readonly type: "user_input";
3258 readonly requestId: OrchestrationV2RuntimeRequest["id"];
3259 readonly answers: Deferred.Deferred<ProviderUserInputAnswers, never>;
3260 };
3261
3262export function claudeUserInputQuestions(
3263 input: unknown,
3264): ReadonlyArray<OrchestrationV2UserInputQuestion> {
3265 const value =
3266 typeof input === "object" && input !== null && Reflect.get(input, "type") === "record"
3267 ? Reflect.get(input, "value")
3268 : input;
3269 const questions =
3270 typeof value === "object" && value !== null ? Reflect.get(value, "questions") : undefined;
3271 if (!Array.isArray(questions)) return [];
3272 return questions.flatMap((value: unknown, index: number) => {
3273 if (typeof value !== "object" || value === null) return [];
3274 const record = value as Record<string, unknown>;
3275 const question = typeof record.question === "string" ? record.question.trim() : "";
3276 if (question.length === 0) return [];
3277 const header =
3278 typeof record.header === "string" && record.header.trim().length > 0
3279 ? record.header.trim()
3280 : `Question ${index + 1}`;
3281 const options = Array.isArray(record.options)
3282 ? record.options.flatMap((option) => {
3283 if (typeof option !== "object" || option === null) return [];
3284 const optionRecord = option as Record<string, unknown>;
3285 const label = typeof optionRecord.label === "string" ? optionRecord.label.trim() : "";
3286 if (label.length === 0) return [];
3287 return [
3288 {
3289 label,
3290 description:
3291 typeof optionRecord.description === "string" ? optionRecord.description.trim() : "",
3292 },
3293 ];
3294 })
3295 : [];
3296 return [
3297 {
3298 id: question,
3299 header,
3300 question,
3301 options,
3302 multiSelect: record.multiSelect === true,
3303 },
3304 ];
3305 });
3306}
3307
3308export function claudeSdkUserInputAnswers(
3309 answers: ProviderUserInputAnswers,
3310): NonNullable<AskUserQuestionInput["answers"]> {
3311 return Object.fromEntries(
3312 Object.entries(answers).map(([question, answer]) => [
3313 question,
3314 Array.isArray(answer)
3315 ? answer.filter((value): value is string => typeof value === "string").join(", ")
3316 : typeof answer === "string"
3317 ? answer
3318 : String(answer ?? ""),
3319 ]),
3320 );
3321}
3322
3323export function claudeTodoSteps(input: unknown): ReadonlyArray<OrchestrationV2PlanStep> {
3324 const value =
3325 typeof input === "object" && input !== null && Reflect.get(input, "type") === "record"
3326 ? Reflect.get(input, "value")
3327 : input;
3328 const todos =
3329 typeof value === "object" && value !== null ? Reflect.get(value, "todos") : undefined;
3330 if (!Array.isArray(todos)) return [];
3331 return todos.flatMap((todo, index) => {
3332 if (typeof todo !== "object" || todo === null) return [];
3333 const text =
3334 typeof Reflect.get(todo, "content") === "string"
3335 ? String(Reflect.get(todo, "content")).trim()
3336 : "";
3337 if (text.length === 0) return [];
3338 const nativeStatus = Reflect.get(todo, "status");
3339 return [
3340 {
3341 id: `todo-${index}`,
3342 text,
3343 status:
3344 nativeStatus === "completed"
3345 ? "completed"
3346 : nativeStatus === "in_progress"
3347 ? "running"
3348 : "pending",
3349 },
3350 ];
3351 });
3352}
3353
3354/** One native thread's Claude task list, keyed by Claude's task id, in creation order. */
3355type ClaudeTaskList = ReadonlyMap<string, Omit<OrchestrationV2PlanStep, "id">>;
3356
3357function claudeTaskField(value: unknown, key: string): unknown {
3358 return typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined;
3359}
3360
3361function claudeTaskString(value: unknown, key: string): string | undefined {
3362 const field = claudeTaskField(value, key);
3363 return typeof field === "string" && field.trim().length > 0 ? field.trim() : undefined;
3364}
3365
3366function claudeTaskStatus(value: unknown): OrchestrationV2PlanStep["status"] | undefined {
3367 switch (value) {
3368 case "pending":
3369 return "pending";
3370 case "in_progress":
3371 return "running";
3372 case "completed":
3373 return "completed";
3374 default:
3375 return undefined;
3376 }
3377}
3378
3379/**
3380 * Applies a finished TaskCreate, TaskUpdate or TaskList call to a task list.
3381 * Claude 5 tracks todos with these tools instead of TodoWrite. Ids come from
3382 * Claude (TaskCreate reports them in its result), a `deleted` status removes
3383 * the task, and TaskList replaces the whole list. Returns undefined when the
3384 * call is not a task tool or leaves the list unchanged.
3385 */
3386function applyClaudeTaskTool(
3387 tasks: ClaudeTaskList,
3388 toolName: string,
3389 input: unknown,
3390 result: unknown,
3391): ClaudeTaskList | undefined {
3392 if (toolName === "TaskList") {
3393 const listed = claudeTaskField(result, "tasks");
3394 if (!Array.isArray(listed)) return undefined;
3395 const next = new Map<string, Omit<OrchestrationV2PlanStep, "id">>();
3396 for (const task of listed) {
3397 const id = claudeTaskString(task, "id");
3398 const text = claudeTaskString(task, "subject");
3399 const status = claudeTaskStatus(claudeTaskField(task, "status"));
3400 if (id !== undefined && text !== undefined && status !== undefined) {
3401 next.set(id, { text, status });
3402 }
3403 }
3404 return next.size === 0 && tasks.size === 0 ? undefined : next;
3405 }
3406 if (toolName === "TaskCreate") {
3407 const created = claudeTaskField(result, "task");
3408 const id = claudeTaskString(created, "id");
3409 const text = claudeTaskString(created, "subject") ?? claudeTaskString(input, "subject");
3410 if (id === undefined || text === undefined) return undefined;
3411 return new Map(tasks).set(id, { text, status: "pending" });
3412 }
3413 if (toolName !== "TaskUpdate" || claudeTaskField(result, "success") === false) {
3414 return undefined;
3415 }
3416 const id = claudeTaskString(input, "taskId") ?? claudeTaskString(result, "taskId");
3417 const task = id === undefined ? undefined : tasks.get(id);
3418 if (id === undefined || task === undefined) return undefined;
3419 const nativeStatus = claudeTaskField(input, "status");
3420 if (nativeStatus === "deleted") {
3421 const next = new Map(tasks);
3422 next.delete(id);
3423 return next;
3424 }
3425 const text = claudeTaskString(input, "subject") ?? task.text;
3426 const status = claudeTaskStatus(nativeStatus) ?? task.status;
3427 if (text === task.text && status === task.status) return undefined;
3428 return new Map(tasks).set(id, { text, status });
3429}
3430
3431function claudeTaskSteps(tasks: ClaudeTaskList): ReadonlyArray<OrchestrationV2PlanStep> {
3432 return [...tasks].map(([id, task]) => ({ id: `task-${id}`, ...task }));
3433}
3434
3435export function claudeProposedPlan(input: unknown): string | null {
3436 const value =
3437 typeof input === "object" && input !== null && Reflect.get(input, "type") === "record"
3438 ? Reflect.get(input, "value")
3439 : input;
3440 const plan = typeof value === "object" && value !== null ? Reflect.get(value, "plan") : undefined;
3441 return typeof plan === "string" && plan.trim().length > 0 ? plan.trim() : null;
3442}
3443
3444export interface ClaudeAdapterV2Options {
3445 readonly instanceId: ProviderInstanceId;
3446 readonly settings: ClaudeSettings;
3447 readonly environment: NodeJS.ProcessEnv;
3448 readonly attachmentsDir: string;
3449 readonly fileSystem: FileSystem.FileSystem;
3450 readonly path: Path.Path;
3451 readonly crypto: Crypto.Crypto;
3452 readonly idAllocator: IdAllocator.IdAllocatorV2["Service"];
3453 readonly queryRunner: ClaudeAgentSdkQueryRunnerShape;
3454 readonly scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>;
3455 readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"];
3456 /** Sink for wake-turn continuation requests; defaults to dropping them. */
3457 readonly continuationRequests?: {
3458 readonly offer: (
3459 request: ProviderContinuationRequests.ProviderContinuationRequest,
3460 ) => Effect.Effect<void>;
3461 };
3462}
3463
3464export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
3465 adapterOptions: ClaudeAdapterV2Options,
3466) {
3467 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
3468 const scriptProgress = yield* ScriptProgress.ScriptProgress;
3469 const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions;
3470 // Subagent model names and effort levels, including the instance's custom models.
3471 const subagentModelCatalog = scopeClaudeModelCatalog(
3472 BUNDLED_CLAUDE_MODEL_CATALOG,
3473 adapterOptions.settings.customModels,
3474 );
3475 const continuationRequests = adapterOptions.continuationRequests ?? {
3476 offer: () => Effect.void,
3477 };
3478
3479 // Re-scan on every send: skills are added and switched off mid-session, and
3480 // the scan is a few directory reads. A skill switched off via skillOverrides,
3481 // or reserved for the agent with `user-invocable: false`, is left as prose:
3482 // the CLI would answer `/name` with a notice instead of running it.
3483 const userInvocableSkillNames = (cwd: string | null) =>
3484 discoverClaudeSkills(
3485 adapterOptions.settings,
3486 cwd ?? undefined,
3487 adapterOptions.environment,
3488 ).pipe(
3489 Effect.map(
3490 (skills) =>
3491 new Set(
3492 skills
3493 .filter((skill) => skill.enabled && skill.userInvocable !== false)
3494 .map((skill) => skill.name),
3495 ),
3496 ),
3497 Effect.provideService(FileSystem.FileSystem, fileSystem),
3498 Effect.provideService(Path.Path, path),
3499 );
3500
3501 return ProviderAdapter.ProviderAdapterV2.of({
3502 instanceId: adapterOptions.instanceId,
3503 driver: CLAUDE_PROVIDER,
3504 getCapabilities: () => Effect.succeed(ClaudeProviderCapabilitiesV2),
3505 planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()),
3506 openSession: Effect.fn("ClaudeAdapterV2.openSession")(
3507 function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) {
3508 const sessionScope = yield* Effect.scope;
3509 const now = yield* DateTime.now;
3510 const session = providerSession({
3511 providerSessionId: input.providerSessionId,
3512 providerInstanceId: adapterOptions.instanceId,
3513 cwd: input.runtimePolicy.cwd,
3514 model: input.modelSelection.model,
3515 now,
3516 });
3517 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
3518 const activeTurn = yield* Ref.make<ActiveClaudeTurnContext | null>(null);
3519 const sdkMessageLock = yield* Semaphore.make(1);
3520 // Survives turn settle: members mostly finish after the launching
3521 // turn has ended, and each transition must be emitted exactly once.
3522 const workflowMemberStates = yield* Ref.make(
3523 new Map<
3524 string,
3525 {
3526 readonly status: OrchestrationV2Subagent["status"];
3527 readonly attempt: number;
3528 /** Digest of the last emitted transcript answer, not the text: it can run to the transcript cap. */
3529 readonly transcriptAnswerDigest: string | undefined;
3530 readonly transcriptReadAttempts: number;
3531 readonly finalTranscriptRead: boolean;
3532 readonly result: string | undefined;
3533 readonly label: string;
3534 readonly prompt: string;
3535 readonly model: string;
3536 readonly createdAt: DateTime.Utc;
3537 readonly startedAt: DateTime.Utc | null;
3538 readonly completedAt: DateTime.Utc | null;
3539 }
3540 >(),
3541 );
3542 const interruptedTurns = yield* Ref.make(new Set<OrchestrationV2ProviderTurn["id"]>());
3543 const steeredTurns = yield* Ref.make(new Set<OrchestrationV2ProviderTurn["id"]>());
3544 const queryContext = yield* Ref.make<ClaudeLiveQueryContext | null>(null);
3545 const openedNativeThreads = yield* Ref.make(new Set<string>());
3546 const latestPlanByKind = yield* Ref.make(new Map<string, OrchestrationV2PlanArtifact>());
3547 // Claude 5 task tools edit one list per native session, across turns.
3548 const taskListsByNativeThread = yield* Ref.make(new Map<string, ClaudeTaskList>());
3549 const planIdsByNativeItem = yield* Ref.make(
3550 new Map<string, OrchestrationV2PlanArtifact["id"]>(),
3551 );
3552 const providerRetries = yield* Ref.make(
3553 new Map<OrchestrationV2ProviderTurn["id"], ActiveClaudeProviderRetry>(),
3554 );
3555 const pendingRuntimeRequests = yield* Ref.make(
3556 new Map<string, PendingClaudeRuntimeRequest>(),
3557 );
3558 // Background-task wake support. Claude can settle a turn while a
3559 // local_bash background task keeps running; the CLI later re-invokes
3560 // the model (a "wake turn") on the same query stream with no active
3561 // provider turn. These refs track pending background tasks, buffer
3562 // wake messages until a continuation run attaches, and remember where
3563 // to dispatch that run.
3564 const lastTurnRouteByNativeThread = yield* Ref.make(
3565 new Map<
3566 string,
3567 { readonly threadId: ThreadId; readonly providerThreadId: ProviderThreadId }
3568 >(),
3569 );
3570 // Authoritative + incremental background-task roster for post-settle
3571 // Waiting UI. Outer key is native Claude session id so concurrent
3572 // provider threads on one runtime cannot share or clear each other.
3573 const pendingBackgroundTasksByNativeThread = yield* Ref.make(
3574 new Map<string, Map<string, OrchestrationV2PendingBackgroundTask>>(),
3575 );
3576 // Wake eligibility is separate from the Waiting roster. It survives
3577 // empty background_tasks_changed levels (SDK: empty level can precede
3578 // task_notification) and is consumed when the first idle notification
3579 // is buffered/offered so duplicates cannot re-buffer. A short-lived
3580 // replay tombstone then covers continuation drain classification so
3581 // local_bash is never projected as a subagent; the tombstone is
3582 // cleared after that drained notification is processed. Both sets
3583 // clear on CLI process open/replacement and failed/interrupted turns.
3584 const wakeEligibleBackgroundTasksByNativeThread = yield* Ref.make(
3585 new Map<string, Set<string>>(),
3586 );
3587 const opaqueBackgroundTaskReplayTombstonesByNativeThread = yield* Ref.make(
3588 new Map<string, Set<string>>(),
3589 );
3590 // Last known provider-thread payload per native session, used to emit
3591 // roster-only provider_thread.updated events between turns without
3592 // resurrecting an active status after root settlement.
3593 const lastProviderThreadByNativeThread = yield* Ref.make(
3594 new Map<string, OrchestrationV2ProviderThread>(),
3595 );
3596 // Native `/goal` per session, seeded from the persisted provider thread.
3597 const goalsByNativeThread = new Map<string, OrchestrationV2ProviderGoal | null>();
3598 // Turns whose model output met an active goal's Stop hook check at its end.
3599 const goalCheckedTurns = new Set<string>();
3600 // Subagent registry that survives turn settle: a background subagent
3601 // (Agent with run_in_background) can complete after the root turn
3602 // ended, and its task_notification must both count as wake evidence
3603 // and hydrate the original subagent node instead of being dropped.
3604 const sessionSubagentsByTaskId = yield* Ref.make(new Map<string, ActiveClaudeSubagent>());
3605 // Subagent frames carry only parent_tool_use_id. Frames that outlive
3606 // the turn that launched the subagent (wake drain, later turns) resolve
3607 // their subagent through this index into sessionSubagentsByTaskId.
3608 const sessionSubagentTaskIdsByToolUseId = yield* Ref.make(new Map<string, string>());
3609 // A subagent's tool calls can outlive the turn that started them, so
3610 // they live here rather than on a turn context: a turn that ends while
3611 // one runs neither fails it nor hides it from the turn that follows.
3612 const subagentToolCalls = new Map<string, ActiveClaudeToolCall>();
3613 // Launches seen before their task_started. A running subagent's Agent
3614 // call can be projected while the root is idle and its task_started
3615 // only in the continuation turn, so these outlive a turn too.
3616 const pendingSubagentLaunchesByToolUseId = new Map<string, PendingClaudeSubagentLaunch>();
3617 // The turn that settled last on each native thread. While the root is
3618 // idle, a running subagent's frames are projected through it at once
3619 // instead of waiting in the wake buffer for a continuation run.
3620 const settledTurnByNativeThread = new Map<string, ActiveClaudeTurnContext>();
3621 const subagentStopOutcomes = new Map<
3622 string,
3623 Deferred.Deferred<ProviderAdapter.ProviderAdapterV2SubagentOutcome>
3624 >();
3625 // An idle outcome stays buffered for the parent's wake, but must not
3626 // settle the child again after that child resumes.
3627 const appliedSubagentOutcomes = new WeakSet<SDKMessage>();
3628 // Subagent frames can precede the task_started that registers their
3629 // subagent (the same race rememberPendingClaudeSubagentLaunch covers).
3630 // They wait here and replay once task_started registers the owner.
3631 const pendingSubagentFramesByToolUseId = yield* Ref.make(
3632 new Map<string, ReadonlyArray<SDKMessage>>(),
3633 );
3634 const wakeBuffers = yield* Ref.make(
3635 new Map<
3636 string,
3637 { readonly messages: ReadonlyArray<SDKMessage>; readonly detail: string | null }
3638 >(),
3639 );
3640 // Background work that ended and has not been named by a wake offer yet,
3641 // by native thread and task id, with the user turn it ended in. Claude's
3642 // wake result carries no task id, and its wake can run after a prompt the
3643 // user queued meanwhile, so reports survive one user turn and then expire:
3644 // a notification Claude folded into its own turn cannot name a later wake.
3645 // One Ref holds both, so a report cannot be stamped with a turn that
3646 // has already been superseded.
3647 const wakeReportsByNativeThread = yield* Ref.make<{
3648 readonly userTurns: ReadonlyMap<string, number>;
3649 readonly reports: ReadonlyMap<
3650 string,
3651 ReadonlyMap<string, { readonly report: BackgroundWorkReport; readonly turn: number }>
3652 >;
3653 }>({ userTurns: new Map(), reports: new Map() });
3654 // Subagents Claude started in the background. Only their ends wake the root.
3655 const backgroundedSubagentTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set());
3656 // Subagents another subagent started (spawn_depth above 1). Claude
3657 // reports their end to the owning subagent, so it never wakes the root.
3658 const nestedSubagentTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set());
3659 const isNestedSubagentTask = (taskId: string) =>
3660 Ref.get(nestedSubagentTaskIds).pipe(Effect.map((ids) => ids.has(taskId)));
3661 const recordWakeReport = (
3662 nativeThreadId: string,
3663 taskId: string,
3664 report: BackgroundWorkReport,
3665 ) =>
3666 Ref.update(wakeReportsByNativeThread, ({ userTurns, reports }) => ({
3667 userTurns,
3668 reports: new Map(reports).set(
3669 nativeThreadId,
3670 new Map(reports.get(nativeThreadId)).set(taskId, {
3671 report,
3672 turn: userTurns.get(nativeThreadId) ?? 0,
3673 }),
3674 ),
3675 }));
3676 const startUserTurnForWakeReports = (nativeThreadId: string) =>
3677 Ref.update(wakeReportsByNativeThread, ({ userTurns, reports }) => {
3678 const turn = (userTurns.get(nativeThreadId) ?? 0) + 1;
3679 const updatedTurns = new Map(userTurns).set(nativeThreadId, turn);
3680 const threadReports = reports.get(nativeThreadId);
3681 if (threadReports === undefined) return { userTurns: updatedTurns, reports };
3682 const kept = new Map([...threadReports].filter(([, entry]) => entry.turn >= turn - 1));
3683 const updatedReports = new Map(reports);
3684 if (kept.size === 0) updatedReports.delete(nativeThreadId);
3685 else updatedReports.set(nativeThreadId, kept);
3686 return { userTurns: updatedTurns, reports: updatedReports };
3687 });
3688 /** Removes and returns a thread's reports. */
3689 const takeWakeReports = (nativeThreadId: string) =>
3690 Ref.modify(wakeReportsByNativeThread, (current) => {
3691 const taken = current.reports.get(nativeThreadId);
3692 if (taken === undefined) return [taken, current] as const;
3693 const reports = new Map(current.reports);
3694 reports.delete(nativeThreadId);
3695 return [taken, { userTurns: current.userTurns, reports }] as const;
3696 });
3697 const clearWakeReports = (nativeThreadId: string) =>
3698 takeWakeReports(nativeThreadId).pipe(Effect.asVoid);
3699 // Last roster entry per opaque task. An empty roster level can land before
3700 // the task's notification, and the wake still needs to name the task.
3701 const lastKnownOpaqueTasks = yield* Ref.make(
3702 new Map<string, OrchestrationV2PendingBackgroundTask>(),
3703 );
3704 // Live Claude monitors. A Monitor call waits in `calls` until its
3705 // task_started links it to a task, or its tool_result ends the call
3706 // without one. A task stays in `tasks` until it leaves its native
3707 // thread's roster or its task_notification arrives, so a monitor is
3708 // never forgotten while it runs.
3709 const claudeMonitors = yield* Ref.make<{
3710 readonly calls: ReadonlySet<string>;
3711 readonly tasks: ReadonlyMap<
3712 string,
3713 { readonly toolUseId: string; readonly nativeThreadId: string }
3714 >;
3715 }>({ calls: new Set(), tasks: new Map() });
3716 const trackClaudeMonitorCalls = (message: SDKMessage) => {
3717 const started = claudeToolUseBlocksFromAssistantMessage(message).flatMap((toolUse) =>
3718 toolUse.name === "Monitor" ? [toolUse.id] : [],
3719 );
3720 const returned = [
3721 ...claudeToolResultBlocksFromAssistantMessage(message),
3722 ...claudeToolResultBlocksFromUserMessage(message),
3723 ].map((toolResult) => toolResult.tool_use_id);
3724 if (started.length === 0 && returned.length === 0) return Effect.void;
3725 return Ref.update(claudeMonitors, (current) => {
3726 // A replayed tool_use frame must not reopen a call whose task already started.
3727 const linked = new Set([...current.tasks.values()].map((task) => task.toolUseId));
3728 const opened = started.filter((id) => !linked.has(id) && !current.calls.has(id));
3729 const closed = returned.filter((id) => current.calls.has(id));
3730 if (opened.length === 0 && closed.length === 0) return current;
3731 const calls = new Set([...current.calls, ...opened]);
3732 for (const id of closed) calls.delete(id);
3733 return { ...current, calls };
3734 });
3735 };
3736 /** True when a Monitor call started this task; links the task to that call. */
3737 const isClaudeMonitorTask = (input: {
3738 readonly nativeThreadId: string;
3739 readonly taskId: string;
3740 readonly toolUseId: string | undefined;
3741 }) =>
3742 Ref.modify(claudeMonitors, (current) => {
3743 if (current.tasks.has(input.taskId)) return [true, current] as const;
3744 if (input.toolUseId === undefined || !current.calls.has(input.toolUseId)) {
3745 return [false, current] as const;
3746 }
3747 const calls = new Set(current.calls);
3748 calls.delete(input.toolUseId);
3749 const tasks = new Map(current.tasks).set(input.taskId, {
3750 toolUseId: input.toolUseId,
3751 nativeThreadId: input.nativeThreadId,
3752 });
3753 return [true, { calls, tasks }] as const;
3754 });
3755 /** Drops monitor tasks that ended: gone from the thread's roster, or notified. */
3756 const endClaudeMonitorTasks = (
3757 ended: (taskId: string, task: { readonly nativeThreadId: string }) => boolean,
3758 ) =>
3759 Ref.modify(claudeMonitors, (current) => {
3760 const endedIds = [...current.tasks].filter(([taskId, task]) => ended(taskId, task));
3761 if (endedIds.length === 0) return [current.tasks, current] as const;
3762 const tasks = new Map(current.tasks);
3763 for (const [taskId] of endedIds) tasks.delete(taskId);
3764 return [tasks, { ...current, tasks }] as const;
3765 });
3766 const claudeTaskOutcome = (status: "completed" | "failed" | "stopped") =>
3767 status === "completed" ? "completed" : status === "stopped" ? "cancelled" : "failed";
3768 // Reads the roster, so call it before the notification clears the task from it.
3769 const opaqueTaskWakeReport = Effect.fnUntraced(function* (
3770 nativeThreadId: string,
3771 message: Extract<SDKMessage, { readonly subtype: "task_notification" }>,
3772 ): Effect.fn.Return<BackgroundWorkReport> {
3773 const task =
3774 rosterForNativeThread(
3775 yield* Ref.get(pendingBackgroundTasksByNativeThread),
3776 nativeThreadId,
3777 ).get(message.task_id) ?? (yield* Ref.get(lastKnownOpaqueTasks)).get(message.task_id);
3778 yield* Ref.update(lastKnownOpaqueTasks, (current) => {
3779 if (!current.has(message.task_id)) return current;
3780 const updated = new Map(current);
3781 updated.delete(message.task_id);
3782 return updated;
3783 });
3784 const outcome = claudeTaskOutcome(message.status);
3785 const label = task?.description;
3786 switch (task?.kind) {
3787 case "subagent":
3788 return { kind: "subagent", label, outcome, childThreadId: task.childThreadId };
3789 case "monitor":
3790 case "background_task":
3791 return { kind: task.kind, label, outcome };
3792 case "command":
3793 case undefined:
3794 // Only local_bash is opaque background work today.
3795 return { kind: "command", label, outcome };
3796 }
3797 });
3798 const requestedContinuations = yield* Ref.make(new Set<string>());
3799 // ExitPlanMode plans whose permission callback fired while the tool's
3800 // root frames were held for a prompt echo. Each projects when its
3801 // tool_use frame is handled, in whichever run that frame is routed
3802 // to (the prompt's turn, or the continuation that drains a wake).
3803 const heldProposedPlansByToolUseId = new Map<string, string>();
3804 const runtimeContext = yield* Effect.context<never>();
3805 const runPromise = Effect.runPromiseWith(runtimeContext);
3806
3807 const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) =>
3808 Queue.offer(events, event).pipe(Effect.asVoid);
3809
3810 // Claude emits retry progress but no recovered frame; the next
3811 // assistant message is the first reliable evidence of recovery.
3812 const completeProviderRetry = Effect.fn("ClaudeAdapterV2.completeProviderRetry")(function* (
3813 context: ActiveClaudeTurnContext,
3814 updatedAt: DateTime.Utc,
3815 ) {
3816 const providerRetry = yield* Ref.modify(providerRetries, (current) => {
3817 const retry = current.get(context.providerTurnId);
3818 if (retry === undefined) {
3819 return [undefined, current] as const;
3820 }
3821 const updated = new Map(current);
3822 updated.delete(context.providerTurnId);
3823 return [retry, updated] as const;
3824 });
3825 if (providerRetry === undefined) {
3826 return;
3827 }
3828 yield* emitProviderEvent({
3829 type: "turn_item.updated",
3830 driver: CLAUDE_PROVIDER,
3831 turnItem: makeProviderRetryTurnItem({
3832 driver: CLAUDE_PROVIDER,
3833 threadId: context.input.threadId,
3834 runId: context.input.runId,
3835 nodeId: context.input.rootNodeId,
3836 providerThreadId: context.input.providerThread.id,
3837 providerTurnId: context.providerTurnId,
3838 itemOrdinal: providerRetry.itemOrdinal,
3839 failure: providerRetry.failure,
3840 retry: providerRetry.retry,
3841 status: "completed",
3842 startedAt: providerRetry.startedAt,
3843 updatedAt,
3844 }),
3845 });
3846 });
3847
3848 const rememberProviderThread = (providerThread: OrchestrationV2ProviderThread) =>
3849 Effect.gen(function* () {
3850 const nativeThreadId = providerThread.nativeThreadRef?.nativeId;
3851 if (nativeThreadId === undefined || nativeThreadId === null) {
3852 return;
3853 }
3854 yield* Ref.update(lastProviderThreadByNativeThread, (current) =>
3855 new Map(current).set(nativeThreadId, providerThread),
3856 );
3857 });
3858
3859 const rosterForNativeThread = (
3860 all: ReadonlyMap<string, Map<string, OrchestrationV2PendingBackgroundTask>>,
3861 nativeThreadId: string,
3862 ): Map<string, OrchestrationV2PendingBackgroundTask> =>
3863 all.get(nativeThreadId) ?? new Map<string, OrchestrationV2PendingBackgroundTask>();
3864
3865 const hasPendingBackgroundTaskOnNativeThread = (nativeThreadId: string, taskId: string) =>
3866 Ref.get(pendingBackgroundTasksByNativeThread).pipe(
3867 Effect.map((all) => rosterForNativeThread(all, nativeThreadId).has(taskId)),
3868 );
3869
3870 const taskIdSetForNativeThread = (
3871 all: ReadonlyMap<string, Set<string>>,
3872 nativeThreadId: string,
3873 ): Set<string> => all.get(nativeThreadId) ?? new Set<string>();
3874
3875 const addTaskIdsToNativeThreadSet = (
3876 ref: Ref.Ref<Map<string, Set<string>>>,
3877 nativeThreadId: string,
3878 taskIds: ReadonlyArray<string>,
3879 ) =>
3880 Ref.update(ref, (current) => {
3881 if (taskIds.length === 0) {
3882 return current;
3883 }
3884 const next = new Set(taskIdSetForNativeThread(current, nativeThreadId));
3885 let changed = false;
3886 for (const taskId of taskIds) {
3887 if (!next.has(taskId)) {
3888 next.add(taskId);
3889 changed = true;
3890 }
3891 }
3892 return changed ? new Map(current).set(nativeThreadId, next) : current;
3893 });
3894
3895 const clearTaskIdFromNativeThreadSet = (
3896 ref: Ref.Ref<Map<string, Set<string>>>,
3897 nativeThreadId: string,
3898 taskId: string,
3899 ) =>
3900 Ref.update(ref, (current) => {
3901 const existing = taskIdSetForNativeThread(current, nativeThreadId);
3902 if (!existing.has(taskId)) {
3903 return current;
3904 }
3905 const next = new Set(existing);
3906 next.delete(taskId);
3907 const updated = new Map(current);
3908 if (next.size === 0) {
3909 updated.delete(nativeThreadId);
3910 } else {
3911 updated.set(nativeThreadId, next);
3912 }
3913 return updated;
3914 });
3915
3916 const clearNativeThreadTaskIdSet = (
3917 ref: Ref.Ref<Map<string, Set<string>>>,
3918 nativeThreadId: string,
3919 ) =>
3920 Ref.update(ref, (current) => {
3921 if (!current.has(nativeThreadId)) {
3922 return current;
3923 }
3924 const updated = new Map(current);
3925 updated.delete(nativeThreadId);
3926 return updated;
3927 });
3928
3929 // First-notification wake offering only: not the Waiting roster and
3930 // not the post-buffer replay tombstone.
3931 const isWakeEligibleOpaqueBackgroundTaskOnNativeThread = (
3932 nativeThreadId: string,
3933 taskId: string,
3934 ) =>
3935 Ref.get(wakeEligibleBackgroundTasksByNativeThread).pipe(
3936 Effect.map((all) => taskIdSetForNativeThread(all, nativeThreadId).has(taskId)),
3937 );
3938
3939 const hasOpaqueBackgroundTaskReplayTombstoneOnNativeThread = (
3940 nativeThreadId: string,
3941 taskId: string,
3942 ) =>
3943 Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread).pipe(
3944 Effect.map((all) => taskIdSetForNativeThread(all, nativeThreadId).has(taskId)),
3945 );
3946
3947 // Classify a task_notification as opaque local_bash (not a subagent):
3948 // live roster, still-eligible first notification, or short-lived
3949 // replay tombstone left after the idle notification was buffered.
3950 const isKnownOpaqueBackgroundTaskOnNativeThread = (
3951 nativeThreadId: string,
3952 taskId: string,
3953 ) =>
3954 Effect.gen(function* () {
3955 if (yield* hasPendingBackgroundTaskOnNativeThread(nativeThreadId, taskId)) {
3956 return true;
3957 }
3958 if (yield* isWakeEligibleOpaqueBackgroundTaskOnNativeThread(nativeThreadId, taskId)) {
3959 return true;
3960 }
3961 return yield* hasOpaqueBackgroundTaskReplayTombstoneOnNativeThread(
3962 nativeThreadId,
3963 taskId,
3964 );
3965 });
3966
3967 // Admit onto wake eligibility only. Replay tombstones are created when
3968 // the first idle notification is buffered, not at task start.
3969 const markWakeEligibleOpaqueBackgroundTasks = (
3970 nativeThreadId: string,
3971 taskIds: ReadonlyArray<string>,
3972 ) =>
3973 addTaskIdsToNativeThreadSet(
3974 wakeEligibleBackgroundTasksByNativeThread,
3975 nativeThreadId,
3976 taskIds,
3977 );
3978
3979 // After the first idle opaque notification is buffered/offered: stop
3980 // further wake buffering for this task id, but keep a replay tombstone
3981 // until the continuation drain classifies the buffered notification.
3982 const consumeWakeEligibilityForBufferedNotification = (
3983 nativeThreadId: string,
3984 taskId: string,
3985 ) =>
3986 Effect.gen(function* () {
3987 yield* clearTaskIdFromNativeThreadSet(
3988 wakeEligibleBackgroundTasksByNativeThread,
3989 nativeThreadId,
3990 taskId,
3991 );
3992 yield* addTaskIdsToNativeThreadSet(
3993 opaqueBackgroundTaskReplayTombstonesByNativeThread,
3994 nativeThreadId,
3995 [taskId],
3996 );
3997 });
3998
3999 const clearOpaqueBackgroundTaskReplayTombstone = (nativeThreadId: string, taskId: string) =>
4000 clearTaskIdFromNativeThreadSet(
4001 opaqueBackgroundTaskReplayTombstonesByNativeThread,
4002 nativeThreadId,
4003 taskId,
4004 );
4005
4006 const emitProviderThreadRoster = Effect.fnUntraced(function* (input: {
4007 readonly nativeThreadId: string;
4008 readonly providerThread: OrchestrationV2ProviderThread;
4009 readonly status?: OrchestrationV2ProviderThread["status"];
4010 }) {
4011 const roster = rosterForNativeThread(
4012 yield* Ref.get(pendingBackgroundTasksByNativeThread),
4013 input.nativeThreadId,
4014 );
4015 const now = yield* DateTime.now;
4016 const providerThread: OrchestrationV2ProviderThread = {
4017 ...input.providerThread,
4018 providerSessionId: session.id,
4019 ...(input.status === undefined ? {} : { status: input.status }),
4020 pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster),
4021 ...(goalsByNativeThread.has(input.nativeThreadId)
4022 ? { goal: goalsByNativeThread.get(input.nativeThreadId) ?? null }
4023 : {}),
4024 updatedAt: now,
4025 };
4026 yield* rememberProviderThread(providerThread);
4027 yield* emitProviderEvent({
4028 type: "provider_thread.updated",
4029 driver: CLAUDE_PROVIDER,
4030 providerThread,
4031 });
4032 });
4033
4034 /** Applies one root frame's goal signal and writes any change onto the provider thread. */
4035 const trackClaudeGoal = Effect.fnUntraced(function* (input: {
4036 readonly nativeThreadId: string;
4037 readonly context: ActiveClaudeTurnContext;
4038 readonly message: SDKMessage;
4039 }) {
4040 const current = goalsByNativeThread.get(input.nativeThreadId) ?? null;
4041 if (
4042 current?.status === "active" &&
4043 input.message.type === "assistant" &&
4044 input.message.parent_tool_use_id === null &&
4045 input.message.message.model !== "<synthetic>"
4046 ) {
4047 goalCheckedTurns.add(input.context.providerTurnId);
4048 }
4049 const next = nextClaudeGoal(current, input.message);
4050 if (next === undefined || providerGoalsEqual(current, next)) return;
4051 goalsByNativeThread.set(input.nativeThreadId, next);
4052 const providerThread = (yield* Ref.get(lastProviderThreadByNativeThread)).get(
4053 input.nativeThreadId,
4054 );
4055 if (providerThread === undefined) return;
4056 const updated = { ...providerThread, goal: next, updatedAt: yield* DateTime.now };
4057 yield* rememberProviderThread(updated);
4058 yield* emitProviderEvent({
4059 type: "provider_thread.updated",
4060 driver: CLAUDE_PROVIDER,
4061 providerThread: updated,
4062 });
4063 });
4064
4065 const rememberOpaqueTasks = (tasks: ReadonlyArray<OrchestrationV2PendingBackgroundTask>) =>
4066 tasks.length === 0
4067 ? Effect.void
4068 : Ref.update(lastKnownOpaqueTasks, (current) => {
4069 const updated = new Map(current);
4070 for (const task of tasks) updated.set(task.taskId, task);
4071 // Bounded: an entry only outlives its task when no notification came.
4072 for (const oldest of updated.keys()) {
4073 if (updated.size <= 64) break;
4074 updated.delete(oldest);
4075 }
4076 return updated;
4077 });
4078
4079 const replacePendingBackgroundTasks = (
4080 nativeThreadId: string,
4081 tasks: ReadonlyArray<OrchestrationV2PendingBackgroundTask>,
4082 ) =>
4083 Effect.gen(function* () {
4084 yield* Ref.update(pendingBackgroundTasksByNativeThread, (current) => {
4085 const updated = new Map(current);
4086 if (tasks.length === 0) {
4087 updated.delete(nativeThreadId);
4088 } else {
4089 updated.set(
4090 nativeThreadId,
4091 new Map(tasks.map((task) => [task.taskId, task] as const)),
4092 );
4093 }
4094 return updated;
4095 });
4096 yield* rememberOpaqueTasks(tasks);
4097 // Empty level must not drop wake eligibility: notification may
4098 // still be in flight. Non-empty level admits new task ids to
4099 // wake eligibility only (replay tombstones are edge-created).
4100 if (tasks.length > 0) {
4101 yield* markWakeEligibleOpaqueBackgroundTasks(
4102 nativeThreadId,
4103 tasks.map((task) => task.taskId),
4104 );
4105 }
4106 });
4107
4108 const upsertPendingBackgroundTask = (
4109 nativeThreadId: string,
4110 task: OrchestrationV2PendingBackgroundTask,
4111 ) =>
4112 Effect.gen(function* () {
4113 yield* Ref.update(pendingBackgroundTasksByNativeThread, (current) => {
4114 const roster = new Map(rosterForNativeThread(current, nativeThreadId));
4115 roster.set(task.taskId, task);
4116 return new Map(current).set(nativeThreadId, roster);
4117 });
4118 yield* rememberOpaqueTasks([task]);
4119 yield* markWakeEligibleOpaqueBackgroundTasks(nativeThreadId, [task.taskId]);
4120 });
4121
4122 const clearPendingBackgroundTask = (nativeThreadId: string, taskId: string) =>
4123 Ref.modify(pendingBackgroundTasksByNativeThread, (current) => {
4124 const roster = rosterForNativeThread(current, nativeThreadId);
4125 if (!roster.has(taskId)) {
4126 return [false, current] as const;
4127 }
4128 const nextRoster = new Map(roster);
4129 nextRoster.delete(taskId);
4130 const updated = new Map(current);
4131 if (nextRoster.size === 0) {
4132 updated.delete(nativeThreadId);
4133 } else {
4134 updated.set(nativeThreadId, nextRoster);
4135 }
4136 return [true, updated] as const;
4137 });
4138
4139 const clearPendingBackgroundTasksForNativeThread = (nativeThreadId: string) =>
4140 Effect.gen(function* () {
4141 yield* Ref.update(pendingBackgroundTasksByNativeThread, (current) => {
4142 if (!current.has(nativeThreadId)) {
4143 return current;
4144 }
4145 const updated = new Map(current);
4146 updated.delete(nativeThreadId);
4147 return updated;
4148 });
4149 // The thread's process died or its turn failed; those monitors never notify.
4150 yield* endClaudeMonitorTasks((_taskId, task) => task.nativeThreadId === nativeThreadId);
4151 });
4152
4153 // Drop idle wake traffic for a dead native process so it cannot pin
4154 // session-wide pending work after sibling query replacement.
4155 const clearWakeStateForNativeThread = (nativeThreadId: string) =>
4156 Effect.gen(function* () {
4157 yield* clearWakeReports(nativeThreadId);
4158 yield* Ref.update(wakeBuffers, (current) => {
4159 if (!current.has(nativeThreadId)) {
4160 return current;
4161 }
4162 const updated = new Map(current);
4163 updated.delete(nativeThreadId);
4164 return updated;
4165 });
4166 yield* Ref.update(requestedContinuations, (current) => {
4167 if (!current.has(nativeThreadId)) {
4168 return current;
4169 }
4170 const updated = new Set(current);
4171 updated.delete(nativeThreadId);
4172 return updated;
4173 });
4174 });
4175
4176 // Process-scoped level: SDK emits nothing at CLI start, so both the
4177 // Waiting roster and wake eligibility reset when a live query opens
4178 // or is replaced for this native thread. Opaque replay tombstones that
4179 // already covered buffered task_notification frames are restored so a
4180 // model/policy query replacement still classifies those local_bash
4181 // completions on continuation drain. Buffer membership alone must not
4182 // invent opaque classification: session-registered subagent
4183 // notifications share the same buffer.
4184 const resetBackgroundTaskStateForNativeThreadProcess = Effect.fnUntraced(function* (
4185 nativeThreadId: string,
4186 options?: {
4187 // openQuery during startTurn: activeTurn is not installed yet, but
4188 // ProviderTurnStartService already marked the provider thread active.
4189 readonly status?: OrchestrationV2ProviderThread["status"];
4190 },
4191 ) {
4192 const hadRoster =
4193 rosterForNativeThread(
4194 yield* Ref.get(pendingBackgroundTasksByNativeThread),
4195 nativeThreadId,
4196 ).size > 0;
4197 const remembered = (yield* Ref.get(lastProviderThreadByNativeThread)).get(nativeThreadId);
4198 const hadPersistedRoster = (remembered?.pendingBackgroundTasks?.length ?? 0) > 0;
4199 const priorOpaqueTombstones = taskIdSetForNativeThread(
4200 yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread),
4201 nativeThreadId,
4202 );
4203 const bufferedTaskNotificationIds = new Set<string>();
4204 const buffered = (yield* Ref.get(wakeBuffers)).get(nativeThreadId);
4205 if (buffered !== undefined) {
4206 for (const message of buffered.messages) {
4207 if (message.type === "system" && message.subtype === "task_notification") {
4208 bufferedTaskNotificationIds.add(message.task_id);
4209 }
4210 }
4211 }
4212 const preservedOpaqueTombstones = [...priorOpaqueTombstones].filter((taskId) =>
4213 bufferedTaskNotificationIds.has(taskId),
4214 );
4215 yield* clearPendingBackgroundTasksForNativeThread(nativeThreadId);
4216 yield* clearNativeThreadTaskIdSet(
4217 wakeEligibleBackgroundTasksByNativeThread,
4218 nativeThreadId,
4219 );
4220 yield* clearNativeThreadTaskIdSet(
4221 opaqueBackgroundTaskReplayTombstonesByNativeThread,
4222 nativeThreadId,
4223 );
4224 if (preservedOpaqueTombstones.length > 0) {
4225 yield* addTaskIdsToNativeThreadSet(
4226 opaqueBackgroundTaskReplayTombstonesByNativeThread,
4227 nativeThreadId,
4228 preservedOpaqueTombstones,
4229 );
4230 }
4231 if (!hadRoster && !hadPersistedRoster) {
4232 return;
4233 }
4234 if (remembered === undefined) {
4235 return;
4236 }
4237 // Prefer an explicit starting-turn status so a successful openQuery
4238 // replacement clear cannot emit idle over an already-active thread.
4239 // Otherwise: between turns never resurrect active from process reset;
4240 // with a live activeTurn context, upgrade idle → active.
4241 const activeContext = yield* Ref.get(activeTurn);
4242 const status =
4243 options?.status ??
4244 (activeContext === null
4245 ? ("idle" as const)
4246 : remembered.status === "idle"
4247 ? ("active" as const)
4248 : remembered.status);
4249 yield* emitProviderThreadRoster({
4250 nativeThreadId,
4251 providerThread: remembered,
4252 status,
4253 });
4254 });
4255
4256 // A subagent's projection ids derive from its task id, so they are
4257 // the same whether this process created the subagent or not.
4258 const claudeSubagentIds = (context: ActiveClaudeTurnContext, taskId: string) => ({
4259 nodeId: idAllocator.derive.nodeFromProviderItem({
4260 driver: CLAUDE_PROVIDER,
4261 nativeItemId: `task:${taskId}`,
4262 }),
4263 childRootNodeId: idAllocator.derive.nodeFromProviderItem({
4264 driver: CLAUDE_PROVIDER,
4265 nativeItemId: `task:${taskId}:thread-root`,
4266 }),
4267 childThreadId: idAllocator.derive.threadFromProviderThread({
4268 driver: CLAUDE_PROVIDER,
4269 nativeThreadId: `${context.input.providerThread.id}:${taskId}`,
4270 }),
4271 turnItemId: idAllocator.derive.turnItemFromProviderItem({
4272 driver: CLAUDE_PROVIDER,
4273 nativeItemId: `task:${taskId}:subagent`,
4274 }),
4275 });
4276
4277 const resolveItemOrdinal = (context: ActiveClaudeTurnContext, nativeItemId: string) =>
4278 Effect.sync(() => {
4279 const existing = context.itemOrdinals.get(nativeItemId);
4280 if (existing !== undefined) {
4281 return existing;
4282 }
4283 const ordinal = context.input.providerTurnOrdinal * 100 + context.itemOrdinals.size + 1;
4284 context.itemOrdinals.set(nativeItemId, ordinal);
4285 return ordinal;
4286 });
4287
4288 const providerTurnPayload = (input: {
4289 readonly context: ActiveClaudeTurnContext;
4290 readonly status: OrchestrationV2ProviderTurn["status"];
4291 readonly completedAt: DateTime.Utc | null;
4292 }): OrchestrationV2ProviderTurn => ({
4293 id: input.context.providerTurnId,
4294 providerThreadId: input.context.input.providerThread.id,
4295 nodeId: input.context.input.rootNodeId,
4296 runAttemptId: input.context.input.attemptId,
4297 nativeTurnRef: {
4298 driver: CLAUDE_PROVIDER,
4299 nativeId: input.context.nativeMessageCursor ?? input.context.nativeTurnId,
4300 strength: "weak",
4301 },
4302 ordinal: input.context.providerTurnOrdinal,
4303 status: input.status,
4304 startedAt: input.context.startedAt,
4305 completedAt: input.completedAt,
4306 });
4307
4308 const buildToolCallArtifacts = (input: {
4309 readonly context: ActiveClaudeTurnContext;
4310 readonly nativeItemId: string;
4311 readonly toolName: string;
4312 readonly classification: ClaudeToolClassification;
4313 readonly toolInput: ClaudeNativeToolInput;
4314 readonly threadId: ThreadId;
4315 readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null;
4316 readonly rootNodeId: OrchestrationV2ExecutionNode["id"];
4317 readonly parentNodeId: OrchestrationV2ExecutionNode["id"];
4318 readonly ordinal: number;
4319 readonly output: ClaudeNativeToolOutput;
4320 readonly status: Extract<
4321 OrchestrationV2TurnItem["status"],
4322 "running" | "completed" | "failed" | "interrupted" | "cancelled"
4323 >;
4324 readonly startedAt: DateTime.Utc;
4325 readonly updatedAt: DateTime.Utc;
4326 readonly presentation: ClaudeToolPresentation | undefined;
4327 readonly toolNonExecutionKind?: string;
4328 }) => {
4329 const completedAt = input.status === "running" ? null : input.updatedAt;
4330 const nodeId = idAllocator.derive.nodeFromProviderItem({
4331 driver: CLAUDE_PROVIDER,
4332 nativeItemId: input.nativeItemId,
4333 });
4334 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
4335 driver: CLAUDE_PROVIDER,
4336 nativeItemId: input.nativeItemId,
4337 });
4338 const nativeItemRef = {
4339 driver: CLAUDE_PROVIDER,
4340 nativeId: input.nativeItemId,
4341 strength: "strong" as const,
4342 };
4343 const node: OrchestrationV2ExecutionNode = {
4344 id: nodeId,
4345 threadId: input.threadId,
4346 runId: input.runId,
4347 parentNodeId: input.parentNodeId,
4348 rootNodeId: input.rootNodeId,
4349 kind: "tool_call",
4350 status: input.status,
4351 countsForRun: false,
4352 providerThreadId: input.runId === null ? null : input.context.input.providerThread.id,
4353 providerTurnId: input.runId === null ? null : input.context.providerTurnId,
4354 nativeItemRef,
4355 runtimeRequestId: null,
4356 checkpointScopeId: null,
4357 startedAt: input.startedAt,
4358 completedAt,
4359 };
4360 const readPath = ["read", "read file"].includes(input.classification.normalizedName)
4361 ? firstStringInputField(input.toolInput, ["file_path", "path"])?.trim()
4362 : undefined;
4363 const nativeToolInput = claudeNativeToolInputValue(input.toolInput);
4364 const searchTitle = ["grep", "glob", "ls"].includes(input.classification.normalizedName)
4365 ? formatSearchToolLabel({
4366 input:
4367 nativeToolInput !== null &&
4368 typeof nativeToolInput === "object" &&
4369 !Array.isArray(nativeToolInput)
4370 ? (nativeToolInput as Record<string, unknown>)
4371 : undefined,
4372 })
4373 : undefined;
4374 const itemBase = {
4375 ...(input.toolNonExecutionKind === undefined
4376 ? {}
4377 : { toolNonExecutionKind: input.toolNonExecutionKind }),
4378 id: turnItemId,
4379 threadId: input.threadId,
4380 runId: input.runId,
4381 nodeId,
4382 providerThreadId: input.runId === null ? null : input.context.input.providerThread.id,
4383 providerTurnId: input.runId === null ? null : input.context.providerTurnId,
4384 nativeItemRef,
4385 parentItemId: null,
4386 ordinal: input.ordinal,
4387 status: input.status,
4388 title:
4389 readPath !== undefined
4390 ? formatReadToolLabel(readPath)
4391 : (searchTitle ??
4392 dynamicToolTitle(input.toolName, nativeToolInput) ??
4393 input.presentation?.title ??
4394 null),
4395 startedAt: input.startedAt,
4396 completedAt,
4397 updatedAt: input.updatedAt,
4398 } satisfies Pick<
4399 OrchestrationV2TurnItem,
4400 | "id"
4401 | "threadId"
4402 | "runId"
4403 | "nodeId"
4404 | "providerThreadId"
4405 | "providerTurnId"
4406 | "nativeItemRef"
4407 | "parentItemId"
4408 | "ordinal"
4409 | "status"
4410 | "title"
4411 | "startedAt"
4412 | "completedAt"
4413 | "updatedAt"
4414 >;
4415 const viewedImagePath =
4416 readPath &&
4417 readPath.length <= 4096 &&
4418 !/[\r\n]/.test(readPath) &&
4419 isWorkspaceImagePreviewPath(readPath)
4420 ? readPath
4421 : undefined;
4422 const itemType = input.classification.itemType;
4423 const webSearchPatterns = webSearchPatternsFromClaudeTool({
4424 toolInput: input.toolInput,
4425 output: input.output,
4426 });
4427 const webSearchResults = webSearchResultsFromClaudeOutput(input.output);
4428 const webFetchUrl = firstStringInputField(input.toolInput, ["url"])?.trim();
4429 const outputValue = claudeNativeToolOutputValue(input.output);
4430 const outputText =
4431 itemType === "command_execution"
4432 ? claudeCommandOutputText(input.output)
4433 : claudeNativeToolOutputText(input.output);
4434 const turnItem: OrchestrationV2TurnItem =
4435 itemType === "command_execution"
4436 ? {
4437 ...itemBase,
4438 type: "command_execution",
4439 input: commandInputFromClaudeTool(input.toolName, input.toolInput),
4440 ...(outputText.length === 0 ? {} : { output: outputText }),
4441 }
4442 : itemType === "file_change"
4443 ? {
4444 ...itemBase,
4445 type: "file_change",
4446 fileName: fileNameFromClaudeTool(input.toolName, input.toolInput),
4447 ...(outputText.length === 0 ? {} : { diffStr: outputText }),
4448 }
4449 : itemType === "web_search"
4450 ? {
4451 ...itemBase,
4452 type: "web_search",
4453 ...(webSearchPatterns.length === 0
4454 ? {}
4455 : { patterns: [...webSearchPatterns] }),
4456 ...(webSearchResults.length > 0
4457 ? { results: [...webSearchResults] }
4458 : input.classification.normalizedName === "webfetch" &&
4459 outputText.trim().length > 0
4460 ? {
4461 // WebFetch returns page text, not search hits. Keep a
4462 // bounded preview so the row has something to show.
4463 results: [
4464 {
4465 ...(webFetchUrl === undefined ? {} : { url: webFetchUrl }),
4466 snippet: outputText.slice(0, WEB_FETCH_SNIPPET_MAX_CHARS),
4467 },
4468 ],
4469 }
4470 : {}),
4471 }
4472 : {
4473 ...itemBase,
4474 type: "dynamic_tool",
4475 ...(input.presentation?.toolIcon === undefined
4476 ? {}
4477 : { toolIcon: input.presentation.toolIcon }),
4478 ...(input.presentation?.toolSource === undefined
4479 ? {}
4480 : { toolSource: input.presentation.toolSource }),
4481 toolName: input.toolName,
4482 ...(viewedImagePath === undefined ? {} : { viewedImagePath }),
4483 input: claudeNativeToolInputValue(input.toolInput),
4484 ...(outputValue === undefined ? {} : { output: outputValue }),
4485 };
4486 return { node, turnItem };
4487 };
4488
4489 const emitToolCallArtifacts = Effect.fnUntraced(function* (artifacts: {
4490 readonly node: OrchestrationV2ExecutionNode;
4491 readonly turnItem: OrchestrationV2TurnItem;
4492 }) {
4493 yield* emitProviderEvent({
4494 type: "node.updated",
4495 driver: CLAUDE_PROVIDER,
4496 node: artifacts.node,
4497 });
4498 yield* emitProviderEvent({
4499 type: "turn_item.updated",
4500 driver: CLAUDE_PROVIDER,
4501 turnItem: artifacts.turnItem,
4502 });
4503 });
4504
4505 // A subagent launched before a server restart is missing from the
4506 // registry when SendMessage resumes it. The resume's task_started
4507 // names only the SendMessage call, while the resumed run's frames
4508 // carry the launch call as parent_tool_use_id, which only the CLI's
4509 // session storage still records. Registering the subagent as a
4510 // finished run lets the resume reopen it like an in-session resume:
4511 // same child thread, launch prompt kept, the new prompt appended.
4512 const recoverResumedClaudeSubagent = Effect.fnUntraced(function* (resume: {
4513 readonly context: ActiveClaudeTurnContext;
4514 readonly nativeThreadId: string;
4515 readonly taskId: string;
4516 readonly toolUseId: string | undefined;
4517 readonly title: string;
4518 }) {
4519 // An Agent launch never enters toolCalls, so a task_started under a
4520 // tracked tool call is a resume.
4521 if (
4522 resume.toolUseId === undefined ||
4523 findToolCall(resume.context, resume.toolUseId) === undefined ||
4524 resume.context.subagentsByTaskId.has(resume.taskId) ||
4525 (yield* Ref.get(sessionSubagentsByTaskId)).has(resume.taskId)
4526 ) {
4527 return;
4528 }
4529 const launchToolUseId = yield* queryRunner
4530 .subagentLaunchToolUseId({
4531 sessionId: resume.nativeThreadId,
4532 agentId: resume.taskId,
4533 dir: resume.context.input.runtimePolicy.cwd,
4534 threadId: resume.context.input.threadId,
4535 providerSessionId: input.providerSessionId,
4536 })
4537 .pipe(
4538 Effect.catch(() =>
4539 Effect.logWarning("orchestration-v2.claude-subagent-launch-lookup-failed", {
4540 taskId: resume.taskId,
4541 }).pipe(Effect.as(null)),
4542 ),
4543 );
4544 if (launchToolUseId === null || launchToolUseId === resume.toolUseId) {
4545 return;
4546 }
4547 const ids = claudeSubagentIds(resume.context, resume.taskId);
4548 const now = yield* DateTime.now;
4549 const subagent: ActiveClaudeSubagent = {
4550 confirmedOutcome: undefined,
4551 childThread: null,
4552 task: {
4553 id: ids.nodeId,
4554 threadId: resume.context.input.threadId,
4555 runId: resume.context.input.runId,
4556 parentNodeId: resume.context.input.rootNodeId,
4557 origin: "provider_native",
4558 createdBy: "agent",
4559 driver: CLAUDE_PROVIDER,
4560 providerInstanceId: resume.context.input.modelSelection.instanceId,
4561 providerThreadId: null,
4562 childThreadId: ids.childThreadId,
4563 nativeTaskRef: {
4564 driver: CLAUDE_PROVIDER,
4565 nativeId: resume.taskId,
4566 strength: "strong",
4567 },
4568 prompt: "",
4569 title: resume.title,
4570 model: null,
4571 result: null,
4572 status: "completed",
4573 startedAt: now,
4574 completedAt: now,
4575 updatedAt: now,
4576 },
4577 rootNodeId: resume.context.input.rootNodeId,
4578 childThreadId: ids.childThreadId,
4579 childRootNodeId: ids.childRootNodeId,
4580 turnItemId: ids.turnItemId,
4581 turnItemOrdinal: yield* resolveItemOrdinal(
4582 resume.context,
4583 `task:${resume.taskId}:subagent`,
4584 ),
4585 runToolUseId: launchToolUseId,
4586 // The launch's effort went with the old process. Without a task
4587 // modelSelection the child thread keeps the one it was given.
4588 effort: undefined,
4589 nextChildItemOrdinal: 100,
4590 resultItemOrdinal: null,
4591 lastAssistantText: null,
4592 lastAssistantMessageId: null,
4593 };
4594 yield* Ref.update(sessionSubagentsByTaskId, (current) =>
4595 new Map(current).set(resume.taskId, subagent),
4596 );
4597 yield* Ref.update(sessionSubagentTaskIdsByToolUseId, (current) =>
4598 new Map(current).set(launchToolUseId, resume.taskId),
4599 );
4600 });
4601
4602 /**
4603 * Stamps each member with the thread it owns. Derived from the
4604 * coordinator's own child thread so the id inherits its provider-thread
4605 * scoping, and done here so the snapshot the clients read carries the
4606 * same id the projection emits.
4607 */
4608 const withWorkflowMemberThreadIds = (
4609 workflow: OrchestrationV2SubagentWorkflow,
4610 coordinatorChildThreadId: ThreadId,
4611 ): OrchestrationV2SubagentWorkflow => ({
4612 ...workflow,
4613 agents: workflow.agents.map((member) => ({
4614 ...member,
4615 childThreadId: idAllocator.derive.threadFromProviderThread({
4616 driver: CLAUDE_PROVIDER,
4617 nativeThreadId: `${coordinatorChildThreadId}:agent:${member.index}`,
4618 }),
4619 })),
4620 });
4621
4622 /** Emits one conversation message into a subagent's own thread. */
4623 const emitSubagentConversationMessage = Effect.fnUntraced(function* (input: {
4624 readonly nativeItemId: string;
4625 readonly threadId: ThreadId;
4626 readonly senderThreadId?: ThreadId;
4627 readonly rootNodeId: OrchestrationV2ExecutionNode["id"];
4628 readonly role: "user" | "assistant";
4629 readonly text: string;
4630 readonly ordinal: number;
4631 readonly now: DateTime.Utc;
4632 }) {
4633 const artifacts = makeSubagentConversationArtifacts({
4634 messageId: idAllocator.derive.messageFromProviderItem({
4635 driver: CLAUDE_PROVIDER,
4636 nativeItemId: input.nativeItemId,
4637 }),
4638 turnItemId: idAllocator.derive.turnItemFromProviderItem({
4639 driver: CLAUDE_PROVIDER,
4640 nativeItemId: input.nativeItemId,
4641 }),
4642 threadId: input.threadId,
4643 ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }),
4644 rootNodeId: input.rootNodeId,
4645 providerThreadId: null,
4646 providerTurnId: null,
4647 nativeItemRef: {
4648 driver: CLAUDE_PROVIDER,
4649 nativeId: input.nativeItemId,
4650 strength: "strong",
4651 },
4652 role: input.role,
4653 text: input.text,
4654 ordinal: input.ordinal,
4655 now: input.now,
4656 });
4657 yield* emitProviderEvent({
4658 type: "message.updated",
4659 driver: CLAUDE_PROVIDER,
4660 message: artifacts.message,
4661 });
4662 yield* emitProviderEvent({
4663 type: "turn_item.updated",
4664 driver: CLAUDE_PROVIDER,
4665 turnItem: artifacts.turnItem,
4666 });
4667 });
4668
4669 /**
4670 * Projects a workflow's members as ordinary subagents.
4671 *
4672 * A member is not a provider task — it exists only inside the
4673 * coordinator's progress snapshot — but everything the user expects
4674 * from a subagent (a thread to open, a prompt, an answer) is expressed
4675 * by the same events, so this emits exactly the sequence
4676 * updateClaudeSubagentNode emits rather than inventing a parallel
4677 * shape. Members hang off the coordinator's thread, not the run's, so
4678 * a fan-out of fifty does not bury the parent's lineage.
4679 */
4680 const projectClaudeWorkflowMembers = Effect.fnUntraced(function* (input: {
4681 readonly coordinator: ActiveClaudeSubagent;
4682 readonly workflow: OrchestrationV2SubagentWorkflow;
4683 }) {
4684 const parentThread = input.coordinator.childThread;
4685 const taskId = input.coordinator.task.nativeTaskRef?.nativeId;
4686 if (parentThread === null || taskId === undefined || taskId === null) return;
4687 const now = yield* DateTime.now;
4688 const seen = yield* Ref.get(workflowMemberStates);
4689 const transcriptDir = input.workflow.runHandles?.transcriptDir;
4690
4691 for (const member of input.workflow.agents) {
4692 if ((yield* Ref.get(sessionSubagentsByTaskId)).get(taskId) !== input.coordinator)
4693 return;
4694 const memberKey = `${input.coordinator.childThreadId}:agent:${member.index}`;
4695 const previous = seen.get(memberKey);
4696 const status =
4697 (member.state === "queued" || member.state === "running") &&
4698 input.coordinator.task.status !== "running"
4699 ? input.coordinator.task.status
4700 : member.state === "queued"
4701 ? "pending"
4702 : member.state;
4703 const settled = status !== "pending" && status !== "running";
4704 const prompt = member.prompt ?? member.label;
4705 const model = member.model ?? parentThread.modelSelection.model;
4706 const attempt = member.attempt ?? previous?.attempt ?? 1;
4707 const restarted = previous !== undefined && attempt > previous.attempt;
4708 const createdAt = previous?.createdAt ?? now;
4709 const startedAt =
4710 status === "pending"
4711 ? null
4712 : member.startedAt !== undefined
4713 ? DateTime.makeUnsafe(member.startedAt)
4714 : ((restarted ? null : previous?.startedAt) ??
4715 (status === "running" ? now : null));
4716 const completedAt = settled
4717 ? ((restarted ? null : previous?.completedAt) ?? now)
4718 : null;
4719 const changed =
4720 restarted ||
4721 previous?.status !== status ||
4722 !Equal.equals(previous?.startedAt, startedAt) ||
4723 previous?.result !== member.result ||
4724 previous?.label !== member.label ||
4725 previous?.prompt !== prompt ||
4726 previous?.model !== model;
4727 const agentId = member.agentId;
4728 const readTranscript =
4729 settled &&
4730 transcriptDir !== undefined &&
4731 agentId !== undefined &&
4732 (input.coordinator.task.status === "running"
4733 ? (restarted ? 0 : (previous?.transcriptReadAttempts ?? 0)) < 3
4734 : restarted || previous?.finalTranscriptRead !== true);
4735 if (!changed && !readTranscript) continue;
4736
4737 const nodeId = idAllocator.derive.nodeFromProviderItem({
4738 driver: CLAUDE_PROVIDER,
4739 nativeItemId: memberKey,
4740 });
4741 const childRootNodeId = idAllocator.derive.nodeFromProviderItem({
4742 driver: CLAUDE_PROVIDER,
4743 nativeItemId: `${memberKey}:thread-root`,
4744 });
4745 const childThreadId = member.childThreadId;
4746 if (childThreadId === undefined) continue;
4747
4748 if (
4749 previous === undefined ||
4750 previous.label !== member.label ||
4751 previous.model !== model
4752 ) {
4753 yield* emitProviderEvent({
4754 type: "app_thread.created",
4755 driver: CLAUDE_PROVIDER,
4756 appThread: makeSubagentChildThread({
4757 parentThread,
4758 childThreadId,
4759 parentNodeId: nodeId,
4760 activeProviderThreadId: null,
4761 providerInstanceId: parentThread.providerInstanceId,
4762 modelSelection: { instanceId: parentThread.providerInstanceId, model },
4763 title: member.label,
4764 now: createdAt,
4765 createdBy: "agent",
4766 creationSource: "provider",
4767 }),
4768 });
4769 }
4770 if (previous === undefined || previous.prompt !== prompt) {
4771 yield* emitSubagentConversationMessage({
4772 nativeItemId: `${memberKey}:prompt`,
4773 threadId: childThreadId,
4774 rootNodeId: childRootNodeId,
4775 role: "user",
4776 text: prompt,
4777 ordinal: 100,
4778 now,
4779 });
4780 }
4781 if (restarted && !settled) {
4782 yield* emitSubagentConversationMessage({
4783 nativeItemId: `${memberKey}:answer:200`,
4784 threadId: childThreadId,
4785 rootNodeId: childRootNodeId,
4786 role: "assistant",
4787 text: "Retry in progress.",
4788 ordinal: 200,
4789 now,
4790 });
4791 }
4792
4793 const task = {
4794 id: nodeId,
4795 threadId: parentThread.id,
4796 runId: null,
4797 parentNodeId: input.coordinator.childRootNodeId,
4798 origin: "provider_native" as const,
4799 createdBy: "agent" as const,
4800 driver: CLAUDE_PROVIDER,
4801 providerInstanceId: parentThread.providerInstanceId,
4802 providerThreadId: null,
4803 childThreadId,
4804 nativeTaskRef: {
4805 driver: CLAUDE_PROVIDER,
4806 nativeId: memberKey,
4807 strength: "strong" as const,
4808 },
4809 prompt,
4810 title: member.label,
4811 model,
4812 status,
4813 result: member.result ?? null,
4814 startedAt,
4815 completedAt,
4816 updatedAt: now,
4817 } satisfies OrchestrationV2Subagent;
4818
4819 const nodeBase = {
4820 runId: null,
4821 status,
4822 countsForRun: false,
4823 providerThreadId: null,
4824 providerTurnId: null,
4825 nativeItemRef: task.nativeTaskRef,
4826 runtimeRequestId: null,
4827 checkpointScopeId: null,
4828 startedAt,
4829 completedAt,
4830 };
4831 if (changed) {
4832 for (const node of [
4833 {
4834 ...nodeBase,
4835 id: nodeId,
4836 threadId: parentThread.id,
4837 parentNodeId: input.coordinator.childRootNodeId,
4838 rootNodeId: input.coordinator.childRootNodeId,
4839 kind: "subagent" as const,
4840 },
4841 {
4842 ...nodeBase,
4843 id: childRootNodeId,
4844 threadId: childThreadId,
4845 parentNodeId: null,
4846 rootNodeId: childRootNodeId,
4847 kind: "root_turn" as const,
4848 },
4849 ]) {
4850 yield* emitProviderEvent({ type: "node.updated", driver: CLAUDE_PROVIDER, node });
4851 }
4852 yield* emitProviderEvent({
4853 type: "subagent.updated",
4854 driver: CLAUDE_PROVIDER,
4855 subagent: task,
4856 });
4857 }
4858
4859 let transcriptAnswerDigest = restarted ? undefined : previous?.transcriptAnswerDigest;
4860 let transcript: ReadonlyArray<string> = [];
4861 if (settled) {
4862 // The member's own transcript, when the run left one: a missing or
4863 // unreadable file is expected (a run predating run-handle capture,
4864 // a member that never started) and falls back to the excerpt.
4865 transcript = readTranscript
4866 ? yield* readWorkflowAgentAnswers({
4867 transcriptDir,
4868 agentId,
4869 ...(adapterOptions.environment.CLAUDE_CONFIG_DIR === undefined
4870 ? {}
4871 : {
4872 configDir: path.resolve(
4873 session.cwd,
4874 adapterOptions.environment.CLAUDE_CONFIG_DIR,
4875 ),
4876 }),
4877 }).pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []))
4878 : [];
4879 const transcriptAnswer = transcript.length > 0 ? transcript.join("\n\n") : undefined;
4880 const answerDigest =
4881 transcriptAnswer === undefined
4882 ? undefined
4883 : yield* sha256Hex(crypto, transcriptAnswer);
4884 // A continuation may settle or resume this workflow during the read.
4885 if ((yield* Ref.get(sessionSubagentsByTaskId)).get(taskId) !== input.coordinator)
4886 return;
4887 const previousDigest = transcriptAnswerDigest;
4888 if (answerDigest !== undefined) transcriptAnswerDigest = answerDigest;
4889 const answer =
4890 transcriptAnswer ??
4891 (member.result !== undefined && changed && previousDigest === undefined
4892 ? `Partial answer from workflow progress:\n\n${member.result}`
4893 : attempt > 1 && changed && previousDigest === undefined
4894 ? "No answer from this workflow attempt."
4895 : undefined);
4896 if (
4897 answer !== undefined &&
4898 (transcriptAnswer === undefined || restarted || answerDigest !== previousDigest)
4899 ) {
4900 yield* emitSubagentConversationMessage({
4901 nativeItemId: `${memberKey}:answer:200`,
4902 threadId: childThreadId,
4903 rootNodeId: childRootNodeId,
4904 role: "assistant",
4905 text: answer,
4906 ordinal: 200,
4907 now,
4908 });
4909 }
4910 }
4911
4912 yield* Ref.update(workflowMemberStates, (current) =>
4913 new Map(current).set(memberKey, {
4914 status,
4915 attempt,
4916 result: member.result,
4917 label: member.label,
4918 prompt,
4919 model,
4920 transcriptAnswerDigest,
4921 transcriptReadAttempts:
4922 (restarted ? 0 : (previous?.transcriptReadAttempts ?? 0)) +
4923 (readTranscript ? 1 : 0),
4924 finalTranscriptRead:
4925 (!restarted && previous?.finalTranscriptRead === true) ||
4926 (readTranscript &&
4927 transcript.length > 0 &&
4928 input.coordinator.task.status !== "running"),
4929 createdAt,
4930 startedAt,
4931 completedAt,
4932 }),
4933 );
4934 }
4935 });
4936
4937 const confirmedSubagentOutcome = (task: OrchestrationV2Subagent | undefined) => {
4938 if (
4939 task === undefined ||
4940 task.completedAt === null ||
4941 (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled")
4942 )
4943 return;
4944 return { status: task.status, completedAt: task.completedAt, result: task.result };
4945 };
4946
4947 const discardBufferedSubagentStarts = (
4948 nativeThreadId: string,
4949 taskId: string,
4950 outcome: SDKMessage,
4951 ) =>
4952 Ref.update(wakeBuffers, (current) => {
4953 const entry = current.get(nativeThreadId);
4954 if (entry === undefined) return current;
4955 const outcomeIndex = entry.messages.indexOf(outcome);
4956 const messages = entry.messages.filter(
4957 (buffered, index) =>
4958 !(
4959 (outcomeIndex === -1 || index < outcomeIndex) &&
4960 buffered.type === "system" &&
4961 (buffered.subtype === "task_started" || buffered.subtype === "task_progress") &&
4962 buffered.task_id === taskId
4963 ),
4964 );
4965 return messages.length === entry.messages.length
4966 ? current
4967 : new Map(current).set(nativeThreadId, { ...entry, messages });
4968 });
4969
4970 const updateClaudeSubagentNode = Effect.fnUntraced(function* (input: {
4971 readonly context: ActiveClaudeTurnContext;
4972 readonly taskId: string;
4973 readonly toolUseId?: string;
4974 readonly prompt?: string;
4975 readonly title?: string;
4976 readonly model?: string;
4977 // The Agent call's effort and the subagent whose own Agent call
4978 // started this one; read only when this call registers the subagent.
4979 readonly effort?: string;
4980 readonly owner?: ActiveClaudeSubagent;
4981 readonly progress?: string;
4982 /** Raw SDK frame, folded into the coordinator's workflow snapshot. */
4983 readonly workflowFrame?: unknown;
4984 readonly workflowName?: string;
4985 readonly workflowRunHandles?: OrchestrationV2WorkflowRunHandles;
4986 readonly result?: string;
4987 readonly status: Extract<
4988 OrchestrationV2ExecutionNode["status"],
4989 "running" | "completed" | "failed" | "cancelled"
4990 >;
4991 readonly reopen?: boolean;
4992 readonly nativeOutcome?: Extract<
4993 SDKMessage,
4994 { readonly type: "system"; readonly subtype: "task_notification" }
4995 >;
4996 readonly onlyIfRunningInRun?: RunId | null;
4997 }) {
4998 // The session registry lets a wake-replay turn (fresh context maps)
4999 // hydrate a subagent that was created by an earlier, settled turn.
5000 const registeredSubagent = (yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId);
5001 // Terminal cleanup must include snapshots received after the turn settled.
5002 const existingSubagent =
5003 input.onlyIfRunningInRun !== undefined
5004 ? registeredSubagent
5005 : (input.context.subagentsByTaskId.get(input.taskId) ??
5006 (input.toolUseId === undefined
5007 ? undefined
5008 : input.context.subagentsByToolUseId.get(input.toolUseId)) ??
5009 registeredSubagent);
5010 if (existingSubagent === undefined && input.status !== "running") {
5011 return;
5012 }
5013 if (
5014 input.onlyIfRunningInRun !== undefined &&
5015 (existingSubagent?.task.status !== "running" ||
5016 existingSubagent.task.runId !== input.onlyIfRunningInRun)
5017 )
5018 return;
5019 // Status is monotone with one exception: task_started for a known
5020 // task id is an authoritative CLI lifecycle event (SendMessage to a
5021 // completed subagent resumes it and re-emits task_started with the
5022 // same id), so it may re-open a terminal entry. A late or
5023 // out-of-order task_progress must not.
5024 const isReopen =
5025 input.reopen === true &&
5026 existingSubagent !== undefined &&
5027 existingSubagent.task.status !== "running" &&
5028 input.status === "running";
5029 if (
5030 existingSubagent !== undefined &&
5031 existingSubagent.task.status !== "running" &&
5032 input.status === "running" &&
5033 !isReopen
5034 ) {
5035 return;
5036 }
5037 // A task_started under a tool call other than the current run's is
5038 // a resume: SendMessage re-emits task_started for the same task id
5039 // under its own tool_use_id, with the sent message as the prompt.
5040 const resumeToolUseId =
5041 input.reopen === true &&
5042 existingSubagent !== undefined &&
5043 input.toolUseId !== undefined &&
5044 input.toolUseId !== existingSubagent.runToolUseId &&
5045 input.prompt !== undefined
5046 ? input.toolUseId
5047 : null;
5048 const lifecycleChanged =
5049 existingSubagent === undefined ||
5050 existingSubagent.task.status !== input.status ||
5051 // A drain-replayed resume task_started finds the registry entry
5052 // already pre-opened to running by bufferWakeMessage while the
5053 // projection node still holds the old terminal status; re-emit
5054 // the node lifecycle for authoritative task_started updates.
5055 (input.reopen === true && input.status === "running");
5056
5057 const now = yield* DateTime.now;
5058 const nativeItemId = `task:${input.taskId}`;
5059 const derivedIds = claudeSubagentIds(input.context, input.taskId);
5060 const nodeId = existingSubagent?.task.id ?? derivedIds.nodeId;
5061 const childRootNodeId = existingSubagent?.childRootNodeId ?? derivedIds.childRootNodeId;
5062 const childThreadId = existingSubagent?.childThreadId ?? derivedIds.childThreadId;
5063 if (existingSubagent === undefined) {
5064 input.context.subagentNodesByTaskId.set(input.taskId, nodeId);
5065 }
5066 const turnItemOrdinal =
5067 existingSubagent?.turnItemOrdinal ??
5068 (yield* resolveItemOrdinal(input.context, `${nativeItemId}:subagent`));
5069 // A resumed subagent's previous final answer and progress no longer
5070 // represent its outcome; the next task_progress/task_notification
5071 // carry the new ones.
5072 const priorTask =
5073 existingSubagent === undefined
5074 ? undefined
5075 : isReopen
5076 ? (({ progress: _staleProgress, ...rest }) => ({ ...rest, result: null }))(
5077 existingSubagent.task,
5078 )
5079 : existingSubagent.task;
5080 // Workflow telemetry is cumulative: fold this frame into whatever the
5081 // coordinator already carried, so a usage-only frame cannot blank the
5082 // member roster the user is watching.
5083 const workflow = mergeClaudeWorkflowProgress({
5084 previous: priorTask?.workflow,
5085 ...(input.workflowName === undefined ? {} : { name: input.workflowName }),
5086 ...(input.workflowRunHandles === undefined
5087 ? {}
5088 : { runHandles: input.workflowRunHandles }),
5089 message: input.workflowFrame,
5090 });
5091 const workflowWithThreads =
5092 workflow === undefined
5093 ? undefined
5094 : withWorkflowMemberThreadIds(workflow, childThreadId);
5095 const effort =
5096 existingSubagent === undefined
5097 ? (input.effort ??
5098 input.owner?.effort ??
5099 compileClaudeModelSelection(input.context.input.modelSelection).effort)
5100 : existingSubagent.effort;
5101 const model = input.model ?? priorTask?.model ?? null;
5102 const task = {
5103 ...(priorTask ?? {
5104 id: nodeId,
5105 threadId: input.context.input.threadId,
5106 runId: input.context.input.runId,
5107 parentNodeId: input.owner?.task.id ?? input.context.input.rootNodeId,
5108 origin: "provider_native" as const,
5109 createdBy: "agent" as const,
5110 driver: CLAUDE_PROVIDER,
5111 providerInstanceId: input.context.input.modelSelection.instanceId,
5112 providerThreadId: null,
5113 childThreadId,
5114 nativeTaskRef: {
5115 driver: CLAUDE_PROVIDER,
5116 nativeId: input.taskId,
5117 strength: "strong" as const,
5118 },
5119 prompt: input.prompt ?? "",
5120 title: input.title ?? null,
5121 model: input.model ?? null,
5122 result: null,
5123 startedAt: now,
5124 }),
5125 status: input.status,
5126 // A reopen replayed under a continuation run re-attributes the
5127 // subagent to that run. RunExecutionService routes parent-thread
5128 // events by runId, and only the resuming run's ingestion fiber is
5129 // guaranteed alive (the launch run's fiber stops once its child
5130 // subagents terminalize); attribution also enrolls the subagent
5131 // in the resuming run's active-child tracking so its fiber
5132 // outlives settle until the resumed task completes.
5133 ...(input.reopen === true &&
5134 input.status === "running" &&
5135 existingSubagent !== undefined
5136 ? { runId: input.context.input.runId }
5137 : {}),
5138 ...(input.prompt === undefined ? {} : { prompt: input.prompt }),
5139 ...(input.title === undefined ? {} : { title: input.title }),
5140 model,
5141 // What the subagent runs with, unless it was recovered after a
5142 // restart. The parent's other options belong to the parent's session.
5143 ...(existingSubagent === undefined || existingSubagent.task.modelSelection !== undefined
5144 ? {
5145 modelSelection: claudeSubagentModelSelection(
5146 subagentModelCatalog,
5147 input.context.input.modelSelection.instanceId,
5148 model || input.context.input.modelSelection.model,
5149 effort,
5150 ),
5151 }
5152 : {}),
5153 ...(input.progress === undefined ? {} : { progress: input.progress }),
5154 ...(workflowWithThreads === undefined ? {} : { workflow: workflowWithThreads }),
5155 ...(input.result === undefined ? {} : { result: input.result }),
5156 ...(isReopen ? { startedAt: now } : {}),
5157 completedAt: input.status === "running" ? null : (priorTask?.completedAt ?? now),
5158 updatedAt: now,
5159 } satisfies OrchestrationV2Subagent;
5160 const subagent = {
5161 task,
5162 confirmedOutcome:
5163 input.nativeOutcome !== undefined
5164 ? confirmedSubagentOutcome(task)
5165 : isReopen
5166 ? undefined
5167 : existingSubagent?.confirmedOutcome,
5168 rootNodeId: existingSubagent?.rootNodeId ?? input.context.input.rootNodeId,
5169 childThreadId,
5170 childRootNodeId,
5171 turnItemId: existingSubagent?.turnItemId ?? derivedIds.turnItemId,
5172 turnItemOrdinal,
5173 runToolUseId:
5174 existingSubagent === undefined
5175 ? (input.toolUseId ?? null)
5176 : (resumeToolUseId ?? existingSubagent.runToolUseId),
5177 childThread: existingSubagent?.childThread ?? null,
5178 effort,
5179 nextChildItemOrdinal: existingSubagent?.nextChildItemOrdinal ?? 100,
5180 resultItemOrdinal: existingSubagent?.resultItemOrdinal ?? null,
5181 // Every task_started begins a new run of the subagent (including a
5182 // resume that bufferWakeMessage already pre-opened), so text from
5183 // an earlier run can no longer stand in for the next result.
5184 lastAssistantText:
5185 input.reopen === true ? null : (existingSubagent?.lastAssistantText ?? null),
5186 lastAssistantMessageId:
5187 input.reopen === true ? null : (existingSubagent?.lastAssistantMessageId ?? null),
5188 } satisfies ActiveClaudeSubagent;
5189 // The same terminal protection, applied atomically: a concurrent
5190 // fiber (live stream vs continuation drain) may have terminalized
5191 // the registry entry after this update's lookup read it. A resume
5192 // re-open (task_started) bypasses it only when the registered entry
5193 // is still the terminal generation the lookup resolved; if a
5194 // concurrent fiber installed a newer terminal entry meanwhile, the
5195 // re-open must not clobber its result.
5196 const accepted = yield* Ref.modify(sessionSubagentsByTaskId, (current) => {
5197 const registered = current.get(input.taskId);
5198 if (
5199 (input.onlyIfRunningInRun !== undefined &&
5200 (registered?.task.status !== "running" ||
5201 registered.task.runId !== input.onlyIfRunningInRun)) ||
5202 (registered !== undefined &&
5203 registered.task.status !== "running" &&
5204 input.status === "running" &&
5205 !(isReopen && registered === existingSubagent))
5206 ) {
5207 return [false, current] as const;
5208 }
5209 return [true, new Map(current).set(input.taskId, subagent)] as const;
5210 });
5211 if (!accepted) return;
5212 if (input.nativeOutcome !== undefined) appliedSubagentOutcomes.add(input.nativeOutcome);
5213 // A call the subagent left open never returns once it ends. It ends
5214 // first, while the run that owns the child thread still takes its
5215 // events.
5216 if (existingSubagent !== undefined && input.status !== "running") {
5217 const openCalls = [...subagentToolCalls.values()].filter(
5218 (toolCall) => toolCall.threadId === existingSubagent.childThreadId,
5219 );
5220 yield* endToolCalls({
5221 context: input.context,
5222 toolCalls: openCalls,
5223 status: input.status === "cancelled" ? "interrupted" : "failed",
5224 completedAt: yield* DateTime.now,
5225 });
5226 for (const toolCall of openCalls) {
5227 subagentToolCalls.delete(toolCall.nativeItemId);
5228 }
5229 }
5230 input.context.subagentsByTaskId.set(input.taskId, subagent);
5231 if (input.toolUseId !== undefined) {
5232 input.context.subagentsByToolUseId.set(input.toolUseId, subagent);
5233 const toolUseId = input.toolUseId;
5234 yield* Ref.update(sessionSubagentTaskIdsByToolUseId, (current) =>
5235 current.get(toolUseId) === input.taskId
5236 ? current
5237 : new Map(current).set(toolUseId, input.taskId),
5238 );
5239 }
5240
5241 if (subagent.childThread === null) {
5242 const childThread = makeSubagentChildThread({
5243 parentThread: input.context.input.appThread,
5244 childThreadId,
5245 parentNodeId: nodeId,
5246 activeProviderThreadId: null,
5247 providerInstanceId: input.context.input.modelSelection.instanceId,
5248 modelSelection: task.modelSelection ?? input.context.input.modelSelection,
5249 title: subagentThreadTitle({
5250 parentTitle: input.context.input.appThread.title,
5251 prompt: task.prompt,
5252 title:
5253 workflowWithThreads === undefined
5254 ? task.title
5255 : `Workflow · ${workflowWithThreads.name ?? task.title ?? "Untitled"}`,
5256 ordinal: input.context.subagentsByTaskId.size,
5257 }),
5258 now,
5259 createdBy: "agent",
5260 creationSource: "provider",
5261 });
5262 if (existingSubagent === undefined) {
5263 yield* emitProviderEvent({
5264 type: "app_thread.created",
5265 driver: CLAUDE_PROVIDER,
5266 appThread: childThread,
5267 });
5268 }
5269 subagent.childThread = childThread;
5270 }
5271
5272 if (lifecycleChanged) {
5273 yield* emitProviderEvent({
5274 type: "node.updated",
5275 driver: CLAUDE_PROVIDER,
5276 node: {
5277 id: nodeId,
5278 // Parenting stays with the launch run's root node (or the
5279 // owning subagent) even on wake-replay; runId follows
5280 // task.runId, which a reopen re-attributes to the resuming run
5281 // (see task construction).
5282 threadId: task.threadId,
5283 runId: task.runId,
5284 parentNodeId: task.parentNodeId,
5285 rootNodeId: subagent.rootNodeId,
5286 kind: "subagent",
5287 status: input.status,
5288 countsForRun: false,
5289 providerThreadId: input.context.input.providerThread.id,
5290 providerTurnId: input.context.providerTurnId,
5291 nativeItemRef: {
5292 driver: CLAUDE_PROVIDER,
5293 nativeId: input.taskId,
5294 strength: "strong",
5295 },
5296 runtimeRequestId: null,
5297 checkpointScopeId: null,
5298 startedAt: task.startedAt,
5299 completedAt: task.completedAt,
5300 },
5301 });
5302 yield* emitProviderEvent({
5303 type: "node.updated",
5304 driver: CLAUDE_PROVIDER,
5305 node: {
5306 id: childRootNodeId,
5307 threadId: childThreadId,
5308 runId: null,
5309 parentNodeId: null,
5310 rootNodeId: childRootNodeId,
5311 kind: "root_turn",
5312 status: input.status,
5313 countsForRun: false,
5314 providerThreadId: null,
5315 providerTurnId: null,
5316 nativeItemRef: task.nativeTaskRef,
5317 runtimeRequestId: null,
5318 checkpointScopeId: null,
5319 startedAt: task.startedAt,
5320 completedAt: task.completedAt,
5321 },
5322 });
5323 }
5324 // Each run opens with its own prompt in the child thread: the launch
5325 // task, then every message that resumes the subagent.
5326 const promptNativeItemId =
5327 existingSubagent === undefined
5328 ? `${nativeItemId}:prompt`
5329 : resumeToolUseId === null
5330 ? null
5331 : `${nativeItemId}:prompt:${resumeToolUseId}`;
5332 if (promptNativeItemId !== null) {
5333 yield* emitSubagentConversationMessage({
5334 nativeItemId: promptNativeItemId,
5335 senderThreadId: input.context.input.threadId,
5336 threadId: childThreadId,
5337 rootNodeId: childRootNodeId,
5338 role: "user",
5339 text: task.prompt,
5340 ordinal: existingSubagent === undefined ? 100 : ++subagent.nextChildItemOrdinal,
5341 now,
5342 });
5343 }
5344 yield* emitProviderEvent({
5345 type: "subagent.updated",
5346 driver: CLAUDE_PROVIDER,
5347 subagent: task,
5348 });
5349 if (workflowWithThreads !== undefined) {
5350 yield* projectClaudeWorkflowMembers({
5351 coordinator: subagent,
5352 workflow: workflowWithThreads,
5353 });
5354 if ((yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId) !== subagent) return;
5355 }
5356 yield* emitProviderEvent({
5357 type: "turn_item.updated",
5358 driver: CLAUDE_PROVIDER,
5359 turnItem: {
5360 id: subagent.turnItemId,
5361 threadId: task.threadId,
5362 runId: task.runId,
5363 nodeId: task.id,
5364 providerThreadId: input.context.input.providerThread.id,
5365 providerTurnId: input.context.providerTurnId,
5366 nativeItemRef: task.nativeTaskRef,
5367 parentItemId: null,
5368 ordinal: subagent.turnItemOrdinal,
5369 status: task.status,
5370 title: task.title,
5371 startedAt: task.startedAt,
5372 completedAt: task.completedAt,
5373 updatedAt: task.updatedAt,
5374 type: "subagent",
5375 subagentId: task.id,
5376 origin: task.origin,
5377 driver: task.driver,
5378 providerInstanceId: task.providerInstanceId,
5379 childThreadId: task.childThreadId,
5380 prompt: task.prompt,
5381 ...(task.progress === undefined ? {} : { progress: task.progress }),
5382 result: task.result,
5383 },
5384 });
5385
5386 if (input.nativeOutcome !== undefined && subagent.confirmedOutcome !== undefined) {
5387 const outcome = subagentStopOutcomes.get(input.taskId);
5388 if (outcome !== undefined) yield* Deferred.succeed(outcome, subagent.confirmedOutcome);
5389 }
5390
5391 // A completed subagent's result is normally its final assistant
5392 // message, which is already in the child thread when its text was
5393 // routed there. Failures and cancellations always get the message.
5394 const resultAlreadyShown =
5395 input.status === "completed" &&
5396 input.result !== undefined &&
5397 normalizeClaudeResultText(subagent.lastAssistantText ?? "") ===
5398 normalizeClaudeResultText(input.result);
5399 if (
5400 input.result !== undefined &&
5401 input.result.trim().length > 0 &&
5402 input.status !== "running" &&
5403 !resultAlreadyShown
5404 ) {
5405 const resultNativeItemId = `${nativeItemId}:result`;
5406 const resultItemOrdinal = subagent.resultItemOrdinal ?? ++subagent.nextChildItemOrdinal;
5407 subagent.resultItemOrdinal = resultItemOrdinal;
5408 yield* emitSubagentConversationMessage({
5409 nativeItemId: resultNativeItemId,
5410 threadId: childThreadId,
5411 rootNodeId: childRootNodeId,
5412 role: "assistant",
5413 text: input.result,
5414 ordinal: resultItemOrdinal,
5415 now,
5416 });
5417 }
5418 });
5419
5420 const emitClaudePlanProjection = Effect.fnUntraced(function* (input: {
5421 readonly context: ActiveClaudeTurnContext;
5422 readonly nativeItemId: string;
5423 readonly kind: "todo_list" | "proposed_plan";
5424 readonly steps?: ReadonlyArray<OrchestrationV2PlanStep>;
5425 readonly markdown?: string;
5426 }) {
5427 const updatedAt = yield* DateTime.now;
5428 const projectionNativeItemId = `${input.kind}:${input.nativeItemId}`;
5429 const planKey = `${input.context.input.threadId}:${projectionNativeItemId}`;
5430 const existingPlanId = (yield* Ref.get(planIdsByNativeItem)).get(planKey);
5431 const planId =
5432 existingPlanId ??
5433 (yield* idAllocator.allocate.plan({
5434 threadId: input.context.input.threadId,
5435 runId: input.context.input.runId,
5436 driver: CLAUDE_PROVIDER,
5437 }));
5438 if (existingPlanId === undefined) {
5439 yield* Ref.update(planIdsByNativeItem, (current) => {
5440 const updated = new Map(current);
5441 updated.set(planKey, planId);
5442 return updated;
5443 });
5444 }
5445 const nodeId = idAllocator.derive.nodeFromProviderItem({
5446 driver: CLAUDE_PROVIDER,
5447 nativeItemId: projectionNativeItemId,
5448 });
5449 const nativeItemRef = {
5450 driver: CLAUDE_PROVIDER,
5451 nativeId: input.nativeItemId,
5452 strength: "strong" as const,
5453 };
5454 const ordinal = yield* resolveItemOrdinal(input.context, projectionNativeItemId);
5455 const steps = [...(input.steps ?? [])];
5456 const todoStatus = steps.every((step) => step.status === "completed")
5457 ? "completed"
5458 : "active";
5459 const plan: OrchestrationV2PlanArtifact =
5460 input.kind === "todo_list"
5461 ? {
5462 id: planId,
5463 threadId: input.context.input.threadId,
5464 runId: input.context.input.runId,
5465 nodeId,
5466 kind: "todo_list",
5467 status: todoStatus,
5468 steps,
5469 }
5470 : {
5471 id: planId,
5472 threadId: input.context.input.threadId,
5473 runId: input.context.input.runId,
5474 nodeId,
5475 kind: "proposed_plan",
5476 status: "active",
5477 markdown: input.markdown ?? "",
5478 };
5479 const node: OrchestrationV2ExecutionNode = {
5480 id: nodeId,
5481 threadId: input.context.input.threadId,
5482 runId: input.context.input.runId,
5483 parentNodeId: input.context.input.rootNodeId,
5484 rootNodeId: input.context.input.rootNodeId,
5485 kind: input.kind === "todo_list" ? "todo_list" : "plan",
5486 status: "completed",
5487 countsForRun: false,
5488 providerThreadId: input.context.input.providerThread.id,
5489 providerTurnId: input.context.providerTurnId,
5490 nativeItemRef,
5491 runtimeRequestId: null,
5492 checkpointScopeId: null,
5493 startedAt: updatedAt,
5494 completedAt: updatedAt,
5495 };
5496 const common = {
5497 id: idAllocator.derive.turnItemFromProviderItem({
5498 driver: CLAUDE_PROVIDER,
5499 nativeItemId: projectionNativeItemId,
5500 }),
5501 threadId: input.context.input.threadId,
5502 runId: input.context.input.runId,
5503 nodeId,
5504 providerThreadId: input.context.input.providerThread.id,
5505 providerTurnId: input.context.providerTurnId,
5506 nativeItemRef,
5507 parentItemId: null,
5508 ordinal,
5509 status: "completed" as const,
5510 title: null,
5511 startedAt: updatedAt,
5512 completedAt: updatedAt,
5513 updatedAt,
5514 };
5515 const turnItem: OrchestrationV2TurnItem =
5516 input.kind === "todo_list"
5517 ? { ...common, type: "todo_list", planId, steps }
5518 : {
5519 ...common,
5520 type: "proposed_plan",
5521 planId,
5522 markdown: input.markdown ?? "",
5523 streaming: false,
5524 };
5525 const latestPlanKey = `${input.context.input.threadId}:${input.kind}`;
5526 const previousPlan = (yield* Ref.get(latestPlanByKind)).get(latestPlanKey);
5527 yield* Effect.all(
5528 [
5529 ...(previousPlan === undefined ||
5530 previousPlan.id === plan.id ||
5531 previousPlan.status === "completed"
5532 ? []
5533 : [
5534 emitProviderEvent({
5535 type: "plan.updated" as const,
5536 driver: CLAUDE_PROVIDER,
5537 plan: { ...previousPlan, status: "superseded" as const },
5538 }),
5539 ]),
5540 emitProviderEvent({ type: "node.updated", driver: CLAUDE_PROVIDER, node }),
5541 emitProviderEvent({ type: "plan.updated", driver: CLAUDE_PROVIDER, plan }),
5542 emitProviderEvent({ type: "turn_item.updated", driver: CLAUDE_PROVIDER, turnItem }),
5543 ],
5544 { concurrency: 1 },
5545 );
5546 yield* Ref.update(latestPlanByKind, (current) => {
5547 const updated = new Map(current);
5548 updated.set(latestPlanKey, plan);
5549 return updated;
5550 });
5551 });
5552
5553 // Resolves the subagent that owns a parent_tool_use_id. The session
5554 // registry is authoritative: every update replaces the subagent
5555 // object, and per-turn tool-use aliases can lag behind (updates
5556 // without a toolUseId) or be empty (a turn other than the one that
5557 // launched the subagent). Mutations must land on the current object.
5558 const resolveSubagentByToolUseId = Effect.fnUntraced(function* (
5559 context: ActiveClaudeTurnContext,
5560 toolUseId: string,
5561 ) {
5562 const taskId = (yield* Ref.get(sessionSubagentTaskIdsByToolUseId)).get(toolUseId);
5563 const registered =
5564 taskId === undefined
5565 ? undefined
5566 : (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId);
5567 return registered ?? context.subagentsByToolUseId.get(toolUseId);
5568 });
5569
5570 const findToolCall = (context: ActiveClaudeTurnContext, nativeItemId: string) =>
5571 context.toolCalls.get(nativeItemId) ?? subagentToolCalls.get(nativeItemId);
5572 // Only a subagent's calls have no run: they belong to its child thread.
5573 const toolCallsFor = (context: ActiveClaudeTurnContext, toolCall: ActiveClaudeToolCall) =>
5574 toolCall.runId === null ? subagentToolCalls : context.toolCalls;
5575
5576 const ensureToolCallStarted = Effect.fnUntraced(function* (input: {
5577 readonly context: ActiveClaudeTurnContext;
5578 readonly nativeItemId: string;
5579 readonly toolName: string;
5580 readonly toolInput: ClaudeNativeToolInput;
5581 readonly parentToolUseId: string | null;
5582 readonly presentation?: ClaudeToolPresentation | undefined;
5583 }) {
5584 const existing = findToolCall(input.context, input.nativeItemId);
5585 if (existing !== undefined) {
5586 // The permission callback can start a call before its assistant
5587 // frame arrives with the tool's display name and icon.
5588 if (input.presentation === undefined || existing.presentation !== undefined) {
5589 return existing;
5590 }
5591 const presented = { ...existing, presentation: input.presentation };
5592 toolCallsFor(input.context, presented).set(input.nativeItemId, presented);
5593 const updatedAt = yield* DateTime.now;
5594 yield* emitToolCallArtifacts(
5595 buildToolCallArtifacts({
5596 context: input.context,
5597 nativeItemId: presented.nativeItemId,
5598 toolName: presented.toolName,
5599 classification: presented.classification,
5600 toolInput: presented.input,
5601 threadId: presented.threadId,
5602 runId: presented.runId,
5603 rootNodeId: presented.rootNodeId,
5604 parentNodeId: presented.parentNodeId,
5605 ordinal: presented.ordinal,
5606 output: NO_CLAUDE_NATIVE_TOOL_OUTPUT,
5607 status: "running",
5608 startedAt: presented.startedAt,
5609 updatedAt,
5610 presentation: input.presentation,
5611 }),
5612 );
5613 return presented;
5614 }
5615 const startedAt = yield* DateTime.now;
5616 const classification = classifyClaudeNativeTool(input.toolName);
5617 const subagent =
5618 input.parentToolUseId === null
5619 ? undefined
5620 : yield* resolveSubagentByToolUseId(input.context, input.parentToolUseId);
5621 const threadId = subagent?.childThreadId ?? input.context.input.threadId;
5622 const runId = subagent === undefined ? input.context.input.runId : null;
5623 const rootNodeId = subagent?.childRootNodeId ?? input.context.input.rootNodeId;
5624 const parentNodeId = rootNodeId;
5625 const ordinal =
5626 subagent === undefined
5627 ? yield* resolveItemOrdinal(input.context, input.nativeItemId)
5628 : ++subagent.nextChildItemOrdinal;
5629 const toolCall: ActiveClaudeToolCall = {
5630 nativeItemId: input.nativeItemId,
5631 toolName: input.toolName,
5632 classification,
5633 input: input.toolInput,
5634 threadId,
5635 runId,
5636 rootNodeId,
5637 parentNodeId,
5638 ordinal,
5639 startedAt,
5640 ...(input.presentation === undefined ? {} : { presentation: input.presentation }),
5641 };
5642 toolCallsFor(input.context, toolCall).set(input.nativeItemId, toolCall);
5643 yield* emitToolCallArtifacts(
5644 buildToolCallArtifacts({
5645 context: input.context,
5646 nativeItemId: input.nativeItemId,
5647 toolName: input.toolName,
5648 classification,
5649 toolInput: input.toolInput,
5650 threadId,
5651 runId,
5652 rootNodeId,
5653 parentNodeId,
5654 ordinal,
5655 output: NO_CLAUDE_NATIVE_TOOL_OUTPUT,
5656 status: "running",
5657 startedAt,
5658 updatedAt: startedAt,
5659 presentation: input.presentation,
5660 }),
5661 );
5662 return toolCall;
5663 });
5664
5665 const buildApprovalRequestArtifacts = Effect.fnUntraced(function* (input: {
5666 readonly context: ActiveClaudeTurnContext;
5667 readonly nativeItemId: string;
5668 readonly nativeRequestId: string;
5669 readonly requestKind: OrchestrationV2RuntimeRequest["kind"];
5670 readonly prompt?: string;
5671 readonly questions?: ReadonlyArray<OrchestrationV2UserInputQuestion>;
5672 }) {
5673 const createdAt = yield* DateTime.now;
5674 const requestId = yield* idAllocator.allocate.runtimeRequest({
5675 driver: CLAUDE_PROVIDER,
5676 providerTurnId: input.context.providerTurnId,
5677 nativeRequestId: input.nativeRequestId,
5678 });
5679 const nodeId = idAllocator.derive.approvalNode({ requestId });
5680 const providerSessionId = input.context.input.providerThread.providerSessionId;
5681 if (providerSessionId === null) {
5682 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
5683 driver: CLAUDE_PROVIDER,
5684 detail: `Provider thread ${input.context.input.providerThread.id} is missing a provider session id.`,
5685 });
5686 }
5687 const ordinal = yield* resolveItemOrdinal(
5688 input.context,
5689 `${input.nativeItemId}:approval:${input.nativeRequestId}`,
5690 );
5691 const nativeItemRef = {
5692 driver: CLAUDE_PROVIDER,
5693 nativeId: input.nativeRequestId,
5694 strength: "strong" as const,
5695 };
5696 const node: OrchestrationV2ExecutionNode = {
5697 id: nodeId,
5698 threadId: input.context.input.threadId,
5699 runId: input.context.input.runId,
5700 parentNodeId: idAllocator.derive.nodeFromProviderItem({
5701 driver: CLAUDE_PROVIDER,
5702 nativeItemId: input.nativeItemId,
5703 }),
5704 rootNodeId: input.context.input.rootNodeId,
5705 kind: input.questions === undefined ? "approval_request" : "user_input_request",
5706 status: "waiting",
5707 countsForRun: false,
5708 providerThreadId: input.context.input.providerThread.id,
5709 providerTurnId: input.context.providerTurnId,
5710 nativeItemRef,
5711 runtimeRequestId: requestId,
5712 checkpointScopeId: null,
5713 startedAt: createdAt,
5714 completedAt: null,
5715 };
5716 const request: OrchestrationV2RuntimeRequest = {
5717 id: requestId,
5718 nodeId,
5719 providerTurnId: input.context.providerTurnId,
5720 nativeRequestRef: {
5721 driver: CLAUDE_PROVIDER,
5722 nativeId: input.nativeRequestId,
5723 strength: "strong",
5724 },
5725 kind: input.requestKind,
5726 status: "pending",
5727 responseCapability: {
5728 type: "live",
5729 providerSessionId,
5730 },
5731 createdAt,
5732 resolvedAt: null,
5733 };
5734 const turnItem: OrchestrationV2TurnItem = {
5735 id: idAllocator.derive.approvalTurnItem({ requestId }),
5736 threadId: input.context.input.threadId,
5737 runId: input.context.input.runId,
5738 nodeId,
5739 providerThreadId: input.context.input.providerThread.id,
5740 providerTurnId: input.context.providerTurnId,
5741 nativeItemRef,
5742 parentItemId: null,
5743 ordinal,
5744 status: "waiting",
5745 title: null,
5746 startedAt: createdAt,
5747 completedAt: null,
5748 updatedAt: createdAt,
5749 ...(input.questions === undefined
5750 ? {
5751 type: "approval_request" as const,
5752 requestId,
5753 requestKind: input.requestKind as ProviderRequestKind,
5754 ...(input.prompt === undefined ? {} : { prompt: input.prompt }),
5755 }
5756 : {
5757 type: "user_input_request" as const,
5758 requestId,
5759 questions: [...input.questions],
5760 }),
5761 };
5762 return { node, request, turnItem };
5763 });
5764
5765 const ensureReasoningBlock = Effect.fnUntraced(function* (
5766 context: ActiveClaudeTurnContext,
5767 itemId: string,
5768 ) {
5769 if (!context.reasoning.blocks.has(itemId)) {
5770 context.reasoning.blocks.set(itemId, {
5771 startedAt: yield* DateTime.now,
5772 ordinal: yield* resolveItemOrdinal(context, itemId),
5773 text: "",
5774 });
5775 }
5776 });
5777 const reasoningDeltas = yield* makeProviderTextDeltaCoalescer({
5778 flushIntervalMs: 50,
5779 emit: (update) =>
5780 Effect.gen(function* () {
5781 const context = yield* Ref.get(activeTurn);
5782 if (context === null || context.nativeTurnId !== update.turnId) return;
5783 const block = context.reasoning.blocks.get(update.itemId);
5784 if (block === undefined || update.text.length === 0) return;
5785 block.text = update.text;
5786 const now = yield* DateTime.now;
5787 yield* emitProviderEvent({
5788 type: "turn_item.updated",
5789 driver: CLAUDE_PROVIDER,
5790 turnItem: {
5791 id: idAllocator.derive.turnItemFromProviderItem({
5792 driver: CLAUDE_PROVIDER,
5793 nativeItemId: update.itemId,
5794 }),
5795 threadId: context.input.threadId,
5796 runId: context.input.runId,
5797 nodeId: context.input.rootNodeId,
5798 providerThreadId: context.input.providerThread.id,
5799 providerTurnId: context.providerTurnId,
5800 nativeItemRef: {
5801 driver: CLAUDE_PROVIDER,
5802 nativeId: update.itemId,
5803 strength: "strong",
5804 },
5805 parentItemId: null,
5806 ordinal: block.ordinal,
5807 type: "reasoning",
5808 title: "Thinking",
5809 text: update.text,
5810 streaming: !update.completed,
5811 status: update.completed ? "completed" : "running",
5812 startedAt: block.startedAt,
5813 completedAt: update.completed ? now : null,
5814 updatedAt: now,
5815 },
5816 });
5817 }),
5818 });
5819
5820 // Ends open calls whose results will never arrive.
5821 const endToolCalls = Effect.fnUntraced(function* (input: {
5822 readonly context: ActiveClaudeTurnContext;
5823 readonly toolCalls: ReadonlyArray<ActiveClaudeToolCall>;
5824 readonly status: Extract<
5825 OrchestrationV2ProviderTurn["status"],
5826 "interrupted" | "failed" | "cancelled"
5827 >;
5828 readonly completedAt: DateTime.Utc;
5829 }) {
5830 for (const toolCall of input.toolCalls) {
5831 yield* emitToolCallArtifacts(
5832 buildToolCallArtifacts({
5833 context: input.context,
5834 nativeItemId: toolCall.nativeItemId,
5835 toolName: toolCall.toolName,
5836 classification: toolCall.classification,
5837 toolInput: toolCall.input,
5838 threadId: toolCall.threadId,
5839 runId: toolCall.runId,
5840 rootNodeId: toolCall.rootNodeId,
5841 parentNodeId: toolCall.parentNodeId,
5842 ordinal: toolCall.ordinal,
5843 output: NO_CLAUDE_NATIVE_TOOL_OUTPUT,
5844 status: input.status,
5845 startedAt: toolCall.startedAt,
5846 updatedAt: input.completedAt,
5847 presentation: toolCall.presentation,
5848 }),
5849 );
5850 }
5851 });
5852 // Ends the calls a subagent kept open past the root's last turn, once
5853 // the CLI process that ran them is gone.
5854 const endSubagentCallsOfClosedQuery = Effect.fnUntraced(function* (
5855 nativeThreadId: string,
5856 status: "interrupted" | "failed",
5857 ) {
5858 const settled = settledTurnByNativeThread.get(nativeThreadId);
5859 if (settled === undefined || subagentToolCalls.size === 0) {
5860 return;
5861 }
5862 yield* endToolCalls({
5863 context: settled,
5864 toolCalls: [...subagentToolCalls.values()],
5865 status,
5866 completedAt: yield* DateTime.now,
5867 });
5868 subagentToolCalls.clear();
5869 });
5870
5871 const finalizeActiveTurn = Effect.fnUntraced(function* (input: {
5872 readonly context: ActiveClaudeTurnContext;
5873 readonly status: Extract<
5874 OrchestrationV2ProviderTurn["status"],
5875 "completed" | "interrupted" | "failed" | "cancelled"
5876 >;
5877 readonly completedAt: DateTime.Utc;
5878 readonly failure?: OrchestrationV2ProviderFailure;
5879 readonly threadDisposition?: "reusable" | "broken";
5880 readonly result?: SDKResultMessage;
5881 }) {
5882 yield* reasoningDeltas.flushTurn(input.context.nativeTurnId);
5883 if (input.status !== "completed") {
5884 // The run stops following its subagents once its root turn fails or is
5885 // interrupted, so a workflow it follows is stopped rather than left
5886 // running where Lineage can no longer see or stop it.
5887 const liveQuery = yield* Ref.get(queryContext);
5888 for (const [taskId, subagent] of yield* Ref.get(sessionSubagentsByTaskId)) {
5889 if (
5890 subagent.task.workflow === undefined ||
5891 subagent.task.status !== "running" ||
5892 subagent.task.runId !== input.context.input.runId
5893 )
5894 continue;
5895 if (liveQuery !== null && !liveQuery.stopping) {
5896 // Best effort and off the terminal path: an unanswered stop must
5897 // not keep the turn from ending.
5898 yield* liveQuery.query
5899 .stopTask(taskId)
5900 .pipe(Effect.timeout("15 seconds"), Effect.ignore, Effect.forkIn(sessionScope));
5901 }
5902 yield* updateClaudeSubagentNode({
5903 context: input.context,
5904 taskId,
5905 status: input.status === "failed" ? "failed" : "cancelled",
5906 onlyIfRunningInRun: input.context.input.runId,
5907 });
5908 }
5909 }
5910 // A subagent still running in the background keeps its open calls:
5911 // their results arrive after this turn. One left by an earlier CLI
5912 // process is gone and never reports them.
5913 const liveQuery = yield* Ref.get(queryContext);
5914 const runningChildThreadIds = new Set(
5915 [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].flatMap((subagent) =>
5916 subagent.task.status === "running" &&
5917 (input.status === "completed" ||
5918 (liveQuery !== null && subagent.task.runId !== input.context.input.runId)) &&
5919 liveQuery?.subagentsFromEarlierProcesses.has(subagent) !== true
5920 ? [subagent.childThreadId]
5921 : [],
5922 ),
5923 );
5924 const endingSubagentCalls = [...subagentToolCalls.values()].filter(
5925 (toolCall) => !runningChildThreadIds.has(toolCall.threadId),
5926 );
5927 yield* endToolCalls({
5928 context: input.context,
5929 toolCalls: [...input.context.toolCalls.values(), ...endingSubagentCalls],
5930 // A stopped turn cuts its open tools short; only a turn that
5931 // ended on its own leaves them failed.
5932 status: input.status === "completed" ? "failed" : input.status,
5933 completedAt: input.completedAt,
5934 });
5935 input.context.toolCalls.clear();
5936 for (const toolCall of endingSubagentCalls) {
5937 subagentToolCalls.delete(toolCall.nativeItemId);
5938 }
5939
5940 if (
5941 input.context.assistant.emittedNativeItemIds.size === 0 &&
5942 input.context.assistant.fallbackText.length > 0
5943 ) {
5944 const ordinal = yield* resolveItemOrdinal(
5945 input.context,
5946 input.context.assistant.fallbackNativeItemId,
5947 );
5948 const artifacts = buildAssistantArtifacts({
5949 idAllocator,
5950 turnInput: input.context.input,
5951 providerTurnId: input.context.providerTurnId,
5952 nativeItemId: input.context.assistant.fallbackNativeItemId,
5953 text: input.context.assistant.fallbackText,
5954 ordinal,
5955 startedAt: input.context.startedAt,
5956 completedAt: input.completedAt,
5957 });
5958 yield* Effect.all(
5959 [
5960 emitProviderEvent({
5961 type: "node.updated",
5962 driver: CLAUDE_PROVIDER,
5963 node: artifacts.node,
5964 }),
5965 emitProviderEvent({
5966 type: "message.updated",
5967 driver: CLAUDE_PROVIDER,
5968 message: artifacts.message,
5969 }),
5970 emitProviderEvent({
5971 type: "turn_item.updated",
5972 driver: CLAUDE_PROVIDER,
5973 turnItem: artifacts.turnItem,
5974 }),
5975 ],
5976 { concurrency: 1 },
5977 );
5978 }
5979
5980 const providerRetry = yield* Ref.modify(providerRetries, (current) => {
5981 const retry = current.get(input.context.providerTurnId);
5982 if (retry === undefined) {
5983 return [undefined, current] as const;
5984 }
5985 const updated = new Map(current);
5986 updated.delete(input.context.providerTurnId);
5987 return [retry, updated] as const;
5988 });
5989 if (providerRetry !== undefined && input.status !== "failed") {
5990 yield* emitProviderEvent({
5991 type: "turn_item.updated",
5992 driver: CLAUDE_PROVIDER,
5993 turnItem: makeProviderRetryTurnItem({
5994 driver: CLAUDE_PROVIDER,
5995 threadId: input.context.input.threadId,
5996 runId: input.context.input.runId,
5997 nodeId: input.context.input.rootNodeId,
5998 providerThreadId: input.context.input.providerThread.id,
5999 providerTurnId: input.context.providerTurnId,
6000 itemOrdinal: providerRetry.itemOrdinal,
6001 failure: providerRetry.failure,
6002 retry: providerRetry.retry,
6003 status: input.status,
6004 startedAt: providerRetry.startedAt,
6005 updatedAt: input.completedAt,
6006 }),
6007 });
6008 }
6009
6010 const threadDisposition = input.threadDisposition ?? "reusable";
6011 const terminalEvent: ProviderAdapter.ProviderAdapterV2Event =
6012 input.status === "failed"
6013 ? {
6014 type: "turn.terminal",
6015 driver: CLAUDE_PROVIDER,
6016 providerThreadId: input.context.input.providerThread.id,
6017 providerTurnId: input.context.providerTurnId,
6018 runOrdinal: input.context.input.runOrdinal,
6019 failureItemOrdinal: yield* resolveItemOrdinal(
6020 input.context,
6021 `terminal-failure:${input.context.providerTurnId}`,
6022 ),
6023 status: input.status,
6024 failure: input.failure ?? makeProviderFailure({ class: "provider_error" }),
6025 ...(providerRetry === undefined
6026 ? {}
6027 : {
6028 retry: providerRetry.retry,
6029 retryStartedAt: providerRetry.startedAt,
6030 }),
6031 threadDisposition,
6032 }
6033 : {
6034 type: "turn.terminal",
6035 driver: CLAUDE_PROVIDER,
6036 providerThreadId: input.context.input.providerThread.id,
6037 providerTurnId: input.context.providerTurnId,
6038 runOrdinal: input.context.input.runOrdinal,
6039 status: input.status,
6040 failure: null,
6041 threadDisposition,
6042 };
6043 yield* Effect.all(
6044 [
6045 emitProviderEvent({
6046 type: "provider_turn.updated",
6047 driver: CLAUDE_PROVIDER,
6048 providerTurn: {
6049 ...providerTurnPayload({
6050 context: input.context,
6051 status: input.status,
6052 completedAt: input.completedAt,
6053 }),
6054 turnTokenUsage: normalizeClaudeTurnTokenUsage(
6055 input.result,
6056 input.context.subagentsByTaskId.size > 0 ||
6057 input.context.subagentsByToolUseId.size > 0,
6058 input.status,
6059 input.context.steeredSegmentUsage,
6060 ),
6061 },
6062 }),
6063 // Surface this native thread's roster before the root turn
6064 // terminals so writeFinalRunEvents preserves it. Failed or
6065 // interrupted turns drop only this thread's roster so sibling
6066 // native threads keep their Waiting state.
6067 Effect.gen(function* () {
6068 const nativeThreadId =
6069 input.context.input.providerThread.nativeThreadRef?.nativeId ?? null;
6070 if (nativeThreadId !== null) {
6071 if (input.status !== "completed") {
6072 yield* clearPendingBackgroundTasksForNativeThread(nativeThreadId);
6073 yield* clearNativeThreadTaskIdSet(
6074 wakeEligibleBackgroundTasksByNativeThread,
6075 nativeThreadId,
6076 );
6077 yield* clearNativeThreadTaskIdSet(
6078 opaqueBackgroundTaskReplayTombstonesByNativeThread,
6079 nativeThreadId,
6080 );
6081 }
6082 }
6083 const roster =
6084 nativeThreadId === null
6085 ? new Map<string, OrchestrationV2PendingBackgroundTask>()
6086 : rosterForNativeThread(
6087 yield* Ref.get(pendingBackgroundTasksByNativeThread),
6088 nativeThreadId,
6089 );
6090 const clearConversationHead =
6091 input.status === "completed" &&
6092 input.context.input.providerThread.nativeConversationHeadRef !== null;
6093 // While a goal is set, Claude ends a turn on its own only after the
6094 // goal's Stop hook passes. It defers that check while background
6095 // work runs, and a hook that stops the turn reports another reason.
6096 // SDK mode does not report an evaluator timeout or an impossible
6097 // verdict, so those still read as complete.
6098 const goal =
6099 nativeThreadId === null ? undefined : goalsByNativeThread.get(nativeThreadId);
6100 const goalChecked = goalCheckedTurns.delete(input.context.providerTurnId);
6101 const terminalReason = input.result?.terminal_reason;
6102 if (
6103 nativeThreadId !== null &&
6104 goal?.status === "active" &&
6105 goalChecked &&
6106 input.status === "completed" &&
6107 (terminalReason === undefined || terminalReason === "completed") &&
6108 roster.size === 0
6109 ) {
6110 goalsByNativeThread.set(nativeThreadId, {
6111 objective: goal.objective,
6112 status: "complete",
6113 ...(goal.checks === undefined ? {} : { checks: goal.checks }),
6114 });
6115 }
6116 const providerThread: OrchestrationV2ProviderThread = {
6117 ...input.context.input.providerThread,
6118 providerSessionId: session.id,
6119 ...(clearConversationHead ? { nativeConversationHeadRef: null } : {}),
6120 ...(nativeThreadId !== null && goalsByNativeThread.has(nativeThreadId)
6121 ? { goal: goalsByNativeThread.get(nativeThreadId) ?? null }
6122 : {}),
6123 firstRunOrdinal:
6124 input.context.input.providerThread.firstRunOrdinal ??
6125 input.context.input.runOrdinal,
6126 lastRunOrdinal: input.context.input.runOrdinal,
6127 pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster),
6128 status: input.status === "completed" ? "active" : "idle",
6129 updatedAt: input.completedAt,
6130 };
6131 yield* rememberProviderThread(providerThread);
6132 yield* emitProviderEvent({
6133 type: "provider_thread.updated" as const,
6134 driver: CLAUDE_PROVIDER,
6135 providerThread,
6136 });
6137 }),
6138 emitProviderEvent(terminalEvent),
6139 ],
6140 { concurrency: 1 },
6141 );
6142 yield* Ref.update(activeTurn, (current) =>
6143 current?.providerTurnId === input.context.providerTurnId ? null : current,
6144 );
6145 if (input.status === "completed") {
6146 settledTurnByNativeThread.set(input.context.nativeThreadId, input.context);
6147 } else {
6148 settledTurnByNativeThread.delete(input.context.nativeThreadId);
6149 }
6150 yield* Ref.update(interruptedTurns, (current) => {
6151 const next = new Set(current);
6152 next.delete(input.context.providerTurnId);
6153 return next;
6154 });
6155 });
6156
6157 const emitAssistantTextArtifacts = Effect.fnUntraced(function* (input: {
6158 readonly context: ActiveClaudeTurnContext;
6159 readonly nativeItemId: string;
6160 readonly text: string;
6161 }) {
6162 if (input.context.assistant.emittedNativeItemIds.has(input.nativeItemId)) {
6163 return;
6164 }
6165 input.context.assistant.emittedNativeItemIds.add(input.nativeItemId);
6166 const now = yield* DateTime.now;
6167 const ordinal = yield* resolveItemOrdinal(input.context, input.nativeItemId);
6168 const artifacts = buildAssistantArtifacts({
6169 idAllocator,
6170 turnInput: input.context.input,
6171 providerTurnId: input.context.providerTurnId,
6172 nativeItemId: input.nativeItemId,
6173 text: input.text,
6174 ordinal,
6175 startedAt: now,
6176 completedAt: now,
6177 });
6178 yield* Effect.all(
6179 [
6180 emitProviderEvent({
6181 type: "node.updated",
6182 driver: CLAUDE_PROVIDER,
6183 node: artifacts.node,
6184 }),
6185 emitProviderEvent({
6186 type: "message.updated",
6187 driver: CLAUDE_PROVIDER,
6188 message: artifacts.message,
6189 }),
6190 emitProviderEvent({
6191 type: "turn_item.updated",
6192 driver: CLAUDE_PROVIDER,
6193 turnItem: artifacts.turnItem,
6194 }),
6195 ],
6196 { concurrency: 1 },
6197 );
6198 });
6199
6200 const finalizeActiveTurnAfterQueryExit = Effect.fnUntraced(function* (
6201 closedQuery: ClaudeLiveQueryContext,
6202 cause?: Cause.Cause<ClaudeAgentSdkQueryRunnerError>,
6203 ) {
6204 const current = yield* Ref.get(activeTurn);
6205 const context =
6206 current?.nativeThreadId === closedQuery.nativeThreadId ? current : closedQuery.lastTurn;
6207 if (context === null) return;
6208 const interrupted =
6209 closedQuery.stopping || (yield* Ref.get(interruptedTurns)).has(context.providerTurnId);
6210 const subagents = yield* Ref.get(sessionSubagentsByTaskId);
6211 const buffered = (yield* Ref.get(wakeBuffers)).get(closedQuery.nativeThreadId)?.messages;
6212 if (current === context) {
6213 yield* finalizeActiveTurn({
6214 context,
6215 status: interrupted ? "interrupted" : "failed",
6216 completedAt: yield* DateTime.now,
6217 ...(interrupted
6218 ? {}
6219 : {
6220 failure: makeProviderFailure({
6221 cause: cause === undefined ? undefined : Cause.squash(cause),
6222 class: "transport_error",
6223 }),
6224 }),
6225 });
6226 } else {
6227 yield* resetBackgroundTaskStateForNativeThreadProcess(closedQuery.nativeThreadId);
6228 }
6229 // A workflow can outlive its launching turn, but not its CLI process.
6230 // Apply buffered outcomes before Stop can discard their wake buffer.
6231 // Capture ownership before finalization, which may yield to a new process.
6232 for (const [taskId, subagent] of subagents) {
6233 if (
6234 subagent.task.workflow === undefined ||
6235 subagent.task.status !== "running" ||
6236 closedQuery.subagentsFromEarlierProcesses.has(subagent)
6237 )
6238 continue;
6239 const latestLifecycle = buffered?.findLast(
6240 (
6241 message,
6242 ): message is Extract<
6243 SDKMessage,
6244 { readonly subtype: "task_started" | "task_notification" }
6245 > =>
6246 message.type === "system" &&
6247 (message.subtype === "task_started" || message.subtype === "task_notification") &&
6248 message.task_id === taskId,
6249 );
6250 // A later start resumes the same task and invalidates its older outcome.
6251 const completion =
6252 latestLifecycle?.subtype === "task_notification" ? latestLifecycle : undefined;
6253 yield* updateClaudeSubagentNode({
6254 context,
6255 taskId,
6256 status: completion
6257 ? claudeTaskOutcome(completion.status)
6258 : interrupted
6259 ? "cancelled"
6260 : "failed",
6261 ...(completion === undefined
6262 ? {}
6263 : { workflowFrame: completion, result: completion.summary }),
6264 onlyIfRunningInRun: subagent.task.runId,
6265 });
6266 }
6267 yield* Ref.update(interruptedTurns, (current) => {
6268 const next = new Set(current);
6269 next.delete(context.providerTurnId);
6270 return next;
6271 });
6272 if (cause !== undefined) {
6273 yield* Effect.logWarning("orchestration-v2.claude-query-stream-failed", {
6274 providerSessionId: input.providerSessionId,
6275 providerThreadId: context.input.providerThread.id,
6276 providerTurnId: context.providerTurnId,
6277 cause,
6278 });
6279 }
6280 });
6281
6282 /**
6283 * Refreshes a background workflow coordinator's entity between turns.
6284 * Deliberately narrow: only a registered coordinator that this frame
6285 * carries a snapshot for is touched, and only `subagent.updated` is
6286 * emitted, because node and turn-item ids need a turn context this path
6287 * does not have. Status is left alone — `task_notification` owns the
6288 * lifecycle, and it has its own turn-aware path.
6289 * These updates only reach the projection while the launching run is still
6290 * ingesting, which holds because a running coordinator keeps that run open.
6291 */
6292 const applyWorkflowProgressWithoutTurn = Effect.fnUntraced(function* (message: SDKMessage) {
6293 if (message.type !== "system" || message.subtype !== "task_progress") return;
6294 if (!hasClaudeWorkflowSnapshot(message)) return;
6295 const taskId = message.task_id;
6296 const registered = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId);
6297 if (
6298 registered === undefined ||
6299 registered.task.workflow === undefined ||
6300 registered.task.status !== "running"
6301 )
6302 return;
6303 const workflow = mergeClaudeWorkflowProgress({
6304 previous: registered.task.workflow,
6305 message,
6306 });
6307 if (workflow === undefined) return;
6308 const now = yield* DateTime.now;
6309 const progress = text(message.description);
6310 const workflowWithThreads = withWorkflowMemberThreadIds(
6311 workflow,
6312 registered.childThreadId,
6313 );
6314 const task = {
6315 ...registered.task,
6316 ...(progress === undefined ? {} : { progress }),
6317 workflow: workflowWithThreads,
6318 updatedAt: now,
6319 } satisfies OrchestrationV2Subagent;
6320 const coordinator = { ...registered, task };
6321 const accepted = yield* Ref.modify(sessionSubagentsByTaskId, (current) => {
6322 const latest = current.get(taskId);
6323 if (latest !== registered || latest.task.status !== "running") {
6324 return [false, current] as const;
6325 }
6326 return [true, new Map(current).set(taskId, coordinator)] as const;
6327 });
6328 if (!accepted) return;
6329 yield* emitProviderEvent({
6330 type: "subagent.updated",
6331 driver: CLAUDE_PROVIDER,
6332 subagent: task,
6333 });
6334 yield* projectClaudeWorkflowMembers({
6335 coordinator,
6336 workflow: workflowWithThreads,
6337 });
6338 });
6339
6340 const bufferWakeMessage = Effect.fnUntraced(function* (wakeInput: {
6341 readonly nativeThreadId: string;
6342 readonly message: SDKMessage;
6343 }) {
6344 const message = wakeInput.message;
6345 const isNotification =
6346 message.type === "system" && message.subtype === "task_notification";
6347 // Only notifications for tracked tasks count as wake evidence: a
6348 // wake-eligible local_bash task (eligibility set, not the Waiting
6349 // roster), or a session-registered subagent that is still running
6350 // (Agent with run_in_background settling after the root turn). A
6351 // stray notification for an unknown task is dropped as before
6352 // instead of triggering a spurious continuation.
6353 const isPendingTaskNotification =
6354 isNotification &&
6355 (yield* isWakeEligibleOpaqueBackgroundTaskOnNativeThread(
6356 wakeInput.nativeThreadId,
6357 message.task_id,
6358 ));
6359 const bufferedMessages =
6360 (yield* Ref.get(wakeBuffers)).get(wakeInput.nativeThreadId)?.messages ?? [];
6361 // A nested subagent's end goes to the subagent that started it, not
6362 // the root. It is buffered so the next drain completes its card, but
6363 // it is not a wake.
6364 const isNestedSubagentNotification =
6365 isNotification &&
6366 !isPendingTaskNotification &&
6367 (yield* isNestedSubagentTask(message.task_id));
6368 const isPendingSubagentNotification =
6369 isNotification &&
6370 !isPendingTaskNotification &&
6371 !isNestedSubagentNotification &&
6372 ((yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id)?.task.status ===
6373 "running" ||
6374 bufferedMessages.some(
6375 (entry) =>
6376 entry.type === "system" &&
6377 entry.subtype === "task_started" &&
6378 entry.task_id === message.task_id,
6379 ));
6380 // A subagent launched while the root is idle (inside a native wake
6381 // turn) must reach the continuation drain with its task_started, or
6382 // its frames have no registered owner and are held indefinitely.
6383 const isNewSubagentTaskStarted =
6384 message.type === "system" &&
6385 message.subtype === "task_started" &&
6386 !isClaudeNonSubagentTask(message);
6387 // A task_started for a session-registered subagent that races past
6388 // settle is a resume (SendMessage to a completed subagent re-emits
6389 // task_started with the same task id). Re-open the registry entry
6390 // so the resumed run pins idle and its eventual notification counts
6391 // as wake evidence again, and buffer the frame so the continuation
6392 // drain re-opens the projection row; it does not itself offer a
6393 // continuation.
6394 const isKnownSubagentTaskStarted =
6395 message.type === "system" &&
6396 message.subtype === "task_started" &&
6397 (yield* Ref.get(sessionSubagentsByTaskId)).has(message.task_id);
6398 if (
6399 isKnownSubagentTaskStarted &&
6400 message.type === "system" &&
6401 message.subtype === "task_started"
6402 ) {
6403 const now = yield* DateTime.now;
6404 yield* Ref.update(sessionSubagentsByTaskId, (current) => {
6405 const registered = current.get(message.task_id);
6406 if (registered === undefined || registered.task.status === "running") {
6407 return current;
6408 }
6409 const { progress: _staleProgress, ...priorTask } = registered.task;
6410 return new Map(current).set(message.task_id, {
6411 ...registered,
6412 confirmedOutcome: undefined,
6413 task: {
6414 ...priorTask,
6415 status: "running",
6416 result: null,
6417 startedAt: now,
6418 completedAt: null,
6419 updatedAt: now,
6420 },
6421 });
6422 });
6423 }
6424 // Rate-limit frames park with the wake output so the drain can
6425 // replay them to the turn that was still starting when they
6426 // arrived; the offer gate below keeps them from requesting a
6427 // continuation on their own.
6428 const isWakeTurnStart = isClaudeTurnStartMessage(message);
6429 const isWakeEvidence =
6430 isPendingTaskNotification ||
6431 isPendingSubagentNotification ||
6432 isNestedSubagentNotification ||
6433 isKnownSubagentTaskStarted ||
6434 isNewSubagentTaskStarted ||
6435 isWakeTurnStart ||
6436 message.type === "assistant" ||
6437 message.type === "user" ||
6438 message.type === "result" ||
6439 message.type === "rate_limit_event";
6440 if (!isWakeEvidence) {
6441 return;
6442 }
6443 const notificationSummary =
6444 isNotification && typeof message.summary === "string" && message.summary.length > 0
6445 ? message.summary
6446 : null;
6447 if (isPendingTaskNotification) {
6448 yield* recordWakeReport(
6449 wakeInput.nativeThreadId,
6450 message.task_id,
6451 yield* opaqueTaskWakeReport(wakeInput.nativeThreadId, message),
6452 );
6453 } else if (isPendingSubagentNotification) {
6454 const registered = (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id);
6455 const started = bufferedMessages.find(
6456 (entry) =>
6457 entry.type === "system" &&
6458 entry.subtype === "task_started" &&
6459 entry.task_id === message.task_id,
6460 );
6461 yield* recordWakeReport(wakeInput.nativeThreadId, message.task_id, {
6462 kind: "subagent",
6463 label:
6464 registered?.task.title ??
6465 (started?.type === "system" && started.subtype === "task_started"
6466 ? started.description
6467 : undefined),
6468 outcome: claudeTaskOutcome(message.status),
6469 childThreadId: registered?.childThreadId,
6470 });
6471 }
6472 yield* Ref.update(wakeBuffers, (current) => {
6473 const existing = current.get(wakeInput.nativeThreadId);
6474 const updated = new Map(current);
6475 updated.set(wakeInput.nativeThreadId, {
6476 messages: [...(existing?.messages ?? []), message],
6477 detail: notificationSummary ?? existing?.detail ?? null,
6478 });
6479 return updated;
6480 });
6481 // First idle opaque notification: consume wake eligibility so a
6482 // duplicate cannot re-buffer, and leave a short-lived replay
6483 // tombstone for continuation-drain classification.
6484 if (isPendingTaskNotification) {
6485 yield* consumeWakeEligibilityForBufferedNotification(
6486 wakeInput.nativeThreadId,
6487 message.task_id,
6488 );
6489 }
6490 // A terminal task notification can clear the Waiting roster without
6491 // Claude dequeuing it into a native model turn. Buffer it for replay,
6492 // but do not open an opaque-task continuation until the turn's `init`,
6493 // or native user, assistant, or result output, proves that Claude
6494 // actually began the wake turn. `init` comes first, so the thread
6495 // shows working while Claude thinks instead of looking finished.
6496 // Subagent notifications retain their existing immediate offer
6497 // because their projected lifecycle owns the continuation. Only
6498 // root frames prove it: a background subagent keeps streaming its
6499 // own frames while the root is idle.
6500 const buffered = (yield* Ref.get(wakeBuffers)).get(wakeInput.nativeThreadId);
6501 const hasBufferedNotification =
6502 buffered?.messages.some(
6503 (entry) => entry.type === "system" && entry.subtype === "task_notification",
6504 ) ?? false;
6505 const isNativeOpaqueWakeFrame =
6506 hasBufferedNotification &&
6507 (message.type === "assistant" || message.type === "user") &&
6508 parentToolUseIdFromSdkMessage(message) === null;
6509 if (
6510 !isPendingSubagentNotification &&
6511 !isNativeOpaqueWakeFrame &&
6512 !isWakeTurnStart &&
6513 message.type !== "result"
6514 ) {
6515 return;
6516 }
6517 const route = (yield* Ref.get(lastTurnRouteByNativeThread)).get(wakeInput.nativeThreadId);
6518 if (route === undefined) {
6519 yield* Effect.logWarning("orchestration-v2.claude-wake-turn-unroutable", {
6520 providerSessionId: input.providerSessionId,
6521 nativeThreadId: wakeInput.nativeThreadId,
6522 });
6523 return;
6524 }
6525 const shouldOffer = yield* Ref.modify(requestedContinuations, (current) => {
6526 if (current.has(wakeInput.nativeThreadId)) {
6527 return [false, current] as const;
6528 }
6529 const updated = new Set(current);
6530 updated.add(wakeInput.nativeThreadId);
6531 return [true, updated] as const;
6532 });
6533 if (!shouldOffer) {
6534 return;
6535 }
6536 const detail =
6537 (yield* Ref.get(wakeBuffers)).get(wakeInput.nativeThreadId)?.detail ?? null;
6538 const reports = yield* takeWakeReports(wakeInput.nativeThreadId);
6539 const notification = backgroundWorkNotification(
6540 [...(reports?.values() ?? [])].map((entry) => entry.report),
6541 );
6542 yield* Effect.logInfo("orchestration-v2.claude-wake-turn-detected", {
6543 providerSessionId: input.providerSessionId,
6544 threadId: route.threadId,
6545 providerThreadId: route.providerThreadId,
6546 });
6547 yield* continuationRequests.offer({
6548 threadId: route.threadId,
6549 providerThreadId: route.providerThreadId,
6550 driver: CLAUDE_PROVIDER,
6551 detail,
6552 ...(notification === null ? {} : { notification }),
6553 });
6554 });
6555
6556 const applyBackgroundTaskRosterMessage = Effect.fnUntraced(function* (input: {
6557 readonly nativeThreadId: string;
6558 readonly message: SDKMessage;
6559 readonly activeContext: ActiveClaudeTurnContext | null;
6560 }) {
6561 const message = input.message;
6562 let rosterChanged = false;
6563
6564 if (isClaudeBackgroundTasksChangedMessage(message)) {
6565 const roster = Reflect.get(message, "tasks");
6566 if (!Array.isArray(roster)) {
6567 return false;
6568 }
6569 const nextTasks: OrchestrationV2PendingBackgroundTask[] = [];
6570 const listedTaskIds = new Set(
6571 roster.flatMap((entry) => {
6572 const taskId =
6573 entry !== null && typeof entry === "object"
6574 ? Reflect.get(entry, "task_id")
6575 : undefined;
6576 return typeof taskId === "string" ? [taskId] : [];
6577 }),
6578 );
6579 const monitorTasks = yield* endClaudeMonitorTasks(
6580 (taskId, task) =>
6581 task.nativeThreadId === input.nativeThreadId && !listedTaskIds.has(taskId),
6582 );
6583 for (const entry of roster) {
6584 const task = parseClaudeBackgroundTaskEntry(entry, monitorTasks);
6585 if (task !== null) {
6586 nextTasks.push(task);
6587 }
6588 }
6589 yield* replacePendingBackgroundTasks(input.nativeThreadId, nextTasks);
6590 rosterChanged = true;
6591 } else if (message.type === "system" && message.subtype === "task_started") {
6592 // Incremental fallback when background_tasks_changed is absent.
6593 // Subagent tasks project as subagent turn items; only non-subagent
6594 // background work (e.g. local_bash) lives on the provider-thread roster.
6595 // A foreground task blocks its tool call (a subagent's own Bash
6596 // steps included), so it is not background work; one moved to
6597 // the background later arrives in background_tasks_changed.
6598 if (!isClaudeNonSubagentTask(message) || message.is_backgrounded === false) {
6599 return false;
6600 }
6601 yield* upsertPendingBackgroundTask(
6602 input.nativeThreadId,
6603 claudePendingBackgroundTask({
6604 taskId: message.task_id,
6605 taskType: claudeTaskTypeFromSdkMessage(message),
6606 startedByMonitor: yield* isClaudeMonitorTask({
6607 nativeThreadId: input.nativeThreadId,
6608 taskId: message.task_id,
6609 toolUseId: message.tool_use_id,
6610 }),
6611 description:
6612 typeof message.description === "string" ? message.description : undefined,
6613 }),
6614 );
6615 rosterChanged = true;
6616 } else if (message.type === "system" && message.subtype === "task_notification") {
6617 yield* endClaudeMonitorTasks((taskId) => taskId === message.task_id);
6618 const removed = yield* clearPendingBackgroundTask(
6619 input.nativeThreadId,
6620 message.task_id,
6621 );
6622 // Waiting roster clears on the notification edge. Wake eligibility
6623 // is consumed when the first idle notification is buffered; clear
6624 // here too for same-turn active notifications that never entered
6625 // the idle buffer path. Replay tombstones are not cleared here.
6626 yield* clearTaskIdFromNativeThreadSet(
6627 wakeEligibleBackgroundTasksByNativeThread,
6628 input.nativeThreadId,
6629 message.task_id,
6630 );
6631 rosterChanged = removed;
6632 }
6633
6634 if (!rosterChanged) {
6635 return false;
6636 }
6637
6638 const baseThread =
6639 input.activeContext?.input.providerThread ??
6640 (yield* Ref.get(lastProviderThreadByNativeThread)).get(input.nativeThreadId);
6641 if (baseThread === undefined) {
6642 return true;
6643 }
6644
6645 // Between turns, never resurrect active status from a late empty
6646 // roster update. During an active turn, preserve the thread status.
6647 const status =
6648 input.activeContext === null
6649 ? ("idle" as const)
6650 : baseThread.status === "idle"
6651 ? ("active" as const)
6652 : baseThread.status;
6653 yield* emitProviderThreadRoster({
6654 nativeThreadId: input.nativeThreadId,
6655 providerThread: baseThread,
6656 status,
6657 });
6658 return true;
6659 });
6660
6661 // A background subagent keeps working after the root turn settles. Its
6662 // own frames and progress reach its thread and card at once, through
6663 // the turn that settled last; root output and wake evidence still wait
6664 // in the wake buffer for a continuation run.
6665 const settledTurnForSubagentFrame = Effect.fnUntraced(function* (
6666 nativeThreadId: string,
6667 message: SDKMessage,
6668 ) {
6669 const settled = settledTurnByNativeThread.get(nativeThreadId);
6670 if (settled === undefined) {
6671 return null;
6672 }
6673 // The result of a call already shown as its row only ends that row,
6674 // so it never waits.
6675 const userResults = claudeToolResultBlocksFromUserMessage(message);
6676 if (
6677 message.type === "user" &&
6678 userResults.length > 0 &&
6679 userResults.length === message.message.content.length &&
6680 userResults.every(
6681 (toolResult) => findToolCall(settled, toolResult.tool_use_id) !== undefined,
6682 )
6683 ) {
6684 return settled;
6685 }
6686 // A subagent's task_started still in the wake buffer registers or
6687 // re-opens it only when a continuation drains it, and only that run
6688 // stores the work that follows. So that subagent's own frames, and
6689 // the result of the Agent call that launched it, wait behind it.
6690 const bufferedStarts = (
6691 (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? []
6692 ).flatMap((entry) =>
6693 entry.type === "system" &&
6694 entry.subtype === "task_started" &&
6695 !isClaudeNonSubagentTask(entry)
6696 ? [entry]
6697 : [],
6698 );
6699 const parentToolUseId = parentToolUseIdFromSdkMessage(message);
6700 const taskId =
6701 message.type === "system" && message.subtype === "task_progress"
6702 ? message.task_id
6703 : parentToolUseId === null
6704 ? undefined
6705 : (yield* Ref.get(sessionSubagentTaskIdsByToolUseId)).get(parentToolUseId);
6706 const returnedToolUseIds = new Set(
6707 [...claudeToolResultBlocksFromAssistantMessage(message), ...userResults].map(
6708 (toolResult) => toolResult.tool_use_id,
6709 ),
6710 );
6711 if (
6712 bufferedStarts.some(
6713 (start) =>
6714 start.task_id === taskId ||
6715 (start.tool_use_id !== undefined &&
6716 (start.tool_use_id === parentToolUseId ||
6717 returnedToolUseIds.has(start.tool_use_id))),
6718 )
6719 ) {
6720 return null;
6721 }
6722 // A frame whose owner is named later, by progress, waits in the hold
6723 // used during a turn, keyed by that owner, so its later frames queue
6724 // behind it there and are released in order.
6725 if (taskId === undefined) {
6726 return parentToolUseId === null ? null : settled;
6727 }
6728 // Without the launching call's id, any result may be its launch.
6729 if (
6730 returnedToolUseIds.size > 0 &&
6731 bufferedStarts.some((start) => start.tool_use_id === undefined)
6732 ) {
6733 return null;
6734 }
6735 const subagent = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId);
6736 return subagent?.task.status === "running" ? settled : null;
6737 });
6738
6739 const handleSdkMessageFrame = Effect.fnUntraced(function* (input: {
6740 readonly query: ClaudeAgentSdkQuerySession;
6741 readonly message: SDKMessage;
6742 // A held subagent frame replayed after its owner registered. Its
6743 // turn-level assistant bookkeeping already ran when it arrived.
6744 readonly replayed?: boolean;
6745 }) {
6746 const liveQuery = yield* Ref.get(queryContext);
6747 if (liveQuery?.query !== input.query) {
6748 return;
6749 }
6750
6751 const message = input.message;
6752 // Before any routing: a Monitor started during an idle wake turn
6753 // reports its task before the drain replays the tool call.
6754 yield* trackClaudeMonitorCalls(message);
6755 // Before routing too: the wake gate reads it while the root is idle.
6756 if (
6757 message.type === "system" &&
6758 message.subtype === "task_started" &&
6759 !isClaudeNonSubagentTask(message) &&
6760 (message.spawn_depth ?? 1) > 1
6761 ) {
6762 yield* Ref.update(nestedSubagentTaskIds, (current) =>
6763 current.has(message.task_id) ? current : new Set(current).add(message.task_id),
6764 );
6765 }
6766 if (message.type === "rate_limit_event") {
6767 const rateLimitInfo = message.rate_limit_info;
6768 if (!rateLimitInfo) return;
6769 const names = adapterOptions.scopedLimitNames
6770 ? yield* Ref.get(adapterOptions.scopedLimitNames)
6771 : { overageIncluded: undefined };
6772 const update = claudeRateLimitEventToUpdate(rateLimitInfo, names);
6773 const now = yield* DateTime.now;
6774 if (update && adapterOptions.onUsageLimits) {
6775 yield* adapterOptions.onUsageLimits({
6776 ...update,
6777 checkedAt: DateTime.formatIso(now),
6778 });
6779 }
6780 const context = yield* Ref.get(activeTurn);
6781 if (context === null) {
6782 // A rejected window can open the CLI's notification wake,
6783 // before the continuation turn exists to record it on. Park
6784 // the frame with the wake output so the drain replays it to
6785 // the turn; dropping it here loses the reset time the
6786 // provider just reported.
6787 yield* bufferWakeMessage({
6788 nativeThreadId: liveQuery.nativeThreadId,
6789 message,
6790 });
6791 return;
6792 }
6793 const overageAllowed =
6794 rateLimitInfo.overageStatus === "allowed" ||
6795 rateLimitInfo.overageStatus === "allowed_warning" ||
6796 rateLimitInfo.isUsingOverage === true ||
6797 rateLimitInfo.overageInUse === true;
6798 const blocked = rateLimitInfo.status === "rejected" && !overageAllowed;
6799 const limitType = rateLimitInfo.rateLimitType ?? "unknown";
6800 if (blocked) {
6801 context.rejectedRateLimitTypes.add(limitType);
6802 const resetMs = (rateLimitInfo.resetsAt ?? NaN) * 1000;
6803 context.rateLimitResetTimes.set(
6804 limitType,
6805 Number.isFinite(resetMs) && resetMs > 0 && resetMs < 8.64e15
6806 ? DateTime.formatIso(DateTime.makeUnsafe(resetMs))
6807 : null,
6808 );
6809 } else if (
6810 rateLimitInfo.status === "allowed" ||
6811 rateLimitInfo.status === "allowed_warning" ||
6812 overageAllowed
6813 ) {
6814 context.rejectedRateLimitTypes.delete(limitType);
6815 context.rateLimitResetTimes.delete(limitType);
6816 }
6817 // Rejected windows pause the SDK without ending its turn. Overage
6818 // and warnings keep running; repeats of a window need only one notice.
6819 if (blocked) {
6820 const limitKey = `${limitType}:${rateLimitInfo.resetsAt ?? "unknown"}`;
6821 if (!context.announcedUsageLimits.has(limitKey)) {
6822 context.announcedUsageLimits.add(limitKey);
6823 const nativeItemId = `usage-limit:${context.providerTurnId}:${limitKey}`;
6824 const notice = describeClaudeUsageLimit(
6825 rateLimitInfo,
6826 DateTime.toEpochMillis(now),
6827 names,
6828 );
6829 yield* emitProviderEvent({
6830 type: "turn_item.updated",
6831 driver: CLAUDE_PROVIDER,
6832 turnItem: {
6833 id: idAllocator.derive.turnItemFromProviderItem({
6834 driver: CLAUDE_PROVIDER,
6835 nativeItemId,
6836 }),
6837 threadId: context.input.threadId,
6838 runId: context.input.runId,
6839 nodeId: context.input.rootNodeId,
6840 providerThreadId: context.input.providerThread.id,
6841 providerTurnId: context.providerTurnId,
6842 nativeItemRef: {
6843 driver: CLAUDE_PROVIDER,
6844 nativeId: nativeItemId,
6845 strength: "weak",
6846 },
6847 parentItemId: null,
6848 ordinal: yield* resolveItemOrdinal(context, nativeItemId),
6849 type: "system_notice",
6850 status: "completed",
6851 title: notice,
6852 message: notice,
6853 startedAt: now,
6854 completedAt: now,
6855 updatedAt: now,
6856 },
6857 });
6858 }
6859 }
6860 return;
6861 }
6862 const context =
6863 (yield* Ref.get(activeTurn)) ??
6864 (yield* settledTurnForSubagentFrame(liveQuery.nativeThreadId, message));
6865 if (context === null) {
6866 // A workflow is normally launched in the background, so most of its
6867 // run happens after the turn that started it has settled. Nothing
6868 // here can build turn items or nodes without a turn context, but the
6869 // coordinator entity is what Lineage renders, so refresh it from the
6870 // session registry and a live run keeps reporting its phases, members
6871 // and usage instead of freezing at turn end.
6872 yield* applyWorkflowProgressWithoutTurn(message);
6873 // task_notification must buffer wake evidence while still tracked
6874 // on the roster; clearing first would drop the wake pin.
6875 if (message.type === "system" && message.subtype === "task_notification") {
6876 yield* bufferWakeMessage({ nativeThreadId: liveQuery.nativeThreadId, message });
6877 yield* applyBackgroundTaskRosterMessage({
6878 nativeThreadId: liveQuery.nativeThreadId,
6879 message,
6880 activeContext: null,
6881 });
6882 // Wake delivery must not delay a known child's actual outcome.
6883 // Record wake evidence first, while the child is still running.
6884 const registered = (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id);
6885 const settled = settledTurnByNativeThread.get(liveQuery.nativeThreadId);
6886 const hasBufferedStart =
6887 (yield* Ref.get(wakeBuffers))
6888 .get(liveQuery.nativeThreadId)
6889 ?.messages.some(
6890 (entry) =>
6891 entry.type === "system" &&
6892 entry.subtype === "task_started" &&
6893 entry.task_id === message.task_id,
6894 ) ?? false;
6895 // Starts and workflow transcripts still need the continuation's
6896 // context. A pending Stop must receive its native outcome now.
6897 const canApplyOutcome =
6898 subagentStopOutcomes.has(message.task_id) ||
6899 (!hasBufferedStart && registered?.task.workflow === undefined);
6900 if (registered !== undefined && settled !== undefined && canApplyOutcome) {
6901 yield* discardBufferedSubagentStarts(
6902 liveQuery.nativeThreadId,
6903 message.task_id,
6904 message,
6905 );
6906 yield* updateClaudeSubagentNode({
6907 context: settled,
6908 taskId: message.task_id,
6909 ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }),
6910 workflowFrame: message,
6911 nativeOutcome: message,
6912 result: message.summary,
6913 status: claudeTaskOutcome(message.status),
6914 onlyIfRunningInRun: registered.task.runId,
6915 });
6916 }
6917 } else {
6918 yield* applyBackgroundTaskRosterMessage({
6919 nativeThreadId: liveQuery.nativeThreadId,
6920 message,
6921 activeContext: null,
6922 });
6923 yield* bufferWakeMessage({ nativeThreadId: liveQuery.nativeThreadId, message });
6924 }
6925 return;
6926 }
6927 yield* trackClaudeGoal({ nativeThreadId: liveQuery.nativeThreadId, context, message });
6928
6929 // Subagent narration belongs to its child thread, never the parent log.
6930 if (message.type === "stream_event" && !message.parent_tool_use_id) {
6931 const event = message.event;
6932 const reasoning = context.reasoning;
6933 if (event.type === "message_start") {
6934 reasoning.messageId = event.message.id;
6935 reasoning.streamBlocks.clear();
6936 } else if (
6937 event.type === "content_block_start" &&
6938 event.content_block.type === "thinking" &&
6939 reasoning.messageId !== null
6940 ) {
6941 const index = reasoning.nextBlockIndex.get(reasoning.messageId) ?? 0;
6942 reasoning.nextBlockIndex.set(reasoning.messageId, index + 1);
6943 const itemId = `${reasoning.messageId}:thinking:${index}`;
6944 reasoning.streamBlocks.set(event.index, itemId);
6945 yield* ensureReasoningBlock(context, itemId);
6946 yield* reasoningDeltas.append({
6947 turnId: context.nativeTurnId,
6948 itemId,
6949 delta: event.content_block.thinking,
6950 });
6951 } else if (
6952 event.type === "content_block_delta" &&
6953 event.delta.type === "thinking_delta"
6954 ) {
6955 const itemId = reasoning.streamBlocks.get(event.index);
6956 if (itemId !== undefined) {
6957 yield* reasoningDeltas.append({
6958 turnId: context.nativeTurnId,
6959 itemId,
6960 delta: event.delta.thinking,
6961 });
6962 }
6963 } else if (event.type === "content_block_stop") {
6964 const itemId = reasoning.streamBlocks.get(event.index);
6965 if (itemId !== undefined) {
6966 yield* reasoningDeltas.complete({
6967 turnId: context.nativeTurnId,
6968 itemId,
6969 emitEmpty: false,
6970 });
6971 reasoning.streamBlocks.delete(event.index);
6972 }
6973 }
6974 return;
6975 }
6976 if (
6977 message.type === "assistant" &&
6978 !message.parent_tool_use_id &&
6979 !context.reasoning.snapshots.has(message.uuid)
6980 ) {
6981 context.reasoning.snapshots.add(message.uuid);
6982 // The SDK emits one assistant snapshot per completed content block.
6983 // Count thinking blocks separately so snapshots share the streamed ID.
6984 for (const block of message.message.content) {
6985 if (block.type !== "thinking") {
6986 const update = context.reasoning.lastThinking;
6987 context.reasoning.lastThinking = null;
6988 if (
6989 block.type === "tool_use" &&
6990 update !== null &&
6991 update.messageId === message.message.id &&
6992 update.followsThinking &&
6993 claudeWritesUpdatesAsThinking(message.message.model)
6994 ) {
6995 // Same item ID, so the update replaces the Thought in place.
6996 yield* emitAssistantTextArtifacts({
6997 context,
6998 nativeItemId: update.itemId,
6999 text: update.text,
7000 });
7001 }
7002 continue;
7003 }
7004 const index = context.reasoning.snapshotBlockIndex.get(message.message.id) ?? 0;
7005 context.reasoning.snapshotBlockIndex.set(message.message.id, index + 1);
7006 const itemId = `${message.message.id}:thinking:${index}`;
7007 yield* ensureReasoningBlock(context, itemId);
7008 const finalText = block.thinking || context.reasoning.blocks.get(itemId)?.text;
7009 const completedText = yield* reasoningDeltas.complete({
7010 turnId: context.nativeTurnId,
7011 itemId,
7012 ...(finalText ? { finalText } : {}),
7013 emitEmpty: false,
7014 });
7015 const previous = context.reasoning.lastThinking;
7016 context.reasoning.lastThinking = completedText.trim()
7017 ? {
7018 messageId: message.message.id,
7019 itemId,
7020 text: completedText,
7021 followsThinking: previous !== null && previous.messageId === message.message.id,
7022 }
7023 : null;
7024 }
7025 }
7026
7027 if (message.type === "assistant" && input.replayed !== true) {
7028 context.nativeMessageCursor = message.uuid;
7029 if (message.parent_tool_use_id === null) {
7030 context.latestAssistantRateLimited = message.error === "rate_limit";
7031 if (message.error === "authentication_failed") {
7032 context.authenticationFailureMessage = claudeSignedOutMessage({
7033 configDir: adapterOptions.environment.CLAUDE_CONFIG_DIR,
7034 cwd: path.resolve(context.input.runtimePolicy.cwd ?? "."),
7035 });
7036 }
7037 }
7038 }
7039
7040 if (message.type === "system" && message.subtype === "compact_boundary") {
7041 const now = yield* DateTime.now;
7042 const nativeItemId = message.uuid;
7043 const postTokens = message.compact_metadata.post_tokens;
7044 const afterTokenCount =
7045 typeof postTokens === "number" && Number.isFinite(postTokens) && postTokens > 0
7046 ? Math.round(postTokens)
7047 : undefined;
7048 if (afterTokenCount !== undefined) {
7049 yield* emitProviderEvent({
7050 type: "provider_turn.updated",
7051 driver: CLAUDE_PROVIDER,
7052 threadId: context.input.threadId,
7053 providerTurn: {
7054 id: context.providerTurnId,
7055 providerThreadId: context.input.providerThread.id,
7056 nodeId: context.input.rootNodeId,
7057 runAttemptId: context.input.attemptId,
7058 nativeTurnRef: {
7059 driver: CLAUDE_PROVIDER,
7060 nativeId: context.nativeTurnId,
7061 strength: "strong",
7062 },
7063 ordinal: context.providerTurnOrdinal,
7064 status: "running",
7065 startedAt: context.startedAt,
7066 completedAt: null,
7067 tokenUsage: {
7068 usedTokens: afterTokenCount,
7069 maxTokens: claudeContextWindow(context.input.modelSelection),
7070 updatedAt: DateTime.formatIso(now),
7071 },
7072 },
7073 });
7074 }
7075 yield* emitProviderEvent({
7076 type: "turn_item.updated",
7077 driver: CLAUDE_PROVIDER,
7078 turnItem: {
7079 id: idAllocator.derive.turnItemFromProviderItem({
7080 driver: CLAUDE_PROVIDER,
7081 nativeItemId,
7082 }),
7083 threadId: context.input.threadId,
7084 runId: context.input.runId,
7085 nodeId: context.input.rootNodeId,
7086 providerThreadId: context.input.providerThread.id,
7087 providerTurnId: context.providerTurnId,
7088 nativeItemRef: {
7089 driver: CLAUDE_PROVIDER,
7090 nativeId: nativeItemId,
7091 strength: "strong",
7092 },
7093 parentItemId: null,
7094 ordinal: yield* resolveItemOrdinal(context, nativeItemId),
7095 type: "compaction",
7096 driver: CLAUDE_PROVIDER,
7097 status: "completed",
7098 title: "Context compacted",
7099 startedAt: now,
7100 completedAt: now,
7101 updatedAt: now,
7102 ...(message.compact_metadata.pre_tokens === undefined
7103 ? {}
7104 : { beforeTokenCount: message.compact_metadata.pre_tokens }),
7105 ...(afterTokenCount === undefined ? {} : { afterTokenCount }),
7106 },
7107 });
7108 return;
7109 }
7110
7111 if (message.type === "system" && message.subtype === "model_refusal_fallback") {
7112 const now = yield* DateTime.now;
7113 const nativeItemId = message.uuid;
7114 yield* emitProviderEvent({
7115 type: "turn_item.updated",
7116 driver: CLAUDE_PROVIDER,
7117 turnItem: {
7118 id: idAllocator.derive.turnItemFromProviderItem({
7119 driver: CLAUDE_PROVIDER,
7120 nativeItemId,
7121 }),
7122 threadId: context.input.threadId,
7123 runId: context.input.runId,
7124 nodeId: context.input.rootNodeId,
7125 providerThreadId: context.input.providerThread.id,
7126 providerTurnId: context.providerTurnId,
7127 nativeItemRef: {
7128 driver: CLAUDE_PROVIDER,
7129 nativeId: nativeItemId,
7130 strength: "strong",
7131 },
7132 parentItemId: null,
7133 ordinal: yield* resolveItemOrdinal(context, nativeItemId),
7134 type: "system_notice",
7135 status: "completed",
7136 title: message.content,
7137 message: message.content,
7138 startedAt: now,
7139 completedAt: now,
7140 updatedAt: now,
7141 },
7142 });
7143 return;
7144 }
7145
7146 if (message.type === "system" && message.subtype === "api_retry") {
7147 const updatedAt = yield* DateTime.now;
7148 const previous = (yield* Ref.get(providerRetries)).get(context.providerTurnId);
7149 const retry: OrchestrationV2ProviderRetry = {
7150 attempt: Math.max(1, Math.trunc(message.attempt)),
7151 maxAttempts: Math.max(1, Math.trunc(message.max_retries)),
7152 retryDelayMs: Math.max(0, Math.trunc(message.retry_delay_ms)),
7153 };
7154 const failure = providerFailureFromApiRetry(message);
7155 const itemOrdinal =
7156 previous?.itemOrdinal ??
7157 (yield* resolveItemOrdinal(context, `terminal-failure:${context.providerTurnId}`));
7158 const state: ActiveClaudeProviderRetry = {
7159 retry,
7160 failure,
7161 startedAt: previous?.startedAt ?? updatedAt,
7162 itemOrdinal,
7163 };
7164 yield* Ref.update(providerRetries, (current) => {
7165 const updated = new Map(current);
7166 updated.set(context.providerTurnId, state);
7167 return updated;
7168 });
7169 yield* emitProviderEvent({
7170 type: "turn_item.updated",
7171 driver: CLAUDE_PROVIDER,
7172 turnItem: makeProviderRetryTurnItem({
7173 driver: CLAUDE_PROVIDER,
7174 threadId: context.input.threadId,
7175 runId: context.input.runId,
7176 nodeId: context.input.rootNodeId,
7177 providerThreadId: context.input.providerThread.id,
7178 providerTurnId: context.providerTurnId,
7179 itemOrdinal,
7180 failure,
7181 retry,
7182 status: "running",
7183 startedAt: state.startedAt,
7184 updatedAt,
7185 }),
7186 });
7187 return;
7188 }
7189
7190 if (message.type === "assistant" && input.replayed !== true) {
7191 const now = yield* DateTime.now;
7192 yield* completeProviderRetry(context, now);
7193 // `<synthetic>` frames (usage limits, sign-in errors, /goal status)
7194 // are generated by the CLI and report zero usage, not the context.
7195 if (
7196 message.parent_tool_use_id === null &&
7197 message.message.usage !== undefined &&
7198 message.message.model !== "<synthetic>"
7199 ) {
7200 yield* emitProviderEvent({
7201 type: "provider_turn.updated",
7202 driver: CLAUDE_PROVIDER,
7203 threadId: context.input.threadId,
7204 providerTurn: {
7205 id: context.providerTurnId,
7206 providerThreadId: context.input.providerThread.id,
7207 nodeId: context.input.rootNodeId,
7208 runAttemptId: context.input.attemptId,
7209 nativeTurnRef: {
7210 driver: CLAUDE_PROVIDER,
7211 nativeId: context.nativeTurnId,
7212 strength: "strong",
7213 },
7214 ordinal: context.providerTurnOrdinal,
7215 status: "running",
7216 startedAt: context.startedAt,
7217 completedAt: null,
7218 tokenUsage: claudeProviderTurnTokenUsage(
7219 message.message.usage,
7220 context.input.modelSelection,
7221 DateTime.formatIso(now),
7222 ),
7223 },
7224 });
7225 }
7226 const parentToolUseId = message.parent_tool_use_id;
7227 const snapshotModel =
7228 typeof message.message.model === "string" ? message.message.model.trim() : "";
7229 const model =
7230 snapshotModel.length === 0
7231 ? undefined
7232 : canonicalClaudeSubagentModel(subagentModelCatalog, snapshotModel);
7233 if (parentToolUseId !== null && model !== undefined) {
7234 const subagent = yield* resolveSubagentByToolUseId(context, parentToolUseId);
7235 if (subagent === undefined) {
7236 rememberPendingClaudeSubagentLaunch(
7237 pendingSubagentLaunchesByToolUseId,
7238 parentToolUseId,
7239 { model },
7240 );
7241 } else if (subagent.task.status === "running" && subagent.task.model !== model) {
7242 yield* updateClaudeSubagentNode({
7243 context,
7244 taskId: subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id),
7245 toolUseId: parentToolUseId,
7246 model,
7247 status: subagent.task.status,
7248 });
7249 }
7250 }
7251 }
7252
7253 const frameParentToolUseId = parentToolUseIdFromSdkMessage(message);
7254 if (
7255 frameParentToolUseId !== null &&
7256 (yield* resolveSubagentByToolUseId(context, frameParentToolUseId)) === undefined
7257 ) {
7258 // Owner not registered yet: hold the frame rather than letting its
7259 // text and tools fall back to the parent thread.
7260 const droppedToolUseId = yield* Ref.modify(
7261 pendingSubagentFramesByToolUseId,
7262 (current) => {
7263 const frames = current.get(frameParentToolUseId) ?? [];
7264 if (frames.length >= PENDING_CLAUDE_SUBAGENT_FRAME_CAP) {
7265 return [frameParentToolUseId, current] as const;
7266 }
7267 const next = new Map(current).set(frameParentToolUseId, [...frames, message]);
7268 const oldest = next.keys().next();
7269 if (next.size > PENDING_CLAUDE_SUBAGENT_CAP && !oldest.done) {
7270 next.delete(oldest.value);
7271 return [oldest.value, next] as const;
7272 }
7273 return [null, next] as const;
7274 },
7275 );
7276 if (droppedToolUseId !== null) {
7277 yield* Effect.logWarning("orchestration-v2.claude-subagent-frames-dropped", {
7278 providerTurnId: context.providerTurnId,
7279 parentToolUseId: droppedToolUseId,
7280 });
7281 }
7282 return;
7283 }
7284
7285 if (isClaudeBackgroundTasksChangedMessage(message)) {
7286 yield* applyBackgroundTaskRosterMessage({
7287 nativeThreadId: liveQuery.nativeThreadId,
7288 message,
7289 activeContext: context,
7290 });
7291 return;
7292 }
7293
7294 if (message.type === "system" && message.subtype === "task_started") {
7295 if (isClaudeNonSubagentTask(message)) {
7296 context.ignoredTaskIds.add(message.task_id);
7297 yield* applyBackgroundTaskRosterMessage({
7298 nativeThreadId: liveQuery.nativeThreadId,
7299 message,
7300 activeContext: context,
7301 });
7302 } else {
7303 const launch =
7304 message.tool_use_id === undefined
7305 ? undefined
7306 : pendingSubagentLaunchesByToolUseId.get(message.tool_use_id);
7307 if (message.tool_use_id !== undefined) {
7308 pendingSubagentLaunchesByToolUseId.delete(message.tool_use_id);
7309 }
7310 const owner =
7311 launch?.ownerToolUseId === undefined
7312 ? undefined
7313 : yield* resolveSubagentByToolUseId(context, launch.ownerToolUseId);
7314 // With no model of its own, a nested subagent runs on its
7315 // owner's (the SDK's default for a subagent's Agent call).
7316 const model = launch?.model ?? owner?.task.model ?? undefined;
7317 // A nested subagent's end goes to its owner, so it never wakes the root.
7318 if (
7319 message.is_backgrounded === true &&
7320 !(yield* isNestedSubagentTask(message.task_id))
7321 ) {
7322 yield* Ref.update(backgroundedSubagentTaskIds, (current) =>
7323 new Set(current).add(message.task_id),
7324 );
7325 }
7326 yield* recoverResumedClaudeSubagent({
7327 context,
7328 nativeThreadId: liveQuery.nativeThreadId,
7329 taskId: message.task_id,
7330 toolUseId: message.tool_use_id,
7331 title: message.description,
7332 });
7333 // A workflow coordinator is marked as one the moment it starts, so
7334 // Lineage can expand it before the first member spawns rather than
7335 // after the first progress snapshot.
7336 const isWorkflow =
7337 claudeTaskTypeFromSdkMessage(message) === CLAUDE_WORKFLOW_TASK_TYPE;
7338 // `meta.name` from the workflow script; only workflow tasks carry it.
7339 const workflowName = isWorkflow ? text(field(message, "workflow_name")) : undefined;
7340
7341 yield* updateClaudeSubagentNode({
7342 context,
7343 taskId: message.task_id,
7344 ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }),
7345 ...(message.prompt === undefined ? {} : { prompt: message.prompt }),
7346 ...(model === undefined ? {} : { model }),
7347 ...(launch?.effort === undefined ? {} : { effort: launch.effort }),
7348 ...(owner === undefined ? {} : { owner }),
7349 ...(isWorkflow
7350 ? {
7351 workflowFrame: message,
7352 ...(workflowName === undefined ? {} : { workflowName }),
7353 }
7354 : {}),
7355
7356 title: message.description,
7357 status: "running",
7358 reopen: true,
7359 });
7360 // A resume supersedes earlier buffered starts and outcomes for this task.
7361 // Replaying a start preserves the completion buffered after it.
7362 yield* Ref.update(wakeBuffers, (current) => {
7363 const entry = current.get(liveQuery.nativeThreadId);
7364 if (entry === undefined) return current;
7365 const replayIndex = entry.messages.indexOf(message);
7366 const messages = entry.messages.filter(
7367 (buffered, index) =>
7368 !(
7369 (replayIndex === -1 || index < replayIndex) &&
7370 buffered.type === "system" &&
7371 (buffered.subtype === "task_started" ||
7372 buffered.subtype === "task_notification") &&
7373 buffered.task_id === message.task_id
7374 ),
7375 );
7376 return messages.length === entry.messages.length
7377 ? current
7378 : new Map(current).set(liveQuery.nativeThreadId, { ...entry, messages });
7379 });
7380 }
7381 }
7382
7383 if (message.type === "system" && message.subtype === "task_progress") {
7384 const progress = message.description.trim();
7385 const isBackgroundTask = yield* hasPendingBackgroundTaskOnNativeThread(
7386 liveQuery.nativeThreadId,
7387 message.task_id,
7388 );
7389 // A workflow frame is worth an update even with an empty description:
7390 // it carries the phase plan, the member roster and the run's usage.
7391 const carriesWorkflowTelemetry = hasClaudeWorkflowSnapshot(message);
7392 const isWorkflowCoordinator =
7393 (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id)?.task.workflow !==
7394 undefined;
7395 if (
7396 (carriesWorkflowTelemetry || (progress.length > 0 && !isWorkflowCoordinator)) &&
7397 !context.ignoredTaskIds.has(message.task_id) &&
7398 !isBackgroundTask
7399 ) {
7400 yield* updateClaudeSubagentNode({
7401 context,
7402 taskId: message.task_id,
7403 ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }),
7404 ...(progress.length === 0 ? {} : { progress }),
7405 workflowFrame: message,
7406 status: "running",
7407 });
7408 }
7409 }
7410
7411 if (message.type === "system" && message.subtype === "task_notification") {
7412 // A live outcome supersedes earlier idle starts. During replay only
7413 // remove frames before this notification, preserving a later resume.
7414 if (
7415 !appliedSubagentOutcomes.has(message) &&
7416 (yield* Ref.get(sessionSubagentsByTaskId)).has(message.task_id)
7417 ) {
7418 yield* discardBufferedSubagentStarts(
7419 liveQuery.nativeThreadId,
7420 message.task_id,
7421 message,
7422 );
7423 }
7424 // A wake-replay turn has empty ignoredTaskIds, so opaque-task
7425 // tracking (live roster, wake eligibility, or the short-lived
7426 // post-buffer replay tombstone) classifies local_bash before any
7427 // subagent handling.
7428 const wasBackgroundTask = yield* isKnownOpaqueBackgroundTaskOnNativeThread(
7429 liveQuery.nativeThreadId,
7430 message.task_id,
7431 );
7432 // Backgrounded work that ends during a user turn wakes the root after
7433 // it. Drained wake frames replay here too; they were recorded idle.
7434 if (!isClaudeProviderContinuationTurn(context.input)) {
7435 if (wasBackgroundTask) {
7436 yield* recordWakeReport(
7437 liveQuery.nativeThreadId,
7438 message.task_id,
7439 yield* opaqueTaskWakeReport(liveQuery.nativeThreadId, message),
7440 );
7441 } else if (
7442 !wasBackgroundTask &&
7443 (yield* Ref.get(backgroundedSubagentTaskIds)).has(message.task_id)
7444 ) {
7445 const registered = (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id);
7446 yield* recordWakeReport(liveQuery.nativeThreadId, message.task_id, {
7447 kind: "subagent",
7448 label: registered?.task.title ?? undefined,
7449 outcome: claudeTaskOutcome(message.status),
7450 childThreadId: registered?.childThreadId,
7451 });
7452 }
7453 }
7454 // A resume starts the subagent again and says again whether it is backgrounded.
7455 yield* Ref.update(backgroundedSubagentTaskIds, (current) => {
7456 if (!current.has(message.task_id)) return current;
7457 const updated = new Set(current);
7458 updated.delete(message.task_id);
7459 return updated;
7460 });
7461 yield* applyBackgroundTaskRosterMessage({
7462 nativeThreadId: liveQuery.nativeThreadId,
7463 message,
7464 activeContext: context,
7465 });
7466 if (
7467 !wasBackgroundTask &&
7468 !context.ignoredTaskIds.has(message.task_id) &&
7469 !appliedSubagentOutcomes.has(message)
7470 ) {
7471 yield* updateClaudeSubagentNode({
7472 context,
7473 taskId: message.task_id,
7474 ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }),
7475 // Terminal usage totals arrive here, after the last snapshot.
7476 workflowFrame: message,
7477 nativeOutcome: message,
7478 result: message.summary,
7479 status:
7480 message.status === "completed"
7481 ? "completed"
7482 : message.status === "stopped"
7483 ? "cancelled"
7484 : "failed",
7485 });
7486 }
7487 // Replay tombstone only needs to outlive buffering until this
7488 // drained/live classification runs; drop it so it cannot leak.
7489 if (wasBackgroundTask) {
7490 yield* clearOpaqueBackgroundTaskReplayTombstone(
7491 liveQuery.nativeThreadId,
7492 message.task_id,
7493 );
7494 }
7495 }
7496
7497 const toolPresentations = claudeToolPresentationsFromAssistantMessage(message);
7498 for (const toolUse of claudeToolUseBlocksFromAssistantMessage(message)) {
7499 const nativeToolInput = claudeNativeToolInputFromUnknown(toolUse.input);
7500 if (toolUse.name === "Agent") {
7501 rememberClaudeSubagentLaunch(
7502 subagentModelCatalog,
7503 context,
7504 pendingSubagentLaunchesByToolUseId,
7505 toolUse.id,
7506 nativeToolInput,
7507 parentToolUseIdFromSdkMessage(message),
7508 );
7509 continue;
7510 }
7511 if (toolUse.name === "TodoWrite" && parentToolUseIdFromSdkMessage(message) === null) {
7512 yield* emitClaudePlanProjection({
7513 context,
7514 nativeItemId: toolUse.id,
7515 kind: "todo_list",
7516 steps: claudeTodoSteps(nativeToolInput),
7517 }).pipe(Effect.orDie);
7518 }
7519 yield* ensureToolCallStarted({
7520 context,
7521 nativeItemId: toolUse.id,
7522 toolName: toolUse.name,
7523 toolInput: nativeToolInput,
7524 parentToolUseId: parentToolUseIdFromSdkMessage(message),
7525 presentation:
7526 toolPresentations.get(toolUse.id) ??
7527 mcpToolPresentation({
7528 toolName: toolUse.name.replace(/^mcp__claude_ai_/u, "mcp__"),
7529 }),
7530 });
7531 const heldPlan = heldProposedPlansByToolUseId.get(toolUse.id);
7532 if (heldPlan !== undefined) {
7533 heldProposedPlansByToolUseId.delete(toolUse.id);
7534 yield* emitClaudePlanProjection({
7535 context,
7536 nativeItemId: toolUse.id,
7537 kind: "proposed_plan",
7538 markdown: heldPlan,
7539 }).pipe(Effect.orDie);
7540 }
7541 }
7542
7543 for (const { toolResult, output } of claudeToolResultEntriesFromMessage(message)) {
7544 const subagent = yield* resolveSubagentByToolUseId(context, toolResult.tool_use_id);
7545 const runHandles = parseClaudeWorkflowRunHandles(claudeNativeToolOutputValue(output));
7546 // Independent of the branches below: unlike the Agent tool, a
7547 // Workflow tool_use is an ordinary tool call and so lands in
7548 // toolCalls, which skips the subagent branch entirely. Its launch
7549 // acknowledgement is the only carrier of the run's filesystem
7550 // handles, so harvest them wherever the result arrives.
7551 if (subagent !== undefined && runHandles !== undefined) {
7552 yield* updateClaudeSubagentNode({
7553 context,
7554 taskId: subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id),
7555 toolUseId: toolResult.tool_use_id,
7556 workflowRunHandles: runHandles,
7557 // The acknowledgement arrives at launch, so the coordinator
7558 // is still running; a late one is dropped by the same
7559 // terminal protection that guards every other update.
7560 status: "running",
7561 });
7562 }
7563 // A resume task_started reuses the resuming tool call's
7564 // tool_use_id (e.g. SendMessage), whose tool_result only
7565 // acknowledges delivery. Only the Agent launch's tool_result may
7566 // terminalize the subagent, and Agent tool_uses never enter
7567 // toolCalls (they project as subagent rows instead).
7568 if (
7569 subagent !== undefined &&
7570 findToolCall(context, toolResult.tool_use_id) === undefined
7571 ) {
7572 // A background Agent launch resolves its tool_use immediately
7573 // with an async-launch ACK while the task keeps running; only
7574 // the eventual task_notification terminalizes the subagent.
7575 if (
7576 !isClaudeToolResultError(toolResult) &&
7577 (runHandles !== undefined || isClaudeSubagentAsyncLaunchAck(output))
7578 ) {
7579 continue;
7580 }
7581 const result = claudeSubagentResultText(output);
7582 yield* updateClaudeSubagentNode({
7583 context,
7584 taskId: subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id),
7585 toolUseId: toolResult.tool_use_id,
7586 ...(result.length === 0 ? {} : { result }),
7587 status: isClaudeToolResultError(toolResult) ? "failed" : "completed",
7588 });
7589 continue;
7590 }
7591 const parentToolUseId = parentToolUseIdFromSdkMessage(message);
7592 const toolCall =
7593 findToolCall(context, toolResult.tool_use_id) ??
7594 (yield* ensureToolCallStarted({
7595 context,
7596 nativeItemId: toolResult.tool_use_id,
7597 toolName: toolNameFromClaudeToolResult(toolResult),
7598 toolInput: EMPTY_CLAUDE_NATIVE_TOOL_INPUT,
7599 parentToolUseId,
7600 }));
7601 const completedAt = yield* DateTime.now;
7602 const toolNonExecutionKind = claudeToolNonExecutionKind(
7603 message,
7604 toolResult.tool_use_id,
7605 );
7606 const artifacts = buildToolCallArtifacts({
7607 context,
7608 nativeItemId: toolCall.nativeItemId,
7609 toolName: toolCall.toolName,
7610 classification: toolCall.classification,
7611 toolInput: toolCall.input,
7612 threadId: toolCall.threadId,
7613 runId: toolCall.runId,
7614 rootNodeId: toolCall.rootNodeId,
7615 parentNodeId: toolCall.parentNodeId,
7616 ordinal: toolCall.ordinal,
7617 output,
7618 status:
7619 toolNonExecutionKind === "cancelled"
7620 ? "cancelled"
7621 : isClaudeToolResultError(toolResult)
7622 ? "failed"
7623 : "completed",
7624 ...(toolNonExecutionKind === undefined ? {} : { toolNonExecutionKind }),
7625 startedAt: toolCall.startedAt,
7626 updatedAt: completedAt,
7627 presentation: toolCall.presentation,
7628 });
7629 yield* emitToolCallArtifacts(artifacts);
7630 toolCallsFor(context, toolCall).delete(toolCall.nativeItemId);
7631 // A subagent's task tools must not replace the parent's list.
7632 if (parentToolUseId === null && !isClaudeToolResultError(toolResult)) {
7633 const taskLists = yield* Ref.get(taskListsByNativeThread);
7634 const taskList = applyClaudeTaskTool(
7635 taskLists.get(liveQuery.nativeThreadId) ?? new Map(),
7636 toolCall.toolName,
7637 claudeNativeToolInputValue(toolCall.input),
7638 claudeNativeToolOutputValue(output),
7639 );
7640 if (taskList !== undefined) {
7641 yield* Ref.set(
7642 taskListsByNativeThread,
7643 new Map(taskLists).set(liveQuery.nativeThreadId, taskList),
7644 );
7645 // One list per turn, updated in place; a later turn supersedes it.
7646 yield* emitClaudePlanProjection({
7647 context,
7648 nativeItemId: `claude-tasks:${context.providerTurnId}`,
7649 kind: "todo_list",
7650 steps: claudeTaskSteps(taskList),
7651 }).pipe(Effect.orDie);
7652 }
7653 }
7654 }
7655
7656 const assistantParentToolUseId = parentToolUseIdFromSdkMessage(message);
7657 if (message.type === "assistant" && assistantParentToolUseId !== null) {
7658 // Claude never streams subagent output, so each thinking block
7659 // arrives whole in its own snapshot and lands in the child thread.
7660 const thinking = message.message.content.flatMap((block) =>
7661 block.type === "thinking" && block.thinking.trim().length > 0 ? [block.thinking] : [],
7662 );
7663 const subagent =
7664 thinking.length === 0
7665 ? undefined
7666 : yield* resolveSubagentByToolUseId(context, assistantParentToolUseId);
7667 if (subagent !== undefined) {
7668 const now = yield* DateTime.now;
7669 for (const [index, text] of thinking.entries()) {
7670 const nativeItemId = `${message.uuid}:thinking:${index}`;
7671 yield* emitProviderEvent({
7672 type: "turn_item.updated",
7673 driver: CLAUDE_PROVIDER,
7674 turnItem: {
7675 id: idAllocator.derive.turnItemFromProviderItem({
7676 driver: CLAUDE_PROVIDER,
7677 nativeItemId,
7678 }),
7679 threadId: subagent.childThreadId,
7680 runId: null,
7681 nodeId: subagent.childRootNodeId,
7682 providerThreadId: null,
7683 providerTurnId: null,
7684 nativeItemRef: {
7685 driver: CLAUDE_PROVIDER,
7686 nativeId: nativeItemId,
7687 strength: "strong",
7688 },
7689 parentItemId: null,
7690 ordinal: ++subagent.nextChildItemOrdinal,
7691 type: "reasoning",
7692 title: "Thinking",
7693 text,
7694 streaming: false,
7695 status: "completed",
7696 startedAt: now,
7697 completedAt: now,
7698 updatedAt: now,
7699 },
7700 });
7701 }
7702 }
7703 }
7704 const assistantText = assistantTextFromSdkMessage(message);
7705 if (
7706 assistantText !== null &&
7707 assistantText.text.length > 0 &&
7708 assistantParentToolUseId !== null
7709 ) {
7710 // Subagent text belongs to the subagent's child thread, never the
7711 // parent transcript. Unowned frames were already held above.
7712 const subagent = yield* resolveSubagentByToolUseId(context, assistantParentToolUseId);
7713 if (subagent === undefined) {
7714 return;
7715 }
7716 const now = yield* DateTime.now;
7717 // One native message arrives as one snapshot per content block.
7718 const nativeMessageId =
7719 message.type === "assistant" && typeof message.message.id === "string"
7720 ? message.message.id
7721 : null;
7722 subagent.lastAssistantText =
7723 nativeMessageId !== null && nativeMessageId === subagent.lastAssistantMessageId
7724 ? `${subagent.lastAssistantText ?? ""}\n${assistantText.text}`
7725 : assistantText.text;
7726 subagent.lastAssistantMessageId = nativeMessageId;
7727 yield* emitSubagentConversationMessage({
7728 nativeItemId: assistantText.nativeItemId,
7729 threadId: subagent.childThreadId,
7730 rootNodeId: subagent.childRootNodeId,
7731 role: "assistant",
7732 text: assistantText.text,
7733 ordinal: ++subagent.nextChildItemOrdinal,
7734 now,
7735 });
7736 return;
7737 }
7738 if (assistantText !== null && assistantText.text.length > 0) {
7739 yield* emitAssistantTextArtifacts({
7740 context,
7741 nativeItemId: assistantText.nativeItemId,
7742 text: assistantText.text,
7743 });
7744 return;
7745 }
7746
7747 // A zero-turn task-notification-origin result is almost always
7748 // lifecycle debris, so it must not finalize a normal turn or supply
7749 // fallback assistant text. Provider continuation turns still consume
7750 // it when draining buffered wake messages.
7751 //
7752 // "Almost always" is the honest word: num_turns is a workload count,
7753 // not a causal link to the prompt this turn is waiting on. A genuine
7754 // wake that fails before any model turn also reports zero, and is
7755 // dropped here, so that turn hangs until query exit. Narrowing the
7756 // drop to zero-turn results only shrinks a pre-existing drop; closing
7757 // the residue needs a correlation id on the result, which the wire
7758 // does not carry today.
7759 if (
7760 isClaudeTaskNotificationOriginResult(message) &&
7761 !isClaudeProviderContinuationTurn(context.input) &&
7762 message.num_turns === 0
7763 ) {
7764 // Routine lifecycle noise, so debug rather than warning: interrupt
7765 // recovery produces this every time.
7766 yield* Effect.logDebug("orchestration-v2.claude-task-notification-result-dropped", {
7767 providerTurnId: context.providerTurnId,
7768 num_turns: message.num_turns,
7769 stop_reason: message.stop_reason,
7770 terminal_reason: message.terminal_reason,
7771 uuid: message.uuid,
7772 session_id: message.session_id,
7773 createdBy: context.input.message.createdBy,
7774 creationSource: context.input.message.creationSource,
7775 });
7776 return;
7777 }
7778
7779 // The converse of the drop above, and the case actually worth
7780 // watching: a positive-turn task-notification result settling a turn
7781 // T3 did not mark as a continuation. That is the hang fix working,
7782 // but it is also the shape a stale result would take if one ever
7783 // carried model turns, which nothing on the wire lets us rule out.
7784 if (
7785 isClaudeTaskNotificationOriginResult(message) &&
7786 !isClaudeProviderContinuationTurn(context.input)
7787 ) {
7788 // This user turn ran the wake itself, so no offer will name its work.
7789 yield* clearWakeReports(liveQuery.nativeThreadId);
7790 yield* Effect.logInfo("orchestration-v2.claude-task-notification-result-accepted", {
7791 providerTurnId: context.providerTurnId,
7792 num_turns: message.num_turns,
7793 stop_reason: message.stop_reason,
7794 terminal_reason: message.terminal_reason,
7795 uuid: message.uuid,
7796 session_id: message.session_id,
7797 createdBy: context.input.message.createdBy,
7798 creationSource: context.input.message.creationSource,
7799 });
7800 }
7801
7802 // Failed result text belongs on the terminal-failure item, including
7803 // structured failures whose SDK result still has is_error=false.
7804 const resultText =
7805 message.type === "result" &&
7806 ((message.subtype === "success" && message.is_error) ||
7807 terminalStatusFromResult(message) === "failed")
7808 ? null
7809 : resultTextFromSdkMessage(message);
7810 if (
7811 context.assistant.emittedNativeItemIds.size === 0 &&
7812 context.assistant.fallbackText.length === 0 &&
7813 resultText !== null &&
7814 resultText.text.length > 0
7815 ) {
7816 context.assistant.fallbackText = resultText.text;
7817 context.assistant.fallbackNativeItemId = resultText.nativeItemId;
7818 }
7819
7820 if (message.type === "result") {
7821 const completedAt = yield* DateTime.now;
7822 const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId);
7823 const wasSteered = (yield* Ref.get(steeredTurns)).has(context.providerTurnId);
7824 if (!interrupted && wasSteered && isClaudeActiveSteeringAbortResult(message)) {
7825 context.steeredSegmentUsage.push(message.usage);
7826 return;
7827 }
7828 yield* Ref.update(steeredTurns, (current) => {
7829 const next = new Set(current);
7830 next.delete(context.providerTurnId);
7831 return next;
7832 });
7833 const usageLimited =
7834 context.authenticationFailureMessage === undefined &&
7835 (context.rejectedRateLimitTypes.size > 0 || context.latestAssistantRateLimited) &&
7836 (message.subtype !== "success" ||
7837 message.api_error_status == null ||
7838 message.api_error_status === 429) &&
7839 (message.terminal_reason == null ||
7840 message.terminal_reason === "api_error" ||
7841 message.terminal_reason === "blocking_limit");
7842 // The CLI also labels a 403 authentication_failed. Logging in again
7843 // can't grant a permission, so show the CLI's own text instead.
7844 const permissionDenied =
7845 message.subtype === "success" && message.api_error_status === 403;
7846 const failureHint = permissionDenied
7847 ? message.result
7848 : (context.authenticationFailureMessage ??
7849 (usageLimited
7850 ? "Claude usage limit reached. Send the message again once the limit resets."
7851 : undefined));
7852 const resetTimes = Array.from(context.rateLimitResetTimes.values());
7853 const resetAt =
7854 resetTimes.length > 0 && resetTimes.every((time) => time !== null)
7855 ? resetTimes.reduce((latest, time) => (time! > latest ? time! : latest), "")
7856 : null;
7857 const resultFailure = interrupted
7858 ? null
7859 : providerFailureFromResult(message, completedAt, failureHint, usageLimited);
7860 const terminalFailure =
7861 resultFailure?.class === "usage_limit"
7862 ? { ...resultFailure, resetAt: resetAt ?? resultFailure.resetAt ?? null }
7863 : resultFailure;
7864 yield* finalizeActiveTurn({
7865 context,
7866 status: interrupted ? "interrupted" : terminalStatusFromResult(message, failureHint),
7867 completedAt,
7868 result: message,
7869 ...(terminalFailure === null ? {} : { failure: terminalFailure }),
7870 });
7871 }
7872 });
7873
7874 // Held subagent frames whose owner this lifecycle frame resolves. The
7875 // frame's (task_id, tool_use_id) pair is authoritative, so it also
7876 // fills the tool-use index when task_started lacked a tool_use_id.
7877 const takeReleasableSubagentFrames = Effect.fnUntraced(function* (message: SDKMessage) {
7878 if (
7879 message.type !== "system" ||
7880 (message.subtype !== "task_started" &&
7881 message.subtype !== "task_progress" &&
7882 message.subtype !== "task_notification") ||
7883 message.tool_use_id === undefined ||
7884 !(yield* Ref.get(sessionSubagentsByTaskId)).has(message.task_id)
7885 ) {
7886 return [];
7887 }
7888 const toolUseId = message.tool_use_id;
7889 const taskId = message.task_id;
7890 yield* Ref.update(sessionSubagentTaskIdsByToolUseId, (current) =>
7891 current.get(toolUseId) === taskId ? current : new Map(current).set(toolUseId, taskId),
7892 );
7893 return yield* Ref.modify(pendingSubagentFramesByToolUseId, (current) => {
7894 const frames = current.get(toolUseId);
7895 if (frames === undefined) {
7896 return [[] as ReadonlyArray<SDKMessage>, current] as const;
7897 }
7898 const next = new Map(current);
7899 next.delete(toolUseId);
7900 return [frames, next] as const;
7901 });
7902 });
7903
7904 const handleRoutedSdkMessage = Effect.fnUntraced(function* (input: {
7905 readonly query: ClaudeAgentSdkQuerySession;
7906 readonly message: SDKMessage;
7907 }) {
7908 // Progress or a notification can be the first frame naming a known
7909 // subagent's tool_use_id; its held frames must precede the result.
7910 if (input.message.type !== "system" || input.message.subtype !== "task_started") {
7911 for (const message of yield* takeReleasableSubagentFrames(input.message)) {
7912 yield* handleSdkMessageFrame({ query: input.query, message, replayed: true });
7913 }
7914 }
7915 yield* handleSdkMessageFrame(input);
7916 // task_started registers its subagent while being handled.
7917 for (const message of yield* takeReleasableSubagentFrames(input.message)) {
7918 yield* handleSdkMessageFrame({ query: input.query, message, replayed: true });
7919 }
7920 });
7921
7922 // Root tool_use ids among a turn's held frames.
7923 const heldToolUseIds = (context: ActiveClaudeTurnContext): ReadonlySet<string> =>
7924 new Set(
7925 context.heldRootFrames.flatMap((frame) =>
7926 parentToolUseIdFromSdkMessage(frame) === null
7927 ? claudeToolUseBlocksFromAssistantMessage(frame).map((toolUse) => toolUse.id)
7928 : [],
7929 ),
7930 );
7931
7932 // Gives held root output to the pending prompt turn.
7933 const releaseHeldRootFrames = Effect.fnUntraced(function* (
7934 context: ActiveClaudeTurnContext,
7935 ) {
7936 context.promptEcho = "confirmed";
7937 const liveQuery = yield* Ref.get(queryContext);
7938 const held = context.heldRootFrames.splice(0);
7939 if (liveQuery === null) {
7940 return;
7941 }
7942 for (const message of held) {
7943 yield* handleRoutedSdkMessage({ query: liveQuery.query, message });
7944 }
7945 });
7946
7947 // A prompt offered while Claude has a turn of its own queued (a
7948 // background task or subagent finished, a peer message arrived, a
7949 // resume reports work the previous process left) is answered only
7950 // after that turn runs, and the two look alike until each turn's
7951 // result. Claude echoes the prompt's uuid on the turn that answers
7952 // it, so a result for another turn (see isClaudeResultForOtherTurn)
7953 // never settles this one. On a CLI that echoes on the turn's first
7954 // frame, root output that arrives before the echo is held: the echo
7955 // releases it to this turn, and another turn's result sends it to the
7956 // wake buffer and so to a continuation run. The echo lands on the
7957 // turn's first frame, so the prompt's own turn streams without delay.
7958 // A CLI that echoes only on the result (or not at all) is never held,
7959 // exactly as before this gate.
7960 const handleSdkMessage = Effect.fnUntraced(function* (input: {
7961 readonly query: ClaudeAgentSdkQuerySession;
7962 readonly message: SDKMessage;
7963 }) {
7964 const message = input.message;
7965 const context = yield* Ref.get(activeTurn);
7966 const liveQuery = yield* Ref.get(queryContext);
7967 if (
7968 context === null ||
7969 context.promptUuid === null ||
7970 context.promptEcho === "confirmed" ||
7971 liveQuery?.query !== input.query
7972 ) {
7973 yield* handleRoutedSdkMessage(input);
7974 return;
7975 }
7976 if (claudeEchoedPromptUuids(message).includes(context.promptUuid)) {
7977 if (
7978 liveQuery.promptEchoMode === "unknown" ||
7979 liveQuery.promptEchoMode === "acknowledged"
7980 ) {
7981 liveQuery.promptEchoMode =
7982 message.type !== "result" && context.gatedFramesBeforeEcho === 0
7983 ? "early"
7984 : "result_only";
7985 }
7986 yield* releaseHeldRootFrames(context);
7987 yield* handleRoutedSdkMessage(input);
7988 return;
7989 }
7990 const lifecycle = claudePromptLifecycle(message);
7991 if (lifecycle?.uuid === context.promptUuid) {
7992 if (liveQuery.promptEchoMode === "unknown") {
7993 liveQuery.promptEchoMode = "acknowledged";
7994 }
7995 if (lifecycle.state === "started") {
7996 context.promptStarted = true;
7997 }
7998 // Claude finishes a prompt whose uuid its transcript already
7999 // holds without starting it, and no turn will ever answer it.
8000 if (lifecycle.state === "completed" && !context.promptStarted) {
8001 yield* Effect.logWarning("orchestration-v2.claude-prompt-completed-without-turn", {
8002 providerTurnId: context.providerTurnId,
8003 promptUuid: context.promptUuid,
8004 });
8005 yield* finalizeActiveTurn({
8006 context,
8007 status: "failed",
8008 completedAt: yield* DateTime.now,
8009 failure: makeProviderFailure({
8010 class: "provider_error",
8011 message: "Claude acknowledged the message but never started a turn for it.",
8012 }),
8013 });
8014 return;
8015 }
8016 }
8017 if (!isClaudePromptEchoGatedFrame(message)) {
8018 // Frames the held turn produced through its own tool uses (a
8019 // subagent it launched, its task lifecycle) follow that turn;
8020 // anything else, such as an earlier subagent still streaming,
8021 // keeps its own owner.
8022 if (
8023 context.heldRootFrames.length > 0 &&
8024 claudeFrameToolUseIds(message).some((id) => heldToolUseIds(context).has(id))
8025 ) {
8026 context.heldRootFrames.push(message);
8027 return;
8028 }
8029 yield* handleRoutedSdkMessage(input);
8030 return;
8031 }
8032 context.gatedFramesBeforeEcho += 1;
8033 if (
8034 message.type === "result" &&
8035 isClaudeResultForOtherTurn({
8036 message,
8037 promptUuid: context.promptUuid,
8038 promptEchoMode: liveQuery.promptEchoMode,
8039 })
8040 ) {
8041 const held = context.heldRootFrames.splice(0);
8042 // The prompt's own turn follows, so echo timing is learned from it alone.
8043 context.gatedFramesBeforeEcho = 0;
8044 if (message.num_turns === 0) {
8045 // Lifecycle debris, not a turn: any held output stays held for
8046 // its real owner.
8047 context.heldRootFrames.push(...held);
8048 yield* Effect.logDebug("orchestration-v2.claude-result-for-other-turn-dropped", {
8049 providerTurnId: context.providerTurnId,
8050 origin: message.origin?.kind,
8051 uuid: message.uuid,
8052 });
8053 return;
8054 }
8055 if (liveQuery.promptEchoMode === "early") {
8056 // The held turn ran before this prompt's turn, which still
8057 // follows on the same stream.
8058 yield* Effect.logInfo("orchestration-v2.claude-wake-turn-before-prompt", {
8059 providerTurnId: context.providerTurnId,
8060 origin: message.origin?.kind,
8061 heldFrames: held.length,
8062 });
8063 for (const frame of [...held, message]) {
8064 yield* bufferWakeMessage({
8065 nativeThreadId: liveQuery.nativeThreadId,
8066 message: frame,
8067 });
8068 }
8069 return;
8070 }
8071 // Its output already streamed into this turn, so only its result
8072 // stays out: settling here would end the turn before its prompt
8073 // runs. The prompt's own turn ends it with an echoing result.
8074 if (isClaudeTaskNotificationOriginResult(message)) {
8075 // This turn showed the wake, so no offer will name its work.
8076 yield* clearWakeReports(liveQuery.nativeThreadId);
8077 }
8078 yield* Effect.logInfo("orchestration-v2.claude-result-for-other-turn", {
8079 providerTurnId: context.providerTurnId,
8080 origin: message.origin?.kind,
8081 num_turns: message.num_turns,
8082 uuid: message.uuid,
8083 });
8084 return;
8085 }
8086 if (liveQuery.promptEchoMode !== "early") {
8087 yield* handleRoutedSdkMessage(input);
8088 return;
8089 }
8090 if (message.type !== "result") {
8091 context.heldRootFrames.push(message);
8092 return;
8093 }
8094 // A result from no other turn (an interrupt, a startup failure)
8095 // settles the prompt's turn.
8096 yield* releaseHeldRootFrames(context);
8097 yield* handleRoutedSdkMessage(input);
8098 });
8099
8100 const canUseToolEffect = Effect.fn("ClaudeAdapterV2.canUseTool")(function* (
8101 toolName: Parameters<CanUseTool>[0],
8102 toolInput: Parameters<CanUseTool>[1],
8103 callbackOptions: Parameters<CanUseTool>[2],
8104 ) {
8105 const context = yield* Ref.get(activeTurn);
8106 if (context === null) {
8107 return {
8108 behavior: "deny",
8109 message: "Claude V2 adapter has no active turn for this tool request.",
8110 toolUseID: callbackOptions.toolUseID,
8111 } satisfies PermissionResult;
8112 }
8113
8114 const nativeRequestId = callbackOptions.toolUseID;
8115 const nativeToolInput = claudeNativeToolInputFromRecord(toolInput);
8116 // While root output awaits its prompt echo, the tool's streamed
8117 // frames are held and may belong to a queued wake turn, so the
8118 // callback must not attach anything to the pending prompt turn:
8119 // the held tool_use frame starts the tool (and projects an
8120 // ExitPlanMode plan) in whichever run it is released to.
8121 const heldForEcho = context.heldRootFrames.length > 0;
8122 if (toolName === "Agent") {
8123 rememberClaudeSubagentLaunch(
8124 subagentModelCatalog,
8125 context,
8126 pendingSubagentLaunchesByToolUseId,
8127 nativeRequestId,
8128 nativeToolInput,
8129 null,
8130 );
8131 } else if (!heldForEcho) {
8132 yield* ensureToolCallStarted({
8133 context,
8134 nativeItemId: nativeRequestId,
8135 toolName,
8136 toolInput: nativeToolInput,
8137 parentToolUseId: null,
8138 });
8139 }
8140
8141 if (
8142 heldForEcho &&
8143 toolName !== "ExitPlanMode" &&
8144 (toolName === "AskUserQuestion" || requiresClaudeApproval(context))
8145 ) {
8146 // The SDK blocks on the answer, and the prompt echo cannot arrive
8147 // until the held turn goes on, so the request is raised now and
8148 // the held output goes with it to the pending prompt turn, as it
8149 // did before this turn was held. ExitPlanMode is answered at once
8150 // below, so its frames stay held.
8151 yield* releaseHeldRootFrames(context);
8152 if (toolName !== "Agent") {
8153 yield* ensureToolCallStarted({
8154 context,
8155 nativeItemId: nativeRequestId,
8156 toolName,
8157 toolInput: nativeToolInput,
8158 parentToolUseId: null,
8159 });
8160 }
8161 }
8162
8163 if (toolName === "AskUserQuestion") {
8164 const questions = claudeUserInputQuestions(nativeToolInput);
8165 const artifacts = yield* buildApprovalRequestArtifacts({
8166 context,
8167 nativeItemId: nativeRequestId,
8168 nativeRequestId,
8169 requestKind: "user_input",
8170 questions,
8171 });
8172 const answers = yield* Deferred.make<ProviderUserInputAnswers, never>();
8173 yield* Ref.update(pendingRuntimeRequests, (current) => {
8174 const updated = new Map(current);
8175 updated.set(String(artifacts.request.id), {
8176 type: "user_input",
8177 requestId: artifacts.request.id,
8178 answers,
8179 });
8180 return updated;
8181 });
8182 yield* Effect.all(
8183 [
8184 emitProviderEvent({
8185 type: "node.updated",
8186 driver: CLAUDE_PROVIDER,
8187 node: artifacts.node,
8188 }),
8189 emitProviderEvent({
8190 type: "runtime_request.updated",
8191 driver: CLAUDE_PROVIDER,
8192 runtimeRequest: artifacts.request,
8193 }),
8194 emitProviderEvent({
8195 type: "turn_item.updated",
8196 driver: CLAUDE_PROVIDER,
8197 turnItem: artifacts.turnItem,
8198 }),
8199 ],
8200 { concurrency: 1 },
8201 );
8202 const resolvedAnswers = yield* awaitClaudeUserInputAnswers(
8203 answers,
8204 callbackOptions.signal,
8205 ).pipe(
8206 Effect.ensuring(
8207 Ref.update(pendingRuntimeRequests, (current) => {
8208 const updated = new Map(current);
8209 updated.delete(String(artifacts.request.id));
8210 return updated;
8211 }),
8212 ),
8213 );
8214 return callbackOptions.signal.aborted
8215 ? ({
8216 behavior: "deny",
8217 message: "User cancelled tool execution.",
8218 } satisfies PermissionResult)
8219 : ({
8220 behavior: "allow",
8221 updatedInput: {
8222 questions: toolInput.questions,
8223 answers: claudeSdkUserInputAnswers(resolvedAnswers),
8224 },
8225 toolUseID: callbackOptions.toolUseID,
8226 } satisfies PermissionResult);
8227 }
8228
8229 if (toolName === "ExitPlanMode") {
8230 const markdown = claudeProposedPlan(nativeToolInput);
8231 // Defer only while the tool_use frame itself is still held, so
8232 // the plan is published when that frame is handled; otherwise
8233 // publish now. Claude is told the plan was captured either way.
8234 if (markdown !== null && heldToolUseIds(context).has(nativeRequestId)) {
8235 heldProposedPlansByToolUseId.set(nativeRequestId, markdown);
8236 } else if (markdown !== null) {
8237 yield* emitClaudePlanProjection({
8238 context,
8239 nativeItemId: nativeRequestId,
8240 kind: "proposed_plan",
8241 markdown,
8242 });
8243 }
8244 return {
8245 behavior: "deny",
8246 message:
8247 "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.",
8248 toolUseID: callbackOptions.toolUseID,
8249 } satisfies PermissionResult;
8250 }
8251
8252 if (!requiresClaudeApproval(context)) {
8253 return {
8254 behavior: "allow",
8255 updatedInput: toolInput,
8256 toolUseID: callbackOptions.toolUseID,
8257 } satisfies PermissionResult;
8258 }
8259
8260 const requestKind = providerRequestKindFromClaudeTool(toolName);
8261 const prompt =
8262 callbackOptions.title ??
8263 callbackOptions.description ??
8264 callbackOptions.decisionReason ??
8265 summarizeClaudeToolRequest(toolName, nativeToolInput);
8266 const artifacts = yield* buildApprovalRequestArtifacts({
8267 context,
8268 nativeItemId: nativeRequestId,
8269 nativeRequestId,
8270 requestKind,
8271 prompt,
8272 });
8273 const decision = yield* Deferred.make<ProviderApprovalDecision, never>();
8274 yield* Ref.update(pendingRuntimeRequests, (current) => {
8275 const updated = new Map(current);
8276 updated.set(String(artifacts.request.id), {
8277 type: "approval",
8278 requestId: artifacts.request.id,
8279 requestKind,
8280 decision,
8281 });
8282 return updated;
8283 });
8284 yield* Effect.all(
8285 [
8286 emitProviderEvent({
8287 type: "node.updated",
8288 driver: CLAUDE_PROVIDER,
8289 node: artifacts.node,
8290 }),
8291 emitProviderEvent({
8292 type: "runtime_request.updated",
8293 driver: CLAUDE_PROVIDER,
8294 runtimeRequest: artifacts.request,
8295 }),
8296 emitProviderEvent({
8297 type: "turn_item.updated",
8298 driver: CLAUDE_PROVIDER,
8299 turnItem: artifacts.turnItem,
8300 }),
8301 ],
8302 { concurrency: 1 },
8303 );
8304
8305 const resolvedDecision = yield* awaitClaudeApprovalDecision(
8306 decision,
8307 callbackOptions.signal,
8308 ).pipe(
8309 Effect.ensuring(
8310 Ref.update(pendingRuntimeRequests, (current) => {
8311 const updated = new Map(current);
8312 updated.delete(String(artifacts.request.id));
8313 return updated;
8314 }),
8315 ),
8316 );
8317
8318 return permissionResultFromDecision({
8319 toolName,
8320 decision: resolvedDecision,
8321 toolInput,
8322 toolUseID: callbackOptions.toolUseID,
8323 ...(callbackOptions.suggestions === undefined
8324 ? {}
8325 : { suggestions: callbackOptions.suggestions }),
8326 });
8327 });
8328
8329 const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) =>
8330 runPromise(canUseToolEffect(toolName, toolInput, callbackOptions));
8331
8332 const onUserDialog: NonNullable<ClaudeQueryOptions["onUserDialog"]> = (
8333 request,
8334 callbackOptions,
8335 ) =>
8336 runPromise(
8337 Effect.gen(function* () {
8338 if (request.dialogKind !== "resume_return") {
8339 return { behavior: "cancelled" as const };
8340 }
8341 const ageMinutes =
8342 typeof request.payload.sessionAgeMinutes === "number" &&
8343 Number.isFinite(request.payload.sessionAgeMinutes)
8344 ? Math.max(0, Math.floor(request.payload.sessionAgeMinutes))
8345 : 0;
8346 const estimatedTokens =
8347 typeof request.payload.estimatedTokens === "number" &&
8348 Number.isFinite(request.payload.estimatedTokens)
8349 ? Math.max(0, Math.floor(request.payload.estimatedTokens))
8350 : 0;
8351 const question = formatClaudeResumeCompactionQuestion({
8352 ageMinutes,
8353 estimatedTokens,
8354 });
8355 const result = yield* canUseToolEffect(
8356 "AskUserQuestion",
8357 {
8358 questions: [
8359 {
8360 header: "Resume session",
8361 question,
8362 options: [
8363 {
8364 label: "Compact and continue",
8365 description: "Resume with a summary and use fewer tokens.",
8366 },
8367 {
8368 label: "Keep full history",
8369 description: "Resume without changing the conversation.",
8370 },
8371 {
8372 label: CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
8373 description: "Keep full history and skip future resume prompts.",
8374 },
8375 ],
8376 multiSelect: false,
8377 },
8378 ],
8379 },
8380 {
8381 signal: callbackOptions.signal,
8382 requestId: callbackOptions.requestId,
8383 toolUseID: request.toolUseID ?? callbackOptions.requestId,
8384 },
8385 );
8386 if (result.behavior !== "allow") return { behavior: "cancelled" as const };
8387 const answers =
8388 result.updatedInput === undefined
8389 ? undefined
8390 : Reflect.get(result.updatedInput, "answers");
8391 const selection =
8392 typeof answers === "object" && answers !== null
8393 ? Reflect.get(answers, question)
8394 : undefined;
8395 return {
8396 behavior: "completed" as const,
8397 result:
8398 selection === "Compact and continue"
8399 ? ("compact" as const)
8400 : selection === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER
8401 ? ("never" as const)
8402 : ("continue" as const),
8403 };
8404 }),
8405 );
8406
8407 // Work the live process still runs. A subagent whose completion is
8408 // already buffered is done: the buffer outlives the process.
8409 const liveProcessRunsBackgroundWork = Effect.fnUntraced(function* (
8410 live: ClaudeLiveQueryContext,
8411 ) {
8412 if (
8413 rosterForNativeThread(
8414 yield* Ref.get(pendingBackgroundTasksByNativeThread),
8415 live.nativeThreadId,
8416 ).size > 0
8417 ) {
8418 return true;
8419 }
8420 const buffered = (yield* Ref.get(wakeBuffers)).get(live.nativeThreadId)?.messages ?? [];
8421 for (const [taskId, subagent] of yield* Ref.get(sessionSubagentsByTaskId)) {
8422 if (
8423 subagent.task.status === "running" &&
8424 !live.subagentsFromEarlierProcesses.has(subagent) &&
8425 !buffered.some(
8426 (message) =>
8427 message.type === "system" &&
8428 message.subtype === "task_notification" &&
8429 message.task_id === taskId,
8430 )
8431 ) {
8432 return true;
8433 }
8434 }
8435 return false;
8436 });
8437
8438 const openQuery = Effect.fnUntraced(function* (
8439 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
8440 nativeThreadId: string,
8441 ) {
8442 const queryPolicy = claudeRuntimeQueryPolicyForRuntimePolicy(turnInput.runtimePolicy);
8443 const mcpOverrides = claudeMcpQueryOverrides({
8444 mcpSession: yield* mcpSessions.read(turnInput.threadId),
8445 readOnlySandbox:
8446 sandboxPolicyKindForClaudeRuntimePolicy(turnInput.runtimePolicy) === "readOnly",
8447 ...(queryPolicy.allowedTools === undefined
8448 ? {}
8449 : { allowedTools: queryPolicy.allowedTools }),
8450 });
8451 const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides);
8452 const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection);
8453 const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread);
8454 const existing = yield* Ref.get(queryContext);
8455 // A continuation prompts nothing: it drains output the live process
8456 // already produced, so it keeps that process whatever its selection.
8457 if (
8458 existing !== null &&
8459 existing.nativeThreadId === nativeThreadId &&
8460 isClaudeProviderContinuationTurn(turnInput)
8461 ) {
8462 return existing;
8463 }
8464 // Model, effort, ultracode and fast mode change on the live process.
8465 // A restart would kill its background agents and shells.
8466 if (
8467 existing !== null &&
8468 existing.nativeThreadId === nativeThreadId &&
8469 existing.queryPolicyKey === queryPolicyKey &&
8470 !existing.stopping &&
8471 existing.selection.queryIdentity !== compiledSelection.queryIdentity &&
8472 canApplyClaudeSelectionLive(existing.selection, compiledSelection)
8473 ) {
8474 const live = existing;
8475 yield* live.query.setModel(compiledSelection.apiModelId).pipe(
8476 Effect.andThen(
8477 live.query.applyFlagSettings(claudeLiveSelectionFlagSettings(compiledSelection)),
8478 ),
8479 // A partial change leaves the process state unknown. A blank
8480 // identity matches no selection, so the next turn sends both again.
8481 Effect.tapError(() =>
8482 Effect.sync(() => {
8483 live.selection = { ...live.selection, queryIdentity: "" };
8484 }),
8485 ),
8486 );
8487 live.selection = compiledSelection;
8488 }
8489 if (
8490 existing !== null &&
8491 existing.nativeThreadId === nativeThreadId &&
8492 existing.queryPolicyKey === queryPolicyKey &&
8493 existing.selection.queryIdentity === compiledSelection.queryIdentity
8494 ) {
8495 // Claude can switch its own mode mid-session (EnterPlanMode), and
8496 // a denied ExitPlanMode leaves it there. Put the live process back
8497 // in the thread's mode before the next prompt.
8498 if (existing.permissionMode !== existing.openedPermissionMode) {
8499 yield* existing.query.setPermissionMode(existing.openedPermissionMode);
8500 existing.permissionMode = existing.openedPermissionMode;
8501 }
8502 return existing;
8503 }
8504
8505 // Background agents and shells run inside the CLI process, so a
8506 // new selection would kill them and lose their results. Refuse until
8507 // they finish or the user presses Stop, which closes the process.
8508 // Another native thread on this session is one the app thread has
8509 // left (Claude sessions serve one app thread), so it is replaced.
8510 if (
8511 existing !== null &&
8512 existing.nativeThreadId === nativeThreadId &&
8513 !existing.stopping &&
8514 (yield* liveProcessRunsBackgroundWork(existing))
8515 ) {
8516 return yield* new ClaudeBackgroundWorkBlocksQueryReplacementError();
8517 }
8518
8519 // openQuery owns one live process. Closing it for another native
8520 // thread kills that sibling's CLI; it can never emit a roster clear,
8521 // so drop its process-scoped Waiting/wake state immediately. Closing
8522 // for the same native thread leaves a non-authoritative roster until
8523 // the replacement open succeeds or fails below.
8524 const closedExistingNativeThreadId = existing !== null ? existing.nativeThreadId : null;
8525 if (existing !== null) {
8526 yield* existing.query.close.pipe(Effect.ignore);
8527 if (existing.nativeThreadId !== nativeThreadId) {
8528 yield* clearWakeStateForNativeThread(existing.nativeThreadId);
8529 yield* resetBackgroundTaskStateForNativeThreadProcess(existing.nativeThreadId, {
8530 status: "idle",
8531 });
8532 }
8533 }
8534
8535 const openedWithResume = (yield* Ref.get(openedNativeThreads)).has(nativeThreadId);
8536 // openedNativeThreads is per session instance and is lost when the
8537 // provider session is idle-released. A prior turn on this native id
8538 // requires resume; sessionId would fail with "already in use".
8539 // A fresh-session fallback keeps provider-thread history but binds
8540 // a new native id, which must be created before it can be resumed.
8541 const hasPersistedProviderTurn =
8542 turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1;
8543 const shouldResume =
8544 resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn;
8545 const queryOptions = makeClaudeQueryOptions({
8546 modelSelection: turnInput.modelSelection,
8547 nativeThreadId,
8548 resume: shouldResume,
8549 ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }),
8550 cwd: turnInput.runtimePolicy.cwd,
8551 attachmentsDir,
8552 settings: adapterOptions.settings,
8553 environment: ScriptProgress.withScriptProgressEnvironment(
8554 adapterOptions.environment,
8555 yield* scriptProgress.environmentForThread(turnInput.threadId),
8556 ),
8557 tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS,
8558 ...(mcpOverrides.allowedTools === undefined
8559 ? {}
8560 : { allowedTools: mcpOverrides.allowedTools }),
8561 ...(mcpOverrides.mcpServers === undefined
8562 ? {}
8563 : { mcpServers: mcpOverrides.mcpServers }),
8564 permissionMode: queryPolicy.permissionMode,
8565 ...(queryPolicy.allowDangerouslySkipPermissions === undefined
8566 ? {}
8567 : {
8568 allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions,
8569 }),
8570 canUseTool,
8571 onUserDialog,
8572 supportedDialogKinds: ["resume_return"],
8573 });
8574 const querySession = yield* queryRunner
8575 .open({
8576 threadId: turnInput.threadId,
8577 providerSessionId: input.providerSessionId,
8578 options: queryOptions,
8579 })
8580 .pipe(
8581 // An interrupted open leaves the old process just as dead.
8582 Effect.onError(() =>
8583 // Same-native-thread replacement: the old process is already
8584 // dead, so its process-scoped roster is not authoritative.
8585 // First-ever failed open (no prior live query) must not invent
8586 // native-session reset events.
8587 closedExistingNativeThreadId === nativeThreadId
8588 ? Effect.gen(function* () {
8589 yield* clearWakeStateForNativeThread(nativeThreadId);
8590 yield* resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, {
8591 status: "idle",
8592 });
8593 })
8594 : Effect.void,
8595 ),
8596 );
8597 // Marked only after a successful open: a failed create must not
8598 // leave the runtime believing the native session exists, or the
8599 // retry would resume a session that was never created.
8600 yield* Ref.update(openedNativeThreads, (current) => {
8601 if (current.has(nativeThreadId)) {
8602 return current;
8603 }
8604 const updated = new Set(current);
8605 updated.add(nativeThreadId);
8606 return updated;
8607 });
8608 // Level is per CLI process: reset Waiting roster and wake
8609 // eligibility whenever this native thread's process starts or is
8610 // replaced. Membership repopulates on the next snapshot/edge.
8611 // openQuery only runs from startTurn after ProviderTurnStartService
8612 // marked the provider thread active, and before activeTurn is set.
8613 // Buffered local_bash task_notification classification is preserved
8614 // across this reset (see resetBackgroundTaskStateForNativeThreadProcess).
8615 yield* resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, {
8616 status: "active",
8617 });
8618 const closed = yield* Deferred.make<void, never>();
8619 const context: ClaudeLiveQueryContext = {
8620 nativeThreadId,
8621 query: querySession,
8622 queryPolicyKey,
8623 selection: compiledSelection,
8624 closed,
8625 lastTurn: null,
8626 promptEchoMode: "unknown",
8627 openedPermissionMode: queryOptions.permissionMode,
8628 permissionMode: queryOptions.permissionMode,
8629 stopping: false,
8630 subagentsFromEarlierProcesses: new Set(
8631 [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].filter(
8632 (subagent) => subagent.task.status === "running",
8633 ),
8634 ),
8635 };
8636 yield* Ref.set(queryContext, context);
8637 yield* querySession.messages.pipe(
8638 Stream.runForEach((message) =>
8639 sdkMessageLock.withPermits(1)(
8640 Effect.suspend(() => {
8641 if (
8642 message.type === "system" &&
8643 (message.subtype === "init" || message.subtype === "status") &&
8644 message.permissionMode !== undefined
8645 ) {
8646 context.permissionMode = message.permissionMode;
8647 }
8648 return handleSdkMessage({ query: querySession, message });
8649 }),
8650 ),
8651 ),
8652 Effect.exit,
8653 Effect.flatMap(
8654 Effect.fnUntraced(function* (exit: ClaudeQueryStreamExit) {
8655 // Output held for a prompt echo that never came still
8656 // belongs to the turn this stream ended in. A replaced
8657 // query's exit must not touch the next query's turn.
8658 const heldContext = yield* Ref.get(activeTurn);
8659 if (
8660 (yield* Ref.get(queryContext))?.query === querySession &&
8661 heldContext !== null &&
8662 heldContext.heldRootFrames.length > 0
8663 ) {
8664 yield* releaseHeldRootFrames(heldContext);
8665 }
8666 const ownsLiveQuery = yield* Ref.modify(queryContext, (current) =>
8667 current?.query === querySession ? [true, null] : [false, current],
8668 );
8669 if (ownsLiveQuery) {
8670 yield* finalizeActiveTurnAfterQueryExit(
8671 context,
8672 exit._tag === "Failure" ? exit.cause : undefined,
8673 );
8674 yield* endSubagentCallsOfClosedQuery(
8675 context.nativeThreadId,
8676 context.stopping ? "interrupted" : "failed",
8677 );
8678 }
8679 }),
8680 ),
8681 Effect.ensuring(Deferred.succeed(closed, undefined)),
8682 Effect.forkIn(sessionScope),
8683 );
8684 return context;
8685 });
8686
8687 const startTurn = Effect.fn("ClaudeAdapterV2.startTurn")(
8688 function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) {
8689 const startedAt = yield* DateTime.now;
8690 const nativeThreadId = yield* getNativeThreadId(turnInput.providerThread);
8691 const nativeTurnId = `turn:${turnInput.attemptId}`;
8692 // Fresh for every offer: Claude acks a prompt whose uuid its
8693 // transcript already holds without running a turn, and a session
8694 // can outlive this database (or be resumed from a copy of it), so
8695 // a uuid derived from run ids can repeat.
8696 const promptUuid = isClaudeProviderContinuationTurn(turnInput)
8697 ? null
8698 : yield* crypto.randomUUIDv4().pipe(
8699 Effect.filterOrFail(isClaudePromptUuid),
8700 Effect.mapError(
8701 (cause) =>
8702 new ProviderAdapter.ProviderAdapterProtocolError({
8703 driver: CLAUDE_PROVIDER,
8704 detail: "Failed to allocate a Claude prompt uuid.",
8705 payload: cause,
8706 }),
8707 ),
8708 );
8709 const providerTurnId = idAllocator.derive.providerTurn({
8710 driver: CLAUDE_PROVIDER,
8711 nativeTurnId,
8712 });
8713 const providerTurnOrdinal = turnInput.providerTurnOrdinal;
8714 const currentTurn = yield* Ref.get(activeTurn);
8715 if (currentTurn !== null) {
8716 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
8717 driver: CLAUDE_PROVIDER,
8718 detail: `Claude provider turn ${currentTurn.providerTurnId} is still active.`,
8719 });
8720 }
8721 yield* Ref.update(lastTurnRouteByNativeThread, (current) => {
8722 const updated = new Map(current);
8723 updated.set(nativeThreadId, {
8724 threadId: turnInput.threadId,
8725 providerThreadId: turnInput.providerThread.id,
8726 });
8727 return updated;
8728 });
8729 yield* rememberProviderThread(turnInput.providerThread);
8730 if (!goalsByNativeThread.has(nativeThreadId)) {
8731 goalsByNativeThread.set(nativeThreadId, turnInput.providerThread.goal ?? null);
8732 }
8733 const context: ActiveClaudeTurnContext = {
8734 input: turnInput,
8735 nativeThreadId,
8736 nativeTurnId,
8737 nativeMessageCursor: null,
8738 providerTurnId,
8739 providerTurnOrdinal,
8740 startedAt,
8741 itemOrdinals: new Map(),
8742 assistant: {
8743 fallbackText: "",
8744 fallbackNativeItemId: `assistant:${turnInput.runId}`,
8745 emittedNativeItemIds: new Set(),
8746 },
8747 reasoning: {
8748 messageId: null,
8749 streamBlocks: new Map(),
8750 nextBlockIndex: new Map(),
8751 snapshotBlockIndex: new Map(),
8752 lastThinking: null,
8753 snapshots: new Set(),
8754 blocks: new Map(),
8755 },
8756 toolCalls: new Map(),
8757 ignoredTaskIds: new Set(),
8758 announcedUsageLimits: new Set(),
8759 authenticationFailureMessage: undefined,
8760 rejectedRateLimitTypes: new Set(),
8761 rateLimitResetTimes: new Map(),
8762 latestAssistantRateLimited: false,
8763 steeredSegmentUsage: [],
8764 subagentsByTaskId: new Map(),
8765 subagentsByToolUseId: new Map(),
8766 subagentNodesByTaskId: new Map(),
8767 promptUuid,
8768 promptEcho: isClaudeProviderContinuationTurn(turnInput) ? "confirmed" : "pending",
8769 promptStarted: false,
8770 gatedFramesBeforeEcho: 0,
8771 heldRootFrames: [],
8772 };
8773 // Continuation turns attach to the wake output the CLI already
8774 // produced instead of prompting it again: drain the buffered wake
8775 // messages into this turn and let any still-streaming messages
8776 // follow live. The continuation prompt text never reaches the CLI.
8777 const userMessage =
8778 promptUuid === null
8779 ? null
8780 : yield* makeClaudeUserMessageWithAttachments({
8781 text: applyClaudePromptEffortPrefix(
8782 turnInput.message.text,
8783 compileClaudeModelSelection(turnInput.modelSelection).promptEffort,
8784 ),
8785 attachments: turnInput.message.attachments,
8786 attachmentsDir,
8787 fileSystem,
8788 skillNames: yield* userInvocableSkillNames(turnInput.runtimePolicy.cwd),
8789 uuid: promptUuid,
8790 });
8791 const querySession = yield* openQuery(turnInput, nativeThreadId);
8792 const attachTurn = Effect.gen(function* () {
8793 querySession.lastTurn = context;
8794 yield* Ref.set(activeTurn, context);
8795 yield* emitProviderEvent({
8796 type: "provider_turn.updated",
8797 driver: CLAUDE_PROVIDER,
8798 providerTurn: providerTurnPayload({
8799 context,
8800 status: "running",
8801 completedAt: null,
8802 }),
8803 });
8804 });
8805 if (userMessage !== null) {
8806 yield* attachTurn;
8807 // A user turn that races a wake leaves the buffer alone: the
8808 // continuation run the worker queued behind this run drains it
8809 // afterwards with correct attribution.
8810 // Counted only here, so a turn that failed to start does not age reports.
8811 yield* startUserTurnForWakeReports(nativeThreadId);
8812 yield* querySession.query.offer(userMessage);
8813 return;
8814 }
8815 // Transcript reads suspend replay. Hold live SDK frames until the whole
8816 // buffer has been applied so starts, completions and the root result stay ordered.
8817 yield* sdkMessageLock.withPermits(1)(
8818 Effect.gen(function* () {
8819 if (
8820 (yield* Ref.get(queryContext))?.query !== querySession.query ||
8821 querySession.stopping
8822 ) {
8823 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
8824 driver: CLAUDE_PROVIDER,
8825 detail: "Claude provider query ended before the continuation could start.",
8826 });
8827 }
8828 // Live frames can finish while this continuation waits for the permit.
8829 yield* attachTurn;
8830 // Exit and interrupt cleanup can run during transcript reads.
8831 // Keep pending outcomes visible until replay applies them.
8832 const drained = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? [];
8833 yield* Ref.update(requestedContinuations, (current) => {
8834 const updated = new Set(current);
8835 updated.delete(nativeThreadId);
8836 return updated;
8837 });
8838 if (drained.length === 0) {
8839 // Spurious continuation (buffer already lost with a recycled
8840 // session, or a duplicate request): settle immediately instead
8841 // of leaving a run waiting on a prompt that was never sent.
8842 const completedAt = yield* DateTime.now;
8843 yield* finalizeActiveTurn({ context, status: "completed", completedAt });
8844 return;
8845 }
8846 // Replay any result message last: a result finalizes the turn, and
8847 // replaying it before the rest would drop them back into the wake
8848 // buffer and request another continuation.
8849 const resultMessages = drained.filter((entry) => entry.type === "result");
8850 const opaqueReplayTombstones = taskIdSetForNativeThread(
8851 yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread),
8852 nativeThreadId,
8853 );
8854 const hasOpaqueTaskNotification = drained.some(
8855 (entry) =>
8856 entry.type === "system" &&
8857 entry.subtype === "task_notification" &&
8858 opaqueReplayTombstones.has(entry.task_id),
8859 );
8860 for (const entry of drained) {
8861 if (
8862 !(yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages.includes(entry)
8863 ) {
8864 continue;
8865 }
8866 if (entry.type !== "result") {
8867 yield* handleSdkMessage({ query: querySession.query, message: entry });
8868 }
8869 yield* Ref.update(wakeBuffers, (current) => {
8870 const buffered = current.get(nativeThreadId);
8871 if (buffered === undefined) return current;
8872 const index = buffered.messages.indexOf(entry);
8873 if (index === -1) return current;
8874 // An idle handler may requeue this same frame for the next continuation.
8875 const messages = [
8876 ...buffered.messages.slice(0, index),
8877 ...buffered.messages.slice(index + 1),
8878 ];
8879 const next = new Map(current);
8880 if (messages.length === 0) next.delete(nativeThreadId);
8881 else next.set(nativeThreadId, { ...buffered, messages });
8882 return next;
8883 });
8884 }
8885 const lastResult = resultMessages.at(-1);
8886 if (lastResult !== undefined) {
8887 yield* handleSdkMessage({ query: querySession.query, message: lastResult });
8888 return;
8889 }
8890 // A drained `init` means Claude began the wake turn, so its output
8891 // may still be on the way: stay open for it.
8892 const hasNativeWakeFrame = drained.some(
8893 (entry) =>
8894 entry.type === "user" ||
8895 entry.type === "assistant" ||
8896 isClaudeTurnStartMessage(entry),
8897 );
8898 if (hasOpaqueTaskNotification && !hasNativeWakeFrame) {
8899 const completedAt = yield* DateTime.now;
8900 yield* finalizeActiveTurn({ context, status: "completed", completedAt });
8901 }
8902 }),
8903 );
8904 },
8905 (effect, turnInput) =>
8906 effect.pipe(
8907 Effect.mapError(
8908 (cause) =>
8909 new ProviderAdapter.ProviderAdapterTurnStartError({
8910 driver: CLAUDE_PROVIDER,
8911 threadId: turnInput.threadId,
8912 providerThreadId: turnInput.providerThread.id,
8913 runId: turnInput.runId,
8914 cause,
8915 }),
8916 ),
8917 ),
8918 );
8919
8920 const interruptTurn = Effect.fn("ClaudeAdapterV2.interruptTurn")(
8921 function* (turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput) {
8922 const existing = yield* Ref.get(queryContext);
8923 const currentTurn = yield* Ref.get(activeTurn);
8924 const nativeThreadId = turnInput.providerThread.nativeThreadRef?.nativeId ?? null;
8925 if (currentTurn === null && turnInput.requestRuntimeRestart === true) {
8926 // Stop after the turn settled. With no CLI process of this
8927 // native thread left, nothing it started is still running: its
8928 // roster is not authoritative any more, and the orchestrator
8929 // settles the items the thread still shows.
8930 if (nativeThreadId === null) return;
8931 if (existing === null || existing.nativeThreadId !== nativeThreadId) {
8932 yield* clearWakeStateForNativeThread(nativeThreadId);
8933 yield* resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, {
8934 status: "idle",
8935 });
8936 return;
8937 }
8938 // The background shells belong to the CLI process, so closing
8939 // its query is what stops them.
8940 yield* closeLiveQueryForNativeThread(nativeThreadId);
8941 // A turn started while the close was pending may have opened a
8942 // replacement process. Its Waiting and wake state are its own.
8943 const current = yield* Ref.get(queryContext);
8944 if (current === null || current.query === existing.query) {
8945 yield* clearWakeStateForNativeThread(nativeThreadId);
8946 yield* resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, {
8947 status: "idle",
8948 });
8949 }
8950 return;
8951 }
8952 if (existing === null) {
8953 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
8954 driver: CLAUDE_PROVIDER,
8955 detail: `Claude provider thread ${turnInput.providerThread.id} has no live query.`,
8956 });
8957 }
8958 if (currentTurn?.providerTurnId !== turnInput.providerTurnId) {
8959 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
8960 driver: CLAUDE_PROVIDER,
8961 detail: `Claude provider turn ${turnInput.providerTurnId} is not the active turn.`,
8962 });
8963 }
8964 yield* Ref.update(interruptedTurns, (current) => {
8965 const next = new Set(current);
8966 next.add(turnInput.providerTurnId);
8967 return next;
8968 });
8969 yield* existing.query.interrupt;
8970 yield* existing.query.close.pipe(Effect.ignore);
8971 const closed = yield* Deferred.await(existing.closed).pipe(
8972 Effect.timeoutOption("10 seconds"),
8973 );
8974 if (Option.isSome(closed)) {
8975 return;
8976 }
8977
8978 const completedAt = yield* DateTime.now;
8979 yield* Effect.logWarning("orchestration-v2.claude-query-interrupt-timeout", {
8980 providerSessionId: input.providerSessionId,
8981 providerThreadId: turnInput.providerThread.id,
8982 providerTurnId: turnInput.providerTurnId,
8983 });
8984 const ownedLiveQuery = yield* Ref.modify(queryContext, (current) =>
8985 current?.query === existing.query ? [true, null] : [false, current],
8986 );
8987 if (ownedLiveQuery) {
8988 existing.stopping = true;
8989 yield* finalizeActiveTurnAfterQueryExit(existing);
8990 yield* endSubagentCallsOfClosedQuery(existing.nativeThreadId, "interrupted");
8991 } else {
8992 // The stream may already be finalizing while another process starts.
8993 yield* finalizeActiveTurn({
8994 context: currentTurn,
8995 status: "interrupted",
8996 completedAt,
8997 });
8998 }
8999 yield* Deferred.succeed(existing.closed, undefined);
9000 },
9001 (effect, turnInput) =>
9002 effect.pipe(
9003 Effect.mapError(
9004 (cause) =>
9005 new ProviderAdapter.ProviderAdapterInterruptError({
9006 driver: CLAUDE_PROVIDER,
9007 providerThreadId: turnInput.providerThread.id,
9008 providerTurnId: turnInput.providerTurnId,
9009 cause,
9010 }),
9011 ),
9012 ),
9013 );
9014
9015 const steerTurn = Effect.fn("ClaudeAdapterV2.steerTurn")(
9016 function* (turnInput: ProviderAdapter.ProviderAdapterV2SteerInput) {
9017 const existing = yield* Ref.get(queryContext);
9018 if (existing === null) {
9019 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
9020 driver: CLAUDE_PROVIDER,
9021 detail: `Claude provider thread ${turnInput.providerThread.id} has no live query.`,
9022 });
9023 }
9024 const currentTurn = yield* Ref.get(activeTurn);
9025 if (currentTurn?.providerTurnId !== turnInput.providerTurnId) {
9026 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
9027 driver: CLAUDE_PROVIDER,
9028 detail: `Claude provider turn ${turnInput.providerTurnId} is not the active turn.`,
9029 });
9030 }
9031 const userMessage = yield* makeClaudeUserMessageWithAttachments({
9032 text: applyClaudePromptEffortPrefix(
9033 turnInput.message.text,
9034 compileClaudeModelSelection(currentTurn.input.modelSelection).promptEffort,
9035 ),
9036 attachments: turnInput.message.attachments,
9037 priority: "now",
9038 attachmentsDir,
9039 fileSystem,
9040 skillNames: yield* userInvocableSkillNames(currentTurn.input.runtimePolicy.cwd),
9041 });
9042 yield* Ref.update(steeredTurns, (current) => {
9043 const next = new Set(current);
9044 next.add(turnInput.providerTurnId);
9045 return next;
9046 });
9047 yield* existing.query.offer(userMessage);
9048 },
9049 (effect, turnInput) =>
9050 effect.pipe(
9051 Effect.mapError(
9052 (cause) =>
9053 new ProviderAdapter.ProviderAdapterSteerRunError({
9054 driver: CLAUDE_PROVIDER,
9055 providerThreadId: turnInput.providerThread.id,
9056 providerTurnId: turnInput.providerTurnId,
9057 cause,
9058 }),
9059 ),
9060 ),
9061 );
9062
9063 const closeSession = Effect.fnUntraced(function* () {
9064 const existing = yield* Ref.get(queryContext);
9065 if (existing !== null) {
9066 yield* existing.query.close.pipe(Effect.ignore);
9067 }
9068 yield* Effect.yieldNow;
9069 yield* queryRunner.assertComplete.pipe(
9070 Effect.catchCause((cause) =>
9071 Effect.logWarning("orchestration-v2.claude-query-runner-incomplete", {
9072 providerSessionId: input.providerSessionId,
9073 cause,
9074 }),
9075 ),
9076 );
9077 });
9078
9079 const closeLiveQueryForNativeThread = Effect.fnUntraced(function* (nativeThreadId: string) {
9080 const existing = yield* Ref.get(queryContext);
9081 if (existing === null || existing.nativeThreadId !== nativeThreadId) {
9082 return;
9083 }
9084
9085 existing.stopping = true;
9086 yield* existing.query.close.pipe(Effect.ignore);
9087 const closed = yield* Deferred.await(existing.closed).pipe(
9088 Effect.timeoutOption("10 seconds"),
9089 );
9090 if (Option.isSome(closed)) {
9091 return;
9092 }
9093
9094 yield* Effect.logWarning("orchestration-v2.claude-query-close-timeout-before-fork", {
9095 providerSessionId: input.providerSessionId,
9096 nativeThreadId,
9097 });
9098 // Once ownership is dropped here, the old stream's exit skips the
9099 // subagent's open calls, so they end now.
9100 const ownedLiveQuery = yield* Ref.modify(queryContext, (current) =>
9101 current?.query === existing.query ? [true, null] : [false, current],
9102 );
9103 if (ownedLiveQuery) {
9104 yield* finalizeActiveTurnAfterQueryExit(existing);
9105 yield* endSubagentCallsOfClosedQuery(nativeThreadId, "interrupted");
9106 }
9107 yield* Deferred.succeed(existing.closed, undefined);
9108 });
9109 yield* Effect.addFinalizer(() => closeSession());
9110
9111 const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = {
9112 instanceId: adapterOptions.instanceId,
9113 driver: CLAUDE_PROVIDER,
9114 providerSessionId: input.providerSessionId,
9115 providerSession: session,
9116 getModelContextWindow: (selection) =>
9117 resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, selection),
9118 events: Stream.fromEffectRepeat(Queue.take(events)),
9119 // Background shells, agents, monitors, and workflows end with the CLI.
9120 backgroundTasksEndWithProcess: true,
9121 hasPendingBackgroundWork: Effect.gen(function* () {
9122 // Session capability: any native thread with pending work pins idle.
9123 for (const roster of (yield* Ref.get(pendingBackgroundTasksByNativeThread)).values()) {
9124 if (roster.size > 0) {
9125 return true;
9126 }
9127 }
9128 for (const subagent of (yield* Ref.get(sessionSubagentsByTaskId)).values()) {
9129 if (subagent.task.status === "running") {
9130 return true;
9131 }
9132 }
9133 const buffers = yield* Ref.get(wakeBuffers);
9134 for (const entry of buffers.values()) {
9135 if (
9136 entry.messages.some(
9137 (message) =>
9138 message.type === "user" ||
9139 message.type === "assistant" ||
9140 message.type === "result",
9141 )
9142 ) {
9143 return true;
9144 }
9145 }
9146 return false;
9147 }),
9148 hasPendingBackgroundWorkForThread: (providerThread) =>
9149 Effect.gen(function* () {
9150 const nativeThreadId = providerThread.nativeThreadRef?.nativeId;
9151 if (nativeThreadId === undefined || nativeThreadId === null) {
9152 return false;
9153 }
9154 // Root-run stop gate: only this native thread's roster. Session
9155 // subagents and wake buffers stay on the session-wide probe.
9156 return (
9157 rosterForNativeThread(
9158 yield* Ref.get(pendingBackgroundTasksByNativeThread),
9159 nativeThreadId,
9160 ).size > 0
9161 );
9162 }),
9163 ensureThread: Effect.fn("ClaudeAdapterV2.ensureThread")(
9164 function* (threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput) {
9165 const createdAt = yield* DateTime.now;
9166 const nativeThreadId = yield* queryRunner.allocateSessionId;
9167 return makeProviderThread({
9168 idAllocator,
9169 providerInstanceId: adapterOptions.instanceId,
9170 appThreadId: threadInput.threadId,
9171 providerSessionId: input.providerSessionId,
9172 nativeThreadId,
9173 now: createdAt,
9174 });
9175 },
9176 (effect, threadInput) =>
9177 effect.pipe(
9178 Effect.mapError(
9179 (cause) =>
9180 new ProviderAdapter.ProviderAdapterEnsureThreadError({
9181 driver: CLAUDE_PROVIDER,
9182 threadId: threadInput.threadId,
9183 cause,
9184 }),
9185 ),
9186 ),
9187 ),
9188 resumeThread: Effect.fn("ClaudeAdapterV2.resumeThread")(
9189 function* (threadInput: { readonly providerThread: OrchestrationV2ProviderThread }) {
9190 const updatedAt = yield* DateTime.now;
9191 return {
9192 ...threadInput.providerThread,
9193 providerSessionId: input.providerSessionId,
9194 status: "idle" as const,
9195 updatedAt,
9196 };
9197 },
9198 (effect, threadInput) =>
9199 effect.pipe(
9200 Effect.mapError(
9201 (cause) =>
9202 new ProviderAdapter.ProviderAdapterResumeThreadError({
9203 driver: CLAUDE_PROVIDER,
9204 providerSessionId: input.providerSessionId,
9205 providerThreadId: threadInput.providerThread.id,
9206 cause,
9207 }),
9208 ),
9209 ),
9210 ),
9211 startTurn,
9212 compactThread: (turnInput) =>
9213 startTurn({
9214 ...turnInput,
9215 message: { ...turnInput.message, text: "/compact" },
9216 }),
9217 steerTurn,
9218 stopTask: ({ providerThread, taskId }) =>
9219 Effect.gen(function* () {
9220 const liveQuery = yield* Ref.get(queryContext);
9221 const task = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId);
9222 if (
9223 liveQuery === null ||
9224 liveQuery.stopping ||
9225 liveQuery.nativeThreadId !== providerThread.nativeThreadRef?.nativeId ||
9226 task === undefined ||
9227 task.task.threadId !== providerThread.appThreadId ||
9228 task.task.workflow === undefined ||
9229 task.task.status !== "running" ||
9230 liveQuery.subagentsFromEarlierProcesses.has(task)
9231 ) {
9232 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
9233 driver: CLAUDE_PROVIDER,
9234 detail: "No running workflow task belongs to this provider thread",
9235 });
9236 }
9237 yield* liveQuery.query.stopTask(taskId).pipe(
9238 Effect.mapError(
9239 (cause) =>
9240 new ProviderAdapter.ProviderAdapterProtocolError({
9241 driver: CLAUDE_PROVIDER,
9242 detail: "Workflow stop request failed",
9243 cause,
9244 }),
9245 ),
9246 );
9247 }),
9248 interruptTurn,
9249 stopSubagent: Effect.fn("ClaudeAdapterV2.stopSubagent")(function* (input) {
9250 const existing = yield* Ref.get(queryContext);
9251 const nativeThreadId = input.providerThread.nativeThreadRef?.nativeId;
9252 const subagent = (yield* Ref.get(sessionSubagentsByTaskId)).get(input.nativeTaskId);
9253 if (subagent?.task.threadId !== input.providerThread.appThreadId) {
9254 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
9255 driver: CLAUDE_PROVIDER,
9256 detail: "Claude subagent has no known owning task to confirm its outcome.",
9257 });
9258 }
9259 if (
9260 input.requestedAt !== undefined &&
9261 subagent.task.startedAt !== null &&
9262 DateTime.toEpochMillis(subagent.task.startedAt) >
9263 DateTime.toEpochMillis(input.requestedAt)
9264 )
9265 return;
9266 const confirmed = subagent.confirmedOutcome;
9267 if (confirmed !== undefined) return confirmed;
9268 if (
9269 existing === null ||
9270 existing.nativeThreadId !== nativeThreadId ||
9271 existing.subagentsFromEarlierProcesses.has(subagent) ||
9272 existing.query.stopTask === undefined
9273 ) {
9274 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
9275 driver: CLAUDE_PROVIDER,
9276 detail: "Claude subagent has no live owning query with task stopping support.",
9277 });
9278 }
9279 const outcome =
9280 subagentStopOutcomes.get(input.nativeTaskId) ??
9281 (yield* Deferred.make<ProviderAdapter.ProviderAdapterV2SubagentOutcome>());
9282 subagentStopOutcomes.set(input.nativeTaskId, outcome);
9283 const recorded = (yield* Ref.get(sessionSubagentsByTaskId)).get(input.nativeTaskId);
9284 if (
9285 recorded?.task.startedAt === subagent.task.startedAt &&
9286 recorded.confirmedOutcome !== undefined
9287 ) {
9288 if (subagentStopOutcomes.get(input.nativeTaskId) === outcome)
9289 subagentStopOutcomes.delete(input.nativeTaskId);
9290 return recorded.confirmedOutcome;
9291 }
9292 const confirmedOutcome = yield* existing.query.stopTask(input.nativeTaskId).pipe(
9293 Effect.andThen(Deferred.await(outcome)),
9294 Effect.timeout("2 seconds"),
9295 Effect.mapError(
9296 (cause) =>
9297 new ProviderAdapter.ProviderAdapterProtocolError({
9298 driver: CLAUDE_PROVIDER,
9299 detail: "Claude subagent Stop was not confirmed by a native outcome.",
9300 cause,
9301 }),
9302 ),
9303 Effect.ensuring(
9304 Effect.sync(() => {
9305 if (subagentStopOutcomes.get(input.nativeTaskId) === outcome)
9306 subagentStopOutcomes.delete(input.nativeTaskId);
9307 }),
9308 ),
9309 );
9310 const current = (yield* Ref.get(sessionSubagentsByTaskId)).get(input.nativeTaskId);
9311 return current?.task.startedAt === subagent.task.startedAt
9312 ? confirmedOutcome
9313 : undefined;
9314 }),
9315 respondToRuntimeRequest: Effect.fn("ClaudeAdapterV2.respondToRuntimeRequest")(
9316 function* (requestInput) {
9317 const pending = (yield* Ref.get(pendingRuntimeRequests)).get(
9318 String(requestInput.requestId),
9319 );
9320 if (pending === undefined) {
9321 return yield* new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({
9322 driver: CLAUDE_PROVIDER,
9323 requestId: requestInput.requestId,
9324 cause: new ProviderAdapter.ProviderAdapterProtocolError({
9325 driver: CLAUDE_PROVIDER,
9326 detail: `No pending Claude runtime request ${requestInput.requestId}.`,
9327 }),
9328 });
9329 }
9330 if (pending.type === "user_input") {
9331 yield* Deferred.succeed(pending.answers, requestInput.answers ?? {});
9332 return;
9333 }
9334 if (requestInput.decision === undefined) {
9335 return yield* new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({
9336 driver: CLAUDE_PROVIDER,
9337 requestId: requestInput.requestId,
9338 cause: new ProviderAdapter.ProviderAdapterProtocolError({
9339 driver: CLAUDE_PROVIDER,
9340 detail: `Claude ${pending.requestKind} request ${requestInput.requestId} requires an approval decision.`,
9341 }),
9342 });
9343 }
9344 yield* Deferred.succeed(pending.decision, requestInput.decision);
9345 },
9346 (effect, requestInput) =>
9347 effect.pipe(
9348 Effect.mapError((cause) =>
9349 isProviderAdapterRuntimeRequestResponseError(cause)
9350 ? cause
9351 : new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({
9352 driver: CLAUDE_PROVIDER,
9353 requestId: requestInput.requestId,
9354 cause,
9355 }),
9356 ),
9357 ),
9358 ),
9359 readThreadSnapshot: (snapshotInput) =>
9360 Effect.fail(
9361 new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({
9362 driver: CLAUDE_PROVIDER,
9363 providerThreadId: snapshotInput.providerThread.id,
9364 cause: "Claude V2 adapter does not implement snapshots.",
9365 }),
9366 ),
9367 rollbackThread: Effect.fn("ClaudeAdapterV2.rollbackThread")(
9368 function* (rollbackInput) {
9369 const currentTurn = yield* Ref.get(activeTurn);
9370 if (currentTurn !== null) {
9371 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
9372 driver: CLAUDE_PROVIDER,
9373 detail: `Cannot roll back Claude provider thread ${rollbackInput.providerThread.id} while provider turn ${currentTurn.providerTurnId} is active.`,
9374 });
9375 }
9376
9377 const nativeThreadId = yield* getNativeThreadId(rollbackInput.providerThread);
9378 yield* closeLiveQueryForNativeThread(nativeThreadId);
9379 const now = yield* DateTime.now;
9380
9381 if (rollbackInput.target.type === "thread_start") {
9382 const resetNativeThreadId = yield* queryRunner.allocateSessionId;
9383 return {
9384 providerThread: {
9385 ...makeProviderThread({
9386 idAllocator,
9387 providerInstanceId: adapterOptions.instanceId,
9388 appThreadId: rollbackInput.providerThread.appThreadId,
9389 ...(rollbackInput.providerThread.ownerNodeId === null
9390 ? {}
9391 : { ownerNodeId: rollbackInput.providerThread.ownerNodeId }),
9392 providerSessionId: input.providerSessionId,
9393 nativeThreadId: resetNativeThreadId,
9394 ...(rollbackInput.providerThread.forkedFrom === null
9395 ? {}
9396 : { forkedFrom: rollbackInput.providerThread.forkedFrom }),
9397 now,
9398 }),
9399 handoffIds: rollbackInput.providerThread.handoffIds,
9400 },
9401 providerTurns: [],
9402 messages: [],
9403 runtimeRequests: [],
9404 };
9405 }
9406
9407 const resumeSessionAt = yield* resolveClaudeRollbackResumeSessionAt(rollbackInput);
9408 return {
9409 providerThread: {
9410 ...rollbackInput.providerThread,
9411 providerSessionId: input.providerSessionId,
9412 nativeConversationHeadRef:
9413 resumeSessionAt === null
9414 ? null
9415 : {
9416 driver: CLAUDE_PROVIDER,
9417 nativeId: resumeSessionAt,
9418 strength: "weak" as const,
9419 },
9420 status: "idle" as const,
9421 lastRunOrdinal: rollbackInput.target.appRunOrdinal,
9422 updatedAt: now,
9423 },
9424 providerTurns: [],
9425 messages: [],
9426 runtimeRequests: [],
9427 };
9428 },
9429 (effect, rollbackInput) =>
9430 effect.pipe(
9431 Effect.mapError((cause) =>
9432 isProviderAdapterRollbackThreadError(cause)
9433 ? cause
9434 : new ProviderAdapter.ProviderAdapterRollbackThreadError({
9435 driver: CLAUDE_PROVIDER,
9436 providerThreadId: rollbackInput.providerThread.id,
9437 cause,
9438 }),
9439 ),
9440 ),
9441 ),
9442 forkThread: Effect.fn("ClaudeAdapterV2.forkThread")(
9443 function* (forkInput) {
9444 const currentTurn = yield* Ref.get(activeTurn);
9445 if (currentTurn !== null) {
9446 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
9447 driver: CLAUDE_PROVIDER,
9448 detail: `Cannot fork Claude provider thread ${forkInput.sourceProviderThread.id} while provider turn ${currentTurn.providerTurnId} is active.`,
9449 });
9450 }
9451
9452 const sourceNativeThreadId = yield* getNativeThreadId(forkInput.sourceProviderThread);
9453 yield* closeLiveQueryForNativeThread(sourceNativeThreadId);
9454 const upToMessageId = yield* resolveClaudeForkUpToMessageId(forkInput);
9455 const forkOptions: ForkSessionOptions = {
9456 ...(input.runtimePolicy.cwd === null ? {} : { dir: input.runtimePolicy.cwd }),
9457 ...(upToMessageId === undefined ? {} : { upToMessageId }),
9458 };
9459 const forked = yield* queryRunner.forkSession({
9460 sessionId: sourceNativeThreadId,
9461 options: forkOptions,
9462 threadId: forkInput.targetThreadId,
9463 providerSessionId: input.providerSessionId,
9464 });
9465 yield* Ref.update(openedNativeThreads, (current) => {
9466 const updated = new Set(current);
9467 updated.add(forked.sessionId);
9468 return updated;
9469 });
9470 const now = yield* DateTime.now;
9471 return makeProviderThread({
9472 idAllocator,
9473 providerInstanceId: adapterOptions.instanceId,
9474 appThreadId: forkInput.targetThreadId,
9475 ownerNodeId: forkInput.ownerNodeId ?? null,
9476 providerSessionId: input.providerSessionId,
9477 nativeThreadId: forked.sessionId,
9478 forkedFrom: {
9479 providerThreadId: forkInput.sourceProviderThread.id,
9480 ...(forkInput.providerTurnId === undefined
9481 ? {}
9482 : { providerTurnId: forkInput.providerTurnId }),
9483 },
9484 now,
9485 });
9486 },
9487 (effect, forkInput) =>
9488 effect.pipe(
9489 Effect.mapError(
9490 (cause) =>
9491 new ProviderAdapter.ProviderAdapterForkThreadError({
9492 driver: CLAUDE_PROVIDER,
9493 providerThreadId: forkInput.sourceProviderThread.id,
9494 cause,
9495 }),
9496 ),
9497 ),
9498 ),
9499 };
9500
9501 return runtime;
9502 },
9503 (effect, input) =>
9504 effect.pipe(
9505 Effect.mapError(
9506 (cause) =>
9507 new ProviderAdapter.ProviderAdapterOpenSessionError({
9508 driver: CLAUDE_PROVIDER,
9509 providerSessionId: input.providerSessionId,
9510 cause,
9511 }),
9512 ),
9513 ),
9514 ),
9515 });
9516});
9517
9518export type ClaudeAdapterV2DriverEnv =
9519 | ClaudeAgentSdkQueryRunner
9520 | Crypto.Crypto
9521 | FileSystem.FileSystem
9522 | IdAllocator.IdAllocatorV2
9523 | ScriptProgress.ScriptProgress
9524 | McpProviderSessions.McpProviderSessions
9525 | Path.Path
9526 | ServerConfig.ServerConfig;
9527
9528export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")(
9529 function* (
9530 input: ProviderAdapterDriverCreateInput<ClaudeSettings>,
9531 hooks: Pick<ClaudeAdapterV2Options, "scopedLimitNames" | "onUsageLimits"> = {},
9532 ) {
9533 const { instanceId, environment, enabled, config } = input;
9534 const fileSystem = yield* FileSystem.FileSystem;
9535 const hostEnvironment = yield* HostProcess.Environment;
9536 const idAllocator = yield* IdAllocator.IdAllocatorV2;
9537 const queryRunner = yield* ClaudeAgentSdkQueryRunner;
9538 const serverConfig = yield* ServerConfig.ServerConfig;
9539 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
9540 const baseEnvironment = yield* mergeProviderInstanceEnvironment(environment, hostEnvironment);
9541 const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment);
9542 const path = yield* Path.Path;
9543 const crypto = yield* Crypto.Crypto;
9544 const binaryPath = yield* resolveClaudeSdkExecutablePath(
9545 expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory),
9546 claudeEnvironment,
9547 );
9548 return yield* makeClaudeAdapterV2({
9549 instanceId,
9550 settings: { ...config, enabled, binaryPath },
9551 environment: claudeEnvironment,
9552 attachmentsDir: serverConfig.attachmentsDir,
9553 fileSystem,
9554 path,
9555 crypto,
9556 idAllocator,
9557 queryRunner,
9558 continuationRequests,
9559 ...hooks,
9560 });
9561 },
9562 (effect, input, _hooks) =>
9563 effect.pipe(
9564 Effect.mapError(
9565 (cause) =>
9566 new ProviderAdapterDriverCreateError({
9567 driver: CLAUDE_PROVIDER,
9568 instanceId: input.instanceId,
9569 detail: "Failed to create Claude Agent SDK adapter.",
9570 cause,
9571 }),
9572 ),
9573 ),
9574);
9575
9576export const ClaudeAdapterV2Driver: ProviderAdapterDriver<
9577 ClaudeSettings,
9578 ClaudeAdapterV2DriverEnv
9579> = {
9580 driverKind: CLAUDE_PROVIDER,
9581 configSchema: ClaudeSettings,
9582 defaultConfig: (): ClaudeSettings => DEFAULT_CLAUDE_SETTINGS,
9583 create: (input) => createClaudeAdapterV2(input, {}),
9584};
9585
9586const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* () {
9587 const fileSystem = yield* FileSystem.FileSystem;
9588 const path = yield* Path.Path;
9589 const crypto = yield* Crypto.Crypto;
9590 const hostEnvironment = yield* HostProcess.Environment;
9591 const idAllocator = yield* IdAllocator.IdAllocatorV2;
9592 const queryRunner = yield* ClaudeAgentSdkQueryRunner;
9593 const serverConfig = yield* ServerConfig.ServerConfig;
9594 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
9595
9596 return yield* makeClaudeAdapterV2({
9597 instanceId: CLAUDE_DEFAULT_INSTANCE_ID,
9598 settings: DEFAULT_CLAUDE_SETTINGS,
9599 environment: hostEnvironment,
9600 attachmentsDir: serverConfig.attachmentsDir,
9601 fileSystem,
9602 path,
9603 crypto,
9604 idAllocator,
9605 queryRunner,
9606 continuationRequests,
9607 });
9608});
9609
9610const layer: Layer.Layer<
9611 ProviderAdapter.ProviderAdapterV2,
9612 never,
9613 | ClaudeAgentSdkQueryRunner
9614 | Crypto.Crypto
9615 | FileSystem.FileSystem
9616 | IdAllocator.IdAllocatorV2
9617 | ScriptProgress.ScriptProgress
9618 | McpProviderSessions.McpProviderSessions
9619 | Path.Path
9620 | ServerConfig.ServerConfig
9621> = Layer.effect(ProviderAdapter.ProviderAdapterV2, makeDefaultClaudeAdapterV2());