| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; |
| 2 | import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; |
| 3 | import { |
| 4 | dynamicToolTitle, |
| 5 | formatReadToolLabel, |
| 6 | formatSearchToolLabel, |
| 7 | } from "@t3tools/shared/toolActivity"; |
| 8 | import { field, text } from "../unknownField.ts"; |
| 9 | import { readWorkflowAgentAnswers } from "../workflowAgentAnswers.ts"; |
| 10 | import { |
| 11 | CLAUDE_WORKFLOW_TASK_TYPE, |
| 12 | hasClaudeWorkflowSnapshot, |
| 13 | mergeClaudeWorkflowProgress, |
| 14 | parseClaudeWorkflowRunHandles, |
| 15 | } from "./claudeWorkflowProgress.ts"; |
| 16 | |
| 17 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; |
| 18 | import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts"; |
| 19 | import { |
| 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"; |
| 42 | import 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. |
| 47 | import * as NodeChildProcess from "node:child_process"; |
| 48 | import { parseCliArgs } from "@t3tools/shared/cliArgs"; |
| 49 | import { AgentScope } from "@t3tools/shared/AgentScope"; |
| 50 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 51 | import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; |
| 52 | import { |
| 53 | CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, |
| 54 | formatClaudeResumeCompactionQuestion, |
| 55 | } from "@t3tools/shared/claudeCompaction"; |
| 56 | import { |
| 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 | |
| 91 | import * as Cause from "effect/Cause"; |
| 92 | import * as Context from "effect/Context"; |
| 93 | import * as Crypto from "effect/Crypto"; |
| 94 | import * as Hex from "effect/encoding/Hex"; |
| 95 | import * as DateTime from "effect/DateTime"; |
| 96 | import * as Deferred from "effect/Deferred"; |
| 97 | import * as Effect from "effect/Effect"; |
| 98 | import * as Equal from "effect/Equal"; |
| 99 | import * as Exit from "effect/Exit"; |
| 100 | import * as FileSystem from "effect/FileSystem"; |
| 101 | import * as Layer from "effect/Layer"; |
| 102 | import * as Option from "effect/Option"; |
| 103 | import * as Path from "effect/Path"; |
| 104 | import * as Queue from "effect/Queue"; |
| 105 | import * as Ref from "effect/Ref"; |
| 106 | import * as Schema from "effect/Schema"; |
| 107 | import * as Semaphore from "effect/Semaphore"; |
| 108 | import * as Stream from "effect/Stream"; |
| 109 | |
| 110 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 111 | import { resolveClaudeSdkExecutablePath } from "../../provider/Drivers/ClaudeExecutable.ts"; |
| 112 | import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts"; |
| 113 | import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; |
| 114 | import { |
| 115 | compileClaudeModelSelection, |
| 116 | type CompiledClaudeModelSelection, |
| 117 | } from "../../claudeModelOptions.ts"; |
| 118 | import * as ServerConfig from "../../config.ts"; |
| 119 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 120 | import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; |
| 121 | import { |
| 122 | claudeSignedOutMessage, |
| 123 | makeClaudeEnvironment, |
| 124 | } from "../../provider/Drivers/ClaudeHome.ts"; |
| 125 | import { |
| 126 | BUNDLED_CLAUDE_MODEL_CATALOG, |
| 127 | resolveClaudeCatalogContextWindowTokens, |
| 128 | type ClaudeModelCatalog, |
| 129 | getClaudeCatalogModelCapabilities, |
| 130 | resolveClaudeModelSlug, |
| 131 | scopeClaudeModelCatalog, |
| 132 | } from "../../provider/ClaudeModelCatalog.ts"; |
| 133 | import { |
| 134 | boundProviderEventForLogging, |
| 135 | shouldPersistProviderEvent, |
| 136 | } from "../../provider/EventNdjsonLogger.ts"; |
| 137 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 138 | import { |
| 139 | claudeRateLimitEventToUpdate, |
| 140 | type ClaudeScopedLimitNames, |
| 141 | } from "../../provider/claudeUsageLimits.ts"; |
| 142 | import type { ManagedServerProvider } from "@t3tools/provider-core/server/snapshot"; |
| 143 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; |
| 144 | import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; |
| 145 | import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; |
| 146 | import { |
| 147 | mcpToolPresentation, |
| 148 | normalizeMcpText, |
| 149 | } from "@t3tools/provider-core/server/mcpToolPresentation"; |
| 150 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; |
| 151 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 152 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 153 | import { |
| 154 | makeProviderFailure, |
| 155 | makeProviderRetryTurnItem, |
| 156 | } from "@t3tools/provider-core/server/failure"; |
| 157 | import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; |
| 158 | import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; |
| 159 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 160 | import { |
| 161 | ProviderAdapterDriverCreateError, |
| 162 | type ProviderAdapterDriver, |
| 163 | type ProviderAdapterDriverCreateInput, |
| 164 | } from "@t3tools/provider-core/server/adapterDriver"; |
| 165 | import { |
| 166 | type BackgroundWorkReport, |
| 167 | backgroundWorkNotification, |
| 168 | } from "@t3tools/provider-core/server/notification"; |
| 169 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; |
| 170 | import { |
| 171 | makeSubagentChildThread, |
| 172 | makeSubagentConversationArtifacts, |
| 173 | subagentThreadTitle, |
| 174 | } from "@t3tools/provider-core/server/subagentProjection"; |
| 175 | |
| 176 | export const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent"); |
| 177 | export const CLAUDE_AGENT_SDK_QUERY_PROTOCOL = "claude-agent-sdk.query" as const; |
| 178 | |
| 179 | function claudeContextWindow(modelSelection: ModelSelection): number | null { |
| 180 | return ( |
| 181 | resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, modelSelection) ?? null |
| 182 | ); |
| 183 | } |
| 184 | |
| 185 | interface 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. |
| 193 | function 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 | |
| 202 | export 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 | } |
| 230 | export const CLAUDE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CLAUDE_PROVIDER); |
| 231 | const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); |
| 232 | // Entry extensions the Claude SDK runs through node instead of executing. |
| 233 | const 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. |
| 236 | const 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 | */ |
| 251 | function 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 | |
| 269 | export 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 | |
| 364 | const CLAUDE_CODE_PRESET_TOOLS = { |
| 365 | type: "preset", |
| 366 | preset: "claude_code", |
| 367 | } satisfies NonNullable<ClaudeQueryOptions["tools"]>; |
| 368 | |
| 369 | export type ClaudeAgentSdkQueryToolList = ReadonlyArray<string>; |
| 370 | export interface ClaudeAgentSdkQueryPresetTools { |
| 371 | readonly type: "preset"; |
| 372 | readonly preset: "claude_code"; |
| 373 | } |
| 374 | export type ClaudeAgentSdkQueryTools = ClaudeAgentSdkQueryToolList | ClaudeAgentSdkQueryPresetTools; |
| 375 | |
| 376 | export const CLAUDE_READ_ONLY_ALLOWED_TOOLS = ["Read", "Glob", "Grep"] as const; |
| 377 | |
| 378 | function 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 | |
| 387 | function isClaudeAgentSdkQueryToolList( |
| 388 | tools: ClaudeAgentSdkQueryTools, |
| 389 | ): tools is ClaudeAgentSdkQueryToolList { |
| 390 | return Array.isArray(tools); |
| 391 | } |
| 392 | |
| 393 | type ClaudeAgentSdkThreadIdentity = |
| 394 | | { |
| 395 | readonly sessionId: string; |
| 396 | readonly resume?: never; |
| 397 | } |
| 398 | | { |
| 399 | readonly sessionId?: never; |
| 400 | readonly resume: string; |
| 401 | }; |
| 402 | |
| 403 | export 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 | |
| 412 | export interface ClaudeAgentSdkQueryOpenInput { |
| 413 | readonly options: ClaudeAgentSdkQueryOptions; |
| 414 | readonly threadId: ThreadId; |
| 415 | readonly providerSessionId: OrchestrationV2ProviderSession["id"]; |
| 416 | } |
| 417 | |
| 418 | export 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 | |
| 433 | type ClaudeQueryStreamExit = Exit.Exit<void, ClaudeAgentSdkQueryRunnerError>; |
| 434 | |
| 435 | export 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 | |
| 447 | export 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 | |
| 456 | export 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 | |
| 471 | export class ClaudeAgentSdkQueryRunner extends Context.Service< |
| 472 | ClaudeAgentSdkQueryRunner, |
| 473 | ClaudeAgentSdkQueryRunnerShape |
| 474 | >()("t3/orchestration-v2/Adapters/ClaudeAdapterV2/ClaudeAgentSdkQueryRunner") {} |
| 475 | |
| 476 | export interface ClaudeAgentSdkSessionForkInput { |
| 477 | readonly sessionId: string; |
| 478 | readonly options: ForkSessionOptions; |
| 479 | readonly threadId: ThreadId; |
| 480 | readonly providerSessionId: OrchestrationV2ProviderSession["id"]; |
| 481 | } |
| 482 | |
| 483 | export 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 | |
| 491 | const isClaudeAgentSdkQueryRunnerError = Schema.is(ClaudeAgentSdkQueryRunnerError); |
| 492 | const isProviderAdapterRuntimeRequestResponseError = Schema.is( |
| 493 | ProviderAdapter.ProviderAdapterRuntimeRequestResponseError, |
| 494 | ); |
| 495 | const isProviderAdapterRollbackThreadError = Schema.is( |
| 496 | ProviderAdapter.ProviderAdapterRollbackThreadError, |
| 497 | ); |
| 498 | |
| 499 | function queryRunnerError(cause: unknown, method: string): ClaudeAgentSdkQueryRunnerError { |
| 500 | return isClaudeAgentSdkQueryRunnerError(cause) |
| 501 | ? cause |
| 502 | : new ClaudeAgentSdkQueryRunnerError({ cause, method }); |
| 503 | } |
| 504 | |
| 505 | function 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. |
| 518 | export function claudeQueryMessages(queryRuntime: ClaudeQuery): AsyncIterable<SDKMessage, void> { |
| 519 | return { [Symbol.asyncIterator]: () => queryRuntime }; |
| 520 | } |
| 521 | |
| 522 | export 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 | |
| 543 | export 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 | |
| 646 | export type ClaudeAgentSdkProtocolLogger = ( |
| 647 | event: ClaudeAgentSdkProtocolLogEvent, |
| 648 | ) => Effect.Effect<void>; |
| 649 | |
| 650 | export 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 | |
| 679 | export 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 | |
| 706 | export 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 | |
| 1028 | type 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 | */ |
| 1035 | function 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 | */ |
| 1049 | function 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 | |
| 1059 | export 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 | |
| 1181 | export 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. |
| 1185 | export 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. |
| 1212 | export 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 | |
| 1224 | export 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 | |
| 1254 | function 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 | |
| 1275 | function 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. |
| 1283 | function 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. |
| 1293 | const CLAUDE_GOAL_SET_PREFIX = "Goal set: "; |
| 1294 | const 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 | */ |
| 1300 | function 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 | |
| 1346 | const providerGoalsEqual = Schema.toEquivalence(Schema.NullOr(OrchestrationV2ProviderGoal)); |
| 1347 | |
| 1348 | /** Compares a subagent result with its routed text regardless of block joins. */ |
| 1349 | function normalizeClaudeResultText(text: string): string { |
| 1350 | return text.replace(/\s+/g, " ").trim(); |
| 1351 | } |
| 1352 | |
| 1353 | function 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 | |
| 1365 | function 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 | |
| 1377 | function 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 | |
| 1413 | const 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 | |
| 1426 | const isSyntheticClaudeTurnId = (nativeTurnId: string): boolean => nativeTurnId.startsWith("turn:"); |
| 1427 | |
| 1428 | const isTerminalProviderTurn = (turn: OrchestrationV2ProviderTurn): boolean => |
| 1429 | turn.status === "completed" || |
| 1430 | turn.status === "interrupted" || |
| 1431 | turn.status === "failed" || |
| 1432 | turn.status === "cancelled"; |
| 1433 | |
| 1434 | const 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 | |
| 1456 | const 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 | |
| 1498 | const 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 | |
| 1540 | type ClaudeUserContent = SDKUserMessage["message"]["content"]; |
| 1541 | type ClaudeUserContentBlock = Exclude<ClaudeUserContent, string>[number]; |
| 1542 | |
| 1543 | const SUPPORTED_CLAUDE_IMAGE_MIME_TYPES = [ |
| 1544 | "image/gif", |
| 1545 | "image/jpeg", |
| 1546 | "image/png", |
| 1547 | "image/webp", |
| 1548 | ] as const; |
| 1549 | type SupportedClaudeImageMimeType = (typeof SUPPORTED_CLAUDE_IMAGE_MIME_TYPES)[number]; |
| 1550 | const supportedClaudeImageMimeTypes = new Set<string>(SUPPORTED_CLAUDE_IMAGE_MIME_TYPES); |
| 1551 | |
| 1552 | function isSupportedClaudeImageMimeType( |
| 1553 | mimeType: string, |
| 1554 | ): mimeType is SupportedClaudeImageMimeType { |
| 1555 | return supportedClaudeImageMimeTypes.has(mimeType); |
| 1556 | } |
| 1557 | |
| 1558 | export 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 | |
| 1592 | const 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 | |
| 1691 | const 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 | |
| 1697 | export function isClaudePromptUuid(value: string): value is NonNullable<SDKUserMessage["uuid"]> { |
| 1698 | return /^[^-]+-[^-]+-[^-]+-[^-]+-[^-]+$/.test(value); |
| 1699 | } |
| 1700 | |
| 1701 | type ClaudeAssistantContentBlock = SDKAssistantMessage["message"]["content"][number]; |
| 1702 | type ClaudeToolUseContentBlock = Extract< |
| 1703 | ClaudeAssistantContentBlock, |
| 1704 | { |
| 1705 | readonly id: string; |
| 1706 | readonly name: string; |
| 1707 | readonly input: unknown; |
| 1708 | } |
| 1709 | >; |
| 1710 | type ClaudeAssistantToolResultContentBlock = Extract< |
| 1711 | ClaudeAssistantContentBlock, |
| 1712 | { |
| 1713 | readonly tool_use_id: string; |
| 1714 | } |
| 1715 | >; |
| 1716 | type ClaudeUserToolResultContentBlock = Extract< |
| 1717 | ClaudeUserContentBlock, |
| 1718 | { |
| 1719 | readonly tool_use_id: string; |
| 1720 | } |
| 1721 | >; |
| 1722 | type ClaudeToolResultContentBlock = |
| 1723 | | ClaudeAssistantToolResultContentBlock |
| 1724 | | ClaudeUserToolResultContentBlock; |
| 1725 | type ClaudeTypedToolResultContentBlock = Exclude< |
| 1726 | ClaudeToolResultContentBlock, |
| 1727 | { readonly type: "mcp_tool_result" | "tool_result" } |
| 1728 | >; |
| 1729 | type ClaudeTypedToolResultContent = ClaudeTypedToolResultContentBlock["content"]; |
| 1730 | type ClaudeToolResultOutput = |
| 1731 | | Extract<ClaudeToolResultContentBlock, { readonly type: "tool_result" }>["content"] |
| 1732 | | Extract<ClaudeToolResultContentBlock, { readonly type: "mcp_tool_result" }>["content"] |
| 1733 | | ClaudeTypedToolResultContent; |
| 1734 | |
| 1735 | function assertNever(value: never): never { |
| 1736 | throw new Error(`Unhandled Claude SDK variant: ${jsonStringifyForTool(value)}`); |
| 1737 | } |
| 1738 | |
| 1739 | const ClaudeRuntimeSandboxPolicyKind = Schema.Struct({ |
| 1740 | type: Schema.Literals(["dangerFullAccess", "externalSandbox", "readOnly", "workspaceWrite"]), |
| 1741 | }); |
| 1742 | type ClaudeRuntimeSandboxPolicy = typeof ClaudeRuntimeSandboxPolicyKind.Type; |
| 1743 | type ClaudeRuntimeSandboxPolicyKindName = ClaudeRuntimeSandboxPolicy["type"]; |
| 1744 | const isClaudeRuntimeSandboxPolicyKind = Schema.is(ClaudeRuntimeSandboxPolicyKind); |
| 1745 | |
| 1746 | const ClaudeRuntimeReadOnlyFullAccessSandboxPolicy = Schema.Struct({ |
| 1747 | type: Schema.Literal("readOnly"), |
| 1748 | access: Schema.Struct({ |
| 1749 | type: Schema.Literal("fullAccess"), |
| 1750 | }), |
| 1751 | }); |
| 1752 | const isClaudeRuntimeReadOnlyFullAccessSandboxPolicy = Schema.is( |
| 1753 | ClaudeRuntimeReadOnlyFullAccessSandboxPolicy, |
| 1754 | ); |
| 1755 | |
| 1756 | function 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 | |
| 1765 | function readOnlyPolicyAllowsGlobalReads( |
| 1766 | runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, |
| 1767 | ): boolean { |
| 1768 | return ( |
| 1769 | runtimePolicy.sandboxPolicy !== undefined && |
| 1770 | isClaudeRuntimeReadOnlyFullAccessSandboxPolicy(runtimePolicy.sandboxPolicy) |
| 1771 | ); |
| 1772 | } |
| 1773 | |
| 1774 | function 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 | |
| 1823 | export 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 | |
| 1831 | export 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 | |
| 1869 | function shouldInstallClaudePermissionCallback(policy: ClaudeRuntimeQueryPolicy): boolean { |
| 1870 | return policy.installPermissionCallback; |
| 1871 | } |
| 1872 | |
| 1873 | // Whether a tool's permission callback must ask the user before answering. |
| 1874 | function requiresClaudeApproval(context: ActiveClaudeTurnContext): boolean { |
| 1875 | return shouldInstallClaudePermissionCallback( |
| 1876 | claudeRuntimeQueryPolicyForRuntimePolicy(context.input.runtimePolicy), |
| 1877 | ); |
| 1878 | } |
| 1879 | |
| 1880 | function 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. |
| 1893 | export 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 | |
| 1911 | type ClaudeToolItemType = Extract< |
| 1912 | OrchestrationV2TurnItem["type"], |
| 1913 | "command_execution" | "file_change" | "dynamic_tool" | "web_search" |
| 1914 | >; |
| 1915 | |
| 1916 | interface ClaudeToolClassification { |
| 1917 | readonly known: boolean; |
| 1918 | readonly normalizedName: string; |
| 1919 | readonly itemType: ClaudeToolItemType; |
| 1920 | readonly requestKind: ProviderRequestKind; |
| 1921 | } |
| 1922 | |
| 1923 | function normalizedClaudeToolName(toolName: string): string { |
| 1924 | return toolName.toLowerCase().replaceAll(/[\s_-]/g, ""); |
| 1925 | } |
| 1926 | |
| 1927 | const 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 | |
| 1954 | export 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 | |
| 1971 | function providerRequestKindFromClaudeTool(toolName: string): ProviderRequestKind { |
| 1972 | return classifyClaudeNativeTool(toolName).requestKind; |
| 1973 | } |
| 1974 | |
| 1975 | function 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 | |
| 1985 | const ClaudeNativeToolInputRecord = Schema.Record(Schema.String, Schema.Unknown); |
| 1986 | type ClaudeNativeToolInputRecord = typeof ClaudeNativeToolInputRecord.Type; |
| 1987 | const isClaudeNativeToolInputRecord = Schema.is(ClaudeNativeToolInputRecord); |
| 1988 | |
| 1989 | type ClaudeNativeToolInput = |
| 1990 | | { |
| 1991 | readonly type: "record"; |
| 1992 | readonly value: ClaudeNativeToolInputRecord; |
| 1993 | } |
| 1994 | | { |
| 1995 | readonly type: "non_record"; |
| 1996 | readonly value: unknown; |
| 1997 | }; |
| 1998 | |
| 1999 | const EMPTY_CLAUDE_NATIVE_TOOL_INPUT = { |
| 2000 | type: "record", |
| 2001 | value: {}, |
| 2002 | } satisfies ClaudeNativeToolInput; |
| 2003 | |
| 2004 | function claudeNativeToolInputFromUnknown(input: unknown): ClaudeNativeToolInput { |
| 2005 | return isClaudeNativeToolInputRecord(input) |
| 2006 | ? { type: "record", value: input } |
| 2007 | : { type: "non_record", value: input }; |
| 2008 | } |
| 2009 | |
| 2010 | function claudeNativeToolInputFromRecord(input: Record<string, unknown>): ClaudeNativeToolInput { |
| 2011 | return { type: "record", value: input }; |
| 2012 | } |
| 2013 | |
| 2014 | function claudeNativeToolInputValue(input: ClaudeNativeToolInput): unknown { |
| 2015 | return input.value; |
| 2016 | } |
| 2017 | |
| 2018 | function inputRecordValue(input: ClaudeNativeToolInput, key: string): unknown { |
| 2019 | return input.type === "record" ? input.value[key] : undefined; |
| 2020 | } |
| 2021 | |
| 2022 | function 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 | |
| 2035 | function jsonStringifyForTool(value: unknown): string { |
| 2036 | if (typeof value === "string") { |
| 2037 | return value; |
| 2038 | } |
| 2039 | return JSON.stringify(value) ?? String(value); |
| 2040 | } |
| 2041 | |
| 2042 | function 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. |
| 2053 | const CLAUDE_OPAQUE_BACKGROUND_TASK_KINDS: ReadonlyMap< |
| 2054 | string, |
| 2055 | Exclude<OrchestrationV2PendingBackgroundTask["kind"], "subagent"> |
| 2056 | > = new Map([["local_bash", "command"]]); |
| 2057 | |
| 2058 | function isClaudeOpaqueBackgroundTaskType(taskType: string | null | undefined): boolean { |
| 2059 | return typeof taskType === "string" && CLAUDE_OPAQUE_BACKGROUND_TASK_KINDS.has(taskType); |
| 2060 | } |
| 2061 | |
| 2062 | function 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 | |
| 2083 | function 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 | |
| 2091 | function isClaudeNonSubagentTask(message: SDKMessage): boolean { |
| 2092 | return isClaudeOpaqueBackgroundTaskType(claudeTaskTypeFromSdkMessage(message)); |
| 2093 | } |
| 2094 | |
| 2095 | function 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). |
| 2106 | function isClaudeTurnStartMessage(message: SDKMessage): boolean { |
| 2107 | return message.type === "system" && message.subtype === "init"; |
| 2108 | } |
| 2109 | |
| 2110 | function claudePendingBackgroundTasksFromRoster( |
| 2111 | roster: ReadonlyMap<string, OrchestrationV2PendingBackgroundTask>, |
| 2112 | ): ReadonlyArray<OrchestrationV2PendingBackgroundTask> { |
| 2113 | return Array.from(roster.values()); |
| 2114 | } |
| 2115 | |
| 2116 | function 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 | |
| 2143 | function fileNameFromClaudeTool(toolName: string, input: ClaudeNativeToolInput): string { |
| 2144 | return ( |
| 2145 | firstStringInputField(input, ["file_path", "path", "filename", "fileName"]) ?? |
| 2146 | `${toolName} result` |
| 2147 | ); |
| 2148 | } |
| 2149 | |
| 2150 | type 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 | |
| 2164 | const NO_CLAUDE_NATIVE_TOOL_OUTPUT = { type: "none" } satisfies ClaudeNativeToolOutput; |
| 2165 | |
| 2166 | function 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 | |
| 2173 | function 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 | |
| 2184 | function 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 | |
| 2196 | function 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 | */ |
| 2205 | function 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 | |
| 2223 | function 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 | |
| 2250 | function 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 | |
| 2263 | const WEB_FETCH_SNIPPET_MAX_CHARS = 8_000; |
| 2264 | |
| 2265 | function 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 | |
| 2276 | function 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 | |
| 2295 | function 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 | |
| 2310 | function 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 | |
| 2331 | function 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 | |
| 2349 | function 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 | |
| 2368 | function 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 | |
| 2393 | function isClaudeAssistantToolResultContentBlock( |
| 2394 | part: ClaudeAssistantContentBlock, |
| 2395 | ): part is ClaudeAssistantToolResultContentBlock { |
| 2396 | return "tool_use_id" in part && typeof part.tool_use_id === "string"; |
| 2397 | } |
| 2398 | |
| 2399 | function isClaudeUserToolResultContentBlock( |
| 2400 | part: ClaudeUserContentBlock, |
| 2401 | ): part is ClaudeUserToolResultContentBlock { |
| 2402 | return "tool_use_id" in part && typeof part.tool_use_id === "string"; |
| 2403 | } |
| 2404 | |
| 2405 | function 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 | |
| 2417 | function claudeToolUseBlocksFromAssistantMessage( |
| 2418 | message: SDKMessage, |
| 2419 | ): ReadonlyArray<ClaudeToolUseContentBlock> { |
| 2420 | if (message.type !== "assistant") { |
| 2421 | return []; |
| 2422 | } |
| 2423 | return message.message.content.filter(isClaudeToolUseContentBlock); |
| 2424 | } |
| 2425 | |
| 2426 | type ClaudeToolPresentation = ReturnType<typeof mcpToolPresentation>; |
| 2427 | |
| 2428 | function 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 | |
| 2457 | function claudeToolResultBlocksFromAssistantMessage( |
| 2458 | message: SDKMessage, |
| 2459 | ): ReadonlyArray<ClaudeToolResultContentBlock> { |
| 2460 | if (message.type !== "assistant") { |
| 2461 | return []; |
| 2462 | } |
| 2463 | return message.message.content.filter(isClaudeAssistantToolResultContentBlock); |
| 2464 | } |
| 2465 | |
| 2466 | function 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 | |
| 2475 | function 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. |
| 2501 | const 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 | }); |
| 2509 | const isClaudeToolResultMetadata = Schema.is(ClaudeToolResultMetadata); |
| 2510 | |
| 2511 | function 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 | |
| 2518 | function parentToolUseIdFromSdkMessage(message: SDKMessage): string | null { |
| 2519 | return message.type === "assistant" || message.type === "user" |
| 2520 | ? message.parent_tool_use_id |
| 2521 | : null; |
| 2522 | } |
| 2523 | |
| 2524 | export 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 | |
| 2558 | function 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 | |
| 2579 | export 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 | |
| 2594 | const 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 | */ |
| 2612 | function 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 | |
| 2622 | function 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 | |
| 2654 | function isOverloadedResult(result: SDKResultMessage): boolean { |
| 2655 | return result.subtype === "success" && result.api_error_status === 529; |
| 2656 | } |
| 2657 | |
| 2658 | function 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 | |
| 2693 | function isClaudeActiveSteeringAbortResult(message: SDKResultMessage): boolean { |
| 2694 | return ( |
| 2695 | message.terminal_reason === "aborted_streaming" || message.terminal_reason === "aborted_tools" |
| 2696 | ); |
| 2697 | } |
| 2698 | |
| 2699 | function 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. |
| 2708 | function 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. |
| 2723 | function 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 | |
| 2738 | function 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. |
| 2750 | function 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. |
| 2764 | function 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 | |
| 2778 | function 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 | |
| 2787 | function 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 | |
| 2854 | function 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 | |
| 2872 | function 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 | |
| 2959 | const 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. */ |
| 2969 | const 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 | */ |
| 2977 | function 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 | |
| 3000 | function 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 | |
| 3008 | interface 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 | |
| 3068 | interface ActiveClaudeProviderRetry { |
| 3069 | readonly retry: OrchestrationV2ProviderRetry; |
| 3070 | readonly failure: OrchestrationV2ProviderFailure; |
| 3071 | readonly startedAt: DateTime.Utc; |
| 3072 | readonly itemOrdinal: number; |
| 3073 | } |
| 3074 | |
| 3075 | interface 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 | |
| 3107 | interface 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 | |
| 3135 | interface 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. |
| 3151 | interface 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 | */ |
| 3165 | function 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 | */ |
| 3173 | function 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 | |
| 3189 | const PENDING_CLAUDE_SUBAGENT_CAP = 64; |
| 3190 | // Per-subagent bound on frames held while waiting for task_started. |
| 3191 | const PENDING_CLAUDE_SUBAGENT_FRAME_CAP = 256; |
| 3192 | |
| 3193 | function 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 | */ |
| 3214 | function 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 | |
| 3249 | type 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 | |
| 3262 | export 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 | |
| 3308 | export 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 | |
| 3323 | export 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. */ |
| 3355 | type ClaudeTaskList = ReadonlyMap<string, Omit<OrchestrationV2PlanStep, "id">>; |
| 3356 | |
| 3357 | function claudeTaskField(value: unknown, key: string): unknown { |
| 3358 | return typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined; |
| 3359 | } |
| 3360 | |
| 3361 | function 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 | |
| 3366 | function 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 | */ |
| 3386 | function 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 | |
| 3431 | function claudeTaskSteps(tasks: ClaudeTaskList): ReadonlyArray<OrchestrationV2PlanStep> { |
| 3432 | return [...tasks].map(([id, task]) => ({ id: `task-${id}`, ...task })); |
| 3433 | } |
| 3434 | |
| 3435 | export 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 | |
| 3444 | export 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 | |
| 3464 | export 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 | |
| 9518 | export 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 | |
| 9528 | export 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 | |
| 9576 | export 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 | |
| 9586 | const 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 | |
| 9610 | const 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()); |