@meistrari/agent-core 0.2.0-experimental.1 → 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 (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 +864 -135
  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 +893 -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 +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 +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
@@ -2,7 +2,7 @@ import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
2
2
  import type { TaskCreateInput, TaskCreateOutput, TaskGetInput, TaskGetOutput, TaskListInput, TaskListOutput, TaskUpdateInput, TaskUpdateOutput } from '@anthropic-ai/claude-agent-sdk/sdk-tools'
3
3
  import type { MessageParam } from '@anthropic-ai/sdk/resources'
4
4
  import type { BetaMessage, BetaRawMessageStreamEvent, BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages'
5
- import type { AgentError, AgentEventActor, AgentUsage, JsonValue } from '../../protocol'
5
+ import type { AgentContextSnapshot, AgentError, AgentEventActor, AgentToolContent, AgentUsage, JsonValue } from '../../protocol'
6
6
  import type { AgentWorkObservation } from '../../protocol/agent-work'
7
7
  import type { AgentEventDraft } from '../agent-event-stream'
8
8
  import { randomUUID } from 'node:crypto'
@@ -12,7 +12,9 @@ import { canonicalToolName } from '../../protocol/agent-tool-name'
12
12
  import { agentWorkStatusSchema } from '../../protocol/agent-work'
13
13
  import { createRuntimeTurnId } from '../agent-id'
14
14
  import { AgentProviderProtocolError } from '../agent-runtime-error'
15
+ import { isJsonObject, jsonToolContent, mcpToolContent, ownJsonSlot } from '../mcp-tool-content'
15
16
  import { optionalNonEmpty, removeUndefined } from '../normalize'
17
+ import { claudeFileChangeText } from './claude-file-change'
16
18
  import { CLAUDE_AGENT_TOOL_SERVER } from './claude-tool-mapper'
17
19
 
18
20
  type UserContentBlock = Exclude<MessageParam['content'], string>[number]
@@ -23,6 +25,18 @@ type ClaudeTaskStartedMessage = Extract<SDKMessage, { type: 'system', subtype: '
23
25
  type ClaudeTaskProgressMessage = Extract<SDKMessage, { type: 'system', subtype: 'task_progress' }>
24
26
  type ClaudeTaskNotificationMessage = Extract<SDKMessage, { type: 'system', subtype: 'task_notification' }>
25
27
  type ClaudeUserMessage = Extract<SDKMessage, { type: 'user' }>
28
+ type ClaudeRateLimitInfo = Extract<SDKMessage, { type: 'rate_limit_event' }>['rate_limit_info']
29
+ type ClaudeResultMessage = Extract<SDKMessage, { type: 'result' }>
30
+ /** The main agent's latest context observation in the current turn: the input side of its last model response. */
31
+ type ClaudeContextObservation = { status: 'none' } | { status: 'observed', model: string, usedTokens: number }
32
+ /**
33
+ * Latest subscription usage-limit fact from `rate_limit_event`; a new event overwrites it. A rejection
34
+ * with a reset time (epoch seconds) stays in force until then, because the CLI throttles repeats of an
35
+ * unchanged rejection; one without a reset time applies only to the turn it preceded.
36
+ */
37
+ type ClaudeUsageLimit
38
+ = | { status: 'available' }
39
+ | { status: 'exhausted', resetsAt?: number, rateLimitType?: ClaudeRateLimitInfo['rateLimitType'] }
26
40
  type ClaudeSubagentActor = Extract<AgentEventActor, { type: 'subagent' }>
27
41
  type TaskCreateWorkInput = Pick<TaskCreateInput, 'subject' | 'description'>
28
42
  type TaskGetWorkInput = Pick<TaskGetInput, 'taskId'>
@@ -43,6 +57,11 @@ type ClaudeUnownedTurnEventDraft = ClaudeTurnEventDraft extends infer Draft
43
57
  ? Omit<Draft, 'actor'>
44
58
  : never
45
59
  : never
60
+ interface ClaudeActorContext { actor: AgentEventActor, turnId: string }
61
+ type ClaudeTaskActorResolution
62
+ = | { status: 'resolved', context: { actor: ClaudeSubagentActor, turnId: string } }
63
+ | { status: 'pending' }
64
+ | { status: 'conflict' }
46
65
 
47
66
  const mainActor = { type: 'main', actorId: 'main' } satisfies AgentEventActor
48
67
 
@@ -99,6 +118,14 @@ export interface ClaudeEventMapperState {
99
118
  readonly usageScopeId: string
100
119
  readonly toolNameById: Map<string, string>
101
120
  readonly pendingWorkCallById: Map<string, PendingClaudeWorkCall>
121
+ readonly hostMcpToolCallIds: Set<string>
122
+ readonly mcpToolCallIdByTaskId: Map<string, string | undefined>
123
+ readonly endedMcpTaskIds: Set<string>
124
+ readonly conflictingMcpTaskIds: Set<string>
125
+ /** Tasks that are neither MCP tasks nor visible subagents (for example backgrounded Bash); their lifecycle has no event. */
126
+ readonly nonSubagentTaskIds: Set<string>
127
+ /** Initiating tool calls of those tasks; frames forwarded under them have no visible owner. */
128
+ readonly nonSubagentToolCallIds: Set<string>
102
129
  readonly startedMessageIds: Set<string>
103
130
  readonly endedMessageIds: Set<string>
104
131
  readonly streamedTextByMessageId: Map<string, string>
@@ -106,12 +133,39 @@ export interface ClaudeEventMapperState {
106
133
  readonly streamedReasoningByMessageId: Map<string, string>
107
134
  readonly contentBlockTypeByIndex: Map<number, string>
108
135
  readonly subagentByToolCallId: Map<string, ClaudeSubagentActor>
136
+ readonly subagentByTaskId: Map<string, ClaudeSubagentActor>
137
+ readonly toolOwnerById: Map<string, ClaudeActorContext>
138
+ readonly turnIdBySubagentId: Map<string, string>
139
+ /** Initiating tool call of each subagent's current run: its Agent call, then each SendMessage resume. */
140
+ readonly runToolCallIdBySubagentId: Map<string, string>
141
+ readonly pendingMessages: SDKMessage[]
142
+ readonly startedSubagentIds: Set<string>
143
+ readonly endedSubagentIds: Set<string>
109
144
  readonly actorByMessageId: Map<string, AgentEventActor>
145
+ readonly currentMessageIdByActorId: Map<string, string>
146
+ readonly contentBlockTypeByActorId: Map<string, Map<number, string>>
110
147
  readonly startedReasoningIds: Set<string>
111
148
  readonly endedReasoningIds: Set<string>
112
149
  turnStarted: boolean
113
150
  interrupt: { status: 'none' } | { status: 'requesting' | 'accepted', turnId: string }
151
+ contextObservation: ClaudeContextObservation
152
+ /** The last context snapshot reported in the current turn, so repeated frames do not re-report it. */
153
+ reportedContext?: AgentContextSnapshot
154
+ /** Context windows the CLI reported per model key in result `modelUsage`, reused for mid-turn snapshots. */
155
+ readonly contextWindowByModel: Map<string, number>
156
+ usageLimit: ClaudeUsageLimit
157
+ mainTurnRateLimited: boolean
158
+ sessionCwd?: string
159
+ sessionModel?: string
114
160
  sessionState?: 'running' | 'idle'
161
+ /** The CLI reports its own session state (CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS), so its `idle` ends the session's work. */
162
+ cliReportsSessionState?: true
163
+ /** Steering (`now`) inputs the CLI queued and has not started or dropped; they supersede the running ask. */
164
+ readonly queuedSteerIds: Set<string>
165
+ /** Submitted input identity until the main response confirms consumption. */
166
+ readonly promptCommandByInputId: Map<string, string>
167
+ /** The end of a turn whose ask a queued steer superseded; the turn continues if the steer starts. */
168
+ heldTurnEnd?: Extract<AgentEventDraft, { type: 'turn.ended' }>
115
169
  currentTurnId?: string
116
170
  currentCommandId?: string
117
171
  currentMessageId?: string
@@ -123,6 +177,12 @@ export function createClaudeEventMapperState(): ClaudeEventMapperState {
123
177
  usageScopeId: `claude-query:${randomUUID()}`,
124
178
  toolNameById: new Map(),
125
179
  pendingWorkCallById: new Map(),
180
+ hostMcpToolCallIds: new Set(),
181
+ mcpToolCallIdByTaskId: new Map(),
182
+ endedMcpTaskIds: new Set(),
183
+ conflictingMcpTaskIds: new Set(),
184
+ nonSubagentTaskIds: new Set(),
185
+ nonSubagentToolCallIds: new Set(),
126
186
  startedMessageIds: new Set(),
127
187
  endedMessageIds: new Set(),
128
188
  streamedTextByMessageId: new Map(),
@@ -130,72 +190,158 @@ export function createClaudeEventMapperState(): ClaudeEventMapperState {
130
190
  streamedReasoningByMessageId: new Map(),
131
191
  contentBlockTypeByIndex: new Map(),
132
192
  subagentByToolCallId: new Map(),
193
+ subagentByTaskId: new Map(),
194
+ toolOwnerById: new Map(),
195
+ turnIdBySubagentId: new Map(),
196
+ runToolCallIdBySubagentId: new Map(),
197
+ pendingMessages: [],
198
+ startedSubagentIds: new Set(),
199
+ endedSubagentIds: new Set(),
133
200
  actorByMessageId: new Map(),
201
+ currentMessageIdByActorId: new Map(),
202
+ contentBlockTypeByActorId: new Map(),
134
203
  startedReasoningIds: new Set(),
135
204
  endedReasoningIds: new Set(),
136
205
  turnStarted: false,
137
206
  interrupt: { status: 'none' },
207
+ contextObservation: { status: 'none' },
208
+ contextWindowByModel: new Map(),
209
+ queuedSteerIds: new Set(),
210
+ promptCommandByInputId: new Map(),
211
+ usageLimit: { status: 'available' },
212
+ mainTurnRateLimited: false,
138
213
  }
139
214
  }
140
215
 
141
- export function mapClaudeMessage(message: SDKMessage, state: ClaudeEventMapperState, options: { continueTurn?: boolean } = {}): AgentEventDraft[] {
216
+ export function mapClaudeMessage(message: SDKMessage, state: ClaudeEventMapperState): AgentEventDraft[] {
217
+ const drafts = mapClaudeMessageWithKnownActor(message, state)
218
+ if (drafts === undefined) {
219
+ state.pendingMessages.push(message)
220
+ return []
221
+ }
222
+ return [...drafts, ...flushPendingMessages(state)]
223
+ }
224
+
225
+ function mapClaudeMessageWithKnownActor(message: SDKMessage, state: ClaudeEventMapperState): AgentEventDraft[] | undefined {
142
226
  const drafts: AgentEventDraft[] = []
143
227
 
228
+ if ('parent_tool_use_id' in message && message.parent_tool_use_id !== null && state.nonSubagentToolCallIds.has(message.parent_tool_use_id))
229
+ return drafts
230
+
231
+ // allowed/allowed_warning snapshots, system/api_retry, and auth_status have no provider-neutral
232
+ // event; Codex likewise drops its rate-limit snapshots and will-retry errors. Only a rejected
233
+ // limit is retained, to classify the turn failure it causes.
234
+ if (message.type === 'rate_limit_event') {
235
+ state.usageLimit = usageLimitFromRateLimitInfo(message.rate_limit_info)
236
+ return drafts
237
+ }
238
+
144
239
  if (message.type === 'system') {
240
+ if (message.subtype === 'init') {
241
+ state.sessionCwd = message.cwd
242
+ state.sessionModel = message.model
243
+ }
244
+ if (message.subtype === 'session_state_changed')
245
+ state.cliReportsSessionState = true
145
246
  if (message.subtype === 'session_state_changed' && message.state === 'running')
146
247
  ensureTurnStarted(drafts, state)
147
- if (message.subtype === 'session_state_changed' && message.state === 'idle')
248
+ if (message.subtype === 'session_state_changed' && message.state === 'idle') {
249
+ drafts.push(...flushHeldTurnEnd(state))
148
250
  ensureSessionIdle(drafts, state)
251
+ }
149
252
  if (message.subtype === 'status')
150
253
  drafts.push(...mapStatusMessage(message, state))
151
254
  if (message.subtype === 'compact_boundary')
152
255
  drafts.push(...mapCompactBoundaryMessage(message, state))
153
- if (message.subtype === 'task_started')
154
- drafts.push(...mapTaskStartedMessage(message, state))
155
- if (message.subtype === 'task_progress')
156
- drafts.push(...mapTaskProgressMessage(message, state))
157
- if (message.subtype === 'task_notification')
158
- drafts.push(...mapTaskNotificationMessage(message, state))
256
+ if (message.subtype === 'task_started') {
257
+ const taskDrafts = mapTaskStartedMessage(message, state)
258
+ if (!taskDrafts)
259
+ return undefined
260
+ drafts.push(...taskDrafts)
261
+ }
262
+ if (message.subtype === 'task_progress') {
263
+ const taskDrafts = mapTaskProgressMessage(message, state)
264
+ if (!taskDrafts)
265
+ return undefined
266
+ drafts.push(...taskDrafts)
267
+ }
268
+ if (message.subtype === 'task_notification') {
269
+ const taskDrafts = mapTaskNotificationMessage(message, state)
270
+ if (!taskDrafts)
271
+ return undefined
272
+ drafts.push(...taskDrafts)
273
+ }
159
274
  return drafts
160
275
  }
161
276
 
162
277
  if (message.type === 'assistant') {
163
- const turnId = ensureTurnStarted(drafts, state)
164
- const actor = actorFromParentToolUseId(message.parent_tool_use_id, state)
278
+ const context = actorContextFromParentToolUseId(message.parent_tool_use_id, drafts, state)
279
+ if (!context)
280
+ return undefined
281
+ const { actor, turnId } = context
282
+ if (actor.type === 'main') {
283
+ correlateConsumedPrompts({ message, turnId, drafts, state })
284
+ observeMainAssistantMessage(message, state)
285
+ }
165
286
  ensureAssistantMessageStarted(drafts, state, message.message, turnId, actor)
166
- drafts.push(...mapAssistantContent(message.message, state, turnId, actor, message.parent_tool_use_id === null))
287
+ if (actor.type === 'main')
288
+ reportContextSnapshot({ drafts, state, turnId })
289
+ drafts.push(...mapAssistantContent(message.message, state, turnId, actor, actor.type === 'main'))
167
290
  if (message.message.stop_reason !== null || shouldEndAssistantSnapshot(message.message, state, actor))
168
291
  endAssistantMessage(drafts, state, message.message, turnId, actor)
169
292
  return drafts
170
293
  }
171
294
 
172
295
  if (message.type === 'stream_event') {
173
- const turnId = ensureTurnStarted(drafts, state)
174
- const actor = actorFromParentToolUseId(message.parent_tool_use_id, state)
175
- drafts.push(...mapStreamEvent(message.event, state, turnId, actor))
296
+ const context = actorContextFromParentToolUseId(message.parent_tool_use_id, drafts, state)
297
+ if (!context)
298
+ return undefined
299
+ if (context.actor.type === 'main')
300
+ correlateConsumedPrompts({ message, turnId: context.turnId, drafts, state })
301
+ drafts.push(...mapStreamEvent(message.event, state, context.turnId, context.actor))
176
302
  return drafts
177
303
  }
178
304
 
179
305
  if (message.type === 'user') {
180
- const turnId = ensureTurnStarted(drafts, state)
181
- const actor = actorFromParentToolUseId(message.parent_tool_use_id, state)
306
+ const context = actorContextFromParentToolUseId(message.parent_tool_use_id, drafts, state)
307
+ if (!context)
308
+ return undefined
309
+ const { actor, turnId } = context
182
310
  const content = message.message.content
183
311
  const messageId = message.uuid ?? `user-${turnId}`
184
312
  if (typeof content === 'string')
185
- return actor ? subagentUserMessageEvents({ messageId, text: content, turnId, actor }) : drafts
186
- if (actor)
313
+ return actor.type === 'subagent' ? subagentUserMessageEvents({ messageId, text: content, turnId, actor }) : drafts
314
+ if (actor.type === 'subagent')
187
315
  drafts.push(...subagentUserContentEvents({ messageId, content, turnId, actor }))
188
- drafts.push(...content.flatMap(block => mapUserContentBlock(block, message, state, turnId, actor, message.parent_tool_use_id === null)))
316
+ const hostTrailerToolCallId = correlatedHostTrailerToolCallId({ content, message, state })
317
+ const structuredResultToolCallId = soleToolResultToolCallId(content)
318
+ drafts.push(...content.flatMap(block => mapUserContentBlock({
319
+ block,
320
+ message,
321
+ state,
322
+ turnId,
323
+ actor,
324
+ isMain: actor.type === 'main',
325
+ hostTrailer: block.type === 'tool_result' && block.tool_use_id === hostTrailerToolCallId ? message.tool_use_result : undefined,
326
+ ownsStructuredResult: block.type === 'tool_result' && block.tool_use_id === structuredResultToolCallId,
327
+ })))
189
328
  return drafts
190
329
  }
191
330
 
192
331
  if (message.type === 'result') {
193
332
  const turnId = ensureTurnStarted(drafts, state)
333
+ correlateConsumedPrompts({ message, turnId, drafts, state })
194
334
  const usage = usageFromObject(message.usage)
195
- // SDK usage is per result, but modelUsage is cumulative within this query process.
196
- // A resumed CLI resets modelUsage even when it retains the native session ID.
197
- // Keep display counters separate; the control plane deduplicates/differences accounting.
198
- if (usage) {
335
+ // Claude Agent SDK result usage is per completed ask/turn for the stream-json path we use,
336
+ // not a monotonic counter across the long-lived query. Emit it directly so summing
337
+ // normalized usage events gives the run total; do not diff against prior results.
338
+ // modelUsage is cumulative within this query process and resets when a resumed CLI starts;
339
+ // accounting keeps that distinction so the control plane can deduplicate observations.
340
+ // Accounting needs the result's own identity; a result without one still reports usage.
341
+ if (usage && !message.uuid) {
342
+ drafts.push({ type: 'usage', turnId, actor: mainActor, payload: { usage } })
343
+ }
344
+ else if (usage) {
199
345
  drafts.push({ type: 'usage', turnId, actor: mainActor, payload: { usage, accounting: {
200
346
  observationId: message.uuid,
201
347
  mode: Object.keys(message.modelUsage ?? {}).length ? 'cumulative' : 'increment',
@@ -210,36 +356,64 @@ export function mapClaudeMessage(message: SDKMessage, state: ClaudeEventMapperSt
210
356
  }])),
211
357
  } } })
212
358
  }
213
- // A native steer may finish one query segment while retaining the logical turn.
214
- // Usage belongs to this segment and must still be emitted exactly once. The segment
215
- // may have been aborted mid-generation (error_during_execution): close whatever it
216
- // streamed so the steer's user message follows a closed assistant step.
217
- if (options.continueTurn) {
218
- endOpenReasoning(drafts, state, turnId)
219
- endOpenAssistantMessage(drafts, state, turnId)
220
- return drafts
221
- }
359
+ reportContextSnapshot({ drafts, state, turnId, result: message })
222
360
  endOpenReasoning(drafts, state, turnId)
223
361
  const interruptAccepted = state.interrupt.status === 'accepted' && state.interrupt.turnId === turnId
224
- drafts.push({ type: 'turn.ended', turnId, actor: mainActor, payload: turnEndedPayload(message, interruptAccepted) })
225
- ensureSessionIdle(drafts, state)
362
+ const usageLimit = state.mainTurnRateLimited ? usageLimitInForce(state.usageLimit, Date.now()) : undefined
363
+ const ended: Extract<AgentEventDraft, { type: 'turn.ended' }> = { type: 'turn.ended', turnId, actor: mainActor, payload: turnEndedPayload({ message, interruptAccepted, usageLimit }) }
226
364
  state.pendingWorkCallById.clear()
227
- state.turnStarted = false
228
- state.interrupt = { status: 'none' }
229
- state.currentTurnId = undefined
230
- state.currentCommandId = undefined
365
+ state.contextObservation = { status: 'none' }
366
+ const heldBySteer = !interruptAccepted && state.queuedSteerIds.size > 0
367
+ // A superseded ask may end mid-response: close the streamed message before the steer's
368
+ // input can enter the continued turn.
369
+ if (heldBySteer)
370
+ endOpenAssistantMessage(drafts, state, turnId)
231
371
  state.currentMessageId = undefined
372
+ state.contentBlockTypeByIndex.clear()
232
373
  state.currentCompactionId = undefined
374
+ // Claude Code steers by superseding: a `now` input cancels the running ask, which ends with
375
+ // this result (`error_during_execution` before any model output), and the CLI then starts the
376
+ // steer on the same transcript. The turn stays open until the steer starts (it continues) or is
377
+ // dropped, or the CLI goes idle (the held end is final). An accepted interrupt always ends it.
378
+ if (heldBySteer) {
379
+ state.heldTurnEnd = ended
380
+ return drafts
381
+ }
382
+ drafts.push(ended)
383
+ endTurn(drafts, state)
233
384
  return drafts
234
385
  }
235
386
 
236
387
  return drafts
237
388
  }
238
389
 
390
+ function correlateConsumedPrompts({ message, turnId, drafts, state }: {
391
+ message: Extract<SDKMessage, { type: 'assistant' | 'stream_event' | 'result' }>
392
+ turnId: string
393
+ drafts: AgentEventDraft[]
394
+ state: ClaudeEventMapperState
395
+ }): void {
396
+ // Pair with the turn assigned to this very frame, after ensureTurnStarted and before result
397
+ // clears it. Lifecycle receipts and the turn active at submission do not prove consumption.
398
+ // SDK 0.3.257 declares only the singular field; 0.3.280 adds merged-input UUIDs.
399
+ const inputIds = 'user_message_uuids' in message && Array.isArray(message.user_message_uuids)
400
+ ? message.user_message_uuids.filter((id): id is string => typeof id === 'string')
401
+ : message.user_message_uuid ? [message.user_message_uuid] : []
402
+ for (const inputId of inputIds) {
403
+ const commandId = state.promptCommandByInputId.get(inputId)
404
+ if (!commandId)
405
+ continue
406
+ drafts.push({ type: 'prompt.turn-correlated', payload: { commandId, turnId } })
407
+ state.promptCommandByInputId.delete(inputId)
408
+ }
409
+ }
410
+
239
411
  function mapStatusMessage(message: ClaudeStatusMessage, state: ClaudeEventMapperState): AgentEventDraft[] {
240
412
  const drafts: AgentEventDraft[] = []
241
413
  if (message.status === 'compacting') {
242
414
  const turnId = ensureTurnStarted(drafts, state)
415
+ state.contextObservation = { status: 'none' }
416
+ state.reportedContext = undefined
243
417
  if (state.currentCompactionId)
244
418
  return drafts
245
419
  state.currentCompactionId = message.uuid
@@ -258,7 +432,7 @@ function mapStatusMessage(message: ClaudeStatusMessage, state: ClaudeEventMapper
258
432
  compactionId,
259
433
  trigger: 'unknown',
260
434
  status: 'failed',
261
- error: { message: message.compact_error ?? 'Claude compaction failed.' },
435
+ error: compactionError(message),
262
436
  },
263
437
  })
264
438
  state.currentCompactionId = undefined
@@ -267,13 +441,44 @@ function mapStatusMessage(message: ClaudeStatusMessage, state: ClaudeEventMapper
267
441
  return drafts
268
442
  }
269
443
 
270
- function mapTaskStartedMessage(message: ClaudeTaskStartedMessage, state: ClaudeEventMapperState): AgentEventDraft[] {
271
- const drafts: AgentEventDraft[] = []
272
- const turnId = ensureTurnStarted(drafts, state)
273
- const actor = subagentActorFromTask({ subagentId: message.task_id, parentToolCallId: message.tool_use_id, name: message.subagent_type })
274
- if (message.tool_use_id)
275
- state.subagentByToolCallId.set(message.tool_use_id, actor)
276
- drafts.push({
444
+ function isMcpTaskMessage(message: { task_id: string, tool_use_id?: string }, state: ClaudeEventMapperState): boolean {
445
+ if (state.mcpToolCallIdByTaskId.has(message.task_id))
446
+ return true
447
+ const name = message.tool_use_id ? state.toolNameById.get(message.tool_use_id) : undefined
448
+ return name !== undefined && isHostMcpToolName(name)
449
+ }
450
+
451
+ /**
452
+ * Only an Agent-tool child (`local_agent`) is a subagent; the CLI also reports backgrounded Bash,
453
+ * workflows, monitors, and other tasks through the same lifecycle. The pinned CLI always sets the
454
+ * task type; an untyped task keeps the subagent mapping its ancestry evidence supports. The SDK
455
+ * marks housekeeping tasks `skip_transcript` for hosts to hide.
456
+ */
457
+ function isVisibleSubagentTask(message: ClaudeTaskStartedMessage): boolean {
458
+ return (message.task_type === undefined || message.task_type === 'local_agent') && message.skip_transcript !== true
459
+ }
460
+
461
+ /**
462
+ * SendMessage resumes a finished subagent as a new run: the CLI repeats its task_id with the
463
+ * SendMessage call as tool_use_id. The run keeps the subagent's identity and turn, so it reopens the
464
+ * same subagent. Returns null when the message is not a resume of a known subagent.
465
+ */
466
+ function mapResumedSubagentRun(message: ClaudeTaskStartedMessage, state: ClaudeEventMapperState): AgentEventDraft[] | undefined | null {
467
+ const actor = state.subagentByTaskId.get(message.task_id)
468
+ const toolCallId = message.tool_use_id
469
+ if (!actor || !toolCallId || toolCallId === actor.parentToolCallId || state.subagentByToolCallId.has(toolCallId))
470
+ return null
471
+ const owner = state.toolOwnerById.get(toolCallId)
472
+ if (!owner)
473
+ return undefined
474
+ const turnId = state.turnIdBySubagentId.get(message.task_id)
475
+ if (owner.actor.actorId !== actor.parentActorId || !turnId)
476
+ return []
477
+
478
+ state.subagentByToolCallId.set(toolCallId, actor)
479
+ state.runToolCallIdBySubagentId.set(message.task_id, toolCallId)
480
+ state.endedSubagentIds.delete(message.task_id)
481
+ return [{
277
482
  type: 'subagent.started',
278
483
  turnId,
279
484
  actor,
@@ -281,51 +486,134 @@ function mapTaskStartedMessage(message: ClaudeTaskStartedMessage, state: ClaudeE
281
486
  ...subagentPayloadRef(actor),
282
487
  description: message.description,
283
488
  }),
284
- })
285
- return drafts
489
+ }]
286
490
  }
287
491
 
288
- function mapTaskProgressMessage(message: ClaudeTaskProgressMessage, state: ClaudeEventMapperState): AgentEventDraft[] {
289
- const drafts: AgentEventDraft[] = []
290
- const turnId = ensureTurnStarted(drafts, state)
291
- const actor = subagentActorForTaskMessage({ subagentId: message.task_id, parentToolCallId: message.tool_use_id, name: message.subagent_type }, state)
292
- drafts.push({
293
- type: 'subagent.progress',
492
+ /** A resumed subagent must not take progress or a terminal from a run that SendMessage superseded. */
493
+ function isEarlierSubagentRun(message: { task_id: string, tool_use_id?: string }, state: ClaudeEventMapperState): boolean {
494
+ const runToolCallId = state.runToolCallIdBySubagentId.get(message.task_id)
495
+ return message.tool_use_id !== undefined && runToolCallId !== undefined && message.tool_use_id !== runToolCallId
496
+ }
497
+
498
+ function mapTaskStartedMessage(message: ClaudeTaskStartedMessage, state: ClaudeEventMapperState): AgentEventDraft[] | undefined {
499
+ if (message.task_type === 'mcp_task' || isMcpTaskMessage(message, state)) {
500
+ const previous = state.mcpToolCallIdByTaskId.get(message.task_id)
501
+ if (previous && message.tool_use_id && previous !== message.tool_use_id)
502
+ state.conflictingMcpTaskIds.add(message.task_id)
503
+ else
504
+ state.mcpToolCallIdByTaskId.set(message.task_id, previous ?? message.tool_use_id)
505
+ return []
506
+ }
507
+ if (!isVisibleSubagentTask(message)) {
508
+ state.nonSubagentTaskIds.add(message.task_id)
509
+ if (message.tool_use_id)
510
+ state.nonSubagentToolCallIds.add(message.tool_use_id)
511
+ return []
512
+ }
513
+ const resumed = mapResumedSubagentRun(message, state)
514
+ if (resumed !== null)
515
+ return resumed
516
+ const resolution = subagentActorForTaskMessage({ subagentId: message.task_id, parentToolCallId: message.tool_use_id, name: message.subagent_type }, state)
517
+ if (resolution.status === 'pending')
518
+ return undefined
519
+ if (resolution.status === 'conflict' || state.startedSubagentIds.has(message.task_id))
520
+ return []
521
+
522
+ state.startedSubagentIds.add(message.task_id)
523
+ const { actor, turnId } = resolution.context
524
+ return [{
525
+ type: 'subagent.started',
294
526
  turnId,
295
527
  actor,
296
528
  payload: removeUndefined({
297
529
  ...subagentPayloadRef(actor),
298
- summary: optionalNonEmpty(message.summary),
299
- lastToolName: optionalNonEmpty(message.last_tool_name),
300
- usage: usageFromTaskUsage(message.usage),
530
+ description: message.description,
301
531
  }),
302
- })
303
- return drafts
532
+ }]
304
533
  }
305
534
 
306
- function mapTaskNotificationMessage(message: ClaudeTaskNotificationMessage, state: ClaudeEventMapperState): AgentEventDraft[] {
307
- const drafts: AgentEventDraft[] = []
308
- const turnId = ensureTurnStarted(drafts, state)
309
- const actor = subagentActorForTaskMessage({ subagentId: message.task_id, parentToolCallId: message.tool_use_id }, state)
310
- drafts.push({
311
- type: 'subagent.ended',
535
+ function mapTaskProgressMessage(message: ClaudeTaskProgressMessage, state: ClaudeEventMapperState): AgentEventDraft[] | undefined {
536
+ if (isMcpTaskMessage(message, state)) {
537
+ if (!state.mcpToolCallIdByTaskId.has(message.task_id))
538
+ state.mcpToolCallIdByTaskId.set(message.task_id, message.tool_use_id)
539
+ return []
540
+ }
541
+ if (state.nonSubagentTaskIds.has(message.task_id))
542
+ return []
543
+ const resolution = subagentActorForTaskMessage({ subagentId: message.task_id, parentToolCallId: message.tool_use_id, name: message.subagent_type }, state)
544
+ if (resolution.status === 'pending')
545
+ return undefined
546
+ if (resolution.status === 'conflict' || isEarlierSubagentRun(message, state))
547
+ return []
548
+
549
+ const { actor, turnId } = resolution.context
550
+ return [{
551
+ type: 'subagent.progress',
312
552
  turnId,
313
553
  actor,
314
554
  payload: removeUndefined({
315
555
  ...subagentPayloadRef(actor),
316
- status: taskNotificationStatus(message.status),
317
556
  summary: optionalNonEmpty(message.summary),
318
- usage: message.usage ? usageFromTaskUsage(message.usage) : undefined,
319
- error: message.status === 'failed' ? { message: optionalNonEmpty(message.summary) ?? `Subagent ${message.task_id} failed.` } : undefined,
557
+ lastToolName: optionalNonEmpty(message.last_tool_name),
558
+ usage: usageFromTaskUsage(message.usage),
320
559
  }),
321
- })
322
- return drafts
560
+ }]
561
+ }
562
+
563
+ function mapTaskNotificationMessage(message: ClaudeTaskNotificationMessage, state: ClaudeEventMapperState): AgentEventDraft[] | undefined {
564
+ if (isMcpTaskMessage(message, state) || message.resource_links !== undefined) {
565
+ const rememberedToolCallId = state.mcpToolCallIdByTaskId.get(message.task_id)
566
+ if (rememberedToolCallId && message.tool_use_id && rememberedToolCallId !== message.tool_use_id)
567
+ state.conflictingMcpTaskIds.add(message.task_id)
568
+ if (state.endedMcpTaskIds.has(message.task_id) || state.conflictingMcpTaskIds.has(message.task_id))
569
+ return []
570
+ const toolCallId = message.tool_use_id ?? rememberedToolCallId
571
+ const owner = toolCallId ? state.toolOwnerById.get(toolCallId) : undefined
572
+ if (!toolCallId || !owner)
573
+ return undefined
574
+ state.endedMcpTaskIds.add(message.task_id)
575
+ const content: AgentToolContent[] = [{ type: 'text', text: message.summary }]
576
+ content.push(...(message.resource_links ?? []).map(link => resourceLinkContent(jsonValueSchema.parse(link))))
577
+ if (message.output_file !== '')
578
+ content.push(jsonToolContent({ outputFile: message.output_file }))
579
+ return [{
580
+ type: 'tool.output.updated',
581
+ ...owner,
582
+ payload: { toolCallId, output: { success: message.status === 'completed', content } },
583
+ }]
584
+ }
585
+ if (state.nonSubagentTaskIds.has(message.task_id))
586
+ return []
587
+ const resolution = subagentActorForTaskMessage({ subagentId: message.task_id, parentToolCallId: message.tool_use_id }, state)
588
+ if (resolution.status === 'pending')
589
+ return undefined
590
+ if (resolution.status === 'conflict' || isEarlierSubagentRun(message, state) || state.endedSubagentIds.has(message.task_id))
591
+ return []
592
+
593
+ state.endedSubagentIds.add(message.task_id)
594
+ const { actor, turnId } = resolution.context
595
+ return [
596
+ {
597
+ type: 'subagent.ended',
598
+ turnId,
599
+ actor,
600
+ payload: removeUndefined({
601
+ ...subagentPayloadRef(actor),
602
+ status: taskNotificationStatus(message.status),
603
+ summary: optionalNonEmpty(message.summary),
604
+ usage: message.usage ? usageFromTaskUsage(message.usage) : undefined,
605
+ error: message.status === 'failed' ? { message: optionalNonEmpty(message.summary) ?? `Subagent ${message.task_id} failed.` } : undefined,
606
+ }),
607
+ },
608
+ ]
323
609
  }
324
610
 
325
611
  function mapCompactBoundaryMessage(message: ClaudeCompactBoundaryMessage, state: ClaudeEventMapperState): AgentEventDraft[] {
326
612
  const drafts: AgentEventDraft[] = []
327
613
  const turnId = ensureTurnStarted(drafts, state)
328
614
  const compactionId = state.currentCompactionId ?? message.uuid
615
+ state.contextObservation = { status: 'none' }
616
+ state.reportedContext = undefined
329
617
  drafts.push({
330
618
  type: 'context.compaction.completed',
331
619
  turnId,
@@ -343,7 +631,7 @@ function mapCompactBoundaryMessage(message: ClaudeCompactBoundaryMessage, state:
343
631
  return drafts
344
632
  }
345
633
 
346
- function mapAssistantContent(message: BetaMessage, state: ClaudeEventMapperState, turnId: string, actor: AgentEventActor | undefined, isMain: boolean): AgentEventDraft[] {
634
+ function mapAssistantContent(message: BetaMessage, state: ClaudeEventMapperState, turnId: string, actor: AgentEventActor, isMain: boolean): AgentEventDraft[] {
347
635
  const drafts: AgentEventDraft[] = []
348
636
  const streamedText = state.streamedTextByMessageId.has(message.id)
349
637
  const streamedReasoning = state.streamedReasoningMessageIds.has(message.id)
@@ -360,6 +648,12 @@ function mapAssistantContent(message: BetaMessage, state: ClaudeEventMapperState
360
648
  if (block.type === 'tool_use') {
361
649
  const toolName = normalizeToolName(block.name)
362
650
  state.toolNameById.set(block.id, toolName)
651
+ if (isHostMcpToolName(block.name))
652
+ state.hostMcpToolCallIds.add(block.id)
653
+ // SDK task_id identifies the child, while task_started.tool_use_id identifies this
654
+ // initiating call; the actor that emitted the tool_use is the only ancestry evidence.
655
+ if (!state.toolOwnerById.has(block.id))
656
+ state.toolOwnerById.set(block.id, { actor, turnId })
363
657
  if (isMain) {
364
658
  const pendingWorkCall = pendingClaudeWorkCall(block.name, block.input)
365
659
  if (pendingWorkCall)
@@ -371,27 +665,53 @@ function mapAssistantContent(message: BetaMessage, state: ClaudeEventMapperState
371
665
  return drafts
372
666
  }
373
667
 
374
- function mapUserContentBlock(block: UserContentBlock, message: ClaudeUserMessage, state: ClaudeEventMapperState, turnId: string, actor: AgentEventActor | undefined, isMain: boolean): AgentEventDraft[] {
668
+ function mapUserContentBlock(input: {
669
+ block: UserContentBlock
670
+ message: ClaudeUserMessage
671
+ state: ClaudeEventMapperState
672
+ turnId: string
673
+ actor: AgentEventActor
674
+ isMain: boolean
675
+ hostTrailer?: unknown
676
+ ownsStructuredResult: boolean
677
+ }): AgentEventDraft[] {
678
+ const { block, message, state, turnId, actor, isMain, hostTrailer, ownsStructuredResult } = input
375
679
  if (block.type !== 'tool_result')
376
680
  return []
377
681
  const toolCallId = block.tool_use_id
378
682
  const pendingWorkCall = state.pendingWorkCallById.get(toolCallId)
379
683
  if (pendingWorkCall)
380
684
  state.pendingWorkCallById.delete(toolCallId)
381
-
382
- const text = textFromToolResultContent(block.content)
383
- const output = { success: block.is_error !== true, content: text ? [{ type: 'text' as const, text }] : [] }
685
+ state.hostMcpToolCallIds.delete(toolCallId)
686
+
687
+ const fileChangeText = ownsStructuredResult && block.is_error !== true
688
+ ? claudeFileChangeText({ toolName: state.toolNameById.get(toolCallId), toolUseResult: message.tool_use_result, cwd: state.sessionCwd })
689
+ : undefined
690
+ const mapped = fileChangeText === undefined
691
+ ? mappedModelToolResultContent({ content: block.content, isError: block.is_error === true })
692
+ : { content: [{ type: 'text' as const, text: fileChangeText }], text: fileChangeText }
693
+ const content = hostTrailer === undefined ? mapped.content : mappedHostMcpContent({ trailer: hostTrailer, modelBlocks: mapped.content })
694
+ const structuredContent = ownJsonSlot(hostTrailer, 'structuredContent')
695
+ const reportedError = ownJsonSlot(hostTrailer, 'isError')
696
+ const output = removeUndefined({
697
+ success: block.is_error !== true,
698
+ content,
699
+ structuredContent,
700
+ reportedSuccess: typeof reportedError === 'boolean' ? !reportedError : undefined,
701
+ })
384
702
  const drafts: AgentEventDraft[] = []
385
- if (text)
386
- drafts.push(withActor({ type: 'tool.output.delta', turnId, payload: { toolCallId, delta: text } }, actor))
703
+ if (mapped.text !== undefined)
704
+ drafts.push(withActor({ type: 'tool.output.delta', turnId, payload: { toolCallId, delta: mapped.text } }, actor))
387
705
  drafts.push(withActor({
388
706
  type: 'tool.call.completed',
389
707
  turnId,
390
708
  payload: removeUndefined({
391
709
  toolCallId,
710
+ toolName: state.toolNameById.get(toolCallId),
392
711
  status: block.is_error === true ? 'failed' : 'completed',
393
712
  output,
394
- error: block.is_error === true ? { message: text || `Tool ${state.toolNameById.get(toolCallId) ?? toolCallId} failed.` } : undefined,
713
+ // The failure detail already rides in output; like Codex, the error is a short summary.
714
+ error: block.is_error === true ? { message: `Tool ${state.toolNameById.get(toolCallId) ?? toolCallId} failed.` } : undefined,
395
715
  }),
396
716
  }, actor))
397
717
 
@@ -468,20 +788,19 @@ function parseClaudeTaskResult<Output>({ value, toolName, schema }: { value: unk
468
788
  })
469
789
  }
470
790
 
471
- function mapStreamEvent(event: BetaRawMessageStreamEvent, state: ClaudeEventMapperState, turnId: string, actor: AgentEventActor | undefined): AgentEventDraft[] {
791
+ function mapStreamEvent(event: BetaRawMessageStreamEvent, state: ClaudeEventMapperState, turnId: string, actor: AgentEventActor): AgentEventDraft[] {
472
792
  const drafts: AgentEventDraft[] = []
473
793
  if (event.type === 'message_start') {
474
- state.currentMessageId = event.message.id
475
- if (actor)
476
- state.actorByMessageId.set(event.message.id, actor)
794
+ setCurrentMessageId(state, actor, event.message.id)
795
+ state.actorByMessageId.set(event.message.id, actor)
477
796
  ensureAssistantMessageStarted(drafts, state, event.message, turnId, actor)
478
797
  return drafts
479
798
  }
480
799
 
481
800
  if (event.type === 'content_block_start') {
482
- const messageId = currentMessageId(state)
483
- const messageActor = actor ?? state.actorByMessageId.get(messageId)
484
- state.contentBlockTypeByIndex.set(event.index, event.content_block.type)
801
+ const messageId = currentMessageId(state, actor)
802
+ const messageActor = state.actorByMessageId.get(messageId) ?? actor
803
+ contentBlockTypes(state, actor).set(event.index, event.content_block.type)
485
804
  if (event.content_block.type === 'thinking') {
486
805
  state.streamedReasoningMessageIds.add(messageId)
487
806
  ensureReasoningStarted(drafts, state, turnId, messageId, messageActor)
@@ -490,30 +809,30 @@ function mapStreamEvent(event: BetaRawMessageStreamEvent, state: ClaudeEventMapp
490
809
  }
491
810
 
492
811
  if (event.type === 'content_block_stop') {
493
- const messageId = currentMessageId(state)
494
- const messageActor = actor ?? state.actorByMessageId.get(messageId)
495
- const blockType = state.contentBlockTypeByIndex.get(event.index)
812
+ const messageId = currentMessageId(state, actor)
813
+ const messageActor = state.actorByMessageId.get(messageId) ?? actor
814
+ const blockTypes = contentBlockTypes(state, actor)
815
+ const blockType = blockTypes.get(event.index)
496
816
  if (blockType === 'thinking')
497
817
  endReasoning(drafts, state, turnId, messageId, state.streamedReasoningByMessageId.get(messageId), messageActor)
498
- state.contentBlockTypeByIndex.delete(event.index)
818
+ blockTypes.delete(event.index)
499
819
  return drafts
500
820
  }
501
821
 
502
822
  if (event.type === 'message_stop') {
503
- const messageId = currentMessageId(state)
504
- const messageActor = actor ?? state.actorByMessageId.get(messageId)
823
+ const messageId = currentMessageId(state, actor)
824
+ const messageActor = state.actorByMessageId.get(messageId) ?? actor
505
825
  endAssistantMessageById(drafts, state, turnId, messageId, state.streamedTextByMessageId.get(messageId), messageActor)
506
826
  state.actorByMessageId.delete(messageId)
507
- state.currentMessageId = undefined
508
- state.contentBlockTypeByIndex.clear()
827
+ clearCurrentMessage(state, actor)
509
828
  return drafts
510
829
  }
511
830
 
512
831
  if (event.type !== 'content_block_delta')
513
832
  return drafts
514
833
 
515
- const messageId = currentMessageId(state)
516
- const messageActor = actor ?? state.actorByMessageId.get(messageId)
834
+ const messageId = currentMessageId(state, actor)
835
+ const messageActor = state.actorByMessageId.get(messageId) ?? actor
517
836
  const delta = event.delta
518
837
  if (delta.type === 'thinking_delta') {
519
838
  state.streamedReasoningMessageIds.add(messageId)
@@ -530,18 +849,64 @@ function mapStreamEvent(event: BetaRawMessageStreamEvent, state: ClaudeEventMapp
530
849
  }
531
850
 
532
851
  function ensureTurnStarted(drafts: AgentEventDraft[], state: ClaudeEventMapperState): string {
533
- if (state.turnStarted && state.currentTurnId)
534
- return state.currentTurnId
535
- state.turnStarted = true
536
- state.interrupt = { status: 'none' }
537
- state.currentTurnId = createRuntimeTurnId()
538
- state.currentMessageId = undefined
539
- state.contentBlockTypeByIndex.clear()
540
- drafts.push({ type: 'turn.started', turnId: state.currentTurnId, actor: mainActor, payload: removeUndefined({ commandId: state.currentCommandId }) })
852
+ if (!state.turnStarted || !state.currentTurnId) {
853
+ state.turnStarted = true
854
+ state.interrupt = { status: 'none' }
855
+ state.currentTurnId = createRuntimeTurnId()
856
+ state.currentMessageId = undefined
857
+ state.contentBlockTypeByIndex.clear()
858
+ drafts.push({ type: 'turn.started', turnId: state.currentTurnId, actor: mainActor, payload: removeUndefined({ commandId: state.currentCommandId }) })
859
+ }
541
860
  ensureSessionRunning(drafts, state)
542
861
  return state.currentTurnId
543
862
  }
544
863
 
864
+ // Turn-scoped state after the main turn's `turn.ended`. With CLI state events the CLI's own `idle`
865
+ // ends the session's work; without them the turn end is the only turn-over signal.
866
+ function endTurn(drafts: AgentEventDraft[], state: ClaudeEventMapperState): void {
867
+ if (!state.cliReportsSessionState)
868
+ ensureSessionIdle(drafts, state)
869
+ state.reportedContext = undefined
870
+ state.mainTurnRateLimited = false
871
+ state.usageLimit = usageLimitAfterTurn(state.usageLimit)
872
+ state.turnStarted = false
873
+ state.interrupt = { status: 'none' }
874
+ state.currentTurnId = undefined
875
+ state.currentCommandId = undefined
876
+ }
877
+
878
+ /** Emits a superseded turn's held end as final: its steer will not continue it. */
879
+ export function flushHeldTurnEnd(state: ClaudeEventMapperState): AgentEventDraft[] {
880
+ const held = state.heldTurnEnd
881
+ if (!held)
882
+ return []
883
+ state.heldTurnEnd = undefined
884
+ const drafts: AgentEventDraft[] = [held]
885
+ endTurn(drafts, state)
886
+ return drafts
887
+ }
888
+
889
+ /**
890
+ * Applies the CLI's `command_lifecycle` receipt for a steering input. `queued` marks a pending
891
+ * supersession; `started` continues the held turn (or starts one, so an interrupt has a target);
892
+ * any other state drops the steer, and the held end is final once no steer can continue it.
893
+ */
894
+ export function mapSteerLifecycle(input: { steerId: string, state: 'queued' | 'started' | 'completed' | 'cancelled' | 'discarded' | 'refused' }, state: ClaudeEventMapperState): AgentEventDraft[] {
895
+ if (input.state === 'queued') {
896
+ state.queuedSteerIds.add(input.steerId)
897
+ return []
898
+ }
899
+ if (!state.queuedSteerIds.delete(input.steerId) && input.state !== 'started')
900
+ return []
901
+ if (input.state === 'started') {
902
+ state.heldTurnEnd = undefined
903
+ const drafts: AgentEventDraft[] = []
904
+ ensureTurnStarted(drafts, state)
905
+ return drafts
906
+ }
907
+ return state.queuedSteerIds.size === 0 ? flushHeldTurnEnd(state) : []
908
+ }
909
+
545
910
  function ensureSessionRunning(drafts: AgentEventDraft[], state: ClaudeEventMapperState): void {
546
911
  if (state.sessionState === 'running')
547
912
  return
@@ -556,22 +921,49 @@ function ensureSessionIdle(drafts: AgentEventDraft[], state: ClaudeEventMapperSt
556
921
  drafts.push({ type: 'session.state.changed', payload: { state: 'idle' } })
557
922
  }
558
923
 
559
- function ensureAssistantMessageStarted(drafts: AgentEventDraft[], state: ClaudeEventMapperState, message: BetaMessage, turnId: string, actor: AgentEventActor | undefined): void {
560
- state.currentMessageId = message.id
561
- if (actor)
562
- state.actorByMessageId.set(message.id, actor)
924
+ function ensureAssistantMessageStarted(drafts: AgentEventDraft[], state: ClaudeEventMapperState, message: BetaMessage, turnId: string, actor: AgentEventActor): void {
925
+ setCurrentMessageId(state, actor, message.id)
926
+ state.actorByMessageId.set(message.id, actor)
563
927
  if (state.startedMessageIds.has(message.id))
564
928
  return
565
929
  state.startedMessageIds.add(message.id)
566
930
  drafts.push(withActor({ type: 'message.started', turnId, payload: { messageId: message.id, role: 'assistant' } }, actor))
567
931
  }
568
932
 
569
- function currentMessageId(state: ClaudeEventMapperState): string {
570
- if (state.currentMessageId)
571
- return state.currentMessageId
933
+ function setCurrentMessageId(state: ClaudeEventMapperState, actor: AgentEventActor, messageId: string): void {
934
+ if (actor.type === 'main')
935
+ state.currentMessageId = messageId
936
+ else state.currentMessageIdByActorId.set(actor.actorId, messageId)
937
+ }
938
+
939
+ function currentMessageId(state: ClaudeEventMapperState, actor: AgentEventActor): string {
940
+ const messageId = actor.type === 'main' ? state.currentMessageId : state.currentMessageIdByActorId.get(actor.actorId)
941
+ if (messageId)
942
+ return messageId
572
943
  throw new AgentProviderProtocolError({ message: 'Claude stream emitted content_block_delta before message_start.' })
573
944
  }
574
945
 
946
+ function contentBlockTypes(state: ClaudeEventMapperState, actor: AgentEventActor): Map<number, string> {
947
+ if (actor.type === 'main')
948
+ return state.contentBlockTypeByIndex
949
+ const existing = state.contentBlockTypeByActorId.get(actor.actorId)
950
+ if (existing)
951
+ return existing
952
+ const created = new Map<number, string>()
953
+ state.contentBlockTypeByActorId.set(actor.actorId, created)
954
+ return created
955
+ }
956
+
957
+ function clearCurrentMessage(state: ClaudeEventMapperState, actor: AgentEventActor): void {
958
+ if (actor.type === 'main') {
959
+ state.currentMessageId = undefined
960
+ state.contentBlockTypeByIndex.clear()
961
+ return
962
+ }
963
+ state.currentMessageIdByActorId.delete(actor.actorId)
964
+ state.contentBlockTypeByActorId.delete(actor.actorId)
965
+ }
966
+
575
967
  function endAssistantMessage(drafts: AgentEventDraft[], state: ClaudeEventMapperState, message: BetaMessage, turnId: string, actor: AgentEventActor | undefined): void {
576
968
  if (state.endedMessageIds.has(message.id))
577
969
  return
@@ -605,7 +997,9 @@ function endReasoning(drafts: AgentEventDraft[], state: ClaudeEventMapperState,
605
997
 
606
998
  function endOpenReasoning(drafts: AgentEventDraft[], state: ClaudeEventMapperState, turnId: string): void {
607
999
  for (const reasoningId of state.startedReasoningIds) {
608
- if (!state.endedReasoningIds.has(reasoningId)) {
1000
+ const messageId = reasoningId.slice('reasoning-'.length)
1001
+ const actor = state.actorByMessageId.get(messageId)
1002
+ if (actor?.type === 'main' && !state.endedReasoningIds.has(reasoningId)) {
609
1003
  state.endedReasoningIds.add(reasoningId)
610
1004
  drafts.push({ type: 'reasoning.ended', turnId, actor: mainActor, payload: { reasoningId } })
611
1005
  }
@@ -626,7 +1020,11 @@ function reasoningIdForMessage(messageId: string): string {
626
1020
  return `reasoning-${messageId}`
627
1021
  }
628
1022
 
629
- function turnEndedPayload(message: Extract<SDKMessage, { type: 'result' }>, interruptAccepted: boolean): Extract<AgentEventDraft, { type: 'turn.ended' }>['payload'] {
1023
+ function turnEndedPayload({ message, interruptAccepted, usageLimit }: {
1024
+ message: ClaudeResultMessage
1025
+ interruptAccepted: boolean
1026
+ usageLimit: Extract<ClaudeUsageLimit, { status: 'exhausted' }> | undefined
1027
+ }): Extract<AgentEventDraft, { type: 'turn.ended' }>['payload'] {
630
1028
  if (interruptAccepted && message.subtype === 'error_during_execution') {
631
1029
  return removeUndefined({
632
1030
  status: 'interrupted' as const,
@@ -647,45 +1045,227 @@ function turnEndedPayload(message: Extract<SDKMessage, { type: 'result' }>, inte
647
1045
  status: 'failed' as const,
648
1046
  reason: message.stop_reason ?? message.subtype,
649
1047
  duration: message.duration_ms,
650
- error: resultError(message),
1048
+ error: usageLimit ? usageLimitError({ error: resultError(message), usageLimit }) : resultError(message),
651
1049
  })
652
1050
  }
653
1051
 
654
- function resultError(message: Extract<SDKMessage, { type: 'result' }>): AgentError {
655
- const errors = 'errors' in message && Array.isArray(message.errors) ? message.errors : []
1052
+ // Codex reports a plan usage limit as codexErrorInfo `usageLimitExceeded`; reuse that code so
1053
+ // consumers classify both providers alike. The reset time rides on `cause` because AgentError
1054
+ // has no structured slot for it.
1055
+ function usageLimitError({ error, usageLimit }: { error: AgentError, usageLimit: Extract<ClaudeUsageLimit, { status: 'exhausted' }> }): AgentError {
1056
+ const resetsAt = usageLimit.resetsAt === undefined ? undefined : isoFromEpochSeconds(usageLimit.resetsAt)
1057
+ return removeUndefined({
1058
+ message: error.message,
1059
+ code: 'usageLimitExceeded',
1060
+ cause: resetsAt ? removeUndefined({ message: `Claude usage limit resets at ${resetsAt}.`, code: usageLimit.rateLimitType }) : undefined,
1061
+ })
1062
+ }
1063
+
1064
+ function usageLimitFromRateLimitInfo(info: ClaudeRateLimitInfo): ClaudeUsageLimit {
1065
+ if (info.status !== 'rejected')
1066
+ return { status: 'available' }
1067
+ const resetsAt = info.resetsAt !== undefined && Number.isFinite(info.resetsAt) && info.resetsAt > 0 ? info.resetsAt : undefined
1068
+ return removeUndefined({ status: 'exhausted' as const, resetsAt, rateLimitType: info.rateLimitType })
1069
+ }
1070
+
1071
+ // Mirrors the CLI's own expiry check (`resetsAt * 1000 <= Date.now()` on CLI 2.1.280).
1072
+ function usageLimitInForce(limit: ClaudeUsageLimit, nowMs: number): Extract<ClaudeUsageLimit, { status: 'exhausted' }> | undefined {
1073
+ if (limit.status !== 'exhausted')
1074
+ return undefined
1075
+ return limit.resetsAt === undefined || nowMs < limit.resetsAt * 1000 ? limit : undefined
1076
+ }
1077
+
1078
+ function usageLimitAfterTurn(limit: ClaudeUsageLimit): ClaudeUsageLimit {
1079
+ return limit.status === 'exhausted' && limit.resetsAt !== undefined ? limit : { status: 'available' }
1080
+ }
1081
+
1082
+ function isoFromEpochSeconds(seconds: number): string | undefined {
1083
+ const date = new Date(seconds * 1000)
1084
+ return Number.isFinite(seconds) && seconds > 0 && Number.isFinite(date.getTime()) ? date.toISOString() : undefined
1085
+ }
1086
+
1087
+ function observeMainAssistantMessage(message: Extract<SDKMessage, { type: 'assistant' }>, state: ClaudeEventMapperState): void {
1088
+ if (message.error === 'rate_limit')
1089
+ state.mainTurnRateLimited = true
1090
+ // Claude Code's own context report counts the last response's input side (verified against
1091
+ // getContextUsage on CLI 2.1.280); output is partial on streamed frames and previous thinking
1092
+ // is dropped from later requests. A zero input side is a synthetic frame, not an observation.
1093
+ const { usage } = message.message
1094
+ const usedTokens = usage.input_tokens + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0)
1095
+ if (usedTokens > 0 && Number.isSafeInteger(usedTokens))
1096
+ state.contextObservation = { status: 'observed', model: message.message.model, usedTokens }
1097
+ }
1098
+
1099
+ // Codex reports context on every token-usage update; Claude's equivalent is each main response,
1100
+ // so report whenever the snapshot changes and again at turn end once the result names the window.
1101
+ function reportContextSnapshot({ drafts, state, turnId, result }: {
1102
+ drafts: AgentEventDraft[]
1103
+ state: ClaudeEventMapperState
1104
+ turnId: string
1105
+ result?: ClaudeResultMessage
1106
+ }): void {
1107
+ if (state.contextObservation.status !== 'observed' || state.currentCompactionId !== undefined)
1108
+ return
1109
+ if (result)
1110
+ rememberContextWindows(result, state)
1111
+ const snapshot = contextSnapshot({ observation: state.contextObservation, state })
1112
+ if (state.reportedContext !== undefined && contextSnapshotsEqual(snapshot, state.reportedContext))
1113
+ return
1114
+ state.reportedContext = snapshot
1115
+ drafts.push({ type: 'context.updated', turnId, actor: mainActor, payload: { snapshot } })
1116
+ }
1117
+
1118
+ function rememberContextWindows(result: ClaudeResultMessage, state: ClaudeEventMapperState): void {
1119
+ for (const [model, usage] of Object.entries(result.modelUsage)) {
1120
+ if (Number.isSafeInteger(usage.contextWindow) && usage.contextWindow > 0)
1121
+ state.contextWindowByModel.set(model, usage.contextWindow)
1122
+ }
1123
+ }
1124
+
1125
+ function contextSnapshot({ observation, state }: {
1126
+ observation: Extract<ClaudeContextObservation, { status: 'observed' }>
1127
+ state: ClaudeEventMapperState
1128
+ }): AgentContextSnapshot {
1129
+ // modelUsage is keyed by the requested model string, which is the init model for an explicit id
1130
+ // and the resolved id (also the assistant model) for an alias.
1131
+ const capacityTokens = state.contextWindowByModel.get(observation.model)
1132
+ ?? (state.sessionModel === undefined ? undefined : state.contextWindowByModel.get(state.sessionModel))
1133
+ if (capacityTokens === undefined)
1134
+ return { status: 'observed', usedTokens: observation.usedTokens, window: { status: 'unknown' } }
1135
+ // Claude Code reports context against the raw window without a reserved baseline.
1136
+ const remainingPercent = Math.round(Math.min(Math.max(100 * (capacityTokens - observation.usedTokens) / capacityTokens, 0), 100))
1137
+ return { status: 'observed', usedTokens: observation.usedTokens, window: { status: 'known', capacityTokens, remainingPercent } }
1138
+ }
1139
+
1140
+ function contextSnapshotsEqual(left: AgentContextSnapshot, right: AgentContextSnapshot): boolean {
1141
+ return JSON.stringify(left) === JSON.stringify(right)
1142
+ }
1143
+
1144
+ export function compactionError(message: ClaudeStatusMessage): AgentError {
1145
+ return { message: message.compact_error || 'Claude compaction failed.' }
1146
+ }
1147
+
1148
+ export function resultError(message: Extract<SDKMessage, { type: 'result' }>): AgentError {
1149
+ // SDK success/is_error carries API failure text in result rather than the errors array.
1150
+ const detail = message.subtype === 'success'
1151
+ ? message.result
1152
+ : message.errors.length > 0 ? message.errors.join('\n') : undefined
656
1153
  return {
657
- message: errors.length > 0 ? errors.join('\n') : `Claude result ended with ${message.subtype}.`,
1154
+ message: detail || `Claude result ended with ${message.subtype}.`,
658
1155
  code: message.subtype,
659
1156
  }
660
1157
  }
661
1158
 
662
- function shouldEndAssistantSnapshot(message: BetaMessage, state: ClaudeEventMapperState, actor: ClaudeSubagentActor | undefined): boolean {
663
- return actor !== undefined
1159
+ function shouldEndAssistantSnapshot(message: BetaMessage, state: ClaudeEventMapperState, actor: AgentEventActor): boolean {
1160
+ return actor.type === 'subagent'
664
1161
  && !state.streamedTextByMessageId.has(message.id)
665
1162
  && !state.streamedReasoningMessageIds.has(message.id)
666
1163
  }
667
1164
 
668
- function actorFromParentToolUseId(parentToolUseId: string | null, state: ClaudeEventMapperState): ClaudeSubagentActor | undefined {
669
- return parentToolUseId ? state.subagentByToolCallId.get(parentToolUseId) : undefined
1165
+ function actorContextFromParentToolUseId(parentToolUseId: string | null, drafts: AgentEventDraft[], state: ClaudeEventMapperState): ClaudeActorContext | undefined {
1166
+ if (parentToolUseId === null)
1167
+ return { actor: mainActor, turnId: ensureTurnStarted(drafts, state) }
1168
+
1169
+ const actor = state.subagentByToolCallId.get(parentToolUseId)
1170
+ const turnId = actor ? state.turnIdBySubagentId.get(actor.subagentId) : undefined
1171
+ return actor && turnId ? { actor, turnId } : undefined
1172
+ }
1173
+
1174
+ function subagentActorForTaskMessage(input: { subagentId: string, parentToolCallId?: string, name?: string }, state: ClaudeEventMapperState): ClaudeTaskActorResolution {
1175
+ const existingByTask = state.subagentByTaskId.get(input.subagentId)
1176
+ if (existingByTask) {
1177
+ if (input.parentToolCallId && existingByTask.parentToolCallId !== input.parentToolCallId && state.subagentByToolCallId.get(input.parentToolCallId)?.subagentId !== input.subagentId)
1178
+ return { status: 'conflict' }
1179
+ const turnId = state.turnIdBySubagentId.get(input.subagentId)
1180
+ if (!turnId)
1181
+ return { status: 'conflict' }
1182
+ const actor = actorWithObservedName(existingByTask, input.name, state)
1183
+ return { status: 'resolved', context: { actor, turnId } }
1184
+ }
1185
+
1186
+ if (!input.parentToolCallId)
1187
+ return { status: 'pending' }
1188
+ const existingByTool = state.subagentByToolCallId.get(input.parentToolCallId)
1189
+ if (existingByTool)
1190
+ return { status: existingByTool.subagentId === input.subagentId ? 'pending' : 'conflict' }
1191
+
1192
+ const owner = state.toolOwnerById.get(input.parentToolCallId)
1193
+ if (!owner)
1194
+ return { status: 'pending' }
1195
+ if (input.subagentId === mainActor.actorId || owner.actor.actorId === input.subagentId || wouldCreateActorCycle(input.subagentId, owner.actor, state))
1196
+ return { status: 'conflict' }
1197
+
1198
+ const actor = subagentActorFromTask({
1199
+ subagentId: input.subagentId,
1200
+ parentActorId: owner.actor.actorId,
1201
+ parentToolCallId: input.parentToolCallId,
1202
+ name: input.name,
1203
+ })
1204
+ state.subagentByTaskId.set(input.subagentId, actor)
1205
+ state.subagentByToolCallId.set(input.parentToolCallId, actor)
1206
+ state.turnIdBySubagentId.set(input.subagentId, owner.turnId)
1207
+ state.runToolCallIdBySubagentId.set(input.subagentId, input.parentToolCallId)
1208
+ return { status: 'resolved', context: { actor, turnId: owner.turnId } }
1209
+ }
1210
+
1211
+ function actorWithObservedName(actor: ClaudeSubagentActor, name: string | undefined, state: ClaudeEventMapperState): ClaudeSubagentActor {
1212
+ if (!name || actor.name === name)
1213
+ return actor
1214
+ if (actor.name)
1215
+ return actor
1216
+
1217
+ const named = { ...actor, name }
1218
+ state.subagentByTaskId.set(actor.subagentId, named)
1219
+ if (actor.parentToolCallId)
1220
+ state.subagentByToolCallId.set(actor.parentToolCallId, named)
1221
+ return named
670
1222
  }
671
1223
 
672
- function subagentActorForTaskMessage(input: { subagentId: string, parentToolCallId?: string, name?: string }, state: ClaudeEventMapperState): ClaudeSubagentActor {
673
- const existing = input.parentToolCallId ? state.subagentByToolCallId.get(input.parentToolCallId) : undefined
674
- return existing ?? subagentActorFromTask(input)
1224
+ function wouldCreateActorCycle(subagentId: string, parent: AgentEventActor, state: ClaudeEventMapperState): boolean {
1225
+ let actor = parent
1226
+ const visited = new Set<string>()
1227
+ while (actor.type === 'subagent') {
1228
+ if (actor.actorId === subagentId || visited.has(actor.actorId))
1229
+ return true
1230
+ visited.add(actor.actorId)
1231
+ const next = state.subagentByTaskId.get(actor.parentActorId)
1232
+ if (!next)
1233
+ return false
1234
+ actor = next
1235
+ }
1236
+ return false
675
1237
  }
676
1238
 
677
- function subagentActorFromTask(input: { subagentId: string, parentToolCallId?: string, name?: string }): ClaudeSubagentActor {
1239
+ function subagentActorFromTask(input: { subagentId: string, parentActorId: string, parentToolCallId: string, name?: string }): ClaudeSubagentActor {
678
1240
  return removeUndefined({
679
1241
  type: 'subagent' as const,
680
1242
  actorId: input.subagentId,
681
1243
  subagentId: input.subagentId,
682
- parentActorId: 'main',
683
- origin: input.parentToolCallId ? { type: 'tool_call' as const, toolCallId: input.parentToolCallId } : { type: 'provider_task' as const, taskId: input.subagentId },
1244
+ parentActorId: input.parentActorId,
1245
+ origin: { type: 'tool_call' as const, toolCallId: input.parentToolCallId },
684
1246
  parentToolCallId: input.parentToolCallId,
685
1247
  name: input.name,
686
1248
  })
687
1249
  }
688
1250
 
1251
+ function flushPendingMessages(state: ClaudeEventMapperState): AgentEventDraft[] {
1252
+ const drafts: AgentEventDraft[] = []
1253
+ let resolved = true
1254
+ while (resolved) {
1255
+ resolved = false
1256
+ for (let index = 0; index < state.pendingMessages.length; index += 1) {
1257
+ const pendingDrafts = mapClaudeMessageWithKnownActor(state.pendingMessages[index]!, state)
1258
+ if (pendingDrafts === undefined)
1259
+ continue
1260
+ state.pendingMessages.splice(index, 1)
1261
+ drafts.push(...pendingDrafts)
1262
+ resolved = true
1263
+ break
1264
+ }
1265
+ }
1266
+ return drafts
1267
+ }
1268
+
689
1269
  function subagentPayloadRef(actor: ClaudeSubagentActor): Pick<ClaudeSubagentActor, 'actorId' | 'subagentId' | 'parentActorId' | 'origin' | 'parentToolCallId' | 'name'> {
690
1270
  return removeUndefined({
691
1271
  actorId: actor.actorId,
@@ -737,12 +1317,15 @@ function normalizeToolName(name: string): string {
737
1317
  function usageFromObject(value: BetaUsage | undefined): AgentUsage | undefined {
738
1318
  if (!value)
739
1319
  return undefined
1320
+ // Anthropic input_tokens excludes cache reads and writes; the provider-neutral inputTokens, like
1321
+ // Codex's, counts every input token, with the cached subsets reported alongside it.
1322
+ const inputTokens = value.input_tokens + (value.cache_read_input_tokens ?? 0) + (value.cache_creation_input_tokens ?? 0)
740
1323
  return removeUndefined({
741
- inputTokens: value.input_tokens,
1324
+ inputTokens,
742
1325
  outputTokens: value.output_tokens,
743
1326
  cacheReadTokens: value.cache_read_input_tokens ?? undefined,
744
1327
  cacheWriteTokens: value.cache_creation_input_tokens ?? undefined,
745
- totalTokens: value.input_tokens + value.output_tokens + (value.cache_read_input_tokens ?? 0) + (value.cache_creation_input_tokens ?? 0),
1328
+ totalTokens: inputTokens + value.output_tokens,
746
1329
  })
747
1330
  }
748
1331
 
@@ -756,13 +1339,159 @@ function thinkingFromAssistantContent(content: BetaMessage['content']): string |
756
1339
  return text || undefined
757
1340
  }
758
1341
 
759
- function textFromToolResultContent(content: UserToolResultBlock['content']): string | undefined {
760
- if (!content)
1342
+ function correlatedHostTrailerToolCallId(input: { content: UserContentBlock[], message: ClaudeUserMessage, state: ClaudeEventMapperState }): string | undefined {
1343
+ if (input.message.tool_use_result === undefined)
761
1344
  return undefined
762
- if (typeof content === 'string')
763
- return content
764
- const text = content.map(block => block.type === 'text' ? block.text : '').join('\n')
765
- return text || undefined
1345
+ const toolCallId = soleToolResultToolCallId(input.content)
1346
+ return toolCallId && input.state.hostMcpToolCallIds.has(toolCallId) ? toolCallId : undefined
1347
+ }
1348
+
1349
+ // The SDK attaches one message-level tool_use_result, so it is attributable only to a sole tool_result.
1350
+ function soleToolResultToolCallId(content: UserContentBlock[]): string | undefined {
1351
+ const toolResults = content.filter((block): block is UserToolResultBlock => block.type === 'tool_result')
1352
+ return toolResults.length === 1 ? toolResults[0]!.tool_use_id : undefined
1353
+ }
1354
+
1355
+ function mappedModelToolResultContent({ content, isError }: { content: UserToolResultBlock['content'], isError: boolean }): { content: AgentToolContent[], text?: string } {
1356
+ if (typeof content === 'string') {
1357
+ const text = isError ? withoutToolUseErrorWrapper(content) : content
1358
+ return { content: [{ type: 'text', text }], text }
1359
+ }
1360
+ if (content === undefined)
1361
+ return { content: [] }
1362
+ if (!Array.isArray(content)) {
1363
+ const parsed = jsonValueSchema.safeParse(content)
1364
+ return parsed.success ? { content: [jsonToolContent(parsed.data)] } : { content: [] }
1365
+ }
1366
+
1367
+ const mapped: AgentToolContent[] = []
1368
+ let text = ''
1369
+ let hasText = false
1370
+ for (const block of content) {
1371
+ const parsed = jsonValueSchema.safeParse(block)
1372
+ if (!parsed.success)
1373
+ continue
1374
+ const value = isError ? withoutToolUseErrorBlockWrapper(parsed.data) : parsed.data
1375
+ if (isJsonObject(value) && value.type === 'text' && typeof value.text === 'string') {
1376
+ hasText = true
1377
+ text += value.text
1378
+ }
1379
+ mapped.push(modelToolContent(value))
1380
+ }
1381
+ return removeUndefined({ content: mapped, text: hasText ? text : undefined })
1382
+ }
1383
+
1384
+ // Claude Code wraps its own tool failures as `<tool_use_error>…</tool_use_error>` for the model and
1385
+ // strips the same wrapper before display (CLI 2.1.280), so the wrapper is transport, not output.
1386
+ const TOOL_USE_ERROR_WRAPPER = /^<tool_use_error>([\s\S]*)<\/tool_use_error>$/
1387
+
1388
+ function withoutToolUseErrorWrapper(text: string): string {
1389
+ return TOOL_USE_ERROR_WRAPPER.exec(text)?.[1] ?? text
1390
+ }
1391
+
1392
+ function withoutToolUseErrorBlockWrapper(value: JsonValue): JsonValue {
1393
+ if (!isJsonObject(value) || value.type !== 'text' || typeof value.text !== 'string')
1394
+ return value
1395
+ return { ...value, text: withoutToolUseErrorWrapper(value.text) }
1396
+ }
1397
+
1398
+ function modelToolContent(value: JsonValue): AgentToolContent {
1399
+ if (!isJsonObject(value))
1400
+ return jsonToolContent(value)
1401
+ if (value.type === 'image' && isJsonObject(value.source)) {
1402
+ const source = value.source
1403
+ const extras = Object.fromEntries(Object.entries(value).filter(([key]) => key !== 'type' && key !== 'source'))
1404
+ const sourceExtras = Object.fromEntries(Object.entries(source).filter(([key]) => !['type', 'media_type', 'data', 'url'].includes(key)))
1405
+ if (Object.keys(sourceExtras).length > 0)
1406
+ extras.source = sourceExtras
1407
+ const metadata = Object.keys(extras).length > 0 ? { extras } : {}
1408
+ if (source.type === 'base64' && typeof source.media_type === 'string' && source.media_type.length > 0
1409
+ && typeof source.data === 'string' && source.data.length > 0) {
1410
+ return { type: 'image', mimeType: source.media_type, data: source.data, ...metadata }
1411
+ }
1412
+ if (source.type === 'url' && typeof source.url === 'string' && source.url.length > 0)
1413
+ return { type: 'image', uri: source.url, ...metadata }
1414
+ }
1415
+ return mcpToolContent(value)
1416
+ }
1417
+
1418
+ function mappedHostMcpContent(input: { trailer: unknown, modelBlocks: AgentToolContent[] }): AgentToolContent[] {
1419
+ const content: AgentToolContent[] = [...input.modelBlocks]
1420
+ const hostContent = ownJsonSlot(input.trailer, 'content')
1421
+ if (hostContent !== undefined) {
1422
+ const hostBlocks = Array.isArray(hostContent) ? hostContent.map(mcpToolContent) : [mcpToolContent(hostContent)]
1423
+ mergeToolContentOccurrences(content, hostBlocks)
1424
+ }
1425
+ const resourceLinks = ownJsonSlot(input.trailer, 'resourceLinks')
1426
+ if (resourceLinks !== undefined) {
1427
+ if (Array.isArray(resourceLinks))
1428
+ mergeToolContentOccurrences(content, resourceLinks.map(resourceLinkContent))
1429
+ else
1430
+ content.push(jsonToolContent(resourceLinks))
1431
+ }
1432
+ return content
1433
+ }
1434
+
1435
+ function mergeToolContentOccurrences(target: AgentToolContent[], incoming: AgentToolContent[]): void {
1436
+ let cursor = 0
1437
+ for (let incomingIndex = 0; incomingIndex < incoming.length; incomingIndex += 1) {
1438
+ const block = incoming[incomingIndex]!
1439
+ const index = target.findIndex((existing, position) => position >= cursor
1440
+ && jsonValuesEqual(contentIdentity(existing), contentIdentity(block)))
1441
+ if (index === -1) {
1442
+ const nextMatch = target.findIndex((existing, position) => position >= cursor
1443
+ && incoming.slice(incomingIndex + 1).some(next => jsonValuesEqual(contentIdentity(existing), contentIdentity(next))))
1444
+ const insertion = nextMatch === -1 ? target.length : nextMatch
1445
+ target.splice(insertion, 0, block)
1446
+ cursor = insertion + 1
1447
+ }
1448
+ else {
1449
+ cursor = index + 1
1450
+ const existing = target[index]!
1451
+ if (block.type !== 'json' && existing.type !== 'json') {
1452
+ target[index] = removeUndefined({
1453
+ ...block,
1454
+ annotations: block.annotations ?? existing.annotations,
1455
+ extras: existing.extras || block.extras ? { ...existing.extras, ...block.extras } : undefined,
1456
+ })
1457
+ }
1458
+ else {
1459
+ target[index] = block
1460
+ }
1461
+ }
1462
+ }
1463
+ }
1464
+
1465
+ function contentIdentity(content: AgentToolContent): JsonValue {
1466
+ return Object.fromEntries(Object.entries(content).filter(([key]) => key !== 'extras' && key !== 'annotations'))
1467
+ }
1468
+
1469
+ function resourceLinkContent(value: JsonValue): AgentToolContent {
1470
+ if (isJsonObject(value) && typeof value.name === 'string' && typeof value.uri === 'string')
1471
+ return mcpToolContent({ ...value, type: 'resource_link' })
1472
+ return jsonToolContent(value)
1473
+ }
1474
+
1475
+ function jsonValuesEqual(left: JsonValue | undefined, right: JsonValue | undefined): boolean {
1476
+ if (left === right)
1477
+ return true
1478
+ if (left === undefined || right === undefined || typeof left !== typeof right || left === null || right === null)
1479
+ return false
1480
+ if (Array.isArray(left) || Array.isArray(right)) {
1481
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
1482
+ return false
1483
+ return left.every((entry, index) => jsonValuesEqual(entry, right[index]))
1484
+ }
1485
+ if (!isJsonObject(left) || !isJsonObject(right))
1486
+ return false
1487
+ const leftKeys = Object.keys(left)
1488
+ const rightKeys = Object.keys(right)
1489
+ return leftKeys.length === rightKeys.length
1490
+ && leftKeys.every(key => Object.hasOwn(right, key) && jsonValuesEqual(left[key], right[key]))
1491
+ }
1492
+
1493
+ function isHostMcpToolName(name: string): boolean {
1494
+ return name.startsWith('mcp__') && !name.startsWith(CLAUDE_RUNTIME_TOOL_PREFIX)
766
1495
  }
767
1496
 
768
1497
  function jsonInput(value: unknown): JsonValue {