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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +82 -0
  2. package/docs/consumer-capability-matrix.md +374 -0
  3. package/package.json +17 -10
  4. package/scripts/generate-codex-protocol.ts +711 -0
  5. package/src/agents/agent-operation.ts +8 -0
  6. package/src/agents/agent-provider.ts +3 -0
  7. package/src/agents/agent-run.ts +14 -2
  8. package/src/agents/agent-runtime-error.ts +28 -0
  9. package/src/agents/agent-session-events.ts +33 -0
  10. package/src/agents/agent-tool-runner.ts +12 -11
  11. package/src/agents/agent-tool.ts +12 -4
  12. package/src/agents/author-context.ts +1 -1
  13. package/src/agents/claude/claude-auth.ts +40 -0
  14. package/src/agents/claude/claude-browser-turn-metadata.ts +70 -0
  15. package/src/agents/claude/claude-code-process.ts +98 -0
  16. package/src/agents/claude/claude-command-mapper.ts +63 -3
  17. package/src/agents/claude/claude-desktop.ts +55 -0
  18. package/src/agents/claude/claude-event-mapper.ts +876 -124
  19. package/src/agents/claude/claude-file-change.ts +128 -0
  20. package/src/agents/claude/claude-provider.ts +344 -29
  21. package/src/agents/claude/claude-run.ts +922 -91
  22. package/src/agents/claude/claude-selected-repository-instructions.ts +91 -0
  23. package/src/agents/claude/claude-skill-catalog.ts +75 -0
  24. package/src/agents/claude/claude-steered-notifications.ts +180 -0
  25. package/src/agents/claude/claude-tool-mapper.ts +81 -2
  26. package/src/agents/claude/claude-workspace-skills.ts +87 -0
  27. package/src/agents/claude/index.ts +2 -0
  28. package/src/agents/codex/codex-auth-mapper.ts +27 -0
  29. package/src/agents/codex/codex-auth.ts +19 -0
  30. package/src/agents/codex/codex-browser-auth-broker.ts +320 -0
  31. package/src/agents/codex/codex-browser-auth-status.ts +55 -0
  32. package/src/agents/codex/codex-command-mapper.ts +38 -3
  33. package/src/agents/codex/codex-event-mapper.ts +375 -64
  34. package/src/agents/codex/codex-json-rpc-client.ts +66 -21
  35. package/src/agents/codex/codex-provider.ts +118 -236
  36. package/src/agents/codex/codex-run-auth.ts +113 -0
  37. package/src/agents/codex/codex-run.ts +428 -48
  38. package/src/agents/codex/codex-tool-mapper.ts +2 -2
  39. package/src/agents/codex/codex.errors.ts +8 -0
  40. package/src/agents/codex/generated/meta.gen.ts +144 -6
  41. package/src/agents/codex/generated/namespaces.gen.ts +92 -3
  42. package/src/agents/codex/generated/schema.gen.ts +24698 -15638
  43. package/src/agents/codex/lazy-codex-run-auth.ts +47 -0
  44. package/src/agents/index.ts +5 -0
  45. package/src/agents/input-attachment-preparation.ts +13 -0
  46. package/src/agents/mcp-tool-content.ts +82 -0
  47. package/src/agents/model-rebinding-run.ts +14 -3
  48. package/src/agents/selected-repository-instructions.ts +202 -0
  49. package/src/agents/session-context.ts +41 -0
  50. package/src/agents/subscription-token-refresh.ts +73 -0
  51. package/src/agents/user-input-request.ts +19 -20
  52. package/src/errors/index.ts +1 -1
  53. package/src/errors/validation-error.ts +6 -0
  54. package/src/protocol/agent-catalog.ts +159 -0
  55. package/src/protocol/agent-command.ts +54 -7
  56. package/src/protocol/agent-content.ts +36 -16
  57. package/src/protocol/agent-event.ts +210 -73
  58. package/src/protocol/agent-identity.ts +29 -0
  59. package/src/protocol/agent-json.ts +122 -8
  60. package/src/protocol/agent-metadata.ts +2 -2
  61. package/src/protocol/agent-model.ts +13 -1
  62. package/src/protocol/agent-notification.ts +110 -0
  63. package/src/protocol/agent-presentation.ts +71 -20
  64. package/src/protocol/agent-tool.ts +66 -10
  65. package/src/protocol/agent-usage.ts +4 -1
  66. package/src/protocol/agent-user-input.ts +4 -4
  67. package/src/protocol/agent-work.ts +12 -10
  68. package/src/protocol/index.ts +2 -0
  69. package/src/protocol/rejected-keys.ts +11 -0
  70. package/src/provenance.gen.ts +3 -3
  71. package/src/steps/contract.ts +4 -1
  72. package/src/steps/projector.ts +17 -1
  73. package/src/supervisor/agent-provider-factory.ts +60 -5
  74. package/src/supervisor/bootstrap-binder.ts +3 -2
  75. package/src/supervisor/index.ts +1 -1
  76. package/src/supervisor/persistence/supervisor-store.ts +22 -8
  77. package/src/supervisor/runtime-handler.ts +30 -2
  78. package/src/supervisor-protocol/agent-event-wrapper.ts +30 -0
  79. package/src/supervisor-protocol/bootstrap.ts +4 -1
  80. package/src/supervisor-protocol/command-body.ts +4 -1
  81. package/src/supervisor-protocol/envelopes/supervisor-to-control-plane.ts +1 -0
  82. package/src/testing/in-memory-runtime-control-plane.ts +118 -12
  83. package/src/testing/index.ts +1 -1
  84. package/src/worker-runtime-client/command-pump.ts +85 -10
  85. package/src/worker-runtime-client/connection-attempt.ts +95 -20
  86. package/src/worker-runtime-client/frame-processor.ts +242 -58
  87. package/src/worker-runtime-client/index.ts +8 -2
  88. package/src/worker-runtime-client/ports.ts +34 -2
  89. package/src/worker-runtime-client/rpc-request-manager.ts +152 -107
  90. package/src/worker-runtime-client/sandbox-connection-runtime.ts +72 -35
@@ -2,16 +2,19 @@ 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
+ import { randomUUID } from 'node:crypto'
8
9
  import z from 'zod'
9
10
  import { jsonValueSchema } from '../../protocol'
10
11
  import { canonicalToolName } from '../../protocol/agent-tool-name'
11
12
  import { agentWorkStatusSchema } from '../../protocol/agent-work'
12
13
  import { createRuntimeTurnId } from '../agent-id'
13
14
  import { AgentProviderProtocolError } from '../agent-runtime-error'
15
+ import { isJsonObject, jsonToolContent, mcpToolContent, ownJsonSlot } from '../mcp-tool-content'
14
16
  import { optionalNonEmpty, removeUndefined } from '../normalize'
17
+ import { claudeFileChangeText } from './claude-file-change'
15
18
  import { CLAUDE_AGENT_TOOL_SERVER } from './claude-tool-mapper'
16
19
 
17
20
  type UserContentBlock = Exclude<MessageParam['content'], string>[number]
@@ -22,6 +25,18 @@ type ClaudeTaskStartedMessage = Extract<SDKMessage, { type: 'system', subtype: '
22
25
  type ClaudeTaskProgressMessage = Extract<SDKMessage, { type: 'system', subtype: 'task_progress' }>
23
26
  type ClaudeTaskNotificationMessage = Extract<SDKMessage, { type: 'system', subtype: 'task_notification' }>
24
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'] }
25
40
  type ClaudeSubagentActor = Extract<AgentEventActor, { type: 'subagent' }>
26
41
  type TaskCreateWorkInput = Pick<TaskCreateInput, 'subject' | 'description'>
27
42
  type TaskGetWorkInput = Pick<TaskGetInput, 'taskId'>
@@ -42,6 +57,11 @@ type ClaudeUnownedTurnEventDraft = ClaudeTurnEventDraft extends infer Draft
42
57
  ? Omit<Draft, 'actor'>
43
58
  : never
44
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' }
45
65
 
46
66
  const mainActor = { type: 'main', actorId: 'main' } satisfies AgentEventActor
47
67
 
@@ -95,8 +115,17 @@ const taskListWorkOutputSchema = z.object({
95
115
  }).passthrough() satisfies z.ZodType<TaskListWorkOutput>
96
116
 
97
117
  export interface ClaudeEventMapperState {
118
+ readonly usageScopeId: string
98
119
  readonly toolNameById: Map<string, string>
99
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>
100
129
  readonly startedMessageIds: Set<string>
101
130
  readonly endedMessageIds: Set<string>
102
131
  readonly streamedTextByMessageId: Map<string, string>
@@ -104,12 +133,39 @@ export interface ClaudeEventMapperState {
104
133
  readonly streamedReasoningByMessageId: Map<string, string>
105
134
  readonly contentBlockTypeByIndex: Map<number, string>
106
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>
107
144
  readonly actorByMessageId: Map<string, AgentEventActor>
145
+ readonly currentMessageIdByActorId: Map<string, string>
146
+ readonly contentBlockTypeByActorId: Map<string, Map<number, string>>
108
147
  readonly startedReasoningIds: Set<string>
109
148
  readonly endedReasoningIds: Set<string>
110
149
  turnStarted: boolean
111
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
112
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' }>
113
169
  currentTurnId?: string
114
170
  currentCommandId?: string
115
171
  currentMessageId?: string
@@ -118,8 +174,15 @@ export interface ClaudeEventMapperState {
118
174
 
119
175
  export function createClaudeEventMapperState(): ClaudeEventMapperState {
120
176
  return {
177
+ usageScopeId: `claude-query:${randomUUID()}`,
121
178
  toolNameById: new Map(),
122
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(),
123
186
  startedMessageIds: new Set(),
124
187
  endedMessageIds: new Set(),
125
188
  streamedTextByMessageId: new Map(),
@@ -127,106 +190,230 @@ export function createClaudeEventMapperState(): ClaudeEventMapperState {
127
190
  streamedReasoningByMessageId: new Map(),
128
191
  contentBlockTypeByIndex: new Map(),
129
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(),
130
200
  actorByMessageId: new Map(),
201
+ currentMessageIdByActorId: new Map(),
202
+ contentBlockTypeByActorId: new Map(),
131
203
  startedReasoningIds: new Set(),
132
204
  endedReasoningIds: new Set(),
133
205
  turnStarted: false,
134
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,
135
213
  }
136
214
  }
137
215
 
138
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 {
139
226
  const drafts: AgentEventDraft[] = []
140
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
+
141
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
142
246
  if (message.subtype === 'session_state_changed' && message.state === 'running')
143
247
  ensureTurnStarted(drafts, state)
144
- if (message.subtype === 'session_state_changed' && message.state === 'idle')
248
+ if (message.subtype === 'session_state_changed' && message.state === 'idle') {
249
+ drafts.push(...flushHeldTurnEnd(state))
145
250
  ensureSessionIdle(drafts, state)
251
+ }
146
252
  if (message.subtype === 'status')
147
253
  drafts.push(...mapStatusMessage(message, state))
148
254
  if (message.subtype === 'compact_boundary')
149
255
  drafts.push(...mapCompactBoundaryMessage(message, state))
150
- if (message.subtype === 'task_started')
151
- drafts.push(...mapTaskStartedMessage(message, state))
152
- if (message.subtype === 'task_progress')
153
- drafts.push(...mapTaskProgressMessage(message, state))
154
- if (message.subtype === 'task_notification')
155
- 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
+ }
156
274
  return drafts
157
275
  }
158
276
 
159
277
  if (message.type === 'assistant') {
160
- const turnId = ensureTurnStarted(drafts, state)
161
- 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
+ }
162
286
  ensureAssistantMessageStarted(drafts, state, message.message, turnId, actor)
163
- 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'))
164
290
  if (message.message.stop_reason !== null || shouldEndAssistantSnapshot(message.message, state, actor))
165
291
  endAssistantMessage(drafts, state, message.message, turnId, actor)
166
292
  return drafts
167
293
  }
168
294
 
169
295
  if (message.type === 'stream_event') {
170
- const turnId = ensureTurnStarted(drafts, state)
171
- const actor = actorFromParentToolUseId(message.parent_tool_use_id, state)
172
- 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))
173
302
  return drafts
174
303
  }
175
304
 
176
305
  if (message.type === 'user') {
177
- const turnId = ensureTurnStarted(drafts, state)
178
- 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
179
310
  const content = message.message.content
180
311
  const messageId = message.uuid ?? `user-${turnId}`
181
312
  if (typeof content === 'string')
182
- return actor ? subagentUserMessageEvents({ messageId, text: content, turnId, actor }) : drafts
183
- if (actor)
313
+ return actor.type === 'subagent' ? subagentUserMessageEvents({ messageId, text: content, turnId, actor }) : drafts
314
+ if (actor.type === 'subagent')
184
315
  drafts.push(...subagentUserContentEvents({ messageId, content, turnId, actor }))
185
- 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
+ })))
186
328
  return drafts
187
329
  }
188
330
 
189
331
  if (message.type === 'result') {
190
332
  const turnId = ensureTurnStarted(drafts, state)
333
+ correlateConsumedPrompts({ message, turnId, drafts, state })
191
334
  const usage = usageFromObject(message.usage)
192
335
  // Claude Agent SDK result usage is per completed ask/turn for the stream-json path we use,
193
336
  // not a monotonic counter across the long-lived query. Emit it directly so summing
194
337
  // normalized usage events gives the run total; do not diff against prior results.
195
- if (usage) {
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) {
196
345
  drafts.push({ type: 'usage', turnId, actor: mainActor, payload: { usage, accounting: {
197
346
  observationId: message.uuid,
198
- mode: 'increment',
199
- scopeId: turnId,
347
+ mode: Object.keys(message.modelUsage ?? {}).length ? 'cumulative' : 'increment',
348
+ scopeId: Object.keys(message.modelUsage ?? {}).length ? state.usageScopeId : turnId,
200
349
  includesChildren: true,
201
350
  models: Object.fromEntries(Object.entries(message.modelUsage ?? {}).map(([model, counts]) => [model, {
202
351
  inputTokens: counts.inputTokens,
203
352
  outputTokens: counts.outputTokens,
204
353
  cacheReadTokens: counts.cacheReadInputTokens,
205
354
  cacheWriteTokens: counts.cacheCreationInputTokens,
355
+ webSearchRequests: counts.webSearchRequests,
206
356
  }])),
207
357
  } } })
208
358
  }
359
+ reportContextSnapshot({ drafts, state, turnId, result: message })
209
360
  endOpenReasoning(drafts, state, turnId)
210
361
  const interruptAccepted = state.interrupt.status === 'accepted' && state.interrupt.turnId === turnId
211
- drafts.push({ type: 'turn.ended', turnId, actor: mainActor, payload: turnEndedPayload(message, interruptAccepted) })
212
- 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 }) }
213
364
  state.pendingWorkCallById.clear()
214
- state.turnStarted = false
215
- state.interrupt = { status: 'none' }
216
- state.currentTurnId = undefined
217
- 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)
218
371
  state.currentMessageId = undefined
372
+ state.contentBlockTypeByIndex.clear()
219
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)
220
384
  return drafts
221
385
  }
222
386
 
223
387
  return drafts
224
388
  }
225
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
+
226
411
  function mapStatusMessage(message: ClaudeStatusMessage, state: ClaudeEventMapperState): AgentEventDraft[] {
227
412
  const drafts: AgentEventDraft[] = []
228
413
  if (message.status === 'compacting') {
229
414
  const turnId = ensureTurnStarted(drafts, state)
415
+ state.contextObservation = { status: 'none' }
416
+ state.reportedContext = undefined
230
417
  if (state.currentCompactionId)
231
418
  return drafts
232
419
  state.currentCompactionId = message.uuid
@@ -245,7 +432,7 @@ function mapStatusMessage(message: ClaudeStatusMessage, state: ClaudeEventMapper
245
432
  compactionId,
246
433
  trigger: 'unknown',
247
434
  status: 'failed',
248
- error: { message: message.compact_error ?? 'Claude compaction failed.' },
435
+ error: compactionError(message),
249
436
  },
250
437
  })
251
438
  state.currentCompactionId = undefined
@@ -254,13 +441,44 @@ function mapStatusMessage(message: ClaudeStatusMessage, state: ClaudeEventMapper
254
441
  return drafts
255
442
  }
256
443
 
257
- function mapTaskStartedMessage(message: ClaudeTaskStartedMessage, state: ClaudeEventMapperState): AgentEventDraft[] {
258
- const drafts: AgentEventDraft[] = []
259
- const turnId = ensureTurnStarted(drafts, state)
260
- const actor = subagentActorFromTask({ subagentId: message.task_id, parentToolCallId: message.tool_use_id, name: message.subagent_type })
261
- if (message.tool_use_id)
262
- state.subagentByToolCallId.set(message.tool_use_id, actor)
263
- 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 [{
264
482
  type: 'subagent.started',
265
483
  turnId,
266
484
  actor,
@@ -268,51 +486,134 @@ function mapTaskStartedMessage(message: ClaudeTaskStartedMessage, state: ClaudeE
268
486
  ...subagentPayloadRef(actor),
269
487
  description: message.description,
270
488
  }),
271
- })
272
- return drafts
489
+ }]
273
490
  }
274
491
 
275
- function mapTaskProgressMessage(message: ClaudeTaskProgressMessage, state: ClaudeEventMapperState): AgentEventDraft[] {
276
- const drafts: AgentEventDraft[] = []
277
- const turnId = ensureTurnStarted(drafts, state)
278
- const actor = subagentActorForTaskMessage({ subagentId: message.task_id, parentToolCallId: message.tool_use_id, name: message.subagent_type }, state)
279
- drafts.push({
280
- 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',
281
526
  turnId,
282
527
  actor,
283
528
  payload: removeUndefined({
284
529
  ...subagentPayloadRef(actor),
285
- summary: optionalNonEmpty(message.summary),
286
- lastToolName: optionalNonEmpty(message.last_tool_name),
287
- usage: usageFromTaskUsage(message.usage),
530
+ description: message.description,
288
531
  }),
289
- })
290
- return drafts
532
+ }]
291
533
  }
292
534
 
293
- function mapTaskNotificationMessage(message: ClaudeTaskNotificationMessage, state: ClaudeEventMapperState): AgentEventDraft[] {
294
- const drafts: AgentEventDraft[] = []
295
- const turnId = ensureTurnStarted(drafts, state)
296
- const actor = subagentActorForTaskMessage({ subagentId: message.task_id, parentToolCallId: message.tool_use_id }, state)
297
- drafts.push({
298
- 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',
299
552
  turnId,
300
553
  actor,
301
554
  payload: removeUndefined({
302
555
  ...subagentPayloadRef(actor),
303
- status: taskNotificationStatus(message.status),
304
556
  summary: optionalNonEmpty(message.summary),
305
- usage: message.usage ? usageFromTaskUsage(message.usage) : undefined,
306
- 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),
307
559
  }),
308
- })
309
- 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
+ ]
310
609
  }
311
610
 
312
611
  function mapCompactBoundaryMessage(message: ClaudeCompactBoundaryMessage, state: ClaudeEventMapperState): AgentEventDraft[] {
313
612
  const drafts: AgentEventDraft[] = []
314
613
  const turnId = ensureTurnStarted(drafts, state)
315
614
  const compactionId = state.currentCompactionId ?? message.uuid
615
+ state.contextObservation = { status: 'none' }
616
+ state.reportedContext = undefined
316
617
  drafts.push({
317
618
  type: 'context.compaction.completed',
318
619
  turnId,
@@ -330,7 +631,7 @@ function mapCompactBoundaryMessage(message: ClaudeCompactBoundaryMessage, state:
330
631
  return drafts
331
632
  }
332
633
 
333
- 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[] {
334
635
  const drafts: AgentEventDraft[] = []
335
636
  const streamedText = state.streamedTextByMessageId.has(message.id)
336
637
  const streamedReasoning = state.streamedReasoningMessageIds.has(message.id)
@@ -347,6 +648,12 @@ function mapAssistantContent(message: BetaMessage, state: ClaudeEventMapperState
347
648
  if (block.type === 'tool_use') {
348
649
  const toolName = normalizeToolName(block.name)
349
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 })
350
657
  if (isMain) {
351
658
  const pendingWorkCall = pendingClaudeWorkCall(block.name, block.input)
352
659
  if (pendingWorkCall)
@@ -358,27 +665,53 @@ function mapAssistantContent(message: BetaMessage, state: ClaudeEventMapperState
358
665
  return drafts
359
666
  }
360
667
 
361
- 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
362
679
  if (block.type !== 'tool_result')
363
680
  return []
364
681
  const toolCallId = block.tool_use_id
365
682
  const pendingWorkCall = state.pendingWorkCallById.get(toolCallId)
366
683
  if (pendingWorkCall)
367
684
  state.pendingWorkCallById.delete(toolCallId)
368
-
369
- const text = textFromToolResultContent(block.content)
370
- 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
+ })
371
702
  const drafts: AgentEventDraft[] = []
372
- if (text)
373
- 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))
374
705
  drafts.push(withActor({
375
706
  type: 'tool.call.completed',
376
707
  turnId,
377
708
  payload: removeUndefined({
378
709
  toolCallId,
710
+ toolName: state.toolNameById.get(toolCallId),
379
711
  status: block.is_error === true ? 'failed' : 'completed',
380
712
  output,
381
- 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,
382
715
  }),
383
716
  }, actor))
384
717
 
@@ -455,20 +788,19 @@ function parseClaudeTaskResult<Output>({ value, toolName, schema }: { value: unk
455
788
  })
456
789
  }
457
790
 
458
- function mapStreamEvent(event: BetaRawMessageStreamEvent, state: ClaudeEventMapperState, turnId: string, actor: AgentEventActor | undefined): AgentEventDraft[] {
791
+ function mapStreamEvent(event: BetaRawMessageStreamEvent, state: ClaudeEventMapperState, turnId: string, actor: AgentEventActor): AgentEventDraft[] {
459
792
  const drafts: AgentEventDraft[] = []
460
793
  if (event.type === 'message_start') {
461
- state.currentMessageId = event.message.id
462
- if (actor)
463
- state.actorByMessageId.set(event.message.id, actor)
794
+ setCurrentMessageId(state, actor, event.message.id)
795
+ state.actorByMessageId.set(event.message.id, actor)
464
796
  ensureAssistantMessageStarted(drafts, state, event.message, turnId, actor)
465
797
  return drafts
466
798
  }
467
799
 
468
800
  if (event.type === 'content_block_start') {
469
- const messageId = currentMessageId(state)
470
- const messageActor = actor ?? state.actorByMessageId.get(messageId)
471
- 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)
472
804
  if (event.content_block.type === 'thinking') {
473
805
  state.streamedReasoningMessageIds.add(messageId)
474
806
  ensureReasoningStarted(drafts, state, turnId, messageId, messageActor)
@@ -477,30 +809,30 @@ function mapStreamEvent(event: BetaRawMessageStreamEvent, state: ClaudeEventMapp
477
809
  }
478
810
 
479
811
  if (event.type === 'content_block_stop') {
480
- const messageId = currentMessageId(state)
481
- const messageActor = actor ?? state.actorByMessageId.get(messageId)
482
- 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)
483
816
  if (blockType === 'thinking')
484
817
  endReasoning(drafts, state, turnId, messageId, state.streamedReasoningByMessageId.get(messageId), messageActor)
485
- state.contentBlockTypeByIndex.delete(event.index)
818
+ blockTypes.delete(event.index)
486
819
  return drafts
487
820
  }
488
821
 
489
822
  if (event.type === 'message_stop') {
490
- const messageId = currentMessageId(state)
491
- const messageActor = actor ?? state.actorByMessageId.get(messageId)
823
+ const messageId = currentMessageId(state, actor)
824
+ const messageActor = state.actorByMessageId.get(messageId) ?? actor
492
825
  endAssistantMessageById(drafts, state, turnId, messageId, state.streamedTextByMessageId.get(messageId), messageActor)
493
826
  state.actorByMessageId.delete(messageId)
494
- state.currentMessageId = undefined
495
- state.contentBlockTypeByIndex.clear()
827
+ clearCurrentMessage(state, actor)
496
828
  return drafts
497
829
  }
498
830
 
499
831
  if (event.type !== 'content_block_delta')
500
832
  return drafts
501
833
 
502
- const messageId = currentMessageId(state)
503
- const messageActor = actor ?? state.actorByMessageId.get(messageId)
834
+ const messageId = currentMessageId(state, actor)
835
+ const messageActor = state.actorByMessageId.get(messageId) ?? actor
504
836
  const delta = event.delta
505
837
  if (delta.type === 'thinking_delta') {
506
838
  state.streamedReasoningMessageIds.add(messageId)
@@ -517,18 +849,64 @@ function mapStreamEvent(event: BetaRawMessageStreamEvent, state: ClaudeEventMapp
517
849
  }
518
850
 
519
851
  function ensureTurnStarted(drafts: AgentEventDraft[], state: ClaudeEventMapperState): string {
520
- if (state.turnStarted && state.currentTurnId)
521
- return state.currentTurnId
522
- state.turnStarted = true
523
- state.interrupt = { status: 'none' }
524
- state.currentTurnId = createRuntimeTurnId()
525
- state.currentMessageId = undefined
526
- state.contentBlockTypeByIndex.clear()
527
- 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
+ }
528
860
  ensureSessionRunning(drafts, state)
529
861
  return state.currentTurnId
530
862
  }
531
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
+
532
910
  function ensureSessionRunning(drafts: AgentEventDraft[], state: ClaudeEventMapperState): void {
533
911
  if (state.sessionState === 'running')
534
912
  return
@@ -543,22 +921,49 @@ function ensureSessionIdle(drafts: AgentEventDraft[], state: ClaudeEventMapperSt
543
921
  drafts.push({ type: 'session.state.changed', payload: { state: 'idle' } })
544
922
  }
545
923
 
546
- function ensureAssistantMessageStarted(drafts: AgentEventDraft[], state: ClaudeEventMapperState, message: BetaMessage, turnId: string, actor: AgentEventActor | undefined): void {
547
- state.currentMessageId = message.id
548
- if (actor)
549
- 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)
550
927
  if (state.startedMessageIds.has(message.id))
551
928
  return
552
929
  state.startedMessageIds.add(message.id)
553
930
  drafts.push(withActor({ type: 'message.started', turnId, payload: { messageId: message.id, role: 'assistant' } }, actor))
554
931
  }
555
932
 
556
- function currentMessageId(state: ClaudeEventMapperState): string {
557
- if (state.currentMessageId)
558
- 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
559
943
  throw new AgentProviderProtocolError({ message: 'Claude stream emitted content_block_delta before message_start.' })
560
944
  }
561
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
+
562
967
  function endAssistantMessage(drafts: AgentEventDraft[], state: ClaudeEventMapperState, message: BetaMessage, turnId: string, actor: AgentEventActor | undefined): void {
563
968
  if (state.endedMessageIds.has(message.id))
564
969
  return
@@ -592,18 +997,34 @@ function endReasoning(drafts: AgentEventDraft[], state: ClaudeEventMapperState,
592
997
 
593
998
  function endOpenReasoning(drafts: AgentEventDraft[], state: ClaudeEventMapperState, turnId: string): void {
594
999
  for (const reasoningId of state.startedReasoningIds) {
595
- 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)) {
596
1003
  state.endedReasoningIds.add(reasoningId)
597
1004
  drafts.push({ type: 'reasoning.ended', turnId, actor: mainActor, payload: { reasoningId } })
598
1005
  }
599
1006
  }
600
1007
  }
601
1008
 
1009
+ function endOpenAssistantMessage(drafts: AgentEventDraft[], state: ClaudeEventMapperState, turnId: string): void {
1010
+ const messageId = state.currentMessageId
1011
+ if (!messageId)
1012
+ return
1013
+ endAssistantMessageById(drafts, state, turnId, messageId, state.streamedTextByMessageId.get(messageId), state.actorByMessageId.get(messageId))
1014
+ state.actorByMessageId.delete(messageId)
1015
+ state.currentMessageId = undefined
1016
+ state.contentBlockTypeByIndex.clear()
1017
+ }
1018
+
602
1019
  function reasoningIdForMessage(messageId: string): string {
603
1020
  return `reasoning-${messageId}`
604
1021
  }
605
1022
 
606
- 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'] {
607
1028
  if (interruptAccepted && message.subtype === 'error_during_execution') {
608
1029
  return removeUndefined({
609
1030
  status: 'interrupted' as const,
@@ -624,45 +1045,227 @@ function turnEndedPayload(message: Extract<SDKMessage, { type: 'result' }>, inte
624
1045
  status: 'failed' as const,
625
1046
  reason: message.stop_reason ?? message.subtype,
626
1047
  duration: message.duration_ms,
627
- error: resultError(message),
1048
+ error: usageLimit ? usageLimitError({ error: resultError(message), usageLimit }) : resultError(message),
628
1049
  })
629
1050
  }
630
1051
 
631
- function resultError(message: Extract<SDKMessage, { type: 'result' }>): AgentError {
632
- 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
633
1153
  return {
634
- message: errors.length > 0 ? errors.join('\n') : `Claude result ended with ${message.subtype}.`,
1154
+ message: detail || `Claude result ended with ${message.subtype}.`,
635
1155
  code: message.subtype,
636
1156
  }
637
1157
  }
638
1158
 
639
- function shouldEndAssistantSnapshot(message: BetaMessage, state: ClaudeEventMapperState, actor: ClaudeSubagentActor | undefined): boolean {
640
- return actor !== undefined
1159
+ function shouldEndAssistantSnapshot(message: BetaMessage, state: ClaudeEventMapperState, actor: AgentEventActor): boolean {
1160
+ return actor.type === 'subagent'
641
1161
  && !state.streamedTextByMessageId.has(message.id)
642
1162
  && !state.streamedReasoningMessageIds.has(message.id)
643
1163
  }
644
1164
 
645
- function actorFromParentToolUseId(parentToolUseId: string | null, state: ClaudeEventMapperState): ClaudeSubagentActor | undefined {
646
- 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
647
1172
  }
648
1173
 
649
- function subagentActorForTaskMessage(input: { subagentId: string, parentToolCallId?: string, name?: string }, state: ClaudeEventMapperState): ClaudeSubagentActor {
650
- const existing = input.parentToolCallId ? state.subagentByToolCallId.get(input.parentToolCallId) : undefined
651
- return existing ?? subagentActorFromTask(input)
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
1222
+ }
1223
+
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
652
1237
  }
653
1238
 
654
- function subagentActorFromTask(input: { subagentId: string, parentToolCallId?: string, name?: string }): ClaudeSubagentActor {
1239
+ function subagentActorFromTask(input: { subagentId: string, parentActorId: string, parentToolCallId: string, name?: string }): ClaudeSubagentActor {
655
1240
  return removeUndefined({
656
1241
  type: 'subagent' as const,
657
1242
  actorId: input.subagentId,
658
1243
  subagentId: input.subagentId,
659
- parentActorId: 'main',
660
- 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 },
661
1246
  parentToolCallId: input.parentToolCallId,
662
1247
  name: input.name,
663
1248
  })
664
1249
  }
665
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
+
666
1269
  function subagentPayloadRef(actor: ClaudeSubagentActor): Pick<ClaudeSubagentActor, 'actorId' | 'subagentId' | 'parentActorId' | 'origin' | 'parentToolCallId' | 'name'> {
667
1270
  return removeUndefined({
668
1271
  actorId: actor.actorId,
@@ -714,12 +1317,15 @@ function normalizeToolName(name: string): string {
714
1317
  function usageFromObject(value: BetaUsage | undefined): AgentUsage | undefined {
715
1318
  if (!value)
716
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)
717
1323
  return removeUndefined({
718
- inputTokens: value.input_tokens,
1324
+ inputTokens,
719
1325
  outputTokens: value.output_tokens,
720
1326
  cacheReadTokens: value.cache_read_input_tokens ?? undefined,
721
1327
  cacheWriteTokens: value.cache_creation_input_tokens ?? undefined,
722
- 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,
723
1329
  })
724
1330
  }
725
1331
 
@@ -733,13 +1339,159 @@ function thinkingFromAssistantContent(content: BetaMessage['content']): string |
733
1339
  return text || undefined
734
1340
  }
735
1341
 
736
- function textFromToolResultContent(content: UserToolResultBlock['content']): string | undefined {
737
- if (!content)
1342
+ function correlatedHostTrailerToolCallId(input: { content: UserContentBlock[], message: ClaudeUserMessage, state: ClaudeEventMapperState }): string | undefined {
1343
+ if (input.message.tool_use_result === undefined)
738
1344
  return undefined
739
- if (typeof content === 'string')
740
- return content
741
- const text = content.map(block => block.type === 'text' ? block.text : '').join('\n')
742
- 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)
743
1495
  }
744
1496
 
745
1497
  function jsonInput(value: unknown): JsonValue {