@meistrari/agent-core 0.2.0-experimental.0 → 0.2.0-experimental.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +82 -0
  2. package/docs/consumer-capability-matrix.md +374 -0
  3. package/package.json +17 -10
  4. package/scripts/generate-codex-protocol.ts +711 -0
  5. package/src/agents/agent-operation.ts +8 -0
  6. package/src/agents/agent-provider.ts +3 -0
  7. package/src/agents/agent-run.ts +14 -2
  8. package/src/agents/agent-runtime-error.ts +28 -0
  9. package/src/agents/agent-session-events.ts +33 -0
  10. package/src/agents/agent-tool-runner.ts +12 -11
  11. package/src/agents/agent-tool.ts +12 -4
  12. package/src/agents/author-context.ts +1 -1
  13. package/src/agents/claude/claude-auth.ts +40 -0
  14. package/src/agents/claude/claude-browser-turn-metadata.ts +70 -0
  15. package/src/agents/claude/claude-code-process.ts +98 -0
  16. package/src/agents/claude/claude-command-mapper.ts +63 -3
  17. package/src/agents/claude/claude-desktop.ts +55 -0
  18. package/src/agents/claude/claude-event-mapper.ts +876 -124
  19. package/src/agents/claude/claude-file-change.ts +128 -0
  20. package/src/agents/claude/claude-provider.ts +344 -29
  21. package/src/agents/claude/claude-run.ts +922 -91
  22. package/src/agents/claude/claude-selected-repository-instructions.ts +91 -0
  23. package/src/agents/claude/claude-skill-catalog.ts +75 -0
  24. package/src/agents/claude/claude-steered-notifications.ts +180 -0
  25. package/src/agents/claude/claude-tool-mapper.ts +81 -2
  26. package/src/agents/claude/claude-workspace-skills.ts +87 -0
  27. package/src/agents/claude/index.ts +2 -0
  28. package/src/agents/codex/codex-auth-mapper.ts +27 -0
  29. package/src/agents/codex/codex-auth.ts +19 -0
  30. package/src/agents/codex/codex-browser-auth-broker.ts +320 -0
  31. package/src/agents/codex/codex-browser-auth-status.ts +55 -0
  32. package/src/agents/codex/codex-command-mapper.ts +38 -3
  33. package/src/agents/codex/codex-event-mapper.ts +375 -64
  34. package/src/agents/codex/codex-json-rpc-client.ts +66 -21
  35. package/src/agents/codex/codex-provider.ts +118 -236
  36. package/src/agents/codex/codex-run-auth.ts +113 -0
  37. package/src/agents/codex/codex-run.ts +428 -48
  38. package/src/agents/codex/codex-tool-mapper.ts +2 -2
  39. package/src/agents/codex/codex.errors.ts +8 -0
  40. package/src/agents/codex/generated/meta.gen.ts +144 -6
  41. package/src/agents/codex/generated/namespaces.gen.ts +92 -3
  42. package/src/agents/codex/generated/schema.gen.ts +24698 -15638
  43. package/src/agents/codex/lazy-codex-run-auth.ts +47 -0
  44. package/src/agents/index.ts +5 -0
  45. package/src/agents/input-attachment-preparation.ts +13 -0
  46. package/src/agents/mcp-tool-content.ts +82 -0
  47. package/src/agents/model-rebinding-run.ts +14 -3
  48. package/src/agents/selected-repository-instructions.ts +202 -0
  49. package/src/agents/session-context.ts +41 -0
  50. package/src/agents/subscription-token-refresh.ts +73 -0
  51. package/src/agents/user-input-request.ts +19 -20
  52. package/src/errors/index.ts +1 -1
  53. package/src/errors/validation-error.ts +6 -0
  54. package/src/protocol/agent-catalog.ts +159 -0
  55. package/src/protocol/agent-command.ts +54 -7
  56. package/src/protocol/agent-content.ts +36 -16
  57. package/src/protocol/agent-event.ts +210 -73
  58. package/src/protocol/agent-identity.ts +29 -0
  59. package/src/protocol/agent-json.ts +122 -8
  60. package/src/protocol/agent-metadata.ts +2 -2
  61. package/src/protocol/agent-model.ts +13 -1
  62. package/src/protocol/agent-notification.ts +110 -0
  63. package/src/protocol/agent-presentation.ts +71 -20
  64. package/src/protocol/agent-tool.ts +66 -10
  65. package/src/protocol/agent-usage.ts +4 -1
  66. package/src/protocol/agent-user-input.ts +4 -4
  67. package/src/protocol/agent-work.ts +12 -10
  68. package/src/protocol/index.ts +2 -0
  69. package/src/protocol/rejected-keys.ts +11 -0
  70. package/src/provenance.gen.ts +3 -3
  71. package/src/steps/contract.ts +4 -1
  72. package/src/steps/projector.ts +17 -1
  73. package/src/supervisor/agent-provider-factory.ts +60 -5
  74. package/src/supervisor/bootstrap-binder.ts +3 -2
  75. package/src/supervisor/index.ts +1 -1
  76. package/src/supervisor/persistence/supervisor-store.ts +22 -8
  77. package/src/supervisor/runtime-handler.ts +30 -2
  78. package/src/supervisor-protocol/agent-event-wrapper.ts +30 -0
  79. package/src/supervisor-protocol/bootstrap.ts +4 -1
  80. package/src/supervisor-protocol/command-body.ts +4 -1
  81. package/src/supervisor-protocol/envelopes/supervisor-to-control-plane.ts +1 -0
  82. package/src/testing/in-memory-runtime-control-plane.ts +118 -12
  83. package/src/testing/index.ts +1 -1
  84. package/src/worker-runtime-client/command-pump.ts +85 -10
  85. package/src/worker-runtime-client/connection-attempt.ts +95 -20
  86. package/src/worker-runtime-client/frame-processor.ts +242 -58
  87. package/src/worker-runtime-client/index.ts +8 -2
  88. package/src/worker-runtime-client/ports.ts +34 -2
  89. package/src/worker-runtime-client/rpc-request-manager.ts +152 -107
  90. package/src/worker-runtime-client/sandbox-connection-runtime.ts +72 -35
@@ -1,27 +1,39 @@
1
1
  import type { Query, SDKMessage, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
2
+ import type { UUID } from 'node:crypto'
2
3
  import type {
4
+ AgentContextCompactionRequestResult,
3
5
  AgentError,
6
+ AgentNotifyCommand,
4
7
  AgentReasoningEffort,
5
8
  AgentRunMetadata,
9
+ AgentSettingsChange,
10
+ CompactContextAgentCommand,
6
11
  InterruptAgentCommand,
7
12
  RespondUserInputAgentCommand,
8
13
  SendPromptCommand,
9
14
  StopAgentCommand,
10
15
  } from '../../protocol'
11
16
  import type { AgentEventDraft } from '../agent-event-stream'
12
- import type { AgentOperationOptions } from '../agent-operation'
13
- import type { AgentPromptAcceptance, AgentRun, AgentUserInputSubmission } from '../agent-run'
17
+ import type { AgentOperationOptions, AgentPromptOperationOptions } from '../agent-operation'
18
+ import type { AgentNotificationAcceptance, AgentPromptAcceptance, AgentRun, AgentUserInputSubmission } from '../agent-run'
14
19
  import type { UserInputRequestInput, UserInputRequestStore } from '../user-input-request'
20
+ import type { ClaudeAuthFailure } from './claude-auth'
21
+ import type { ClaudeCodeProcess } from './claude-code-process'
15
22
  import type { ClaudeEventMapperState } from './claude-event-mapper'
23
+ import type { ClaudeSelectedRepositoryInstructions } from './claude-selected-repository-instructions'
24
+ import type { ClaudeSkillCatalog } from './claude-skill-catalog'
16
25
  import type { ClaudeStartupPhase } from './claude-startup-timing'
26
+ import type { ClaudeSteeredNotificationStore, SteeredNotificationDelivery } from './claude-steered-notifications'
27
+ import type { ClaudeWorkspaceSkills } from './claude-workspace-skills'
28
+ import { randomUUID } from 'node:crypto'
17
29
  import z from 'zod'
18
30
  import { serializeAgentError } from '../agent-error-serializer'
19
31
  import { AgentEventStream } from '../agent-event-stream'
20
32
  import { AgentDeliveryUnknownError, AgentNotAcceptedError, AgentPromptDeferredError, AgentProviderMetadataError, AgentProviderProtocolError, AgentRunStateError } from '../agent-runtime-error'
21
- import { sessionConfiguredEvent } from '../agent-session-events'
33
+ import { sessionConfiguredEvent, sessionSettingsChangedEvent } from '../agent-session-events'
22
34
  import { deriveMessageId } from '../message-id'
23
- import { mapClaudeUserMessage, toClaudeUserMessage } from './claude-command-mapper'
24
- import { mapClaudeMessage } from './claude-event-mapper'
35
+ import { claudeNotificationText, mapClaudeUserMessage, toClaudeCompactContextMessage, toClaudeNotificationMessage, toClaudeTaskNotificationMessage, toClaudeUserMessage } from './claude-command-mapper'
36
+ import { compactionError, flushHeldTurnEnd, mapClaudeMessage, mapSteerLifecycle, resultError } from './claude-event-mapper'
25
37
  import { hydrateClaudeToolOutput } from './claude-persisted-output'
26
38
 
27
39
  // The ONE long-lived SDK input stream created with the query. SDK 0.3.207 Query.streamInput ends
@@ -41,24 +53,39 @@ interface ClaudeInitExpectation {
41
53
  environment: Record<string, string | undefined>
42
54
  }
43
55
 
56
+ // The model and reasoning effort a run's inputs run with; an undefined field is the CLI's default.
57
+ interface ClaudeRunSettings {
58
+ model: string | undefined
59
+ effort: AgentReasoningEffort | undefined
60
+ }
61
+
44
62
  type ClaudeInitMessage = Extract<SDKMessage, { type: 'system', subtype: 'init' }>
63
+ type ClaudePreInitHookMessage = Extract<SDKMessage, { type: 'system', subtype: 'hook_started' | 'hook_progress' | 'hook_response' }>
45
64
  interface ClaudeCommandLifecycleMessage {
46
65
  type: 'command_lifecycle'
47
66
  }
48
67
 
49
- // The Claude prompt-acceptance receipt is a pinned, live-verified but undocumented CLI frame
50
- // (SDK 0.3.207 / bundled CLI 2.1.207): it is absent from the SDK's exported SDKMessage union,
51
- // and its exact shape below was proven by scripts/smoke/claude-command-acceptance-probe.ts —
52
- // which must be rerun before any SDK/CLI upgrade. On the pinned runtime, any shape or state
53
- // deviation is provider protocol drift and fails the run closed. The state enum mirrors the pinned
54
- // CLI's embedded schema exactly: `queued`/`started`/`completed` are positive; `cancelled`
55
- // (steered-over/aborted) and `discarded` (session ended with the command still queued) are
56
- // schema-valid NEGATIVE terminals that never mean acceptance.
68
+ function isClaudePreInitHookMessage(message: SDKMessage | ClaudeCommandLifecycleMessage): message is ClaudePreInitHookMessage {
69
+ return message.type === 'system'
70
+ && (message.subtype === 'hook_started' || message.subtype === 'hook_progress' || message.subtype === 'hook_response')
71
+ }
72
+
73
+ // The Claude prompt-acceptance receipt is an undocumented CLI frame absent from the SDK's
74
+ // exported SDKMessage union. Its exact shape and the original states were live-verified on SDK
75
+ // 0.3.207 / bundled CLI 2.1.207 by scripts/smoke/claude-command-acceptance-probe.ts. CLI 2.1.263
76
+ // added one more state, `refused`, and the pinned 0.3.280 / CLI 2.1.280 embeds the same schema,
77
+ // which its own description defines as "declined by the session's receive-side policy before
78
+ // entering the queue (outright, or after being held): not preceded by 'queued', and it will not
79
+ // run in this session". Any other shape or state deviation is provider protocol drift and fails
80
+ // the run closed. Only `queued`/`started`/`completed` are positive. `cancelled` (steered-over,
81
+ // aborted, or user-cancelled) and `discarded` (session ended with the command still queued) are
82
+ // observed non-positive states whose real outcome the run's close settles; `refused` is a definite
83
+ // terminal non-acceptance while the session keeps running, so it settles the waiter itself.
57
84
  const commandLifecycleFrameSchema = z.strictObject({
58
85
  type: z.literal('command_lifecycle'),
59
86
  command_uuid: z.uuid(),
60
87
  session_id: z.uuid(),
61
- state: z.enum(['queued', 'started', 'completed', 'cancelled', 'discarded']),
88
+ state: z.enum(['queued', 'started', 'completed', 'cancelled', 'discarded', 'refused']),
62
89
  uuid: z.uuid(),
63
90
  })
64
91
 
@@ -68,6 +95,51 @@ interface AcceptanceWaiter {
68
95
  reject: (error: unknown) => void
69
96
  }
70
97
 
98
+ // The one in-flight notification (the supervisor submits commands one at a time). It settles on the
99
+ // turn that actually consumes it: `started` on its command_lifecycle frame, then the turn identity.
100
+ interface NotificationWaiter {
101
+ clientMessageId: string
102
+ // The main turn active when the notification was written; a fold into it is a join.
103
+ activeTurnIdAtSubmission: string | undefined
104
+ consumed: boolean
105
+ promise: Promise<AgentNotificationAcceptance>
106
+ resolve: (acceptance: AgentNotificationAcceptance) => void
107
+ reject: (error: unknown) => void
108
+ }
109
+
110
+ // Steered envelopes taken for a follow-up turn whose input Claude has not started yet; they return
111
+ // to the store if Claude drops that input first.
112
+ interface SteeredNotificationFollowUp {
113
+ clientMessageId: UUID
114
+ delivery: SteeredNotificationDelivery
115
+ }
116
+
117
+ // The one-time session context is owed until a prompt carrying it starts in Claude. A carrying
118
+ // prompt Claude drops before starting it (cancelled, discarded, refused, or never written) owes it
119
+ // again, so the next prompt carries it. Synthetic inputs (/compact, notifications) never carry it.
120
+ type ClaudeSessionContextState
121
+ = | { status: 'owed', text: string }
122
+ | { status: 'carried', text: string, clientMessageId: string }
123
+ | { status: 'delivered' }
124
+
125
+ type CommandLifecycleFrame = z.infer<typeof commandLifecycleFrameSchema>
126
+
127
+ // A manual compaction reports one request result: the correlated outcome, the compaction deadline,
128
+ // or the run's end. `compactContext` returns once Claude accepts the input, so an outcome observed
129
+ // later is reported as it arrives, while one observed before acceptance is kept for the submitting
130
+ // call to report and return. Reporting does not end the compaction: an accepted one stays registered,
131
+ // deferring steers and rejecting a second compaction, until Claude ends it (its correlated outcome
132
+ // or the end of its turn) or the run ends, even after the deadline reported `unknown`.
133
+ interface CompactionWaiter {
134
+ commandId: string
135
+ clientMessageId: string
136
+ timeout: ReturnType<typeof setTimeout>
137
+ accepted: boolean
138
+ outcome?: AgentContextCompactionRequestResult
139
+ reported?: AgentContextCompactionRequestResult
140
+ boundaryObserved: boolean
141
+ }
142
+
71
143
  function interruptTargetsTurn(interrupt: ClaudeEventMapperState['interrupt'], turnId: string): boolean {
72
144
  return interrupt.status !== 'none' && interrupt.turnId === turnId
73
145
  }
@@ -79,9 +151,33 @@ interface ClaudeRunInput {
79
151
  input: ClaudeInputStream
80
152
  eventMapperState: ClaudeEventMapperState
81
153
  init: ClaudeInitExpectation
154
+ steeredNotifications: ClaudeSteeredNotificationStore
155
+ // Delivered once, with the session's first prompt, then cleared (see session-context.ts).
156
+ sessionContext: string | undefined
157
+ workspace: ClaudeRunWorkspace
158
+ // The run's Claude Code process; the run is not reported ended until it has exited.
159
+ process: ClaudeRunProcess
160
+ onFinished: () => Promise<void>
161
+ compactionTimeoutMs?: number
82
162
  recordStartup?: (phase: ClaudeStartupPhase) => void
163
+ // Reads SDK-persisted tool results back from the transcript so tool events carry full output.
164
+ hydratePersistedToolOutput?: boolean
165
+ }
166
+
167
+ type ClaudeRunProcess = Pick<ClaudeCodeProcess, 'terminate' | 'stderrTail'>
168
+
169
+ // The run's workspace registration: the generated skills plugin, the repository roots it links,
170
+ // the catalog fed from the CLI's command list, and the managed selected-repository instructions.
171
+ interface ClaudeRunWorkspace {
172
+ // Absent when the provider generates no workspace skills plugin.
173
+ skills?: ClaudeWorkspaceSkills
174
+ repositoryRoots: readonly string[]
175
+ catalog: ClaudeSkillCatalog
176
+ instructions: ClaudeSelectedRepositoryInstructions
83
177
  }
84
178
 
179
+ export const CLAUDE_COMPACTION_TIMEOUT_MS = 180_000
180
+
85
181
  export class ClaudeRun implements AgentRun {
86
182
  readonly events: AgentEventStream
87
183
  readonly metadata: AgentRunMetadata
@@ -90,28 +186,75 @@ export class ClaudeRun implements AgentRun {
90
186
  private readonly input: ClaudeInputStream
91
187
  private readonly eventMapperState: ClaudeEventMapperState
92
188
  private readonly init: ClaudeInitExpectation
93
- private readonly recordStartup: ClaudeRunInput['recordStartup']
94
- private readonly initialization: Promise<void>
95
- private initializationResolve: () => void = () => undefined
189
+ private readonly workspaceSkills: ClaudeWorkspaceSkills | undefined
190
+ private readonly selectedRepositoryInstructions: ClaudeSelectedRepositoryInstructions
191
+ private readonly steeredNotifications: ClaudeSteeredNotificationStore
192
+ private sessionContext: ClaudeSessionContextState
193
+ private readonly skillCatalog: ClaudeSkillCatalog
194
+ private workspaceRepositoryRoots: readonly string[]
195
+ // Set when the skills plugin changed on disk and cleared only by a successful reload, so a
196
+ // repository root whose reload was aborted or failed is reloaded again on retry.
197
+ private skillReloadOwed = false
198
+ private readonly process: ClaudeRunProcess
199
+ private readonly onFinished: () => Promise<void>
200
+ private readonly compactionTimeoutMs: number
201
+ // Settles after the CLI has exited and the terminal events are pushed (set by finish).
202
+ private ended: Promise<void> | undefined
203
+ // Resolves with the model system/init reported: the CLI default when no model was requested.
204
+ private readonly initialization: Promise<string>
205
+ private initializationResolve: (model: string) => void = () => undefined
96
206
  private initializationReject: (error: unknown) => void = () => undefined
97
207
  private initialized = false
208
+ // With CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS the CLI reports `running` for the first input before
209
+ // system/init; those frames are held and mapped right after init so the turn starts on time.
210
+ private readonly preInitSessionStates: SDKMessage[] = []
98
211
  private firstInputPushed = false
99
- private modelChangeReady = true
100
- private modelChangeInFlight = false
101
212
  private finished = false
213
+ private queryClosed = false
102
214
  private interruptRequest?: { turnId: string, promise: Promise<void> }
103
215
  private readonly acceptanceWaiters = new Map<string, AcceptanceWaiter>()
216
+ // Client message ids of steering (`now`) prompts whose lifecycle can supersede the running ask.
217
+ private readonly steerInputIds = new Set<string>()
218
+ private compactionWaiter?: CompactionWaiter
219
+ private notificationWaiter?: NotificationWaiter
220
+ private steeredFollowUp?: SteeredNotificationFollowUp
221
+ private readonly recordStartup: ClaudeRunInput['recordStartup']
222
+ private readonly hydratePersistedToolOutput: boolean
223
+ private modelChangeReady = true
224
+ private modelChangeInFlight = false
225
+ // Provider command identity by derived client message id, until its completed/negative receipt.
104
226
  private readonly commandByClientMessageId = new Map<string, string>()
227
+ // Prompts whose accepted user message has not been emitted into a turn yet.
228
+ private readonly pendingMessages = new Map<string, { messageId: string, mode: 'next' | 'steer', turnId?: string, started: boolean }>()
229
+
230
+ // The accepted settings: they change only once the input carrying a change is accepted.
231
+ private current: ClaudeRunSettings
232
+ // What the CLI was last told, or undefined when a failed control request left it unknown. It
233
+ // differs from `current` only after a change reached the CLI for an input that was then not
234
+ // written or not accepted; every input re-asserts `current` before it is written.
235
+ private providerApplied: ClaudeRunSettings | undefined
105
236
 
106
- constructor({ metadata, query, userInputRequests, input, eventMapperState, init, recordStartup }: ClaudeRunInput) {
237
+ constructor({ metadata, query, userInputRequests, input, eventMapperState, init, steeredNotifications, sessionContext, workspace, process, onFinished, compactionTimeoutMs = CLAUDE_COMPACTION_TIMEOUT_MS, recordStartup, hydratePersistedToolOutput = true }: ClaudeRunInput) {
107
238
  this.metadata = metadata
108
239
  this.query = query
109
240
  this.userInputRequests = userInputRequests
110
241
  this.input = input
111
242
  this.eventMapperState = eventMapperState
112
243
  this.init = init
244
+ this.workspaceSkills = workspace.skills
245
+ this.selectedRepositoryInstructions = workspace.instructions
246
+ this.steeredNotifications = steeredNotifications
247
+ this.sessionContext = sessionContext === undefined ? { status: 'delivered' } : { status: 'owed', text: sessionContext }
248
+ this.skillCatalog = workspace.catalog
249
+ this.workspaceRepositoryRoots = [...workspace.repositoryRoots]
250
+ this.process = process
251
+ this.onFinished = onFinished
252
+ this.compactionTimeoutMs = compactionTimeoutMs
113
253
  this.recordStartup = recordStartup
114
- this.initialization = new Promise<void>((resolve, reject) => {
254
+ this.hydratePersistedToolOutput = hydratePersistedToolOutput
255
+ this.current = { model: init.requestedModel, effort: init.reasoningEffort }
256
+ this.providerApplied = this.current
257
+ this.initialization = new Promise<string>((resolve, reject) => {
115
258
  this.initializationResolve = resolve
116
259
  this.initializationReject = reject
117
260
  })
@@ -127,9 +270,10 @@ export class ClaudeRun implements AgentRun {
127
270
  // Starts the single background query consumer. command_lifecycle frames are the acceptance
128
271
  // receipt: they are strictly validated and ROUTED to settle the matching sendPrompt waiter
129
272
  // (never skipped, never mapped to an AgentEvent), including the first input's queued frame that
130
- // legitimately precedes system/init. PRE_INIT is otherwise strict: the next non-lifecycle
131
- // message must be a matching system/init, and the run's own session lifecycle AgentEvents
132
- // (session.started/configured/state) are emitted only after that init validation.
273
+ // legitimately precedes system/init. SDK-declared SessionStart/Setup hook lifecycle frames may
274
+ // also precede init; they are session-validated and discarded so hook output stays private.
275
+ // PRE_INIT is otherwise strict: the next message must be a matching system/init, and the run's
276
+ // own session lifecycle AgentEvents are emitted only after that init validation.
133
277
  begin(): void {
134
278
  void this.consume()
135
279
  }
@@ -137,6 +281,8 @@ export class ClaudeRun implements AgentRun {
137
281
  pushEvent(draft: AgentEventDraft): void {
138
282
  if (this.finished)
139
283
  return
284
+ if (draft.type === 'turn.started' && draft.actor.type === 'main')
285
+ this.settleNotificationTurn(draft.turnId)
140
286
  if (draft.type === 'turn.started')
141
287
  this.modelChangeReady = false
142
288
  if (draft.type === 'session.state.changed' && draft.payload.state === 'idle')
@@ -147,6 +293,22 @@ export class ClaudeRun implements AgentRun {
147
293
  return
148
294
  }
149
295
  this.events.push(decorated)
296
+ if (draft.type === 'turn.started' && draft.payload.commandId)
297
+ this.emitAcceptedMessage(draft.payload.commandId, draft.turnId)
298
+ }
299
+
300
+ // The accepted input becomes a user message of the turn that consumed it, correlated with the
301
+ // command that carried it, once the native started receipt confirmed that turn.
302
+ private emitAcceptedMessage(commandId: string, turnId: string): void {
303
+ const pending = this.pendingMessages.get(commandId)
304
+ if (!pending?.started || !this.initialized || this.finished)
305
+ return
306
+ if (pending.mode === 'steer' && pending.turnId && pending.turnId !== turnId)
307
+ return
308
+ this.pendingMessages.delete(commandId)
309
+ const actor = { type: 'main', actorId: 'main' } as const
310
+ this.events.push({ type: 'message.started', turnId, actor, payload: { messageId: pending.messageId, commandId, role: 'user' } })
311
+ this.events.push({ type: 'message.ended', turnId, actor, payload: { messageId: pending.messageId, commandId, role: 'user' } })
150
312
  }
151
313
 
152
314
  async requestUserInput(input: UserInputRequestInput): Promise<{ requestId: string }> {
@@ -156,60 +318,416 @@ export class ClaudeRun implements AgentRun {
156
318
  return { requestId: request.requestId }
157
319
  }
158
320
 
321
+ // Codex registers repository skill roots at this barrier; Claude re-links the run's workspace
322
+ // skills plugin now that the Git baseline exists and always refreshes the catalog once.
159
323
  async prepareWorkspace(options: AgentOperationOptions = {}): Promise<void> {
324
+ this.assertRunning()
325
+ options.signal?.throwIfAborted()
326
+ if (!this.workspaceSkills)
327
+ return
328
+ await this.workspaceSkills.sync(this.workspaceRepositoryRoots)
160
329
  options.signal?.throwIfAborted()
330
+ await this.reloadSkills()
161
331
  }
162
332
 
163
- // Claude discovers repository guidance from the filesystem; there is no provider-side root registry.
164
- async addWorkspaceRepositoryRoot(_root: string, options: AgentOperationOptions = {}): Promise<void> {
333
+ async addWorkspaceRepositoryRoot(root: string, options: AgentOperationOptions = {}): Promise<void> {
334
+ this.assertRunning()
335
+ options.signal?.throwIfAborted()
336
+ if (this.workspaceRepositoryRoots.includes(root))
337
+ return
338
+ const repositoryRoots = [...this.workspaceRepositoryRoots, root]
339
+ await this.selectedRepositoryInstructions.update(repositoryRoots)
340
+ if (await this.workspaceSkills?.sync(repositoryRoots))
341
+ this.skillReloadOwed = true
165
342
  options.signal?.throwIfAborted()
343
+ if (this.skillReloadOwed)
344
+ await this.reloadSkills()
345
+ // Recorded last: a retry after an abort or failure above redoes the registration.
346
+ this.workspaceRepositoryRoots = repositoryRoots
347
+ }
348
+
349
+ // Claude Code re-reads plugin skill directories only on reload; the response carries the
350
+ // refreshed command list, which is authoritative for the catalog.
351
+ private async reloadSkills(): Promise<void> {
352
+ const reloaded = await this.query.reloadPlugins()
353
+ this.skillCatalog.publish(reloaded.commands)
354
+ this.skillReloadOwed = false
166
355
  }
167
356
 
168
- async sendPrompt(command: SendPromptCommand, options: AgentOperationOptions = {}): Promise<AgentPromptAcceptance> {
357
+ async sendPrompt(command: SendPromptCommand, options: AgentPromptOperationOptions = {}): Promise<AgentPromptAcceptance> {
169
358
  this.assertCommandTargetsRun(command)
170
359
  this.assertRunning()
171
360
  options.signal?.throwIfAborted()
172
- const input = { prompt: command.prompt, mode: command.mode, commandId: command.commandId, origin: command.origin, author: command.author, senderContext: options.senderContext }
173
- const message = await mapClaudeUserMessage(input, options.inputAttachmentPreparation)
174
- options.signal?.throwIfAborted()
361
+ if (options.submissionOrder === 'bypass' && command.mode === 'next') {
362
+ throw new AgentRunStateError({
363
+ message: 'Bypass submission requires a steer prompt.',
364
+ })
365
+ }
366
+ if (command.reasoningEffort !== undefined && command.reasoningEffort !== this.init.reasoningEffort)
367
+ throw new AgentNotAcceptedError({ message: 'Claude reasoning effort changes are not supported by this adapter.', recovery: { kind: 'terminal', reason: 'reasoning_effort_change_not_supported' } })
368
+ // Claude Code steers by cancelling the running ask, which would abort a manual compaction
369
+ // (CLI 2.1.280). Like Codex's non-steerable compaction turn, the steer waits for it to end.
370
+ if (command.mode === 'steer' && this.compactionWaiter) {
371
+ throw new AgentPromptDeferredError({
372
+ message: 'Claude is compacting context; a steer would cancel the compaction.',
373
+ })
374
+ }
375
+ const settings = this.settingsWith(command.settings)
376
+ // Captured before the change: Claude applies it from the next model request, so a turn
377
+ // running now is the one it enters. With no running turn, the input starts a turn whose id
378
+ // does not exist yet. Either way the turn that handles this input runs on the new settings.
379
+ const turnIdAtSubmission = this.eventMapperState.currentTurnId
380
+ const clientMessageId = deriveMessageId(command.commandId)
381
+ // Claimed before the asynchronous mapping so a concurrent prompt cannot carry it too.
382
+ const sessionContext = this.carrySessionContext(clientMessageId)
383
+ try {
384
+ const input = { prompt: command.prompt, mode: command.mode, commandId: command.commandId, origin: command.origin, author: command.author, senderContext: options.senderContext, authorContext: options.authorContext, sessionContext }
385
+ const message = await mapClaudeUserMessage(input, options.inputAttachmentPreparation)
386
+ options.signal?.throwIfAborted()
387
+ if (command.model || this.modelChangeInFlight)
388
+ await this.applyCommandModel(command, options)
389
+ if (command.turnId && (command.mode !== 'steer' || command.turnId !== this.eventMapperState.currentTurnId))
390
+ throw new AgentNotAcceptedError({ message: 'The targeted claude turn is no longer active.', recovery: { kind: 'terminal', reason: 'target_turn_not_active' } })
391
+ this.modelChangeReady = false
392
+ await this.applyProviderSettings(command.settings ? settings : this.current)
393
+ // Register before the push: the CLI's queued receipt can outrun the local push resolution,
394
+ // and a receipt with no registered waiter would be lost.
395
+ const receipt = this.registerAcceptanceWaiter(clientMessageId)
396
+ if (command.origin === 'user')
397
+ this.eventMapperState.promptCommandByInputId.set(clientMessageId, command.commandId)
398
+ this.commandByClientMessageId.set(clientMessageId, command.commandId)
399
+ this.pendingMessages.set(command.commandId, { messageId: clientMessageId, mode: command.mode, turnId: command.turnId, started: false })
400
+ if (command.mode === 'steer')
401
+ this.steerInputIds.add(clientMessageId)
402
+ try {
403
+ await this.deliverInput(message)
404
+ // `submitted` requires the provider's positive acceptance receipt — the pinned
405
+ // command_lifecycle frame matching this command's derived uuid and this session —
406
+ // in addition to delivery (and, for the first input, validated init identity).
407
+ // Local queue push, transport write, and init alone are never acceptance. No timeout:
408
+ // provider close/error settles the wait as delivery-unknown.
409
+ await receipt.promise
410
+ }
411
+ finally {
412
+ this.acceptanceWaiters.delete(clientMessageId)
413
+ }
414
+ }
415
+ catch (error) {
416
+ if (error instanceof AgentNotAcceptedError)
417
+ this.eventMapperState.promptCommandByInputId.delete(clientMessageId)
418
+ if (sessionContext !== undefined)
419
+ this.releaseSessionContext(clientMessageId)
420
+ throw error
421
+ }
422
+ if (command.settings) {
423
+ this.current = settings
424
+ this.pushEvent(sessionSettingsChangedEvent({
425
+ model: settings.model ?? await this.initialization,
426
+ reasoningEffort: settings.effort,
427
+ commandId: command.commandId,
428
+ turnId: turnIdAtSubmission,
429
+ appliesFrom: 'current_turn',
430
+ }))
431
+ }
432
+ return { kind: 'submitted' }
433
+ }
434
+
435
+ // Applies an application-resolved model through the control channel before the input. Changes
436
+ // wait for an idle boundary with no accepted input still pending, so they never reconfigure a
437
+ // queued prompt. Reapplying the same resolved model after a crash is safe.
438
+ private async applyCommandModel(command: SendPromptCommand, options: AgentOperationOptions): Promise<void> {
175
439
  if (this.modelChangeInFlight)
176
440
  throw new AgentPromptDeferredError({ message: 'Claude model configuration is still in progress.' })
177
441
  const currentModel = this.init.requestedModel ?? this.metadata.model
178
- if (command.model && command.model !== currentModel) {
179
- if (!this.modelChangeReady || this.eventMapperState.currentTurnId || this.commandByClientMessageId.size > 0)
180
- throw new AgentPromptDeferredError({ message: 'Claude model changes require an idle turn boundary.' })
181
- // The control receipt must precede the input. Reapplying the same resolved model
182
- // after a crash is safe; never retry an uncertain prompt to retry configuration.
183
- this.modelChangeInFlight = true
184
- try {
185
- await this.query.setModel(command.model)
442
+ if (!command.model || command.model === currentModel)
443
+ return
444
+ if (!this.modelChangeReady || this.eventMapperState.currentTurnId || this.commandByClientMessageId.size > 0)
445
+ throw new AgentPromptDeferredError({ message: 'Claude model changes require an idle turn boundary.' })
446
+ this.modelChangeInFlight = true
447
+ try {
448
+ await this.query.setModel(command.model)
449
+ }
450
+ finally {
451
+ this.modelChangeInFlight = false
452
+ }
453
+ this.metadata.model = command.model
454
+ this.init.requestedModel = command.model
455
+ this.current = { ...this.current, model: command.model }
456
+ this.providerApplied = this.current
457
+ options.signal?.throwIfAborted()
458
+ }
459
+
460
+ // Model admission belongs to the product; core accepts its resolved provider model ID.
461
+ private settingsWith(change: AgentSettingsChange | undefined): ClaudeRunSettings {
462
+ if (change === undefined)
463
+ return this.current
464
+ return {
465
+ model: change.model ?? this.current.model,
466
+ effort: change.reasoningEffort ?? this.current.effort,
467
+ }
468
+ }
469
+
470
+ // The SDK user message cannot carry a model or effort, so they go as control requests, which
471
+ // take effect from the CLI's next model request, even inside a running turn. Both are awaited
472
+ // before the input is written: a steer pushed first was observed making its first request with
473
+ // the new model but the old effort (CLI 2.1.280). A failure proves the input was not written,
474
+ // but not what the CLI kept, so the next input re-applies both.
475
+ private async applyProviderSettings(settings: ClaudeRunSettings): Promise<void> {
476
+ const known = this.providerApplied
477
+ this.providerApplied = undefined
478
+ try {
479
+ if (known === undefined || known.model !== settings.model)
480
+ await this.query.setModel(settings.model)
481
+ if (known === undefined || known.effort !== settings.effort)
482
+ await this.query.applyFlagSettings({ effortLevel: settings.effort ?? null })
483
+ }
484
+ catch (error) {
485
+ throw new AgentNotAcceptedError({
486
+ message: 'Claude did not apply the model or reasoning effort; the input was not written.',
487
+ recovery: { kind: 'retry', reason: 'provider_settings_not_applied' },
488
+ cause: error,
489
+ })
490
+ }
491
+ this.providerApplied = settings
492
+ }
493
+
494
+ private carrySessionContext(clientMessageId: string): string | undefined {
495
+ if (this.sessionContext.status !== 'owed')
496
+ return undefined
497
+ this.sessionContext = { status: 'carried', text: this.sessionContext.text, clientMessageId }
498
+ return this.sessionContext.text
499
+ }
500
+
501
+ private releaseSessionContext(clientMessageId: string): void {
502
+ if (this.sessionContext.status === 'carried' && this.sessionContext.clientMessageId === clientMessageId)
503
+ this.sessionContext = { status: 'owed', text: this.sessionContext.text }
504
+ }
505
+
506
+ // `started`/`completed` mean the model received the carrying prompt. A later `cancelled` (an
507
+ // aborted turn cancels the inputs it already started) no longer finds it carried.
508
+ private observeSessionContextLifecycle(frame: CommandLifecycleFrame): void {
509
+ if (this.sessionContext.status !== 'carried' || this.sessionContext.clientMessageId !== frame.command_uuid)
510
+ return
511
+ if (frame.state === 'started' || frame.state === 'completed')
512
+ this.sessionContext = { status: 'delivered' }
513
+ else if (frame.state !== 'queued')
514
+ this.releaseSessionContext(frame.command_uuid)
515
+ }
516
+
517
+ // Mirrors CodexRun.notify's observable contract: an idle session starts a turn with the
518
+ // notification, and an active main turn is joined. Claude Code has no mid-turn input channel, so
519
+ // a joined notification is written to the session-local steered-notification store before the
520
+ // `steered` receipt and delivered inside that turn by the run's hooks (next main tool boundary,
521
+ // or a Stop-hook continuation when the turn would otherwise end), or, when it lands after that
522
+ // Stop hook passed, by a follow-up turn. See claude-steered-notifications.ts.
523
+ async notify(command: AgentNotifyCommand, options: AgentOperationOptions = {}): Promise<AgentNotificationAcceptance> {
524
+ this.assertCommandTargetsRun(command)
525
+ this.assertRunning()
526
+ options.signal?.throwIfAborted()
527
+ if (this.userInputRequests.hasUnresolvedUserInput()) {
528
+ throw new AgentPromptDeferredError({
529
+ message: 'Claude is waiting for unresolved user input; a notification cannot be submitted.',
530
+ })
531
+ }
532
+ const activeTurnId = this.eventMapperState.currentTurnId
533
+ if (activeTurnId) {
534
+ if (interruptTargetsTurn(this.eventMapperState.interrupt, activeTurnId)) {
535
+ throw new AgentPromptDeferredError({
536
+ message: 'Claude is interrupting the active turn; the notification waits for the next idle boundary.',
537
+ })
186
538
  }
187
- finally {
188
- this.modelChangeInFlight = false
539
+ const queued = this.steeredNotifications.enqueue({ commandId: command.commandId, turnId: activeTurnId, text: claudeNotificationText(command) })
540
+ return { kind: 'turn', turnId: queued.kind === 'duplicate' ? queued.turnId : activeTurnId, placement: 'steered' }
541
+ }
542
+ if (this.notificationWaiter)
543
+ throw new AgentRunStateError({ message: 'A Claude notification is already awaiting its turn.' })
544
+ const message = toClaudeNotificationMessage(command)
545
+ const waiter = this.registerNotificationWaiter(deriveMessageId(command.commandId))
546
+ try {
547
+ await this.applyProviderSettings(this.current)
548
+ await this.deliverInput(message)
549
+ return await waiter.promise
550
+ }
551
+ finally {
552
+ if (this.notificationWaiter === waiter)
553
+ this.notificationWaiter = undefined
554
+ }
555
+ }
556
+
557
+ private registerNotificationWaiter(clientMessageId: string): NotificationWaiter {
558
+ if (this.finished)
559
+ throw new AgentRunStateError({ message: 'Claude run has stopped.' })
560
+ const { promise, resolve, reject } = Promise.withResolvers<AgentNotificationAcceptance>()
561
+ promise.catch(() => undefined)
562
+ const waiter: NotificationWaiter = {
563
+ clientMessageId,
564
+ activeTurnIdAtSubmission: this.eventMapperState.currentTurnId,
565
+ consumed: false,
566
+ promise,
567
+ resolve,
568
+ reject,
569
+ }
570
+ this.notificationWaiter = waiter
571
+ return waiter
572
+ }
573
+
574
+ // Runs on the notification's command_lifecycle frame. `started`/`completed` mean a turn consumed
575
+ // it: the active turn when it was folded in, else the turn about to be reported by the mapper.
576
+ private settleNotificationLifecycle(waiter: NotificationWaiter, state: CommandLifecycleFrame['state']): void {
577
+ if (state === 'queued')
578
+ return
579
+ if (state === 'refused') {
580
+ this.notificationWaiter = undefined
581
+ waiter.reject(new AgentNotAcceptedError({
582
+ message: 'Claude declined the notification before queueing it; it will not run in this session.',
583
+ recovery: { kind: 'terminal', reason: 'provider_refused' },
584
+ }))
585
+ return
586
+ }
587
+ if (state === 'cancelled' || state === 'discarded') {
588
+ if (!waiter.consumed) {
589
+ this.notificationWaiter = undefined
590
+ waiter.reject(new AgentDeliveryUnknownError({
591
+ message: `Claude ${state} the notification before a turn was observed consuming it.`,
592
+ }))
189
593
  }
190
- this.metadata.model = command.model
191
- this.init.requestedModel = command.model
192
- options.signal?.throwIfAborted()
594
+ return
595
+ }
596
+ if (waiter.consumed)
597
+ return
598
+ waiter.consumed = true
599
+ const activeTurnId = this.eventMapperState.currentTurnId
600
+ if (activeTurnId)
601
+ this.settleNotificationTurn(activeTurnId)
602
+ }
603
+
604
+ // A notify joined to a turn whose Stop hook already passed has no hook left in that turn. As in
605
+ // Codex, where such a steer starts a new turn, a completed main turn that leaves envelopes
606
+ // pending starts one follow-up turn delivering them. Interrupted and failed turns, and a session
607
+ // waiting for user input, keep them for the next input. At most one follow-up is in flight: its
608
+ // own UserPromptSubmit hook takes envelopes that arrive before it starts, so this cannot loop.
609
+ // Envelopes recovered from disk at open do not start a turn by themselves: the supervisor resumes
610
+ // a run only to dispatch an input, whose UserPromptSubmit hook carries them (a `/compact` input
611
+ // skips that hook, but its completed turn reaches this follow-up), and a prewarmed or resumed run
612
+ // must not reach the model on its own before its first input.
613
+ private async startSteeredNotificationFollowUp(): Promise<void> {
614
+ if (this.finished || this.steeredFollowUp || this.userInputRequests.hasUnresolvedUserInput())
615
+ return
616
+ const delivery = this.steeredNotifications.takePending()
617
+ if (!delivery)
618
+ return
619
+ const followUp: SteeredNotificationFollowUp = { clientMessageId: randomUUID(), delivery }
620
+ this.steeredFollowUp = followUp
621
+ try {
622
+ await this.pushOwnedStream(toClaudeTaskNotificationMessage({ text: delivery.text, uuid: followUp.clientMessageId }))
623
+ }
624
+ catch (error) {
625
+ // A rejected push provably wrote nothing.
626
+ this.steeredFollowUp = undefined
627
+ this.steeredNotifications.restore(delivery)
628
+ throw error
629
+ }
630
+ }
631
+
632
+ // `started`/`completed` mean the model received the envelopes. A non-positive state before that
633
+ // means Claude will never run the input, so the envelopes go back to the store for the next
634
+ // input instead of being lost. A run that ends before either keeps them delivered: like a hook
635
+ // hand-off, the input most likely already started.
636
+ private settleSteeredFollowUp(followUp: SteeredNotificationFollowUp, state: CommandLifecycleFrame['state']): void {
637
+ if (state === 'queued')
638
+ return
639
+ this.steeredFollowUp = undefined
640
+ if (state === 'cancelled' || state === 'discarded' || state === 'refused')
641
+ this.steeredNotifications.restore(followUp.delivery)
642
+ }
643
+
644
+ private settleNotificationTurn(turnId: string): void {
645
+ const waiter = this.notificationWaiter
646
+ if (!waiter?.consumed)
647
+ return
648
+ this.notificationWaiter = undefined
649
+ waiter.resolve({
650
+ kind: 'turn',
651
+ turnId,
652
+ placement: turnId === waiter.activeTurnIdAtSubmission ? 'steered' : 'started',
653
+ })
654
+ }
655
+
656
+ async compactContext(command: CompactContextAgentCommand, options: AgentOperationOptions = {}): Promise<AgentContextCompactionRequestResult> {
657
+ this.assertCommandTargetsRun(command)
658
+ this.assertRunning()
659
+ options.signal?.throwIfAborted()
660
+ if (this.eventMapperState.currentTurnId) {
661
+ throw new AgentPromptDeferredError({
662
+ message: 'Claude context compaction must wait for an idle main thread.',
663
+ })
193
664
  }
194
- this.modelChangeReady = false
195
- // Register before the push: the CLI's queued receipt can outrun the local push resolution,
196
- // and a receipt with no registered waiter would be lost.
665
+ if (this.compactionWaiter)
666
+ throw new AgentRunStateError({ message: 'A Claude context compaction request is already pending.' })
667
+
668
+ // The deadline bounds the whole request: it cancels a submission still awaiting delivery or
669
+ // acceptance, and reports `unknown` for an accepted compaction whose outcome never arrived.
670
+ const deadline = new AbortController()
671
+ const operationSignal = options.signal
672
+ ? AbortSignal.any([options.signal, deadline.signal])
673
+ : deadline.signal
197
674
  const clientMessageId = deriveMessageId(command.commandId)
198
675
  const receipt = this.registerAcceptanceWaiter(clientMessageId)
199
- this.commandByClientMessageId.set(clientMessageId, command.commandId)
676
+ const waiter = this.registerCompactionWaiter({ commandId: command.commandId, clientMessageId, deadline })
200
677
  try {
201
- await this.deliverInput(message)
202
- // `submitted` requires the provider's positive acceptance receipt — the pinned
203
- // command_lifecycle frame matching this command's derived uuid and this session —
204
- // in addition to delivery (and, for the first input, validated init identity).
205
- // Local queue push, transport write, and init alone are never acceptance. No timeout:
206
- // provider close/error settles the wait as delivery-unknown.
207
- await receipt.promise
678
+ await this.applyProviderSettings(this.current)
679
+ await this.awaitPossiblyDeliveredCompaction(
680
+ this.deliverInput(toClaudeCompactContextMessage(command.commandId)),
681
+ operationSignal,
682
+ )
683
+ await this.awaitPossiblyDeliveredCompaction(receipt.promise, operationSignal)
684
+ }
685
+ catch (error) {
686
+ if (!(error instanceof AgentDeliveryUnknownError)) {
687
+ this.releaseCompactionWaiter(waiter)
688
+ throw error
689
+ }
690
+ // Not accepted: the input may never have reached Claude, so holding the guard could defer
691
+ // steers indefinitely.
692
+ return this.endCompaction(waiter, { status: 'unknown' })
208
693
  }
209
694
  finally {
210
695
  this.acceptanceWaiters.delete(clientMessageId)
211
696
  }
212
- return { kind: 'submitted' }
697
+ // Accepted: the compaction runs as a Claude turn whose events stream while it runs. Waiting
698
+ // for its outcome here would hold the caller, and every event queued behind it, for the
699
+ // whole compaction. An outcome Claude already reported is definitive; otherwise `unknown`
700
+ // tells the caller a turn is starting, and the outcome is reported when it arrives.
701
+ waiter.accepted = true
702
+ if (waiter.outcome)
703
+ return this.endCompaction(waiter, waiter.outcome)
704
+ return { status: 'unknown' }
705
+ }
706
+
707
+ private async awaitPossiblyDeliveredCompaction<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
708
+ if (signal.aborted) {
709
+ throw new AgentDeliveryUnknownError({
710
+ message: 'Claude context compaction was cancelled after delivery may have begun.',
711
+ cause: signal.reason,
712
+ })
713
+ }
714
+ return await new Promise<T>((resolve, reject) => {
715
+ const aborted = (): void => {
716
+ reject(new AgentDeliveryUnknownError({
717
+ message: 'Claude context compaction was cancelled after delivery may have begun.',
718
+ cause: signal.reason,
719
+ }))
720
+ }
721
+ const settle = (continuation: () => void): void => {
722
+ signal.removeEventListener('abort', aborted)
723
+ continuation()
724
+ }
725
+ signal.addEventListener('abort', aborted, { once: true })
726
+ promise.then(
727
+ value => settle(() => resolve(value)),
728
+ error => settle(() => reject(error)),
729
+ )
730
+ })
213
731
  }
214
732
 
215
733
  async respondUserInput(command: RespondUserInputAgentCommand, options: AgentOperationOptions = {}): Promise<AgentUserInputSubmission> {
@@ -221,6 +739,7 @@ export class ClaudeRun implements AgentRun {
221
739
  return 'already_delivered'
222
740
  // Deliver first: on a prebound resume this is the run's first input, and the resolved
223
741
  // event must not precede the lifecycle events the init barrier emits.
742
+ await this.applyProviderSettings(this.current)
224
743
  await this.deliverInput(toClaudeUserMessage({ prompt: resolved.prompt, mode: 'next', commandId: command.commandId }))
225
744
  if (resolved.event)
226
745
  this.pushEvent(resolved.event)
@@ -233,6 +752,9 @@ export class ClaudeRun implements AgentRun {
233
752
  this.assertRunning()
234
753
  options.signal?.throwIfAborted()
235
754
  const turnId = this.eventMapperState.currentTurnId
755
+ // A targeted interrupt never affects a replacement turn.
756
+ if (command.turnId && command.turnId !== turnId)
757
+ return
236
758
  if (!turnId)
237
759
  return
238
760
  if (this.eventMapperState.interrupt.status === 'accepted' && this.eventMapperState.interrupt.turnId === turnId)
@@ -269,35 +791,141 @@ export class ClaudeRun implements AgentRun {
269
791
  async stop(command: StopAgentCommand, options: AgentOperationOptions = {}): Promise<void> {
270
792
  this.assertCommandTargetsRun(command)
271
793
  options.signal?.throwIfAborted()
272
- if (this.finished)
794
+ // A run that already ended still resolves only after its CLI has exited, so a following
795
+ // resume never overlaps it on the same transcript.
796
+ if (this.finished) {
797
+ await this.ended
273
798
  return
799
+ }
274
800
  this.events.push({ type: 'session.state.changed', payload: { state: 'stopping' } })
275
801
  if (!this.initialized)
276
802
  this.initializationReject(new AgentRunStateError({ message: 'Claude run stopped before initialization.' }))
277
- this.query.close()
278
803
  this.finish('stopped')
804
+ await this.ended
805
+ }
806
+
807
+ // Mirrors CodexRun: a central credential failure is terminal for this run. The typed turn
808
+ // reason lets the supervisor recover owed work instead of observing a generic 401 result.
809
+ failActiveTurnAndEndRun(input: ClaudeAuthFailure): void {
810
+ // A run without input (an idle prewarm) made no model request, so the failure is not its
811
+ // own; ending it would leave a finished run for the supervisor to adopt. Its first request
812
+ // acquires a fresh credential and reports that outcome instead.
813
+ if (this.finished || !this.firstInputPushed)
814
+ return
815
+ this.pushEvent({ type: 'error', payload: { message: 'Claude authentication failed.', fatal: false, source: 'provider', error: input.error } })
816
+ const turnId = this.eventMapperState.currentTurnId
817
+ this.eventMapperState.heldTurnEnd = undefined
818
+ if (turnId) {
819
+ this.pushEvent({
820
+ type: 'turn.ended',
821
+ turnId,
822
+ actor: { type: 'main', actorId: 'main' },
823
+ payload: { status: 'failed', reason: input.reason, error: input.error },
824
+ })
825
+ this.eventMapperState.currentTurnId = undefined
826
+ }
827
+ if (!this.initialized)
828
+ this.initializationReject(new AgentRunStateError({ message: 'Claude authentication failed before initialization.' }))
829
+ this.closeQuery()
830
+ this.finish('failed', input.error)
279
831
  }
280
832
 
281
833
  finish(reason: 'completed' | 'stopped' | 'interrupted' | 'failed', error?: AgentError): void {
282
834
  if (this.finished)
283
835
  return
836
+ const compaction = this.compactionWaiter
837
+ if (compaction)
838
+ this.endCompaction(compaction, { status: 'unknown' })
839
+ // A superseded turn's steer can no longer continue it once the run ends.
840
+ for (const draft of flushHeldTurnEnd(this.eventMapperState)) this.pushEvent(draft)
284
841
  this.finished = true
285
842
  // The run ends before pending inputs saw their acceptance receipt: each may already be in
286
843
  // the CLI stream, so their outcome is delivery-unknown — never a hang, retry, or terminal.
287
844
  for (const waiter of this.acceptanceWaiters.values()) {
288
845
  waiter.reject(new AgentDeliveryUnknownError({
289
846
  message: `Claude run ended (${reason}) before the input's acceptance receipt was observed.`,
847
+ cause: error,
290
848
  }))
291
849
  }
292
850
  this.acceptanceWaiters.clear()
851
+ this.eventMapperState.promptCommandByInputId.clear()
293
852
  this.commandByClientMessageId.clear()
853
+ this.pendingMessages.clear()
854
+ const notification = this.notificationWaiter
855
+ this.notificationWaiter = undefined
856
+ notification?.reject(new AgentDeliveryUnknownError({
857
+ message: `Claude run ended (${reason}) before a turn was observed consuming the notification.`,
858
+ cause: error,
859
+ }))
860
+ this.ended = this.endAfterProcessExit(reason, error)
861
+ }
862
+
863
+ // session.ended means the CLI is gone: the terminal events wait for its exit (bounded by
864
+ // CLAUDE_PROCESS_EXIT_TIMEOUT_MS, then a process-group kill). close() is idempotent and sends
865
+ // the stdin EOF the CLI exits on.
866
+ private async endAfterProcessExit(reason: 'completed' | 'stopped' | 'interrupted' | 'failed', error: AgentError | undefined): Promise<void> {
867
+ this.closeQuery()
868
+ await this.process.terminate()
294
869
  this.events.push({ type: 'session.ended', payload: error ? { reason, error } : { reason } })
295
870
  this.events.push({ type: 'session.state.changed', payload: { state: reason === 'failed' ? 'failed' : 'stopped' } })
296
871
  this.events.end()
872
+ void this.onFinished().catch(() => undefined)
873
+ }
874
+
875
+ // Reports the request's one result; later calls return the reported one. The caller decides
876
+ // whether the compaction has ended and releases the waiter.
877
+ private reportCompactionResult(waiter: CompactionWaiter, result: AgentContextCompactionRequestResult): AgentContextCompactionRequestResult {
878
+ if (waiter.reported)
879
+ return waiter.reported
880
+ waiter.reported = result
881
+ clearTimeout(waiter.timeout)
882
+ this.pushEvent({
883
+ type: 'context.compaction.request.completed',
884
+ payload: { commandId: waiter.commandId, result },
885
+ })
886
+ return result
887
+ }
888
+
889
+ private endCompaction(waiter: CompactionWaiter, result: AgentContextCompactionRequestResult): AgentContextCompactionRequestResult {
890
+ const reported = this.reportCompactionResult(waiter, result)
891
+ this.releaseCompactionWaiter(waiter)
892
+ return reported
893
+ }
894
+
895
+ private releaseCompactionWaiter(waiter: CompactionWaiter): void {
896
+ clearTimeout(waiter.timeout)
897
+ if (this.compactionWaiter === waiter)
898
+ this.compactionWaiter = undefined
899
+ }
900
+
901
+ // First outcome wins. Before acceptance the submitting call reports it (see compactContext).
902
+ private settleCompaction(waiter: CompactionWaiter, outcome: AgentContextCompactionRequestResult): void {
903
+ waiter.outcome ??= outcome
904
+ if (waiter.accepted)
905
+ this.endCompaction(waiter, waiter.outcome)
906
+ }
907
+
908
+ private registerCompactionWaiter({ commandId, clientMessageId, deadline }: { commandId: string, clientMessageId: string, deadline: AbortController }): CompactionWaiter {
909
+ if (this.compactionWaiter)
910
+ throw new AgentRunStateError({ message: 'A Claude context compaction request is already pending.' })
911
+ const waiter: CompactionWaiter = {
912
+ commandId,
913
+ clientMessageId,
914
+ timeout: setTimeout(() => {
915
+ // Before acceptance the abort makes the submitting call report and release. After it,
916
+ // Claude may still be compacting: the result becomes `unknown`, the guard stays.
917
+ deadline.abort(new Error(`Claude context compaction exceeded ${this.compactionTimeoutMs}ms.`))
918
+ if (waiter.accepted)
919
+ this.reportCompactionResult(waiter, { status: 'unknown' })
920
+ }, this.compactionTimeoutMs),
921
+ accepted: false,
922
+ boundaryObserved: false,
923
+ }
924
+ waiter.timeout.unref?.()
925
+ this.compactionWaiter = waiter
926
+ return waiter
297
927
  }
298
928
 
299
- // One pending acceptance per derived command uuid; the promise is pre-observed so a rejection
300
- // while the caller is still awaiting delivery/init can never become an unhandled rejection.
301
929
  private registerAcceptanceWaiter(clientMessageId: string): AcceptanceWaiter {
302
930
  if (this.finished)
303
931
  throw new AgentRunStateError({ message: 'Claude run has stopped.' })
@@ -317,12 +945,16 @@ export class ClaudeRun implements AgentRun {
317
945
 
318
946
  // Strict pinned-runtime boundary for the undocumented receipt frame: shape/state deviation and
319
947
  // session identity mismatch are provider protocol drift and fail the run closed. `cancelled`
320
- // (a steered-over/aborted command) and `discarded` (the session ended with the command still
321
- // queued) are valid observed states but NEGATIVE terminals — never a positive acceptance, so
322
- // they reject any pending waiter as delivery-unknown so command processing can advance.
323
- // Valid positive frames without a pending
324
- // waiter are internal lifecycle for already-settled or non-prompt inputs and are ignored.
325
- private settleAcceptance(frame: ClaudeCommandLifecycleMessage): void {
948
+ // and `discarded` are valid non-positive states that are validated and no-op even when a
949
+ // waiter is pending: the pending prompt's real outcome is settled by run close/error as
950
+ // delivery-unknown. `refused` is the pinned runtime's definite pre-queue rejection: the input
951
+ // will never run in this session, and the session stays open, so the pending waiter is
952
+ // rejected as a terminal not-accepted outcome instead of waiting for a close that may not
953
+ // come. Valid positive frames without a pending waiter are internal lifecycle for
954
+ // already-settled or non-prompt inputs and are ignored. The synthetic `/compact` input is the
955
+ // exception: every negative state for its uuid settles the pending compaction as `failed`, because
956
+ // the request has a definite result to report while the session keeps running.
957
+ private settleAcceptance(frame: ClaudeCommandLifecycleMessage): AgentEventDraft[] {
326
958
  const parsed = commandLifecycleFrameSchema.safeParse(frame)
327
959
  if (!parsed.success) {
328
960
  throw new AgentProviderProtocolError({
@@ -336,22 +968,130 @@ export class ClaudeRun implements AgentRun {
336
968
  details: { expected: this.metadata.providerSessionId, actual: parsed.data.session_id },
337
969
  })
338
970
  }
339
- if (parsed.data.state === 'cancelled' || parsed.data.state === 'discarded') {
340
- this.acceptanceWaiters.get(parsed.data.command_uuid)?.reject(new AgentDeliveryUnknownError({
341
- message: `Claude command ended as ${parsed.data.state} before acceptance was observed.`,
971
+ this.observeSessionContextLifecycle(parsed.data)
972
+ const steerDrafts = this.observeSteerLifecycle(parsed.data)
973
+ this.settleInputLifecycle(parsed.data)
974
+ return steerDrafts
975
+ }
976
+
977
+ // A steer's receipts before system/init belong to the session's first input, which supersedes
978
+ // nothing; afterwards they drive the mapper's supersession of the running ask.
979
+ private observeSteerLifecycle(frame: CommandLifecycleFrame): AgentEventDraft[] {
980
+ if (!this.steerInputIds.has(frame.command_uuid))
981
+ return []
982
+ if (frame.state !== 'queued' && frame.state !== 'started')
983
+ this.steerInputIds.delete(frame.command_uuid)
984
+ if (!this.initialized)
985
+ return []
986
+ return mapSteerLifecycle({ steerId: frame.command_uuid, state: frame.state }, this.eventMapperState)
987
+ }
988
+
989
+ private settleInputLifecycle(frame: CommandLifecycleFrame): void {
990
+ const followUp = this.steeredFollowUp
991
+ if (followUp?.clientMessageId === frame.command_uuid) {
992
+ this.settleSteeredFollowUp(followUp, frame.state)
993
+ return
994
+ }
995
+ const notification = this.notificationWaiter
996
+ if (notification?.clientMessageId === frame.command_uuid) {
997
+ this.settleNotificationLifecycle(notification, frame.state)
998
+ return
999
+ }
1000
+ const waiter = this.acceptanceWaiters.get(frame.command_uuid)
1001
+ const commandId = this.commandByClientMessageId.get(frame.command_uuid)
1002
+ if (frame.state === 'cancelled' || frame.state === 'discarded' || frame.state === 'refused') {
1003
+ this.commandByClientMessageId.delete(frame.command_uuid)
1004
+ if (commandId)
1005
+ this.pendingMessages.delete(commandId)
1006
+ const compaction = this.compactionWaiter
1007
+ if (compaction?.clientMessageId === frame.command_uuid) {
1008
+ this.settleCompaction(compaction, {
1009
+ status: 'failed',
1010
+ error: { message: 'Claude refused context compaction.' },
1011
+ })
1012
+ waiter?.resolve()
1013
+ return
1014
+ }
1015
+ if (frame.state === 'refused') {
1016
+ this.eventMapperState.promptCommandByInputId.delete(frame.command_uuid)
1017
+ waiter?.reject(new AgentNotAcceptedError({
1018
+ message: 'Claude declined the input before queueing it; it will not run in this session.',
1019
+ recovery: { kind: 'terminal', reason: 'provider_refused' },
1020
+ }))
1021
+ return
1022
+ }
1023
+ // A cancelled (steered-over or aborted) or discarded input will not be accepted; the
1024
+ // waiter settles now so command processing can advance while the session stays open.
1025
+ waiter?.reject(new AgentDeliveryUnknownError({
1026
+ message: `Claude command ended as ${frame.state} before acceptance was observed.`,
342
1027
  }))
343
- this.commandByClientMessageId.delete(parsed.data.command_uuid)
344
1028
  return
345
1029
  }
346
- // A queued receipt acknowledges admission, not turn ownership. Keep the
347
- // mapping after acceptance settles: the provider's started receipt is
348
- // the authority for the next root turn, even with several queued inputs.
349
- if (parsed.data.state === 'started') {
350
- this.eventMapperState.currentCommandId = this.commandByClientMessageId.get(parsed.data.command_uuid)
1030
+ // A queued receipt acknowledges admission, not turn ownership: the started receipt binds the
1031
+ // command to the turn that consumes it, even with several queued inputs.
1032
+ if (frame.state === 'started' && commandId) {
1033
+ this.eventMapperState.currentCommandId = commandId
1034
+ const pending = this.pendingMessages.get(commandId)
1035
+ if (pending) {
1036
+ pending.started = true
1037
+ const turnId = this.eventMapperState.currentTurnId
1038
+ if (pending.mode === 'steer' && turnId && (!pending.turnId || pending.turnId === turnId))
1039
+ this.emitAcceptedMessage(commandId, turnId)
1040
+ }
1041
+ }
1042
+ if (frame.state === 'completed') {
1043
+ this.commandByClientMessageId.delete(frame.command_uuid)
1044
+ if (commandId)
1045
+ this.pendingMessages.delete(commandId)
351
1046
  }
352
- if (parsed.data.state === 'completed')
353
- this.commandByClientMessageId.delete(parsed.data.command_uuid)
354
- this.acceptanceWaiters.get(parsed.data.command_uuid)?.resolve()
1047
+ waiter?.resolve()
1048
+ }
1049
+
1050
+ // An accepted compaction runs as the next main turn, since it is submitted only while idle. If that
1051
+ // turn ends without a correlated outcome, the compaction is over: report `unknown` if nothing was
1052
+ // reported yet and stop deferring steers.
1053
+ private endCompactionWithItsTurn(drafts: AgentEventDraft[]): void {
1054
+ const waiter = this.compactionWaiter
1055
+ if (waiter?.accepted && drafts.some(draft => draft.type === 'turn.ended' && draft.actor.type === 'main'))
1056
+ this.endCompaction(waiter, { status: 'unknown' })
1057
+ }
1058
+
1059
+ private observeCompactionResult(message: SDKMessage): void {
1060
+ const waiter = this.compactionWaiter
1061
+ if (!waiter)
1062
+ return
1063
+ if (message.type === 'system' && message.subtype === 'compact_boundary') {
1064
+ if (message.compact_metadata.trigger === 'manual')
1065
+ waiter.boundaryObserved = true
1066
+ return
1067
+ }
1068
+ if (message.type === 'system' && message.subtype === 'status' && message.compact_result === 'failed') {
1069
+ this.settleCompaction(waiter, {
1070
+ status: 'failed',
1071
+ error: compactionError(message),
1072
+ })
1073
+ return
1074
+ }
1075
+ if (message.type !== 'result')
1076
+ return
1077
+ const commandIds = resultUserMessageIds(message)
1078
+ if (!commandIds.includes(waiter.clientMessageId))
1079
+ return
1080
+ if (message.subtype !== 'success' || message.is_error) {
1081
+ this.settleCompaction(waiter, {
1082
+ status: 'failed',
1083
+ error: resultError(message),
1084
+ })
1085
+ return
1086
+ }
1087
+ if (waiter.boundaryObserved) {
1088
+ this.settleCompaction(waiter, { status: 'completed' })
1089
+ return
1090
+ }
1091
+ this.settleCompaction(waiter, {
1092
+ status: 'no_op',
1093
+ reason: message.result,
1094
+ })
355
1095
  }
356
1096
 
357
1097
  // Exactly one message may enter the stream before init: the first input wakes the CLI and then
@@ -426,20 +1166,31 @@ export class ClaudeRun implements AgentRun {
426
1166
  // Acceptance receipts are routed before PRE_INIT and normal mapping: the first
427
1167
  // input's queued frame legitimately precedes system/init.
428
1168
  if (message.type === 'command_lifecycle') {
429
- this.settleAcceptance(message)
1169
+ const interrupt = this.eventMapperState.interrupt
1170
+ await this.emitDrafts(this.settleAcceptance(message), interrupt)
1171
+ continue
1172
+ }
1173
+ // A skill reload can precede the first input, so the command list is routed to the
1174
+ // catalog before the PRE_INIT barrier; it is catalog input, not a session event.
1175
+ if (message.type === 'system' && message.subtype === 'commands_changed') {
1176
+ this.skillCatalog.publish(message.commands)
430
1177
  continue
431
1178
  }
432
1179
  if (!this.initialized) {
1180
+ if (isClaudePreInitHookMessage(message)) {
1181
+ this.admitPreInitializationHook(message)
1182
+ continue
1183
+ }
1184
+ if (message.type === 'system' && message.subtype === 'session_state_changed') {
1185
+ this.admitPreInitializationSessionState(message)
1186
+ continue
1187
+ }
433
1188
  this.completeInitialization(message)
1189
+ for (const held of this.preInitSessionStates.splice(0))
1190
+ await this.mapMessage(held)
434
1191
  continue
435
1192
  }
436
- const completeMessage = message.type === 'user'
437
- ? await hydrateClaudeToolOutput(message, {
438
- transcriptPath: this.metadata.transcriptPath,
439
- toolNameById: this.eventMapperState.toolNameById,
440
- })
441
- : message
442
- for (const draft of mapClaudeMessage(completeMessage, this.eventMapperState)) this.pushEvent(draft)
1193
+ await this.mapMessage(message)
443
1194
  }
444
1195
  // An intentional stop already finalized the run; the ended stream is expected then.
445
1196
  if (this.finished)
@@ -447,6 +1198,13 @@ export class ClaudeRun implements AgentRun {
447
1198
  if (!this.initialized) {
448
1199
  throw new AgentProviderProtocolError({ message: 'Claude SDK ended before emitting system init.' })
449
1200
  }
1201
+ // A superseded ask's held end is final once the stream can deliver no steer to continue it.
1202
+ await this.emitDrafts(flushHeldTurnEnd(this.eventMapperState), this.eventMapperState.interrupt)
1203
+ // The SDK reports non-zero exits and signals as errors, but a clean exit mid-turn ends the stream
1204
+ // without a result: that turn did not complete.
1205
+ if (this.eventMapperState.currentTurnId) {
1206
+ throw new AgentProviderProtocolError({ message: 'Claude SDK ended before the active turn reported its result.' })
1207
+ }
450
1208
  this.finish('completed')
451
1209
  }
452
1210
  catch (error) {
@@ -457,9 +1215,58 @@ export class ClaudeRun implements AgentRun {
457
1215
  }
458
1216
  }
459
1217
 
1218
+ private async mapMessage(message: SDKMessage): Promise<void> {
1219
+ const completeMessage = this.hydratePersistedToolOutput && message.type === 'user'
1220
+ ? await hydrateClaudeToolOutput(message, {
1221
+ transcriptPath: this.metadata.transcriptPath,
1222
+ toolNameById: this.eventMapperState.toolNameById,
1223
+ })
1224
+ : message
1225
+ // The mapper resets the interrupt when it ends the turn; the follow-up needs it.
1226
+ const interrupt = this.eventMapperState.interrupt
1227
+ const drafts = mapClaudeMessage(completeMessage, this.eventMapperState)
1228
+ for (const draft of drafts) this.pushEvent(draft)
1229
+ // A compaction's request result follows the lifecycle events of the message that settles it.
1230
+ this.observeCompactionResult(message)
1231
+ this.endCompactionWithItsTurn(drafts)
1232
+ await this.followUpCompletedTurn(drafts, interrupt)
1233
+ }
1234
+
1235
+ private async emitDrafts(drafts: AgentEventDraft[], interrupt: ClaudeEventMapperState['interrupt']): Promise<void> {
1236
+ for (const draft of drafts) this.pushEvent(draft)
1237
+ await this.followUpCompletedTurn(drafts, interrupt)
1238
+ }
1239
+
1240
+ private async followUpCompletedTurn(drafts: AgentEventDraft[], interrupt: ClaudeEventMapperState['interrupt']): Promise<void> {
1241
+ if (drafts.some(draft => draft.type === 'turn.ended' && draft.actor.type === 'main' && draft.payload.status === 'completed' && !interruptTargetsTurn(interrupt, draft.turnId)))
1242
+ await this.startSteeredNotificationFollowUp()
1243
+ }
1244
+
1245
+ private admitPreInitializationSessionState(message: Extract<SDKMessage, { type: 'system', subtype: 'session_state_changed' }>): void {
1246
+ if (message.session_id !== this.init.expectedSessionId) {
1247
+ throw new AgentProviderProtocolError({
1248
+ message: 'Claude SDK pre-init session state session_id does not match the requested session.',
1249
+ details: { expected: this.init.expectedSessionId, actual: message.session_id },
1250
+ })
1251
+ }
1252
+ this.preInitSessionStates.push(message)
1253
+ }
1254
+
1255
+ private admitPreInitializationHook(message: ClaudePreInitHookMessage): void {
1256
+ if (message.session_id !== this.init.expectedSessionId) {
1257
+ throw new AgentProviderProtocolError({
1258
+ message: 'Claude SDK pre-init hook session_id does not match the requested session.',
1259
+ details: { expected: this.init.expectedSessionId, actual: message.session_id },
1260
+ })
1261
+ }
1262
+ }
1263
+
460
1264
  private completeInitialization(message: SDKMessage): void {
461
1265
  if (message.type !== 'system' || message.subtype !== 'init') {
462
- throw new AgentProviderProtocolError({ message: 'Claude SDK emitted an unexpected message before system init.' })
1266
+ throw new AgentProviderProtocolError({
1267
+ message: 'Claude SDK emitted an unexpected message before system init.',
1268
+ details: { type: message.type, subtype: 'subtype' in message ? message.subtype : undefined },
1269
+ })
463
1270
  }
464
1271
  const init: ClaudeInitMessage = message
465
1272
  if (!init.session_id) {
@@ -471,7 +1278,7 @@ export class ClaudeRun implements AgentRun {
471
1278
  details: { expected: this.init.expectedSessionId, actual: init.session_id },
472
1279
  })
473
1280
  }
474
- const effectiveModel = init.model ?? this.init.requestedModel
1281
+ const effectiveModel = init.model ?? this.current.model
475
1282
  if (!effectiveModel) {
476
1283
  throw new AgentProviderMetadataError({ message: 'Claude SDK init did not include model.', details: { provider: 'claude' } })
477
1284
  }
@@ -481,25 +1288,26 @@ export class ClaudeRun implements AgentRun {
481
1288
  this.pushEvent(sessionConfiguredEvent({
482
1289
  model: effectiveModel,
483
1290
  cwd: init.cwd ?? this.metadata.cwd,
484
- reasoningEffort: this.init.reasoningEffort,
1291
+ reasoningEffort: this.current.effort,
485
1292
  toolNames: init.tools,
486
1293
  environment: this.init.environment,
487
1294
  }))
488
1295
  this.pushEvent({ type: 'session.state.changed', payload: { state: 'ready' } })
489
1296
  for (const draft of mapClaudeMessage(init, this.eventMapperState)) this.pushEvent(draft)
490
1297
  this.initialized = true
1298
+ this.skillCatalog.markReady()
491
1299
  this.recordStartup?.('session_ready')
492
- this.initializationResolve()
1300
+ this.initializationResolve(effectiveModel)
493
1301
  }
494
1302
 
495
1303
  private failRun(error: unknown): void {
496
1304
  if (this.finished)
497
1305
  return
498
- const agentError = serializeAgentError(error)
1306
+ const agentError = withProcessStderr(serializeAgentError(error), this.process.stderrTail())
499
1307
  if (!this.initialized) {
500
1308
  // Pre-init failure: the first-input promise must reject typed instead of hanging.
501
1309
  this.initializationReject(error)
502
- this.query.close()
1310
+ this.closeQuery()
503
1311
  this.finish('failed', agentError)
504
1312
  return
505
1313
  }
@@ -507,6 +1315,14 @@ export class ClaudeRun implements AgentRun {
507
1315
  this.finish('failed', agentError)
508
1316
  }
509
1317
 
1318
+ // Query.close() ends the CLI's stdin; every ending path and the final exit wait share one call.
1319
+ private closeQuery(): void {
1320
+ if (this.queryClosed)
1321
+ return
1322
+ this.queryClosed = true
1323
+ this.query.close()
1324
+ }
1325
+
510
1326
  private assertRunning(): void {
511
1327
  if (this.finished)
512
1328
  throw new AgentRunStateError({ message: 'Claude run has stopped.' })
@@ -518,3 +1334,18 @@ export class ClaudeRun implements AgentRun {
518
1334
  }
519
1335
  }
520
1336
  }
1337
+
1338
+ // The SDK appends the CLI's stderr tail to its process-exit errors only for its own spawn; the run
1339
+ // spawns the CLI itself (claude-code-process.ts), so it restores that diagnostic here.
1340
+ function withProcessStderr(error: AgentError, stderrTail: string): AgentError {
1341
+ if (!stderrTail || !/^Claude Code process (?:exited with code|terminated by signal) \S+$/u.test(error.message))
1342
+ return error
1343
+ return { ...error, message: `${error.message}. stderr: ${stderrTail}` }
1344
+ }
1345
+
1346
+ // SDK 0.3.280 reports every input a result consumed; older pinned runtimes report only one.
1347
+ function resultUserMessageIds(message: Extract<SDKMessage, { type: 'result' }>): readonly string[] {
1348
+ if ('user_message_uuids' in message && Array.isArray(message.user_message_uuids))
1349
+ return message.user_message_uuids.filter((id): id is string => typeof id === 'string')
1350
+ return message.user_message_uuid ? [message.user_message_uuid] : []
1351
+ }