@meistrari/agent-core 0.2.0-experimental.1 → 0.2.0-experimental.3

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 (71) hide show
  1. package/README.md +50 -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 +868 -135
  19. package/src/agents/claude/claude-file-change.ts +128 -0
  20. package/src/agents/claude/claude-provider.ts +356 -30
  21. package/src/agents/claude/claude-run.ts +898 -130
  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 +384 -67
  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 +423 -53
  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 +52 -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 +3 -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/supervisor-protocol/agent-event-wrapper.ts +30 -0
@@ -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
+ // non-positive states that reject an unsettled waiter as delivery-unknown; `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,30 +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 pendingSteerResult?: Extract<SDKMessage, { type: 'result' }>
100
- private modelChangeReady = true
101
- private modelChangeInFlight = false
102
212
  private finished = false
213
+ private queryClosed = false
103
214
  private interruptRequest?: { turnId: string, promise: Promise<void> }
104
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.
105
226
  private readonly commandByClientMessageId = new Map<string, string>()
227
+ // Prompts whose accepted user message has not been emitted into a turn yet.
106
228
  private readonly pendingMessages = new Map<string, { messageId: string, mode: 'next' | 'steer', turnId?: string, started: boolean }>()
107
229
 
108
- constructor({ metadata, query, userInputRequests, input, eventMapperState, init, recordStartup }: ClaudeRunInput) {
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
236
+
237
+ constructor({ metadata, query, userInputRequests, input, eventMapperState, init, steeredNotifications, sessionContext, workspace, process, onFinished, compactionTimeoutMs = CLAUDE_COMPACTION_TIMEOUT_MS, recordStartup, hydratePersistedToolOutput = true }: ClaudeRunInput) {
109
238
  this.metadata = metadata
110
239
  this.query = query
111
240
  this.userInputRequests = userInputRequests
112
241
  this.input = input
113
242
  this.eventMapperState = eventMapperState
114
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
115
253
  this.recordStartup = recordStartup
116
- 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) => {
117
258
  this.initializationResolve = resolve
118
259
  this.initializationReject = reject
119
260
  })
@@ -129,9 +270,10 @@ export class ClaudeRun implements AgentRun {
129
270
  // Starts the single background query consumer. command_lifecycle frames are the acceptance
130
271
  // receipt: they are strictly validated and ROUTED to settle the matching sendPrompt waiter
131
272
  // (never skipped, never mapped to an AgentEvent), including the first input's queued frame that
132
- // legitimately precedes system/init. PRE_INIT is otherwise strict: the next non-lifecycle
133
- // message must be a matching system/init, and the run's own session lifecycle AgentEvents
134
- // (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.
135
277
  begin(): void {
136
278
  void this.consume()
137
279
  }
@@ -139,6 +281,8 @@ export class ClaudeRun implements AgentRun {
139
281
  pushEvent(draft: AgentEventDraft): void {
140
282
  if (this.finished)
141
283
  return
284
+ if (draft.type === 'turn.started' && draft.actor.type === 'main')
285
+ this.settleNotificationTurn(draft.turnId)
142
286
  if (draft.type === 'turn.started')
143
287
  this.modelChangeReady = false
144
288
  if (draft.type === 'session.state.changed' && draft.payload.state === 'idle')
@@ -153,6 +297,8 @@ export class ClaudeRun implements AgentRun {
153
297
  this.emitAcceptedMessage(draft.payload.commandId, draft.turnId)
154
298
  }
155
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.
156
302
  private emitAcceptedMessage(commandId: string, turnId: string): void {
157
303
  const pending = this.pendingMessages.get(commandId)
158
304
  if (!pending?.started || !this.initialized || this.finished)
@@ -172,65 +318,417 @@ export class ClaudeRun implements AgentRun {
172
318
  return { requestId: request.requestId }
173
319
  }
174
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.
175
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)
176
329
  options.signal?.throwIfAborted()
330
+ await this.reloadSkills()
177
331
  }
178
332
 
179
- // Claude discovers repository guidance from the filesystem; there is no provider-side root registry.
180
- async addWorkspaceRepositoryRoot(_root: string, options: AgentOperationOptions = {}): Promise<void> {
333
+ async addWorkspaceRepositoryRoot(root: string, options: AgentOperationOptions = {}): Promise<void> {
334
+ this.assertRunning()
181
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
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
182
355
  }
183
356
 
184
- async sendPrompt(command: SendPromptCommand, options: AgentOperationOptions = {}): Promise<AgentPromptAcceptance> {
357
+ async sendPrompt(command: SendPromptCommand, options: AgentPromptOperationOptions = {}): Promise<AgentPromptAcceptance> {
185
358
  this.assertCommandTargetsRun(command)
186
359
  this.assertRunning()
187
360
  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
+ }
188
366
  if (command.reasoningEffort !== undefined && command.reasoningEffort !== this.init.reasoningEffort)
189
367
  throw new AgentNotAcceptedError({ message: 'Claude reasoning effort changes are not supported by this adapter.', recovery: { kind: 'terminal', reason: 'reasoning_effort_change_not_supported' } })
190
- const input = { prompt: command.prompt, mode: command.mode, commandId: command.commandId, origin: command.origin, author: command.author, senderContext: options.senderContext }
191
- const message = await mapClaudeUserMessage(input, options.inputAttachmentPreparation)
192
- options.signal?.throwIfAborted()
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.pendingMessages.set(command.commandId, { messageId: clientMessageId, mode: command.mode, turnId: command.turnId, started: false })
399
+ }
400
+ this.commandByClientMessageId.set(clientMessageId, command.commandId)
401
+ if (command.mode === 'steer')
402
+ this.steerInputIds.add(clientMessageId)
403
+ try {
404
+ await this.deliverInput(message)
405
+ // `submitted` requires the provider's positive acceptance receipt — the pinned
406
+ // command_lifecycle frame matching this command's derived uuid and this session —
407
+ // in addition to delivery (and, for the first input, validated init identity).
408
+ // Local queue push, transport write, and init alone are never acceptance. No timeout:
409
+ // provider close/error settles the wait as delivery-unknown.
410
+ await receipt.promise
411
+ }
412
+ finally {
413
+ this.acceptanceWaiters.delete(clientMessageId)
414
+ }
415
+ }
416
+ catch (error) {
417
+ if (error instanceof AgentNotAcceptedError)
418
+ this.eventMapperState.promptCommandByInputId.delete(clientMessageId)
419
+ if (sessionContext !== undefined)
420
+ this.releaseSessionContext(clientMessageId)
421
+ throw error
422
+ }
423
+ if (command.settings) {
424
+ this.current = settings
425
+ this.pushEvent(sessionSettingsChangedEvent({
426
+ model: settings.model ?? await this.initialization,
427
+ reasoningEffort: settings.effort,
428
+ commandId: command.commandId,
429
+ turnId: turnIdAtSubmission,
430
+ appliesFrom: 'current_turn',
431
+ }))
432
+ }
433
+ return { kind: 'submitted' }
434
+ }
435
+
436
+ // Applies an application-resolved model through the control channel before the input. Changes
437
+ // wait for an idle boundary with no accepted input still pending, so they never reconfigure a
438
+ // queued prompt. Reapplying the same resolved model after a crash is safe.
439
+ private async applyCommandModel(command: SendPromptCommand, options: AgentOperationOptions): Promise<void> {
193
440
  if (this.modelChangeInFlight)
194
441
  throw new AgentPromptDeferredError({ message: 'Claude model configuration is still in progress.' })
195
442
  const currentModel = this.init.requestedModel ?? this.metadata.model
196
- if (command.model && command.model !== currentModel) {
197
- if (!this.modelChangeReady || this.eventMapperState.currentTurnId || this.commandByClientMessageId.size > 0)
198
- throw new AgentPromptDeferredError({ message: 'Claude model changes require an idle turn boundary.' })
199
- // The control receipt must precede the input. Reapplying the same resolved model
200
- // after a crash is safe; never retry an uncertain prompt to retry configuration.
201
- this.modelChangeInFlight = true
202
- try {
203
- await this.query.setModel(command.model)
443
+ if (!command.model || command.model === currentModel)
444
+ return
445
+ if (!this.modelChangeReady || this.eventMapperState.currentTurnId || this.commandByClientMessageId.size > 0)
446
+ throw new AgentPromptDeferredError({ message: 'Claude model changes require an idle turn boundary.' })
447
+ this.modelChangeInFlight = true
448
+ try {
449
+ await this.query.setModel(command.model)
450
+ }
451
+ finally {
452
+ this.modelChangeInFlight = false
453
+ }
454
+ this.metadata.model = command.model
455
+ this.init.requestedModel = command.model
456
+ this.current = { ...this.current, model: command.model }
457
+ this.providerApplied = this.current
458
+ options.signal?.throwIfAborted()
459
+ }
460
+
461
+ // Model admission belongs to the product; core accepts its resolved provider model ID.
462
+ private settingsWith(change: AgentSettingsChange | undefined): ClaudeRunSettings {
463
+ if (change === undefined)
464
+ return this.current
465
+ return {
466
+ model: change.model ?? this.current.model,
467
+ effort: change.reasoningEffort ?? this.current.effort,
468
+ }
469
+ }
470
+
471
+ // The SDK user message cannot carry a model or effort, so they go as control requests, which
472
+ // take effect from the CLI's next model request, even inside a running turn. Both are awaited
473
+ // before the input is written: a steer pushed first was observed making its first request with
474
+ // the new model but the old effort (CLI 2.1.280). A failure proves the input was not written,
475
+ // but not what the CLI kept, so the next input re-applies both.
476
+ private async applyProviderSettings(settings: ClaudeRunSettings): Promise<void> {
477
+ const known = this.providerApplied
478
+ this.providerApplied = undefined
479
+ try {
480
+ if (known === undefined || known.model !== settings.model)
481
+ await this.query.setModel(settings.model)
482
+ if (known === undefined || known.effort !== settings.effort)
483
+ await this.query.applyFlagSettings({ effortLevel: settings.effort ?? null })
484
+ }
485
+ catch (error) {
486
+ throw new AgentNotAcceptedError({
487
+ message: 'Claude did not apply the model or reasoning effort; the input was not written.',
488
+ recovery: { kind: 'retry', reason: 'provider_settings_not_applied' },
489
+ cause: error,
490
+ })
491
+ }
492
+ this.providerApplied = settings
493
+ }
494
+
495
+ private carrySessionContext(clientMessageId: string): string | undefined {
496
+ if (this.sessionContext.status !== 'owed')
497
+ return undefined
498
+ this.sessionContext = { status: 'carried', text: this.sessionContext.text, clientMessageId }
499
+ return this.sessionContext.text
500
+ }
501
+
502
+ private releaseSessionContext(clientMessageId: string): void {
503
+ if (this.sessionContext.status === 'carried' && this.sessionContext.clientMessageId === clientMessageId)
504
+ this.sessionContext = { status: 'owed', text: this.sessionContext.text }
505
+ }
506
+
507
+ // `started`/`completed` mean the model received the carrying prompt. A later `cancelled` (an
508
+ // aborted turn cancels the inputs it already started) no longer finds it carried.
509
+ private observeSessionContextLifecycle(frame: CommandLifecycleFrame): void {
510
+ if (this.sessionContext.status !== 'carried' || this.sessionContext.clientMessageId !== frame.command_uuid)
511
+ return
512
+ if (frame.state === 'started' || frame.state === 'completed')
513
+ this.sessionContext = { status: 'delivered' }
514
+ else if (frame.state !== 'queued')
515
+ this.releaseSessionContext(frame.command_uuid)
516
+ }
517
+
518
+ // Mirrors CodexRun.notify's observable contract: an idle session starts a turn with the
519
+ // notification, and an active main turn is joined. Claude Code has no mid-turn input channel, so
520
+ // a joined notification is written to the session-local steered-notification store before the
521
+ // `steered` receipt and delivered inside that turn by the run's hooks (next main tool boundary,
522
+ // or a Stop-hook continuation when the turn would otherwise end), or, when it lands after that
523
+ // Stop hook passed, by a follow-up turn. See claude-steered-notifications.ts.
524
+ async notify(command: AgentNotifyCommand, options: AgentOperationOptions = {}): Promise<AgentNotificationAcceptance> {
525
+ this.assertCommandTargetsRun(command)
526
+ this.assertRunning()
527
+ options.signal?.throwIfAborted()
528
+ if (this.userInputRequests.hasUnresolvedUserInput()) {
529
+ throw new AgentPromptDeferredError({
530
+ message: 'Claude is waiting for unresolved user input; a notification cannot be submitted.',
531
+ })
532
+ }
533
+ const activeTurnId = this.eventMapperState.currentTurnId
534
+ if (activeTurnId) {
535
+ if (interruptTargetsTurn(this.eventMapperState.interrupt, activeTurnId)) {
536
+ throw new AgentPromptDeferredError({
537
+ message: 'Claude is interrupting the active turn; the notification waits for the next idle boundary.',
538
+ })
204
539
  }
205
- finally {
206
- this.modelChangeInFlight = false
540
+ const queued = this.steeredNotifications.enqueue({ commandId: command.commandId, turnId: activeTurnId, text: claudeNotificationText(command) })
541
+ return { kind: 'turn', turnId: queued.kind === 'duplicate' ? queued.turnId : activeTurnId, placement: 'steered' }
542
+ }
543
+ if (this.notificationWaiter)
544
+ throw new AgentRunStateError({ message: 'A Claude notification is already awaiting its turn.' })
545
+ const message = toClaudeNotificationMessage(command)
546
+ const waiter = this.registerNotificationWaiter(deriveMessageId(command.commandId))
547
+ try {
548
+ await this.applyProviderSettings(this.current)
549
+ await this.deliverInput(message)
550
+ return await waiter.promise
551
+ }
552
+ finally {
553
+ if (this.notificationWaiter === waiter)
554
+ this.notificationWaiter = undefined
555
+ }
556
+ }
557
+
558
+ private registerNotificationWaiter(clientMessageId: string): NotificationWaiter {
559
+ if (this.finished)
560
+ throw new AgentRunStateError({ message: 'Claude run has stopped.' })
561
+ const { promise, resolve, reject } = Promise.withResolvers<AgentNotificationAcceptance>()
562
+ promise.catch(() => undefined)
563
+ const waiter: NotificationWaiter = {
564
+ clientMessageId,
565
+ activeTurnIdAtSubmission: this.eventMapperState.currentTurnId,
566
+ consumed: false,
567
+ promise,
568
+ resolve,
569
+ reject,
570
+ }
571
+ this.notificationWaiter = waiter
572
+ return waiter
573
+ }
574
+
575
+ // Runs on the notification's command_lifecycle frame. `started`/`completed` mean a turn consumed
576
+ // it: the active turn when it was folded in, else the turn about to be reported by the mapper.
577
+ private settleNotificationLifecycle(waiter: NotificationWaiter, state: CommandLifecycleFrame['state']): void {
578
+ if (state === 'queued')
579
+ return
580
+ if (state === 'refused') {
581
+ this.notificationWaiter = undefined
582
+ waiter.reject(new AgentNotAcceptedError({
583
+ message: 'Claude declined the notification before queueing it; it will not run in this session.',
584
+ recovery: { kind: 'terminal', reason: 'provider_refused' },
585
+ }))
586
+ return
587
+ }
588
+ if (state === 'cancelled' || state === 'discarded') {
589
+ if (!waiter.consumed) {
590
+ this.notificationWaiter = undefined
591
+ waiter.reject(new AgentDeliveryUnknownError({
592
+ message: `Claude ${state} the notification before a turn was observed consuming it.`,
593
+ }))
207
594
  }
208
- this.metadata.model = command.model
209
- this.init.requestedModel = command.model
210
- options.signal?.throwIfAborted()
595
+ return
211
596
  }
212
- if (command.turnId && (command.mode !== 'steer' || command.turnId !== this.eventMapperState.currentTurnId))
213
- throw new AgentNotAcceptedError({ message: 'The targeted claude turn is no longer active.', recovery: { kind: 'terminal', reason: 'target_turn_not_active' } })
214
- this.modelChangeReady = false
215
- // Register before the push: the CLI's queued receipt can outrun the local push resolution,
216
- // and a receipt with no registered waiter would be lost.
597
+ if (waiter.consumed)
598
+ return
599
+ waiter.consumed = true
600
+ const activeTurnId = this.eventMapperState.currentTurnId
601
+ if (activeTurnId)
602
+ this.settleNotificationTurn(activeTurnId)
603
+ }
604
+
605
+ // A notify joined to a turn whose Stop hook already passed has no hook left in that turn. As in
606
+ // Codex, where such a steer starts a new turn, a completed main turn that leaves envelopes
607
+ // pending starts one follow-up turn delivering them. Interrupted and failed turns, and a session
608
+ // waiting for user input, keep them for the next input. At most one follow-up is in flight: its
609
+ // own UserPromptSubmit hook takes envelopes that arrive before it starts, so this cannot loop.
610
+ // Envelopes recovered from disk at open do not start a turn by themselves: the supervisor resumes
611
+ // a run only to dispatch an input, whose UserPromptSubmit hook carries them (a `/compact` input
612
+ // skips that hook, but its completed turn reaches this follow-up), and a prewarmed or resumed run
613
+ // must not reach the model on its own before its first input.
614
+ private async startSteeredNotificationFollowUp(): Promise<void> {
615
+ if (this.finished || this.steeredFollowUp || this.userInputRequests.hasUnresolvedUserInput())
616
+ return
617
+ const delivery = this.steeredNotifications.takePending()
618
+ if (!delivery)
619
+ return
620
+ const followUp: SteeredNotificationFollowUp = { clientMessageId: randomUUID(), delivery }
621
+ this.steeredFollowUp = followUp
622
+ try {
623
+ await this.pushOwnedStream(toClaudeTaskNotificationMessage({ text: delivery.text, uuid: followUp.clientMessageId }))
624
+ }
625
+ catch (error) {
626
+ // A rejected push provably wrote nothing.
627
+ this.steeredFollowUp = undefined
628
+ this.steeredNotifications.restore(delivery)
629
+ throw error
630
+ }
631
+ }
632
+
633
+ // `started`/`completed` mean the model received the envelopes. A non-positive state before that
634
+ // means Claude will never run the input, so the envelopes go back to the store for the next
635
+ // input instead of being lost. A run that ends before either keeps them delivered: like a hook
636
+ // hand-off, the input most likely already started.
637
+ private settleSteeredFollowUp(followUp: SteeredNotificationFollowUp, state: CommandLifecycleFrame['state']): void {
638
+ if (state === 'queued')
639
+ return
640
+ this.steeredFollowUp = undefined
641
+ if (state === 'cancelled' || state === 'discarded' || state === 'refused')
642
+ this.steeredNotifications.restore(followUp.delivery)
643
+ }
644
+
645
+ private settleNotificationTurn(turnId: string): void {
646
+ const waiter = this.notificationWaiter
647
+ if (!waiter?.consumed)
648
+ return
649
+ this.notificationWaiter = undefined
650
+ waiter.resolve({
651
+ kind: 'turn',
652
+ turnId,
653
+ placement: turnId === waiter.activeTurnIdAtSubmission ? 'steered' : 'started',
654
+ })
655
+ }
656
+
657
+ async compactContext(command: CompactContextAgentCommand, options: AgentOperationOptions = {}): Promise<AgentContextCompactionRequestResult> {
658
+ this.assertCommandTargetsRun(command)
659
+ this.assertRunning()
660
+ options.signal?.throwIfAborted()
661
+ if (this.eventMapperState.currentTurnId) {
662
+ throw new AgentPromptDeferredError({
663
+ message: 'Claude context compaction must wait for an idle main thread.',
664
+ })
665
+ }
666
+ if (this.compactionWaiter)
667
+ throw new AgentRunStateError({ message: 'A Claude context compaction request is already pending.' })
668
+
669
+ // The deadline bounds the whole request: it cancels a submission still awaiting delivery or
670
+ // acceptance, and reports `unknown` for an accepted compaction whose outcome never arrived.
671
+ const deadline = new AbortController()
672
+ const operationSignal = options.signal
673
+ ? AbortSignal.any([options.signal, deadline.signal])
674
+ : deadline.signal
217
675
  const clientMessageId = deriveMessageId(command.commandId)
218
676
  const receipt = this.registerAcceptanceWaiter(clientMessageId)
219
- this.commandByClientMessageId.set(clientMessageId, command.commandId)
220
- this.pendingMessages.set(command.commandId, { messageId: clientMessageId, mode: command.mode, turnId: command.turnId, started: false })
677
+ const waiter = this.registerCompactionWaiter({ commandId: command.commandId, clientMessageId, deadline })
221
678
  try {
222
- await this.deliverInput(message)
223
- // `submitted` requires the provider's positive acceptance receipt — the pinned
224
- // command_lifecycle frame matching this command's derived uuid and this session —
225
- // in addition to delivery (and, for the first input, validated init identity).
226
- // Local queue push, transport write, and init alone are never acceptance. No timeout:
227
- // provider close/error settles the wait as delivery-unknown.
228
- await receipt.promise
679
+ await this.applyProviderSettings(this.current)
680
+ await this.awaitPossiblyDeliveredCompaction(
681
+ this.deliverInput(toClaudeCompactContextMessage(command.commandId)),
682
+ operationSignal,
683
+ )
684
+ await this.awaitPossiblyDeliveredCompaction(receipt.promise, operationSignal)
685
+ }
686
+ catch (error) {
687
+ if (!(error instanceof AgentDeliveryUnknownError)) {
688
+ this.releaseCompactionWaiter(waiter)
689
+ throw error
690
+ }
691
+ // Not accepted: the input may never have reached Claude, so holding the guard could defer
692
+ // steers indefinitely.
693
+ return this.endCompaction(waiter, { status: 'unknown' })
229
694
  }
230
695
  finally {
231
696
  this.acceptanceWaiters.delete(clientMessageId)
232
697
  }
233
- return { kind: 'submitted' }
698
+ // Accepted: the compaction runs as a Claude turn whose events stream while it runs. Waiting
699
+ // for its outcome here would hold the caller, and every event queued behind it, for the
700
+ // whole compaction. An outcome Claude already reported is definitive; otherwise `unknown`
701
+ // tells the caller a turn is starting, and the outcome is reported when it arrives.
702
+ waiter.accepted = true
703
+ if (waiter.outcome)
704
+ return this.endCompaction(waiter, waiter.outcome)
705
+ return { status: 'unknown' }
706
+ }
707
+
708
+ private async awaitPossiblyDeliveredCompaction<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
709
+ if (signal.aborted) {
710
+ throw new AgentDeliveryUnknownError({
711
+ message: 'Claude context compaction was cancelled after delivery may have begun.',
712
+ cause: signal.reason,
713
+ })
714
+ }
715
+ return await new Promise<T>((resolve, reject) => {
716
+ const aborted = (): void => {
717
+ reject(new AgentDeliveryUnknownError({
718
+ message: 'Claude context compaction was cancelled after delivery may have begun.',
719
+ cause: signal.reason,
720
+ }))
721
+ }
722
+ const settle = (continuation: () => void): void => {
723
+ signal.removeEventListener('abort', aborted)
724
+ continuation()
725
+ }
726
+ signal.addEventListener('abort', aborted, { once: true })
727
+ promise.then(
728
+ value => settle(() => resolve(value)),
729
+ error => settle(() => reject(error)),
730
+ )
731
+ })
234
732
  }
235
733
 
236
734
  async respondUserInput(command: RespondUserInputAgentCommand, options: AgentOperationOptions = {}): Promise<AgentUserInputSubmission> {
@@ -242,6 +740,7 @@ export class ClaudeRun implements AgentRun {
242
740
  return 'already_delivered'
243
741
  // Deliver first: on a prebound resume this is the run's first input, and the resolved
244
742
  // event must not precede the lifecycle events the init barrier emits.
743
+ await this.applyProviderSettings(this.current)
245
744
  await this.deliverInput(toClaudeUserMessage({ prompt: resolved.prompt, mode: 'next', commandId: command.commandId }))
246
745
  if (resolved.event)
247
746
  this.pushEvent(resolved.event)
@@ -254,6 +753,7 @@ export class ClaudeRun implements AgentRun {
254
753
  this.assertRunning()
255
754
  options.signal?.throwIfAborted()
256
755
  const turnId = this.eventMapperState.currentTurnId
756
+ // A targeted interrupt never affects a replacement turn.
257
757
  if (command.turnId && command.turnId !== turnId)
258
758
  return
259
759
  if (!turnId)
@@ -292,36 +792,141 @@ export class ClaudeRun implements AgentRun {
292
792
  async stop(command: StopAgentCommand, options: AgentOperationOptions = {}): Promise<void> {
293
793
  this.assertCommandTargetsRun(command)
294
794
  options.signal?.throwIfAborted()
295
- if (this.finished)
795
+ // A run that already ended still resolves only after its CLI has exited, so a following
796
+ // resume never overlaps it on the same transcript.
797
+ if (this.finished) {
798
+ await this.ended
296
799
  return
800
+ }
297
801
  this.events.push({ type: 'session.state.changed', payload: { state: 'stopping' } })
298
802
  if (!this.initialized)
299
803
  this.initializationReject(new AgentRunStateError({ message: 'Claude run stopped before initialization.' }))
300
- this.query.close()
301
804
  this.finish('stopped')
805
+ await this.ended
806
+ }
807
+
808
+ // Mirrors CodexRun: a central credential failure is terminal for this run. The typed turn
809
+ // reason lets the supervisor recover owed work instead of observing a generic 401 result.
810
+ failActiveTurnAndEndRun(input: ClaudeAuthFailure): void {
811
+ // A run without input (an idle prewarm) made no model request, so the failure is not its
812
+ // own; ending it would leave a finished run for the supervisor to adopt. Its first request
813
+ // acquires a fresh credential and reports that outcome instead.
814
+ if (this.finished || !this.firstInputPushed)
815
+ return
816
+ this.pushEvent({ type: 'error', payload: { message: 'Claude authentication failed.', fatal: false, source: 'provider', error: input.error } })
817
+ const turnId = this.eventMapperState.currentTurnId
818
+ this.eventMapperState.heldTurnEnd = undefined
819
+ if (turnId) {
820
+ this.pushEvent({
821
+ type: 'turn.ended',
822
+ turnId,
823
+ actor: { type: 'main', actorId: 'main' },
824
+ payload: { status: 'failed', reason: input.reason, error: input.error },
825
+ })
826
+ this.eventMapperState.currentTurnId = undefined
827
+ }
828
+ if (!this.initialized)
829
+ this.initializationReject(new AgentRunStateError({ message: 'Claude authentication failed before initialization.' }))
830
+ this.closeQuery()
831
+ this.finish('failed', input.error)
302
832
  }
303
833
 
304
834
  finish(reason: 'completed' | 'stopped' | 'interrupted' | 'failed', error?: AgentError): void {
305
835
  if (this.finished)
306
836
  return
837
+ const compaction = this.compactionWaiter
838
+ if (compaction)
839
+ this.endCompaction(compaction, { status: 'unknown' })
840
+ // A superseded turn's steer can no longer continue it once the run ends.
841
+ for (const draft of flushHeldTurnEnd(this.eventMapperState)) this.pushEvent(draft)
307
842
  this.finished = true
308
843
  // The run ends before pending inputs saw their acceptance receipt: each may already be in
309
844
  // the CLI stream, so their outcome is delivery-unknown — never a hang, retry, or terminal.
310
845
  for (const waiter of this.acceptanceWaiters.values()) {
311
846
  waiter.reject(new AgentDeliveryUnknownError({
312
847
  message: `Claude run ended (${reason}) before the input's acceptance receipt was observed.`,
848
+ cause: error,
313
849
  }))
314
850
  }
315
851
  this.acceptanceWaiters.clear()
852
+ this.eventMapperState.promptCommandByInputId.clear()
316
853
  this.commandByClientMessageId.clear()
317
854
  this.pendingMessages.clear()
855
+ const notification = this.notificationWaiter
856
+ this.notificationWaiter = undefined
857
+ notification?.reject(new AgentDeliveryUnknownError({
858
+ message: `Claude run ended (${reason}) before a turn was observed consuming the notification.`,
859
+ cause: error,
860
+ }))
861
+ this.ended = this.endAfterProcessExit(reason, error)
862
+ }
863
+
864
+ // session.ended means the CLI is gone: the terminal events wait for its exit (bounded by
865
+ // CLAUDE_PROCESS_EXIT_TIMEOUT_MS, then a process-group kill). close() is idempotent and sends
866
+ // the stdin EOF the CLI exits on.
867
+ private async endAfterProcessExit(reason: 'completed' | 'stopped' | 'interrupted' | 'failed', error: AgentError | undefined): Promise<void> {
868
+ this.closeQuery()
869
+ await this.process.terminate()
318
870
  this.events.push({ type: 'session.ended', payload: error ? { reason, error } : { reason } })
319
871
  this.events.push({ type: 'session.state.changed', payload: { state: reason === 'failed' ? 'failed' : 'stopped' } })
320
872
  this.events.end()
873
+ void this.onFinished().catch(() => undefined)
874
+ }
875
+
876
+ // Reports the request's one result; later calls return the reported one. The caller decides
877
+ // whether the compaction has ended and releases the waiter.
878
+ private reportCompactionResult(waiter: CompactionWaiter, result: AgentContextCompactionRequestResult): AgentContextCompactionRequestResult {
879
+ if (waiter.reported)
880
+ return waiter.reported
881
+ waiter.reported = result
882
+ clearTimeout(waiter.timeout)
883
+ this.pushEvent({
884
+ type: 'context.compaction.request.completed',
885
+ payload: { commandId: waiter.commandId, result },
886
+ })
887
+ return result
888
+ }
889
+
890
+ private endCompaction(waiter: CompactionWaiter, result: AgentContextCompactionRequestResult): AgentContextCompactionRequestResult {
891
+ const reported = this.reportCompactionResult(waiter, result)
892
+ this.releaseCompactionWaiter(waiter)
893
+ return reported
894
+ }
895
+
896
+ private releaseCompactionWaiter(waiter: CompactionWaiter): void {
897
+ clearTimeout(waiter.timeout)
898
+ if (this.compactionWaiter === waiter)
899
+ this.compactionWaiter = undefined
900
+ }
901
+
902
+ // First outcome wins. Before acceptance the submitting call reports it (see compactContext).
903
+ private settleCompaction(waiter: CompactionWaiter, outcome: AgentContextCompactionRequestResult): void {
904
+ waiter.outcome ??= outcome
905
+ if (waiter.accepted)
906
+ this.endCompaction(waiter, waiter.outcome)
907
+ }
908
+
909
+ private registerCompactionWaiter({ commandId, clientMessageId, deadline }: { commandId: string, clientMessageId: string, deadline: AbortController }): CompactionWaiter {
910
+ if (this.compactionWaiter)
911
+ throw new AgentRunStateError({ message: 'A Claude context compaction request is already pending.' })
912
+ const waiter: CompactionWaiter = {
913
+ commandId,
914
+ clientMessageId,
915
+ timeout: setTimeout(() => {
916
+ // Before acceptance the abort makes the submitting call report and release. After it,
917
+ // Claude may still be compacting: the result becomes `unknown`, the guard stays.
918
+ deadline.abort(new Error(`Claude context compaction exceeded ${this.compactionTimeoutMs}ms.`))
919
+ if (waiter.accepted)
920
+ this.reportCompactionResult(waiter, { status: 'unknown' })
921
+ }, this.compactionTimeoutMs),
922
+ accepted: false,
923
+ boundaryObserved: false,
924
+ }
925
+ waiter.timeout.unref?.()
926
+ this.compactionWaiter = waiter
927
+ return waiter
321
928
  }
322
929
 
323
- // One pending acceptance per derived command uuid; the promise is pre-observed so a rejection
324
- // while the caller is still awaiting delivery/init can never become an unhandled rejection.
325
930
  private registerAcceptanceWaiter(clientMessageId: string): AcceptanceWaiter {
326
931
  if (this.finished)
327
932
  throw new AgentRunStateError({ message: 'Claude run has stopped.' })
@@ -341,26 +946,16 @@ export class ClaudeRun implements AgentRun {
341
946
 
342
947
  // Strict pinned-runtime boundary for the undocumented receipt frame: shape/state deviation and
343
948
  // session identity mismatch are provider protocol drift and fail the run closed. `cancelled`
344
- // (a steered-over/aborted command) and `discarded` (the session ended with the command still
345
- // queued) are valid observed states but NEGATIVE terminals — never a positive acceptance, so
346
- // they reject any pending waiter as delivery-unknown so command processing can advance.
347
- // Valid positive frames without a pending
348
- // waiter are internal lifecycle for already-settled or non-prompt inputs and are ignored.
349
- private hasPendingSteer(): boolean {
350
- return [...this.pendingMessages.values()].some(pending => pending.mode === 'steer'
351
- && (!pending.turnId || pending.turnId === this.eventMapperState.currentTurnId))
352
- }
353
-
354
- private flushSteerResult(continueTurn = false): void {
355
- const result = this.pendingSteerResult
356
- this.pendingSteerResult = undefined
357
- if (result) {
358
- for (const draft of mapClaudeMessage(result, this.eventMapperState, { continueTurn }))
359
- this.pushEvent(draft)
360
- }
361
- }
362
-
363
- private settleAcceptance(frame: ClaudeCommandLifecycleMessage): void {
949
+ // and `discarded` reject an unsettled waiter as delivery-unknown; a prior positive acceptance
950
+ // cannot be revoked, and later consumed UUIDs remain valid evidence. `refused` is the pinned
951
+ // runtime's definite pre-queue rejection: the input
952
+ // will never run in this session, and the session stays open, so the pending waiter is
953
+ // rejected as a terminal not-accepted outcome instead of waiting for a close that may not
954
+ // come. Valid positive frames without a pending waiter are internal lifecycle for
955
+ // already-settled or non-prompt inputs and are ignored. The synthetic `/compact` input is the
956
+ // exception: every negative state for its uuid settles the pending compaction as `failed`, because
957
+ // the request has a definite result to report while the session keeps running.
958
+ private settleAcceptance(frame: ClaudeCommandLifecycleMessage): AgentEventDraft[] {
364
959
  const parsed = commandLifecycleFrameSchema.safeParse(frame)
365
960
  if (!parsed.success) {
366
961
  throw new AgentProviderProtocolError({
@@ -374,40 +969,130 @@ export class ClaudeRun implements AgentRun {
374
969
  details: { expected: this.metadata.providerSessionId, actual: parsed.data.session_id },
375
970
  })
376
971
  }
377
- const commandId = this.commandByClientMessageId.get(parsed.data.command_uuid)
378
- if (parsed.data.state === 'cancelled' || parsed.data.state === 'discarded') {
972
+ this.observeSessionContextLifecycle(parsed.data)
973
+ const steerDrafts = this.observeSteerLifecycle(parsed.data)
974
+ this.settleInputLifecycle(parsed.data)
975
+ return steerDrafts
976
+ }
977
+
978
+ // A steer's receipts before system/init belong to the session's first input, which supersedes
979
+ // nothing; afterwards they drive the mapper's supersession of the running ask.
980
+ private observeSteerLifecycle(frame: CommandLifecycleFrame): AgentEventDraft[] {
981
+ if (!this.steerInputIds.has(frame.command_uuid))
982
+ return []
983
+ if (frame.state !== 'queued' && frame.state !== 'started')
984
+ this.steerInputIds.delete(frame.command_uuid)
985
+ if (!this.initialized)
986
+ return []
987
+ return mapSteerLifecycle({ steerId: frame.command_uuid, state: frame.state }, this.eventMapperState)
988
+ }
989
+
990
+ private settleInputLifecycle(frame: CommandLifecycleFrame): void {
991
+ const followUp = this.steeredFollowUp
992
+ if (followUp?.clientMessageId === frame.command_uuid) {
993
+ this.settleSteeredFollowUp(followUp, frame.state)
994
+ return
995
+ }
996
+ const notification = this.notificationWaiter
997
+ if (notification?.clientMessageId === frame.command_uuid) {
998
+ this.settleNotificationLifecycle(notification, frame.state)
999
+ return
1000
+ }
1001
+ const waiter = this.acceptanceWaiters.get(frame.command_uuid)
1002
+ const commandId = this.commandByClientMessageId.get(frame.command_uuid)
1003
+ if (frame.state === 'cancelled' || frame.state === 'discarded' || frame.state === 'refused') {
1004
+ this.commandByClientMessageId.delete(frame.command_uuid)
379
1005
  if (commandId)
380
1006
  this.pendingMessages.delete(commandId)
381
- this.acceptanceWaiters.get(parsed.data.command_uuid)?.reject(new AgentDeliveryUnknownError({
382
- message: `Claude command ended as ${parsed.data.state} before acceptance was observed.`,
1007
+ const compaction = this.compactionWaiter
1008
+ if (compaction?.clientMessageId === frame.command_uuid) {
1009
+ this.settleCompaction(compaction, {
1010
+ status: 'failed',
1011
+ error: { message: 'Claude refused context compaction.' },
1012
+ })
1013
+ waiter?.resolve()
1014
+ return
1015
+ }
1016
+ if (frame.state === 'refused') {
1017
+ this.eventMapperState.promptCommandByInputId.delete(frame.command_uuid)
1018
+ waiter?.reject(new AgentNotAcceptedError({
1019
+ message: 'Claude declined the input before queueing it; it will not run in this session.',
1020
+ recovery: { kind: 'terminal', reason: 'provider_refused' },
1021
+ }))
1022
+ return
1023
+ }
1024
+ // A cancelled (steered-over or aborted) or discarded input will not be accepted; the
1025
+ // waiter settles now so command processing can advance while the session stays open.
1026
+ waiter?.reject(new AgentDeliveryUnknownError({
1027
+ message: `Claude command ended as ${frame.state} before acceptance was observed.`,
383
1028
  }))
384
- this.commandByClientMessageId.delete(parsed.data.command_uuid)
385
- if (!this.hasPendingSteer())
386
- this.flushSteerResult()
387
- return
388
- }
389
- // A queued receipt acknowledges admission, not turn ownership. Keep the
390
- // mapping after acceptance settles: the provider's started receipt is
391
- // the authority for the next root turn, even with several queued inputs.
392
- if (parsed.data.state === 'started') {
393
- const pending = commandId ? this.pendingMessages.get(commandId) : undefined
394
- // Only a native started receipt can join the interrupted segment to a steer.
395
- // A queued receipt alone must never suppress the logical turn's completion.
396
- this.flushSteerResult(pending?.mode === 'steer' && (!pending.turnId || pending.turnId === this.eventMapperState.currentTurnId))
1029
+ return
1030
+ }
1031
+ // A queued receipt acknowledges admission, not turn ownership: the started receipt binds the
1032
+ // command to the turn that consumes it, even with several queued inputs.
1033
+ if (frame.state === 'started' && commandId) {
397
1034
  this.eventMapperState.currentCommandId = commandId
398
- if (pending && commandId) {
1035
+ const pending = this.pendingMessages.get(commandId)
1036
+ if (pending) {
399
1037
  pending.started = true
400
1038
  const turnId = this.eventMapperState.currentTurnId
401
1039
  if (pending.mode === 'steer' && turnId && (!pending.turnId || pending.turnId === turnId))
402
1040
  this.emitAcceptedMessage(commandId, turnId)
403
1041
  }
404
1042
  }
405
- if (parsed.data.state === 'completed') {
406
- this.commandByClientMessageId.delete(parsed.data.command_uuid)
1043
+ if (frame.state === 'completed') {
1044
+ this.commandByClientMessageId.delete(frame.command_uuid)
407
1045
  if (commandId)
408
1046
  this.pendingMessages.delete(commandId)
409
1047
  }
410
- this.acceptanceWaiters.get(parsed.data.command_uuid)?.resolve()
1048
+ waiter?.resolve()
1049
+ }
1050
+
1051
+ // An accepted compaction runs as the next main turn, since it is submitted only while idle. If that
1052
+ // turn ends without a correlated outcome, the compaction is over: report `unknown` if nothing was
1053
+ // reported yet and stop deferring steers.
1054
+ private endCompactionWithItsTurn(drafts: AgentEventDraft[]): void {
1055
+ const waiter = this.compactionWaiter
1056
+ if (waiter?.accepted && drafts.some(draft => draft.type === 'turn.ended' && draft.actor.type === 'main'))
1057
+ this.endCompaction(waiter, { status: 'unknown' })
1058
+ }
1059
+
1060
+ private observeCompactionResult(message: SDKMessage): void {
1061
+ const waiter = this.compactionWaiter
1062
+ if (!waiter)
1063
+ return
1064
+ if (message.type === 'system' && message.subtype === 'compact_boundary') {
1065
+ if (message.compact_metadata.trigger === 'manual')
1066
+ waiter.boundaryObserved = true
1067
+ return
1068
+ }
1069
+ if (message.type === 'system' && message.subtype === 'status' && message.compact_result === 'failed') {
1070
+ this.settleCompaction(waiter, {
1071
+ status: 'failed',
1072
+ error: compactionError(message),
1073
+ })
1074
+ return
1075
+ }
1076
+ if (message.type !== 'result')
1077
+ return
1078
+ const commandIds = resultUserMessageIds(message)
1079
+ if (!commandIds.includes(waiter.clientMessageId))
1080
+ return
1081
+ if (message.subtype !== 'success' || message.is_error) {
1082
+ this.settleCompaction(waiter, {
1083
+ status: 'failed',
1084
+ error: resultError(message),
1085
+ })
1086
+ return
1087
+ }
1088
+ if (waiter.boundaryObserved) {
1089
+ this.settleCompaction(waiter, { status: 'completed' })
1090
+ return
1091
+ }
1092
+ this.settleCompaction(waiter, {
1093
+ status: 'no_op',
1094
+ reason: message.result,
1095
+ })
411
1096
  }
412
1097
 
413
1098
  // Exactly one message may enter the stream before init: the first input wakes the CLI and then
@@ -482,31 +1167,31 @@ export class ClaudeRun implements AgentRun {
482
1167
  // Acceptance receipts are routed before PRE_INIT and normal mapping: the first
483
1168
  // input's queued frame legitimately precedes system/init.
484
1169
  if (message.type === 'command_lifecycle') {
485
- this.settleAcceptance(message)
1170
+ const interrupt = this.eventMapperState.interrupt
1171
+ await this.emitDrafts(this.settleAcceptance(message), interrupt)
486
1172
  continue
487
1173
  }
488
- if (!this.initialized) {
489
- this.completeInitialization(message)
1174
+ // A skill reload can precede the first input, so the command list is routed to the
1175
+ // catalog before the PRE_INIT barrier; it is catalog input, not a session event.
1176
+ if (message.type === 'system' && message.subtype === 'commands_changed') {
1177
+ this.skillCatalog.publish(message.commands)
490
1178
  continue
491
1179
  }
492
- this.flushSteerResult()
493
- if (message.type === 'result' && this.hasPendingSteer()) {
494
- // priority: now ends the current segment and yields its result before the
495
- // cancelled/started receipts: a success after a tool call or mid-response
496
- // (null stop reason), or error_during_execution when the CLI aborted the
497
- // in-flight model call (the steer landed while the model was still thinking).
498
- // Hold it until that native boundary proves continuation of the logical turn
499
- // or its true completion; a steer must never open a new turn.
500
- this.pendingSteerResult = message
1180
+ if (!this.initialized) {
1181
+ if (isClaudePreInitHookMessage(message)) {
1182
+ this.admitPreInitializationHook(message)
1183
+ continue
1184
+ }
1185
+ if (message.type === 'system' && message.subtype === 'session_state_changed') {
1186
+ this.admitPreInitializationSessionState(message)
1187
+ continue
1188
+ }
1189
+ this.completeInitialization(message)
1190
+ for (const held of this.preInitSessionStates.splice(0))
1191
+ await this.mapMessage(held)
501
1192
  continue
502
1193
  }
503
- const completeMessage = message.type === 'user'
504
- ? await hydrateClaudeToolOutput(message, {
505
- transcriptPath: this.metadata.transcriptPath,
506
- toolNameById: this.eventMapperState.toolNameById,
507
- })
508
- : message
509
- for (const draft of mapClaudeMessage(completeMessage, this.eventMapperState)) this.pushEvent(draft)
1194
+ await this.mapMessage(message)
510
1195
  }
511
1196
  // An intentional stop already finalized the run; the ended stream is expected then.
512
1197
  if (this.finished)
@@ -514,7 +1199,17 @@ export class ClaudeRun implements AgentRun {
514
1199
  if (!this.initialized) {
515
1200
  throw new AgentProviderProtocolError({ message: 'Claude SDK ended before emitting system init.' })
516
1201
  }
517
- this.flushSteerResult()
1202
+ // A queued steer was accepted but never consumed or explicitly dropped. Its preceding
1203
+ // ask's held result cannot stand in for that owed input's completion.
1204
+ if (this.eventMapperState.queuedSteerIds.size > 0)
1205
+ throw new AgentProviderProtocolError({ message: 'Claude SDK ended before queued steering input started.' })
1206
+ // A superseded ask's held end is final once the stream can deliver no steer to continue it.
1207
+ await this.emitDrafts(flushHeldTurnEnd(this.eventMapperState), this.eventMapperState.interrupt)
1208
+ // The SDK reports non-zero exits and signals as errors, but a clean exit mid-turn ends the stream
1209
+ // without a result: that turn did not complete.
1210
+ if (this.eventMapperState.currentTurnId) {
1211
+ throw new AgentProviderProtocolError({ message: 'Claude SDK ended before the active turn reported its result.' })
1212
+ }
518
1213
  this.finish('completed')
519
1214
  }
520
1215
  catch (error) {
@@ -525,9 +1220,58 @@ export class ClaudeRun implements AgentRun {
525
1220
  }
526
1221
  }
527
1222
 
1223
+ private async mapMessage(message: SDKMessage): Promise<void> {
1224
+ const completeMessage = this.hydratePersistedToolOutput && message.type === 'user'
1225
+ ? await hydrateClaudeToolOutput(message, {
1226
+ transcriptPath: this.metadata.transcriptPath,
1227
+ toolNameById: this.eventMapperState.toolNameById,
1228
+ })
1229
+ : message
1230
+ // The mapper resets the interrupt when it ends the turn; the follow-up needs it.
1231
+ const interrupt = this.eventMapperState.interrupt
1232
+ const drafts = mapClaudeMessage(completeMessage, this.eventMapperState)
1233
+ for (const draft of drafts) this.pushEvent(draft)
1234
+ // A compaction's request result follows the lifecycle events of the message that settles it.
1235
+ this.observeCompactionResult(message)
1236
+ this.endCompactionWithItsTurn(drafts)
1237
+ await this.followUpCompletedTurn(drafts, interrupt)
1238
+ }
1239
+
1240
+ private async emitDrafts(drafts: AgentEventDraft[], interrupt: ClaudeEventMapperState['interrupt']): Promise<void> {
1241
+ for (const draft of drafts) this.pushEvent(draft)
1242
+ await this.followUpCompletedTurn(drafts, interrupt)
1243
+ }
1244
+
1245
+ private async followUpCompletedTurn(drafts: AgentEventDraft[], interrupt: ClaudeEventMapperState['interrupt']): Promise<void> {
1246
+ if (drafts.some(draft => draft.type === 'turn.ended' && draft.actor.type === 'main' && draft.payload.status === 'completed' && !interruptTargetsTurn(interrupt, draft.turnId)))
1247
+ await this.startSteeredNotificationFollowUp()
1248
+ }
1249
+
1250
+ private admitPreInitializationSessionState(message: Extract<SDKMessage, { type: 'system', subtype: 'session_state_changed' }>): void {
1251
+ if (message.session_id !== this.init.expectedSessionId) {
1252
+ throw new AgentProviderProtocolError({
1253
+ message: 'Claude SDK pre-init session state session_id does not match the requested session.',
1254
+ details: { expected: this.init.expectedSessionId, actual: message.session_id },
1255
+ })
1256
+ }
1257
+ this.preInitSessionStates.push(message)
1258
+ }
1259
+
1260
+ private admitPreInitializationHook(message: ClaudePreInitHookMessage): void {
1261
+ if (message.session_id !== this.init.expectedSessionId) {
1262
+ throw new AgentProviderProtocolError({
1263
+ message: 'Claude SDK pre-init hook session_id does not match the requested session.',
1264
+ details: { expected: this.init.expectedSessionId, actual: message.session_id },
1265
+ })
1266
+ }
1267
+ }
1268
+
528
1269
  private completeInitialization(message: SDKMessage): void {
529
1270
  if (message.type !== 'system' || message.subtype !== 'init') {
530
- throw new AgentProviderProtocolError({ message: 'Claude SDK emitted an unexpected message before system init.' })
1271
+ throw new AgentProviderProtocolError({
1272
+ message: 'Claude SDK emitted an unexpected message before system init.',
1273
+ details: { type: message.type, subtype: 'subtype' in message ? message.subtype : undefined },
1274
+ })
531
1275
  }
532
1276
  const init: ClaudeInitMessage = message
533
1277
  if (!init.session_id) {
@@ -539,7 +1283,7 @@ export class ClaudeRun implements AgentRun {
539
1283
  details: { expected: this.init.expectedSessionId, actual: init.session_id },
540
1284
  })
541
1285
  }
542
- const effectiveModel = init.model ?? this.init.requestedModel
1286
+ const effectiveModel = init.model ?? this.current.model
543
1287
  if (!effectiveModel) {
544
1288
  throw new AgentProviderMetadataError({ message: 'Claude SDK init did not include model.', details: { provider: 'claude' } })
545
1289
  }
@@ -549,25 +1293,26 @@ export class ClaudeRun implements AgentRun {
549
1293
  this.pushEvent(sessionConfiguredEvent({
550
1294
  model: effectiveModel,
551
1295
  cwd: init.cwd ?? this.metadata.cwd,
552
- reasoningEffort: this.init.reasoningEffort,
1296
+ reasoningEffort: this.current.effort,
553
1297
  toolNames: init.tools,
554
1298
  environment: this.init.environment,
555
1299
  }))
556
1300
  this.pushEvent({ type: 'session.state.changed', payload: { state: 'ready' } })
557
1301
  for (const draft of mapClaudeMessage(init, this.eventMapperState)) this.pushEvent(draft)
558
1302
  this.initialized = true
1303
+ this.skillCatalog.markReady()
559
1304
  this.recordStartup?.('session_ready')
560
- this.initializationResolve()
1305
+ this.initializationResolve(effectiveModel)
561
1306
  }
562
1307
 
563
1308
  private failRun(error: unknown): void {
564
1309
  if (this.finished)
565
1310
  return
566
- const agentError = serializeAgentError(error)
1311
+ const agentError = withProcessStderr(serializeAgentError(error), this.process.stderrTail())
567
1312
  if (!this.initialized) {
568
1313
  // Pre-init failure: the first-input promise must reject typed instead of hanging.
569
1314
  this.initializationReject(error)
570
- this.query.close()
1315
+ this.closeQuery()
571
1316
  this.finish('failed', agentError)
572
1317
  return
573
1318
  }
@@ -575,6 +1320,14 @@ export class ClaudeRun implements AgentRun {
575
1320
  this.finish('failed', agentError)
576
1321
  }
577
1322
 
1323
+ // Query.close() ends the CLI's stdin; every ending path and the final exit wait share one call.
1324
+ private closeQuery(): void {
1325
+ if (this.queryClosed)
1326
+ return
1327
+ this.queryClosed = true
1328
+ this.query.close()
1329
+ }
1330
+
578
1331
  private assertRunning(): void {
579
1332
  if (this.finished)
580
1333
  throw new AgentRunStateError({ message: 'Claude run has stopped.' })
@@ -586,3 +1339,18 @@ export class ClaudeRun implements AgentRun {
586
1339
  }
587
1340
  }
588
1341
  }
1342
+
1343
+ // The SDK appends the CLI's stderr tail to its process-exit errors only for its own spawn; the run
1344
+ // spawns the CLI itself (claude-code-process.ts), so it restores that diagnostic here.
1345
+ function withProcessStderr(error: AgentError, stderrTail: string): AgentError {
1346
+ if (!stderrTail || !/^Claude Code process (?:exited with code|terminated by signal) \S+$/u.test(error.message))
1347
+ return error
1348
+ return { ...error, message: `${error.message}. stderr: ${stderrTail}` }
1349
+ }
1350
+
1351
+ // SDK 0.3.280 reports every input a result consumed; older pinned runtimes report only one.
1352
+ function resultUserMessageIds(message: Extract<SDKMessage, { type: 'result' }>): readonly string[] {
1353
+ if ('user_message_uuids' in message && Array.isArray(message.user_message_uuids))
1354
+ return message.user_message_uuids.filter((id): id is string => typeof id === 'string')
1355
+ return message.user_message_uuid ? [message.user_message_uuid] : []
1356
+ }