@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
@@ -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,68 @@ 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
+ // Main-response UUIDs prove consumption even if the started receipt was omitted. This
404
+ // also applies to supervisor inputs, which intentionally have no human-prompt correlation.
405
+ if (state.queuedSteerIds.delete(inputId))
406
+ state.heldTurnEnd = undefined
407
+ const commandId = state.promptCommandByInputId.get(inputId)
408
+ if (!commandId)
409
+ continue
410
+ drafts.push({ type: 'prompt.turn-correlated', payload: { commandId, turnId } })
411
+ state.promptCommandByInputId.delete(inputId)
412
+ }
413
+ }
414
+
239
415
  function mapStatusMessage(message: ClaudeStatusMessage, state: ClaudeEventMapperState): AgentEventDraft[] {
240
416
  const drafts: AgentEventDraft[] = []
241
417
  if (message.status === 'compacting') {
242
418
  const turnId = ensureTurnStarted(drafts, state)
419
+ state.contextObservation = { status: 'none' }
420
+ state.reportedContext = undefined
243
421
  if (state.currentCompactionId)
244
422
  return drafts
245
423
  state.currentCompactionId = message.uuid
@@ -258,7 +436,7 @@ function mapStatusMessage(message: ClaudeStatusMessage, state: ClaudeEventMapper
258
436
  compactionId,
259
437
  trigger: 'unknown',
260
438
  status: 'failed',
261
- error: { message: message.compact_error ?? 'Claude compaction failed.' },
439
+ error: compactionError(message),
262
440
  },
263
441
  })
264
442
  state.currentCompactionId = undefined
@@ -267,13 +445,44 @@ function mapStatusMessage(message: ClaudeStatusMessage, state: ClaudeEventMapper
267
445
  return drafts
268
446
  }
269
447
 
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({
448
+ function isMcpTaskMessage(message: { task_id: string, tool_use_id?: string }, state: ClaudeEventMapperState): boolean {
449
+ if (state.mcpToolCallIdByTaskId.has(message.task_id))
450
+ return true
451
+ const name = message.tool_use_id ? state.toolNameById.get(message.tool_use_id) : undefined
452
+ return name !== undefined && isHostMcpToolName(name)
453
+ }
454
+
455
+ /**
456
+ * Only an Agent-tool child (`local_agent`) is a subagent; the CLI also reports backgrounded Bash,
457
+ * workflows, monitors, and other tasks through the same lifecycle. The pinned CLI always sets the
458
+ * task type; an untyped task keeps the subagent mapping its ancestry evidence supports. The SDK
459
+ * marks housekeeping tasks `skip_transcript` for hosts to hide.
460
+ */
461
+ function isVisibleSubagentTask(message: ClaudeTaskStartedMessage): boolean {
462
+ return (message.task_type === undefined || message.task_type === 'local_agent') && message.skip_transcript !== true
463
+ }
464
+
465
+ /**
466
+ * SendMessage resumes a finished subagent as a new run: the CLI repeats its task_id with the
467
+ * SendMessage call as tool_use_id. The run keeps the subagent's identity and turn, so it reopens the
468
+ * same subagent. Returns null when the message is not a resume of a known subagent.
469
+ */
470
+ function mapResumedSubagentRun(message: ClaudeTaskStartedMessage, state: ClaudeEventMapperState): AgentEventDraft[] | undefined | null {
471
+ const actor = state.subagentByTaskId.get(message.task_id)
472
+ const toolCallId = message.tool_use_id
473
+ if (!actor || !toolCallId || toolCallId === actor.parentToolCallId || state.subagentByToolCallId.has(toolCallId))
474
+ return null
475
+ const owner = state.toolOwnerById.get(toolCallId)
476
+ if (!owner)
477
+ return undefined
478
+ const turnId = state.turnIdBySubagentId.get(message.task_id)
479
+ if (owner.actor.actorId !== actor.parentActorId || !turnId)
480
+ return []
481
+
482
+ state.subagentByToolCallId.set(toolCallId, actor)
483
+ state.runToolCallIdBySubagentId.set(message.task_id, toolCallId)
484
+ state.endedSubagentIds.delete(message.task_id)
485
+ return [{
277
486
  type: 'subagent.started',
278
487
  turnId,
279
488
  actor,
@@ -281,51 +490,134 @@ function mapTaskStartedMessage(message: ClaudeTaskStartedMessage, state: ClaudeE
281
490
  ...subagentPayloadRef(actor),
282
491
  description: message.description,
283
492
  }),
284
- })
285
- return drafts
493
+ }]
286
494
  }
287
495
 
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',
496
+ /** A resumed subagent must not take progress or a terminal from a run that SendMessage superseded. */
497
+ function isEarlierSubagentRun(message: { task_id: string, tool_use_id?: string }, state: ClaudeEventMapperState): boolean {
498
+ const runToolCallId = state.runToolCallIdBySubagentId.get(message.task_id)
499
+ return message.tool_use_id !== undefined && runToolCallId !== undefined && message.tool_use_id !== runToolCallId
500
+ }
501
+
502
+ function mapTaskStartedMessage(message: ClaudeTaskStartedMessage, state: ClaudeEventMapperState): AgentEventDraft[] | undefined {
503
+ if (message.task_type === 'mcp_task' || isMcpTaskMessage(message, state)) {
504
+ const previous = state.mcpToolCallIdByTaskId.get(message.task_id)
505
+ if (previous && message.tool_use_id && previous !== message.tool_use_id)
506
+ state.conflictingMcpTaskIds.add(message.task_id)
507
+ else
508
+ state.mcpToolCallIdByTaskId.set(message.task_id, previous ?? message.tool_use_id)
509
+ return []
510
+ }
511
+ if (!isVisibleSubagentTask(message)) {
512
+ state.nonSubagentTaskIds.add(message.task_id)
513
+ if (message.tool_use_id)
514
+ state.nonSubagentToolCallIds.add(message.tool_use_id)
515
+ return []
516
+ }
517
+ const resumed = mapResumedSubagentRun(message, state)
518
+ if (resumed !== null)
519
+ return resumed
520
+ const resolution = subagentActorForTaskMessage({ subagentId: message.task_id, parentToolCallId: message.tool_use_id, name: message.subagent_type }, state)
521
+ if (resolution.status === 'pending')
522
+ return undefined
523
+ if (resolution.status === 'conflict' || state.startedSubagentIds.has(message.task_id))
524
+ return []
525
+
526
+ state.startedSubagentIds.add(message.task_id)
527
+ const { actor, turnId } = resolution.context
528
+ return [{
529
+ type: 'subagent.started',
294
530
  turnId,
295
531
  actor,
296
532
  payload: removeUndefined({
297
533
  ...subagentPayloadRef(actor),
298
- summary: optionalNonEmpty(message.summary),
299
- lastToolName: optionalNonEmpty(message.last_tool_name),
300
- usage: usageFromTaskUsage(message.usage),
534
+ description: message.description,
301
535
  }),
302
- })
303
- return drafts
536
+ }]
304
537
  }
305
538
 
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',
539
+ function mapTaskProgressMessage(message: ClaudeTaskProgressMessage, state: ClaudeEventMapperState): AgentEventDraft[] | undefined {
540
+ if (isMcpTaskMessage(message, state)) {
541
+ if (!state.mcpToolCallIdByTaskId.has(message.task_id))
542
+ state.mcpToolCallIdByTaskId.set(message.task_id, message.tool_use_id)
543
+ return []
544
+ }
545
+ if (state.nonSubagentTaskIds.has(message.task_id))
546
+ return []
547
+ const resolution = subagentActorForTaskMessage({ subagentId: message.task_id, parentToolCallId: message.tool_use_id, name: message.subagent_type }, state)
548
+ if (resolution.status === 'pending')
549
+ return undefined
550
+ if (resolution.status === 'conflict' || isEarlierSubagentRun(message, state))
551
+ return []
552
+
553
+ const { actor, turnId } = resolution.context
554
+ return [{
555
+ type: 'subagent.progress',
312
556
  turnId,
313
557
  actor,
314
558
  payload: removeUndefined({
315
559
  ...subagentPayloadRef(actor),
316
- status: taskNotificationStatus(message.status),
317
560
  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,
561
+ lastToolName: optionalNonEmpty(message.last_tool_name),
562
+ usage: usageFromTaskUsage(message.usage),
320
563
  }),
321
- })
322
- return drafts
564
+ }]
565
+ }
566
+
567
+ function mapTaskNotificationMessage(message: ClaudeTaskNotificationMessage, state: ClaudeEventMapperState): AgentEventDraft[] | undefined {
568
+ if (isMcpTaskMessage(message, state) || message.resource_links !== undefined) {
569
+ const rememberedToolCallId = state.mcpToolCallIdByTaskId.get(message.task_id)
570
+ if (rememberedToolCallId && message.tool_use_id && rememberedToolCallId !== message.tool_use_id)
571
+ state.conflictingMcpTaskIds.add(message.task_id)
572
+ if (state.endedMcpTaskIds.has(message.task_id) || state.conflictingMcpTaskIds.has(message.task_id))
573
+ return []
574
+ const toolCallId = message.tool_use_id ?? rememberedToolCallId
575
+ const owner = toolCallId ? state.toolOwnerById.get(toolCallId) : undefined
576
+ if (!toolCallId || !owner)
577
+ return undefined
578
+ state.endedMcpTaskIds.add(message.task_id)
579
+ const content: AgentToolContent[] = [{ type: 'text', text: message.summary }]
580
+ content.push(...(message.resource_links ?? []).map(link => resourceLinkContent(jsonValueSchema.parse(link))))
581
+ if (message.output_file !== '')
582
+ content.push(jsonToolContent({ outputFile: message.output_file }))
583
+ return [{
584
+ type: 'tool.output.updated',
585
+ ...owner,
586
+ payload: { toolCallId, output: { success: message.status === 'completed', content } },
587
+ }]
588
+ }
589
+ if (state.nonSubagentTaskIds.has(message.task_id))
590
+ return []
591
+ const resolution = subagentActorForTaskMessage({ subagentId: message.task_id, parentToolCallId: message.tool_use_id }, state)
592
+ if (resolution.status === 'pending')
593
+ return undefined
594
+ if (resolution.status === 'conflict' || isEarlierSubagentRun(message, state) || state.endedSubagentIds.has(message.task_id))
595
+ return []
596
+
597
+ state.endedSubagentIds.add(message.task_id)
598
+ const { actor, turnId } = resolution.context
599
+ return [
600
+ {
601
+ type: 'subagent.ended',
602
+ turnId,
603
+ actor,
604
+ payload: removeUndefined({
605
+ ...subagentPayloadRef(actor),
606
+ status: taskNotificationStatus(message.status),
607
+ summary: optionalNonEmpty(message.summary),
608
+ usage: message.usage ? usageFromTaskUsage(message.usage) : undefined,
609
+ error: message.status === 'failed' ? { message: optionalNonEmpty(message.summary) ?? `Subagent ${message.task_id} failed.` } : undefined,
610
+ }),
611
+ },
612
+ ]
323
613
  }
324
614
 
325
615
  function mapCompactBoundaryMessage(message: ClaudeCompactBoundaryMessage, state: ClaudeEventMapperState): AgentEventDraft[] {
326
616
  const drafts: AgentEventDraft[] = []
327
617
  const turnId = ensureTurnStarted(drafts, state)
328
618
  const compactionId = state.currentCompactionId ?? message.uuid
619
+ state.contextObservation = { status: 'none' }
620
+ state.reportedContext = undefined
329
621
  drafts.push({
330
622
  type: 'context.compaction.completed',
331
623
  turnId,
@@ -343,7 +635,7 @@ function mapCompactBoundaryMessage(message: ClaudeCompactBoundaryMessage, state:
343
635
  return drafts
344
636
  }
345
637
 
346
- function mapAssistantContent(message: BetaMessage, state: ClaudeEventMapperState, turnId: string, actor: AgentEventActor | undefined, isMain: boolean): AgentEventDraft[] {
638
+ function mapAssistantContent(message: BetaMessage, state: ClaudeEventMapperState, turnId: string, actor: AgentEventActor, isMain: boolean): AgentEventDraft[] {
347
639
  const drafts: AgentEventDraft[] = []
348
640
  const streamedText = state.streamedTextByMessageId.has(message.id)
349
641
  const streamedReasoning = state.streamedReasoningMessageIds.has(message.id)
@@ -360,6 +652,12 @@ function mapAssistantContent(message: BetaMessage, state: ClaudeEventMapperState
360
652
  if (block.type === 'tool_use') {
361
653
  const toolName = normalizeToolName(block.name)
362
654
  state.toolNameById.set(block.id, toolName)
655
+ if (isHostMcpToolName(block.name))
656
+ state.hostMcpToolCallIds.add(block.id)
657
+ // SDK task_id identifies the child, while task_started.tool_use_id identifies this
658
+ // initiating call; the actor that emitted the tool_use is the only ancestry evidence.
659
+ if (!state.toolOwnerById.has(block.id))
660
+ state.toolOwnerById.set(block.id, { actor, turnId })
363
661
  if (isMain) {
364
662
  const pendingWorkCall = pendingClaudeWorkCall(block.name, block.input)
365
663
  if (pendingWorkCall)
@@ -371,27 +669,53 @@ function mapAssistantContent(message: BetaMessage, state: ClaudeEventMapperState
371
669
  return drafts
372
670
  }
373
671
 
374
- function mapUserContentBlock(block: UserContentBlock, message: ClaudeUserMessage, state: ClaudeEventMapperState, turnId: string, actor: AgentEventActor | undefined, isMain: boolean): AgentEventDraft[] {
672
+ function mapUserContentBlock(input: {
673
+ block: UserContentBlock
674
+ message: ClaudeUserMessage
675
+ state: ClaudeEventMapperState
676
+ turnId: string
677
+ actor: AgentEventActor
678
+ isMain: boolean
679
+ hostTrailer?: unknown
680
+ ownsStructuredResult: boolean
681
+ }): AgentEventDraft[] {
682
+ const { block, message, state, turnId, actor, isMain, hostTrailer, ownsStructuredResult } = input
375
683
  if (block.type !== 'tool_result')
376
684
  return []
377
685
  const toolCallId = block.tool_use_id
378
686
  const pendingWorkCall = state.pendingWorkCallById.get(toolCallId)
379
687
  if (pendingWorkCall)
380
688
  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 }] : [] }
689
+ state.hostMcpToolCallIds.delete(toolCallId)
690
+
691
+ const fileChangeText = ownsStructuredResult && block.is_error !== true
692
+ ? claudeFileChangeText({ toolName: state.toolNameById.get(toolCallId), toolUseResult: message.tool_use_result, cwd: state.sessionCwd })
693
+ : undefined
694
+ const mapped = fileChangeText === undefined
695
+ ? mappedModelToolResultContent({ content: block.content, isError: block.is_error === true })
696
+ : { content: [{ type: 'text' as const, text: fileChangeText }], text: fileChangeText }
697
+ const content = hostTrailer === undefined ? mapped.content : mappedHostMcpContent({ trailer: hostTrailer, modelBlocks: mapped.content })
698
+ const structuredContent = ownJsonSlot(hostTrailer, 'structuredContent')
699
+ const reportedError = ownJsonSlot(hostTrailer, 'isError')
700
+ const output = removeUndefined({
701
+ success: block.is_error !== true,
702
+ content,
703
+ structuredContent,
704
+ reportedSuccess: typeof reportedError === 'boolean' ? !reportedError : undefined,
705
+ })
384
706
  const drafts: AgentEventDraft[] = []
385
- if (text)
386
- drafts.push(withActor({ type: 'tool.output.delta', turnId, payload: { toolCallId, delta: text } }, actor))
707
+ if (mapped.text !== undefined)
708
+ drafts.push(withActor({ type: 'tool.output.delta', turnId, payload: { toolCallId, delta: mapped.text } }, actor))
387
709
  drafts.push(withActor({
388
710
  type: 'tool.call.completed',
389
711
  turnId,
390
712
  payload: removeUndefined({
391
713
  toolCallId,
714
+ toolName: state.toolNameById.get(toolCallId),
392
715
  status: block.is_error === true ? 'failed' : 'completed',
393
716
  output,
394
- error: block.is_error === true ? { message: text || `Tool ${state.toolNameById.get(toolCallId) ?? toolCallId} failed.` } : undefined,
717
+ // The failure detail already rides in output; like Codex, the error is a short summary.
718
+ error: block.is_error === true ? { message: `Tool ${state.toolNameById.get(toolCallId) ?? toolCallId} failed.` } : undefined,
395
719
  }),
396
720
  }, actor))
397
721
 
@@ -468,20 +792,19 @@ function parseClaudeTaskResult<Output>({ value, toolName, schema }: { value: unk
468
792
  })
469
793
  }
470
794
 
471
- function mapStreamEvent(event: BetaRawMessageStreamEvent, state: ClaudeEventMapperState, turnId: string, actor: AgentEventActor | undefined): AgentEventDraft[] {
795
+ function mapStreamEvent(event: BetaRawMessageStreamEvent, state: ClaudeEventMapperState, turnId: string, actor: AgentEventActor): AgentEventDraft[] {
472
796
  const drafts: AgentEventDraft[] = []
473
797
  if (event.type === 'message_start') {
474
- state.currentMessageId = event.message.id
475
- if (actor)
476
- state.actorByMessageId.set(event.message.id, actor)
798
+ setCurrentMessageId(state, actor, event.message.id)
799
+ state.actorByMessageId.set(event.message.id, actor)
477
800
  ensureAssistantMessageStarted(drafts, state, event.message, turnId, actor)
478
801
  return drafts
479
802
  }
480
803
 
481
804
  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)
805
+ const messageId = currentMessageId(state, actor)
806
+ const messageActor = state.actorByMessageId.get(messageId) ?? actor
807
+ contentBlockTypes(state, actor).set(event.index, event.content_block.type)
485
808
  if (event.content_block.type === 'thinking') {
486
809
  state.streamedReasoningMessageIds.add(messageId)
487
810
  ensureReasoningStarted(drafts, state, turnId, messageId, messageActor)
@@ -490,30 +813,30 @@ function mapStreamEvent(event: BetaRawMessageStreamEvent, state: ClaudeEventMapp
490
813
  }
491
814
 
492
815
  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)
816
+ const messageId = currentMessageId(state, actor)
817
+ const messageActor = state.actorByMessageId.get(messageId) ?? actor
818
+ const blockTypes = contentBlockTypes(state, actor)
819
+ const blockType = blockTypes.get(event.index)
496
820
  if (blockType === 'thinking')
497
821
  endReasoning(drafts, state, turnId, messageId, state.streamedReasoningByMessageId.get(messageId), messageActor)
498
- state.contentBlockTypeByIndex.delete(event.index)
822
+ blockTypes.delete(event.index)
499
823
  return drafts
500
824
  }
501
825
 
502
826
  if (event.type === 'message_stop') {
503
- const messageId = currentMessageId(state)
504
- const messageActor = actor ?? state.actorByMessageId.get(messageId)
827
+ const messageId = currentMessageId(state, actor)
828
+ const messageActor = state.actorByMessageId.get(messageId) ?? actor
505
829
  endAssistantMessageById(drafts, state, turnId, messageId, state.streamedTextByMessageId.get(messageId), messageActor)
506
830
  state.actorByMessageId.delete(messageId)
507
- state.currentMessageId = undefined
508
- state.contentBlockTypeByIndex.clear()
831
+ clearCurrentMessage(state, actor)
509
832
  return drafts
510
833
  }
511
834
 
512
835
  if (event.type !== 'content_block_delta')
513
836
  return drafts
514
837
 
515
- const messageId = currentMessageId(state)
516
- const messageActor = actor ?? state.actorByMessageId.get(messageId)
838
+ const messageId = currentMessageId(state, actor)
839
+ const messageActor = state.actorByMessageId.get(messageId) ?? actor
517
840
  const delta = event.delta
518
841
  if (delta.type === 'thinking_delta') {
519
842
  state.streamedReasoningMessageIds.add(messageId)
@@ -530,18 +853,64 @@ function mapStreamEvent(event: BetaRawMessageStreamEvent, state: ClaudeEventMapp
530
853
  }
531
854
 
532
855
  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 }) })
856
+ if (!state.turnStarted || !state.currentTurnId) {
857
+ state.turnStarted = true
858
+ state.interrupt = { status: 'none' }
859
+ state.currentTurnId = createRuntimeTurnId()
860
+ state.currentMessageId = undefined
861
+ state.contentBlockTypeByIndex.clear()
862
+ drafts.push({ type: 'turn.started', turnId: state.currentTurnId, actor: mainActor, payload: removeUndefined({ commandId: state.currentCommandId }) })
863
+ }
541
864
  ensureSessionRunning(drafts, state)
542
865
  return state.currentTurnId
543
866
  }
544
867
 
868
+ // Turn-scoped state after the main turn's `turn.ended`. With CLI state events the CLI's own `idle`
869
+ // ends the session's work; without them the turn end is the only turn-over signal.
870
+ function endTurn(drafts: AgentEventDraft[], state: ClaudeEventMapperState): void {
871
+ if (!state.cliReportsSessionState)
872
+ ensureSessionIdle(drafts, state)
873
+ state.reportedContext = undefined
874
+ state.mainTurnRateLimited = false
875
+ state.usageLimit = usageLimitAfterTurn(state.usageLimit)
876
+ state.turnStarted = false
877
+ state.interrupt = { status: 'none' }
878
+ state.currentTurnId = undefined
879
+ state.currentCommandId = undefined
880
+ }
881
+
882
+ /** Emits a superseded turn's held end as final: its steer will not continue it. */
883
+ export function flushHeldTurnEnd(state: ClaudeEventMapperState): AgentEventDraft[] {
884
+ const held = state.heldTurnEnd
885
+ if (!held)
886
+ return []
887
+ state.heldTurnEnd = undefined
888
+ const drafts: AgentEventDraft[] = [held]
889
+ endTurn(drafts, state)
890
+ return drafts
891
+ }
892
+
893
+ /**
894
+ * Applies the CLI's `command_lifecycle` receipt for a steering input. `queued` marks a pending
895
+ * supersession; `started` continues the held turn (or starts one, so an interrupt has a target);
896
+ * any other state drops the steer, and the held end is final once no steer can continue it.
897
+ */
898
+ export function mapSteerLifecycle(input: { steerId: string, state: 'queued' | 'started' | 'completed' | 'cancelled' | 'discarded' | 'refused' }, state: ClaudeEventMapperState): AgentEventDraft[] {
899
+ if (input.state === 'queued') {
900
+ state.queuedSteerIds.add(input.steerId)
901
+ return []
902
+ }
903
+ if (!state.queuedSteerIds.delete(input.steerId) && input.state !== 'started')
904
+ return []
905
+ if (input.state === 'started') {
906
+ state.heldTurnEnd = undefined
907
+ const drafts: AgentEventDraft[] = []
908
+ ensureTurnStarted(drafts, state)
909
+ return drafts
910
+ }
911
+ return state.queuedSteerIds.size === 0 ? flushHeldTurnEnd(state) : []
912
+ }
913
+
545
914
  function ensureSessionRunning(drafts: AgentEventDraft[], state: ClaudeEventMapperState): void {
546
915
  if (state.sessionState === 'running')
547
916
  return
@@ -556,22 +925,49 @@ function ensureSessionIdle(drafts: AgentEventDraft[], state: ClaudeEventMapperSt
556
925
  drafts.push({ type: 'session.state.changed', payload: { state: 'idle' } })
557
926
  }
558
927
 
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)
928
+ function ensureAssistantMessageStarted(drafts: AgentEventDraft[], state: ClaudeEventMapperState, message: BetaMessage, turnId: string, actor: AgentEventActor): void {
929
+ setCurrentMessageId(state, actor, message.id)
930
+ state.actorByMessageId.set(message.id, actor)
563
931
  if (state.startedMessageIds.has(message.id))
564
932
  return
565
933
  state.startedMessageIds.add(message.id)
566
934
  drafts.push(withActor({ type: 'message.started', turnId, payload: { messageId: message.id, role: 'assistant' } }, actor))
567
935
  }
568
936
 
569
- function currentMessageId(state: ClaudeEventMapperState): string {
570
- if (state.currentMessageId)
571
- return state.currentMessageId
937
+ function setCurrentMessageId(state: ClaudeEventMapperState, actor: AgentEventActor, messageId: string): void {
938
+ if (actor.type === 'main')
939
+ state.currentMessageId = messageId
940
+ else state.currentMessageIdByActorId.set(actor.actorId, messageId)
941
+ }
942
+
943
+ function currentMessageId(state: ClaudeEventMapperState, actor: AgentEventActor): string {
944
+ const messageId = actor.type === 'main' ? state.currentMessageId : state.currentMessageIdByActorId.get(actor.actorId)
945
+ if (messageId)
946
+ return messageId
572
947
  throw new AgentProviderProtocolError({ message: 'Claude stream emitted content_block_delta before message_start.' })
573
948
  }
574
949
 
950
+ function contentBlockTypes(state: ClaudeEventMapperState, actor: AgentEventActor): Map<number, string> {
951
+ if (actor.type === 'main')
952
+ return state.contentBlockTypeByIndex
953
+ const existing = state.contentBlockTypeByActorId.get(actor.actorId)
954
+ if (existing)
955
+ return existing
956
+ const created = new Map<number, string>()
957
+ state.contentBlockTypeByActorId.set(actor.actorId, created)
958
+ return created
959
+ }
960
+
961
+ function clearCurrentMessage(state: ClaudeEventMapperState, actor: AgentEventActor): void {
962
+ if (actor.type === 'main') {
963
+ state.currentMessageId = undefined
964
+ state.contentBlockTypeByIndex.clear()
965
+ return
966
+ }
967
+ state.currentMessageIdByActorId.delete(actor.actorId)
968
+ state.contentBlockTypeByActorId.delete(actor.actorId)
969
+ }
970
+
575
971
  function endAssistantMessage(drafts: AgentEventDraft[], state: ClaudeEventMapperState, message: BetaMessage, turnId: string, actor: AgentEventActor | undefined): void {
576
972
  if (state.endedMessageIds.has(message.id))
577
973
  return
@@ -605,7 +1001,9 @@ function endReasoning(drafts: AgentEventDraft[], state: ClaudeEventMapperState,
605
1001
 
606
1002
  function endOpenReasoning(drafts: AgentEventDraft[], state: ClaudeEventMapperState, turnId: string): void {
607
1003
  for (const reasoningId of state.startedReasoningIds) {
608
- if (!state.endedReasoningIds.has(reasoningId)) {
1004
+ const messageId = reasoningId.slice('reasoning-'.length)
1005
+ const actor = state.actorByMessageId.get(messageId)
1006
+ if (actor?.type === 'main' && !state.endedReasoningIds.has(reasoningId)) {
609
1007
  state.endedReasoningIds.add(reasoningId)
610
1008
  drafts.push({ type: 'reasoning.ended', turnId, actor: mainActor, payload: { reasoningId } })
611
1009
  }
@@ -626,7 +1024,11 @@ function reasoningIdForMessage(messageId: string): string {
626
1024
  return `reasoning-${messageId}`
627
1025
  }
628
1026
 
629
- function turnEndedPayload(message: Extract<SDKMessage, { type: 'result' }>, interruptAccepted: boolean): Extract<AgentEventDraft, { type: 'turn.ended' }>['payload'] {
1027
+ function turnEndedPayload({ message, interruptAccepted, usageLimit }: {
1028
+ message: ClaudeResultMessage
1029
+ interruptAccepted: boolean
1030
+ usageLimit: Extract<ClaudeUsageLimit, { status: 'exhausted' }> | undefined
1031
+ }): Extract<AgentEventDraft, { type: 'turn.ended' }>['payload'] {
630
1032
  if (interruptAccepted && message.subtype === 'error_during_execution') {
631
1033
  return removeUndefined({
632
1034
  status: 'interrupted' as const,
@@ -647,45 +1049,227 @@ function turnEndedPayload(message: Extract<SDKMessage, { type: 'result' }>, inte
647
1049
  status: 'failed' as const,
648
1050
  reason: message.stop_reason ?? message.subtype,
649
1051
  duration: message.duration_ms,
650
- error: resultError(message),
1052
+ error: usageLimit ? usageLimitError({ error: resultError(message), usageLimit }) : resultError(message),
651
1053
  })
652
1054
  }
653
1055
 
654
- function resultError(message: Extract<SDKMessage, { type: 'result' }>): AgentError {
655
- const errors = 'errors' in message && Array.isArray(message.errors) ? message.errors : []
1056
+ // Codex reports a plan usage limit as codexErrorInfo `usageLimitExceeded`; reuse that code so
1057
+ // consumers classify both providers alike. The reset time rides on `cause` because AgentError
1058
+ // has no structured slot for it.
1059
+ function usageLimitError({ error, usageLimit }: { error: AgentError, usageLimit: Extract<ClaudeUsageLimit, { status: 'exhausted' }> }): AgentError {
1060
+ const resetsAt = usageLimit.resetsAt === undefined ? undefined : isoFromEpochSeconds(usageLimit.resetsAt)
1061
+ return removeUndefined({
1062
+ message: error.message,
1063
+ code: 'usageLimitExceeded',
1064
+ cause: resetsAt ? removeUndefined({ message: `Claude usage limit resets at ${resetsAt}.`, code: usageLimit.rateLimitType }) : undefined,
1065
+ })
1066
+ }
1067
+
1068
+ function usageLimitFromRateLimitInfo(info: ClaudeRateLimitInfo): ClaudeUsageLimit {
1069
+ if (info.status !== 'rejected')
1070
+ return { status: 'available' }
1071
+ const resetsAt = info.resetsAt !== undefined && Number.isFinite(info.resetsAt) && info.resetsAt > 0 ? info.resetsAt : undefined
1072
+ return removeUndefined({ status: 'exhausted' as const, resetsAt, rateLimitType: info.rateLimitType })
1073
+ }
1074
+
1075
+ // Mirrors the CLI's own expiry check (`resetsAt * 1000 <= Date.now()` on CLI 2.1.280).
1076
+ function usageLimitInForce(limit: ClaudeUsageLimit, nowMs: number): Extract<ClaudeUsageLimit, { status: 'exhausted' }> | undefined {
1077
+ if (limit.status !== 'exhausted')
1078
+ return undefined
1079
+ return limit.resetsAt === undefined || nowMs < limit.resetsAt * 1000 ? limit : undefined
1080
+ }
1081
+
1082
+ function usageLimitAfterTurn(limit: ClaudeUsageLimit): ClaudeUsageLimit {
1083
+ return limit.status === 'exhausted' && limit.resetsAt !== undefined ? limit : { status: 'available' }
1084
+ }
1085
+
1086
+ function isoFromEpochSeconds(seconds: number): string | undefined {
1087
+ const date = new Date(seconds * 1000)
1088
+ return Number.isFinite(seconds) && seconds > 0 && Number.isFinite(date.getTime()) ? date.toISOString() : undefined
1089
+ }
1090
+
1091
+ function observeMainAssistantMessage(message: Extract<SDKMessage, { type: 'assistant' }>, state: ClaudeEventMapperState): void {
1092
+ if (message.error === 'rate_limit')
1093
+ state.mainTurnRateLimited = true
1094
+ // Claude Code's own context report counts the last response's input side (verified against
1095
+ // getContextUsage on CLI 2.1.280); output is partial on streamed frames and previous thinking
1096
+ // is dropped from later requests. A zero input side is a synthetic frame, not an observation.
1097
+ const { usage } = message.message
1098
+ const usedTokens = usage.input_tokens + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0)
1099
+ if (usedTokens > 0 && Number.isSafeInteger(usedTokens))
1100
+ state.contextObservation = { status: 'observed', model: message.message.model, usedTokens }
1101
+ }
1102
+
1103
+ // Codex reports context on every token-usage update; Claude's equivalent is each main response,
1104
+ // so report whenever the snapshot changes and again at turn end once the result names the window.
1105
+ function reportContextSnapshot({ drafts, state, turnId, result }: {
1106
+ drafts: AgentEventDraft[]
1107
+ state: ClaudeEventMapperState
1108
+ turnId: string
1109
+ result?: ClaudeResultMessage
1110
+ }): void {
1111
+ if (state.contextObservation.status !== 'observed' || state.currentCompactionId !== undefined)
1112
+ return
1113
+ if (result)
1114
+ rememberContextWindows(result, state)
1115
+ const snapshot = contextSnapshot({ observation: state.contextObservation, state })
1116
+ if (state.reportedContext !== undefined && contextSnapshotsEqual(snapshot, state.reportedContext))
1117
+ return
1118
+ state.reportedContext = snapshot
1119
+ drafts.push({ type: 'context.updated', turnId, actor: mainActor, payload: { snapshot } })
1120
+ }
1121
+
1122
+ function rememberContextWindows(result: ClaudeResultMessage, state: ClaudeEventMapperState): void {
1123
+ for (const [model, usage] of Object.entries(result.modelUsage)) {
1124
+ if (Number.isSafeInteger(usage.contextWindow) && usage.contextWindow > 0)
1125
+ state.contextWindowByModel.set(model, usage.contextWindow)
1126
+ }
1127
+ }
1128
+
1129
+ function contextSnapshot({ observation, state }: {
1130
+ observation: Extract<ClaudeContextObservation, { status: 'observed' }>
1131
+ state: ClaudeEventMapperState
1132
+ }): AgentContextSnapshot {
1133
+ // modelUsage is keyed by the requested model string, which is the init model for an explicit id
1134
+ // and the resolved id (also the assistant model) for an alias.
1135
+ const capacityTokens = state.contextWindowByModel.get(observation.model)
1136
+ ?? (state.sessionModel === undefined ? undefined : state.contextWindowByModel.get(state.sessionModel))
1137
+ if (capacityTokens === undefined)
1138
+ return { status: 'observed', usedTokens: observation.usedTokens, window: { status: 'unknown' } }
1139
+ // Claude Code reports context against the raw window without a reserved baseline.
1140
+ const remainingPercent = Math.round(Math.min(Math.max(100 * (capacityTokens - observation.usedTokens) / capacityTokens, 0), 100))
1141
+ return { status: 'observed', usedTokens: observation.usedTokens, window: { status: 'known', capacityTokens, remainingPercent } }
1142
+ }
1143
+
1144
+ function contextSnapshotsEqual(left: AgentContextSnapshot, right: AgentContextSnapshot): boolean {
1145
+ return JSON.stringify(left) === JSON.stringify(right)
1146
+ }
1147
+
1148
+ export function compactionError(message: ClaudeStatusMessage): AgentError {
1149
+ return { message: message.compact_error || 'Claude compaction failed.' }
1150
+ }
1151
+
1152
+ export function resultError(message: Extract<SDKMessage, { type: 'result' }>): AgentError {
1153
+ // SDK success/is_error carries API failure text in result rather than the errors array.
1154
+ const detail = message.subtype === 'success'
1155
+ ? message.result
1156
+ : message.errors.length > 0 ? message.errors.join('\n') : undefined
656
1157
  return {
657
- message: errors.length > 0 ? errors.join('\n') : `Claude result ended with ${message.subtype}.`,
1158
+ message: detail || `Claude result ended with ${message.subtype}.`,
658
1159
  code: message.subtype,
659
1160
  }
660
1161
  }
661
1162
 
662
- function shouldEndAssistantSnapshot(message: BetaMessage, state: ClaudeEventMapperState, actor: ClaudeSubagentActor | undefined): boolean {
663
- return actor !== undefined
1163
+ function shouldEndAssistantSnapshot(message: BetaMessage, state: ClaudeEventMapperState, actor: AgentEventActor): boolean {
1164
+ return actor.type === 'subagent'
664
1165
  && !state.streamedTextByMessageId.has(message.id)
665
1166
  && !state.streamedReasoningMessageIds.has(message.id)
666
1167
  }
667
1168
 
668
- function actorFromParentToolUseId(parentToolUseId: string | null, state: ClaudeEventMapperState): ClaudeSubagentActor | undefined {
669
- return parentToolUseId ? state.subagentByToolCallId.get(parentToolUseId) : undefined
1169
+ function actorContextFromParentToolUseId(parentToolUseId: string | null, drafts: AgentEventDraft[], state: ClaudeEventMapperState): ClaudeActorContext | undefined {
1170
+ if (parentToolUseId === null)
1171
+ return { actor: mainActor, turnId: ensureTurnStarted(drafts, state) }
1172
+
1173
+ const actor = state.subagentByToolCallId.get(parentToolUseId)
1174
+ const turnId = actor ? state.turnIdBySubagentId.get(actor.subagentId) : undefined
1175
+ return actor && turnId ? { actor, turnId } : undefined
1176
+ }
1177
+
1178
+ function subagentActorForTaskMessage(input: { subagentId: string, parentToolCallId?: string, name?: string }, state: ClaudeEventMapperState): ClaudeTaskActorResolution {
1179
+ const existingByTask = state.subagentByTaskId.get(input.subagentId)
1180
+ if (existingByTask) {
1181
+ if (input.parentToolCallId && existingByTask.parentToolCallId !== input.parentToolCallId && state.subagentByToolCallId.get(input.parentToolCallId)?.subagentId !== input.subagentId)
1182
+ return { status: 'conflict' }
1183
+ const turnId = state.turnIdBySubagentId.get(input.subagentId)
1184
+ if (!turnId)
1185
+ return { status: 'conflict' }
1186
+ const actor = actorWithObservedName(existingByTask, input.name, state)
1187
+ return { status: 'resolved', context: { actor, turnId } }
1188
+ }
1189
+
1190
+ if (!input.parentToolCallId)
1191
+ return { status: 'pending' }
1192
+ const existingByTool = state.subagentByToolCallId.get(input.parentToolCallId)
1193
+ if (existingByTool)
1194
+ return { status: existingByTool.subagentId === input.subagentId ? 'pending' : 'conflict' }
1195
+
1196
+ const owner = state.toolOwnerById.get(input.parentToolCallId)
1197
+ if (!owner)
1198
+ return { status: 'pending' }
1199
+ if (input.subagentId === mainActor.actorId || owner.actor.actorId === input.subagentId || wouldCreateActorCycle(input.subagentId, owner.actor, state))
1200
+ return { status: 'conflict' }
1201
+
1202
+ const actor = subagentActorFromTask({
1203
+ subagentId: input.subagentId,
1204
+ parentActorId: owner.actor.actorId,
1205
+ parentToolCallId: input.parentToolCallId,
1206
+ name: input.name,
1207
+ })
1208
+ state.subagentByTaskId.set(input.subagentId, actor)
1209
+ state.subagentByToolCallId.set(input.parentToolCallId, actor)
1210
+ state.turnIdBySubagentId.set(input.subagentId, owner.turnId)
1211
+ state.runToolCallIdBySubagentId.set(input.subagentId, input.parentToolCallId)
1212
+ return { status: 'resolved', context: { actor, turnId: owner.turnId } }
1213
+ }
1214
+
1215
+ function actorWithObservedName(actor: ClaudeSubagentActor, name: string | undefined, state: ClaudeEventMapperState): ClaudeSubagentActor {
1216
+ if (!name || actor.name === name)
1217
+ return actor
1218
+ if (actor.name)
1219
+ return actor
1220
+
1221
+ const named = { ...actor, name }
1222
+ state.subagentByTaskId.set(actor.subagentId, named)
1223
+ if (actor.parentToolCallId)
1224
+ state.subagentByToolCallId.set(actor.parentToolCallId, named)
1225
+ return named
670
1226
  }
671
1227
 
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)
1228
+ function wouldCreateActorCycle(subagentId: string, parent: AgentEventActor, state: ClaudeEventMapperState): boolean {
1229
+ let actor = parent
1230
+ const visited = new Set<string>()
1231
+ while (actor.type === 'subagent') {
1232
+ if (actor.actorId === subagentId || visited.has(actor.actorId))
1233
+ return true
1234
+ visited.add(actor.actorId)
1235
+ const next = state.subagentByTaskId.get(actor.parentActorId)
1236
+ if (!next)
1237
+ return false
1238
+ actor = next
1239
+ }
1240
+ return false
675
1241
  }
676
1242
 
677
- function subagentActorFromTask(input: { subagentId: string, parentToolCallId?: string, name?: string }): ClaudeSubagentActor {
1243
+ function subagentActorFromTask(input: { subagentId: string, parentActorId: string, parentToolCallId: string, name?: string }): ClaudeSubagentActor {
678
1244
  return removeUndefined({
679
1245
  type: 'subagent' as const,
680
1246
  actorId: input.subagentId,
681
1247
  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 },
1248
+ parentActorId: input.parentActorId,
1249
+ origin: { type: 'tool_call' as const, toolCallId: input.parentToolCallId },
684
1250
  parentToolCallId: input.parentToolCallId,
685
1251
  name: input.name,
686
1252
  })
687
1253
  }
688
1254
 
1255
+ function flushPendingMessages(state: ClaudeEventMapperState): AgentEventDraft[] {
1256
+ const drafts: AgentEventDraft[] = []
1257
+ let resolved = true
1258
+ while (resolved) {
1259
+ resolved = false
1260
+ for (let index = 0; index < state.pendingMessages.length; index += 1) {
1261
+ const pendingDrafts = mapClaudeMessageWithKnownActor(state.pendingMessages[index]!, state)
1262
+ if (pendingDrafts === undefined)
1263
+ continue
1264
+ state.pendingMessages.splice(index, 1)
1265
+ drafts.push(...pendingDrafts)
1266
+ resolved = true
1267
+ break
1268
+ }
1269
+ }
1270
+ return drafts
1271
+ }
1272
+
689
1273
  function subagentPayloadRef(actor: ClaudeSubagentActor): Pick<ClaudeSubagentActor, 'actorId' | 'subagentId' | 'parentActorId' | 'origin' | 'parentToolCallId' | 'name'> {
690
1274
  return removeUndefined({
691
1275
  actorId: actor.actorId,
@@ -737,12 +1321,15 @@ function normalizeToolName(name: string): string {
737
1321
  function usageFromObject(value: BetaUsage | undefined): AgentUsage | undefined {
738
1322
  if (!value)
739
1323
  return undefined
1324
+ // Anthropic input_tokens excludes cache reads and writes; the provider-neutral inputTokens, like
1325
+ // Codex's, counts every input token, with the cached subsets reported alongside it.
1326
+ const inputTokens = value.input_tokens + (value.cache_read_input_tokens ?? 0) + (value.cache_creation_input_tokens ?? 0)
740
1327
  return removeUndefined({
741
- inputTokens: value.input_tokens,
1328
+ inputTokens,
742
1329
  outputTokens: value.output_tokens,
743
1330
  cacheReadTokens: value.cache_read_input_tokens ?? undefined,
744
1331
  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),
1332
+ totalTokens: inputTokens + value.output_tokens,
746
1333
  })
747
1334
  }
748
1335
 
@@ -756,13 +1343,159 @@ function thinkingFromAssistantContent(content: BetaMessage['content']): string |
756
1343
  return text || undefined
757
1344
  }
758
1345
 
759
- function textFromToolResultContent(content: UserToolResultBlock['content']): string | undefined {
760
- if (!content)
1346
+ function correlatedHostTrailerToolCallId(input: { content: UserContentBlock[], message: ClaudeUserMessage, state: ClaudeEventMapperState }): string | undefined {
1347
+ if (input.message.tool_use_result === undefined)
761
1348
  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
1349
+ const toolCallId = soleToolResultToolCallId(input.content)
1350
+ return toolCallId && input.state.hostMcpToolCallIds.has(toolCallId) ? toolCallId : undefined
1351
+ }
1352
+
1353
+ // The SDK attaches one message-level tool_use_result, so it is attributable only to a sole tool_result.
1354
+ function soleToolResultToolCallId(content: UserContentBlock[]): string | undefined {
1355
+ const toolResults = content.filter((block): block is UserToolResultBlock => block.type === 'tool_result')
1356
+ return toolResults.length === 1 ? toolResults[0]!.tool_use_id : undefined
1357
+ }
1358
+
1359
+ function mappedModelToolResultContent({ content, isError }: { content: UserToolResultBlock['content'], isError: boolean }): { content: AgentToolContent[], text?: string } {
1360
+ if (typeof content === 'string') {
1361
+ const text = isError ? withoutToolUseErrorWrapper(content) : content
1362
+ return { content: [{ type: 'text', text }], text }
1363
+ }
1364
+ if (content === undefined)
1365
+ return { content: [] }
1366
+ if (!Array.isArray(content)) {
1367
+ const parsed = jsonValueSchema.safeParse(content)
1368
+ return parsed.success ? { content: [jsonToolContent(parsed.data)] } : { content: [] }
1369
+ }
1370
+
1371
+ const mapped: AgentToolContent[] = []
1372
+ let text = ''
1373
+ let hasText = false
1374
+ for (const block of content) {
1375
+ const parsed = jsonValueSchema.safeParse(block)
1376
+ if (!parsed.success)
1377
+ continue
1378
+ const value = isError ? withoutToolUseErrorBlockWrapper(parsed.data) : parsed.data
1379
+ if (isJsonObject(value) && value.type === 'text' && typeof value.text === 'string') {
1380
+ hasText = true
1381
+ text += value.text
1382
+ }
1383
+ mapped.push(modelToolContent(value))
1384
+ }
1385
+ return removeUndefined({ content: mapped, text: hasText ? text : undefined })
1386
+ }
1387
+
1388
+ // Claude Code wraps its own tool failures as `<tool_use_error>…</tool_use_error>` for the model and
1389
+ // strips the same wrapper before display (CLI 2.1.280), so the wrapper is transport, not output.
1390
+ const TOOL_USE_ERROR_WRAPPER = /^<tool_use_error>([\s\S]*)<\/tool_use_error>$/
1391
+
1392
+ function withoutToolUseErrorWrapper(text: string): string {
1393
+ return TOOL_USE_ERROR_WRAPPER.exec(text)?.[1] ?? text
1394
+ }
1395
+
1396
+ function withoutToolUseErrorBlockWrapper(value: JsonValue): JsonValue {
1397
+ if (!isJsonObject(value) || value.type !== 'text' || typeof value.text !== 'string')
1398
+ return value
1399
+ return { ...value, text: withoutToolUseErrorWrapper(value.text) }
1400
+ }
1401
+
1402
+ function modelToolContent(value: JsonValue): AgentToolContent {
1403
+ if (!isJsonObject(value))
1404
+ return jsonToolContent(value)
1405
+ if (value.type === 'image' && isJsonObject(value.source)) {
1406
+ const source = value.source
1407
+ const extras = Object.fromEntries(Object.entries(value).filter(([key]) => key !== 'type' && key !== 'source'))
1408
+ const sourceExtras = Object.fromEntries(Object.entries(source).filter(([key]) => !['type', 'media_type', 'data', 'url'].includes(key)))
1409
+ if (Object.keys(sourceExtras).length > 0)
1410
+ extras.source = sourceExtras
1411
+ const metadata = Object.keys(extras).length > 0 ? { extras } : {}
1412
+ if (source.type === 'base64' && typeof source.media_type === 'string' && source.media_type.length > 0
1413
+ && typeof source.data === 'string' && source.data.length > 0) {
1414
+ return { type: 'image', mimeType: source.media_type, data: source.data, ...metadata }
1415
+ }
1416
+ if (source.type === 'url' && typeof source.url === 'string' && source.url.length > 0)
1417
+ return { type: 'image', uri: source.url, ...metadata }
1418
+ }
1419
+ return mcpToolContent(value)
1420
+ }
1421
+
1422
+ function mappedHostMcpContent(input: { trailer: unknown, modelBlocks: AgentToolContent[] }): AgentToolContent[] {
1423
+ const content: AgentToolContent[] = [...input.modelBlocks]
1424
+ const hostContent = ownJsonSlot(input.trailer, 'content')
1425
+ if (hostContent !== undefined) {
1426
+ const hostBlocks = Array.isArray(hostContent) ? hostContent.map(mcpToolContent) : [mcpToolContent(hostContent)]
1427
+ mergeToolContentOccurrences(content, hostBlocks)
1428
+ }
1429
+ const resourceLinks = ownJsonSlot(input.trailer, 'resourceLinks')
1430
+ if (resourceLinks !== undefined) {
1431
+ if (Array.isArray(resourceLinks))
1432
+ mergeToolContentOccurrences(content, resourceLinks.map(resourceLinkContent))
1433
+ else
1434
+ content.push(jsonToolContent(resourceLinks))
1435
+ }
1436
+ return content
1437
+ }
1438
+
1439
+ function mergeToolContentOccurrences(target: AgentToolContent[], incoming: AgentToolContent[]): void {
1440
+ let cursor = 0
1441
+ for (let incomingIndex = 0; incomingIndex < incoming.length; incomingIndex += 1) {
1442
+ const block = incoming[incomingIndex]!
1443
+ const index = target.findIndex((existing, position) => position >= cursor
1444
+ && jsonValuesEqual(contentIdentity(existing), contentIdentity(block)))
1445
+ if (index === -1) {
1446
+ const nextMatch = target.findIndex((existing, position) => position >= cursor
1447
+ && incoming.slice(incomingIndex + 1).some(next => jsonValuesEqual(contentIdentity(existing), contentIdentity(next))))
1448
+ const insertion = nextMatch === -1 ? target.length : nextMatch
1449
+ target.splice(insertion, 0, block)
1450
+ cursor = insertion + 1
1451
+ }
1452
+ else {
1453
+ cursor = index + 1
1454
+ const existing = target[index]!
1455
+ if (block.type !== 'json' && existing.type !== 'json') {
1456
+ target[index] = removeUndefined({
1457
+ ...block,
1458
+ annotations: block.annotations ?? existing.annotations,
1459
+ extras: existing.extras || block.extras ? { ...existing.extras, ...block.extras } : undefined,
1460
+ })
1461
+ }
1462
+ else {
1463
+ target[index] = block
1464
+ }
1465
+ }
1466
+ }
1467
+ }
1468
+
1469
+ function contentIdentity(content: AgentToolContent): JsonValue {
1470
+ return Object.fromEntries(Object.entries(content).filter(([key]) => key !== 'extras' && key !== 'annotations'))
1471
+ }
1472
+
1473
+ function resourceLinkContent(value: JsonValue): AgentToolContent {
1474
+ if (isJsonObject(value) && typeof value.name === 'string' && typeof value.uri === 'string')
1475
+ return mcpToolContent({ ...value, type: 'resource_link' })
1476
+ return jsonToolContent(value)
1477
+ }
1478
+
1479
+ function jsonValuesEqual(left: JsonValue | undefined, right: JsonValue | undefined): boolean {
1480
+ if (left === right)
1481
+ return true
1482
+ if (left === undefined || right === undefined || typeof left !== typeof right || left === null || right === null)
1483
+ return false
1484
+ if (Array.isArray(left) || Array.isArray(right)) {
1485
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
1486
+ return false
1487
+ return left.every((entry, index) => jsonValuesEqual(entry, right[index]))
1488
+ }
1489
+ if (!isJsonObject(left) || !isJsonObject(right))
1490
+ return false
1491
+ const leftKeys = Object.keys(left)
1492
+ const rightKeys = Object.keys(right)
1493
+ return leftKeys.length === rightKeys.length
1494
+ && leftKeys.every(key => Object.hasOwn(right, key) && jsonValuesEqual(left[key], right[key]))
1495
+ }
1496
+
1497
+ function isHostMcpToolName(name: string): boolean {
1498
+ return name.startsWith('mcp__') && !name.startsWith(CLAUDE_RUNTIME_TOOL_PREFIX)
766
1499
  }
767
1500
 
768
1501
  function jsonInput(value: unknown): JsonValue {