@tduyng/pi-claude 0.0.0-stage → 0.0.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.
package/src/turn.ts ADDED
@@ -0,0 +1,657 @@
1
+ import {
2
+ calculateCost,
3
+ createAssistantMessageEventStream,
4
+ type Api,
5
+ type AssistantMessage,
6
+ type AssistantMessageEventStream,
7
+ type Model,
8
+ type SimpleStreamOptions,
9
+ type ToolResultMessage,
10
+ type Usage
11
+ } from '@earendil-works/pi-ai'
12
+ import { Validator } from 'ata-validator'
13
+ import { t } from 'ata-validator/t'
14
+
15
+
16
+ import { jsonArray, jsonFlag, jsonObject, jsonStrings, jsonText, sha, type JsonObject, type JsonValue } from './json.js'
17
+ import type { McpServerHandle } from './mcp.js'
18
+ import { ClaudeProcess, claudeBin, type ClaudeSpec } from './protocol.js'
19
+ import {
20
+ allSessions,
21
+ attachSession,
22
+ createSessionStore,
23
+ deliverToolResult,
24
+ dropSession,
25
+ failMissingResults,
26
+ newSessionToken,
27
+ parkToolCall,
28
+ sessionFor,
29
+ updateChain,
30
+ type ClaudeSession,
31
+ type ClaudeSessionStore,
32
+ type SessionKey
33
+ } from './session.js'
34
+ import {
35
+ mcpToolDefs,
36
+ readTranscript,
37
+ replayContent,
38
+ systemPreamble,
39
+ toolNameFromMcp,
40
+ userContent,
41
+ type ClaudeContext,
42
+ type Transcript
43
+ } from './transcript.js'
44
+
45
+ export type ClaudeRuntime = {
46
+ sessions: ClaudeSessionStore
47
+ ensureMcp(): Promise<McpServerHandle>
48
+ }
49
+
50
+ export function createRuntime(ensureMcp: () => Promise<McpServerHandle>): ClaudeRuntime {
51
+ return { sessions: createSessionStore(), ensureMcp }
52
+ }
53
+
54
+ const INTERRUPT_MS = 5000
55
+
56
+ type TurnReason = 'stop' | 'length' | 'toolUse'
57
+ type StepResult = { ok: true; reason: TurnReason } | { ok: false; error: string } | 'aborted'
58
+
59
+ const UsageSchema = t.object(
60
+ {
61
+ input_tokens: t.optional(t.number()),
62
+ output_tokens: t.optional(t.number()),
63
+ cache_read_input_tokens: t.optional(t.number()),
64
+ cache_creation_input_tokens: t.optional(t.number()),
65
+ cache_creation: t.optional(
66
+ t.object({ ephemeral_1h_input_tokens: t.optional(t.number()) }, { additionalProperties: true })
67
+ )
68
+ },
69
+ { additionalProperties: true }
70
+ )
71
+ const isUsageSchema = new Validator(UsageSchema).isValidObject
72
+
73
+ const TextBlockSchema = t.object({ type: t.literal('text') }, { additionalProperties: true })
74
+ const ThinkingBlockSchema = t.object({ type: t.literal('thinking') }, { additionalProperties: true })
75
+ const RedactedBlockSchema = t.object(
76
+ { type: t.literal('redacted_thinking'), data: t.string() },
77
+ { additionalProperties: true }
78
+ )
79
+ const ToolUseSchema = t.object(
80
+ { type: t.literal('tool_use'), id: t.string(), name: t.string() },
81
+ { additionalProperties: true }
82
+ )
83
+ const ContentBlockSchema = t.union([TextBlockSchema, ThinkingBlockSchema, RedactedBlockSchema, ToolUseSchema])
84
+
85
+ const TextDeltaSchema = t.object(
86
+ { type: t.literal('text_delta'), text: t.string() },
87
+ { additionalProperties: true }
88
+ )
89
+ const ThinkingDeltaSchema = t.object(
90
+ { type: t.literal('thinking_delta'), thinking: t.string() },
91
+ { additionalProperties: true }
92
+ )
93
+ const SignatureDeltaSchema = t.object(
94
+ { type: t.literal('signature_delta'), signature: t.string() },
95
+ { additionalProperties: true }
96
+ )
97
+ const JsonDeltaSchema = t.object(
98
+ { type: t.literal('input_json_delta'), partial_json: t.string() },
99
+ { additionalProperties: true }
100
+ )
101
+ const DeltaSchema = t.union([TextDeltaSchema, ThinkingDeltaSchema, SignatureDeltaSchema, JsonDeltaSchema])
102
+
103
+ const StreamEventSchema = t.union([
104
+ t.object(
105
+ {
106
+ type: t.literal('message_start'),
107
+ message: t.object(
108
+ {
109
+ id: t.optional(t.string()),
110
+ model: t.optional(t.string()),
111
+ usage: t.optional(UsageSchema)
112
+ },
113
+ { additionalProperties: true }
114
+ )
115
+ },
116
+ { additionalProperties: true }
117
+ ),
118
+ t.object(
119
+ {
120
+ type: t.literal('content_block_start'),
121
+ index: t.number(),
122
+ content_block: ContentBlockSchema
123
+ },
124
+ { additionalProperties: true }
125
+ ),
126
+ t.object(
127
+ {
128
+ type: t.literal('content_block_delta'),
129
+ index: t.number(),
130
+ delta: DeltaSchema
131
+ },
132
+ { additionalProperties: true }
133
+ ),
134
+ t.object({ type: t.literal('content_block_stop'), index: t.number() }, { additionalProperties: true }),
135
+ t.object(
136
+ {
137
+ type: t.literal('message_delta'),
138
+ delta: t.optional(
139
+ t.object({ stop_reason: t.optional(t.string()) }, { additionalProperties: true })
140
+ ),
141
+ usage: t.optional(UsageSchema)
142
+ },
143
+ { additionalProperties: true }
144
+ ),
145
+ t.object({ type: t.literal('message_stop') }, { additionalProperties: true })
146
+ ])
147
+ const isStreamEventSchema = new Validator(StreamEventSchema).isValidObject
148
+
149
+ type Totals = { input: number; output: number; cacheRead: number; cacheWrite: number; cacheWrite1h: number }
150
+
151
+ function emptyTotals(): Totals {
152
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0 }
153
+ }
154
+
155
+ function emptyUsage(): Usage {
156
+ return {
157
+ input: 0,
158
+ output: 0,
159
+ cacheRead: 0,
160
+ cacheWrite: 0,
161
+ totalTokens: 0,
162
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
163
+ }
164
+ }
165
+
166
+ function effortFor(model: Model<Api>, level: SimpleStreamOptions['reasoning']): string | undefined {
167
+ if (!level) return undefined
168
+ const mapped = model.thinkingLevelMap?.[level]
169
+ if (mapped === null) return undefined
170
+ if (mapped === 'low' || mapped === 'medium' || mapped === 'high' || mapped === 'xhigh' || mapped === 'max')
171
+ return mapped
172
+ if (level === 'minimal') return 'low'
173
+ return level
174
+ }
175
+
176
+ function mapStop(reason: string | undefined): TurnReason {
177
+ if (reason === 'tool_use') return 'toolUse'
178
+ if (reason === 'max_tokens' || reason === 'model_context_window_exceeded') return 'length'
179
+ return 'stop'
180
+ }
181
+
182
+ export function streamClaude(
183
+ runtime: ClaudeRuntime,
184
+ model: Model<Api>,
185
+ context: ClaudeContext,
186
+ options?: SimpleStreamOptions
187
+ ): AssistantMessageEventStream {
188
+ const stream = createAssistantMessageEventStream()
189
+ void runTurn(runtime, stream, model, context, options)
190
+ return stream
191
+ }
192
+
193
+ async function runTurn(
194
+ runtime: ClaudeRuntime,
195
+ stream: AssistantMessageEventStream,
196
+ model: Model<Api>,
197
+ context: ClaudeContext,
198
+ options?: SimpleStreamOptions
199
+ ): Promise<void> {
200
+ const output: AssistantMessage = {
201
+ role: 'assistant',
202
+ content: [],
203
+ api: model.api,
204
+ provider: model.provider,
205
+ model: model.id,
206
+ usage: emptyUsage(),
207
+ stopReason: 'stop',
208
+ timestamp: Date.now()
209
+ }
210
+ let session: ClaudeSession | undefined
211
+
212
+ const fail = (reason: 'error' | 'aborted', message: string): void => {
213
+ output.stopReason = reason
214
+ output.errorMessage = message
215
+ stream.push({ type: 'error', reason, error: output })
216
+ stream.end()
217
+ }
218
+
219
+ try {
220
+ if (options?.signal?.aborted) {
221
+ fail('aborted', 'Request aborted before dispatch')
222
+ return
223
+ }
224
+ if (options?.maxTokens === 1) {
225
+ output.stopReason = 'length'
226
+ stream.push({ type: 'start', partial: output })
227
+ stream.push({ type: 'done', reason: 'length', message: output })
228
+ stream.end()
229
+ return
230
+ }
231
+
232
+ const transcript = readTranscript(context)
233
+ const last = transcript.messages[transcript.messages.length - 1]
234
+ if (!last || last.role === 'assistant') {
235
+ throw new Error('the transcript must end with a user message or tool results')
236
+ }
237
+
238
+ const mcp = await runtime.ensureMcp()
239
+ const cwd = process.cwd()
240
+ const systemPrompt = [transcript.systemPrompt.trim(), systemPreamble(cwd)]
241
+ .filter((part) => part.length > 0)
242
+ .join('\n\n')
243
+ const effort = effortFor(model, options?.reasoning)
244
+ const maxTokens = options?.maxTokens ?? model.maxTokens
245
+ const key: SessionKey = {
246
+ model: model.id,
247
+ systemPrompt,
248
+ toolsHash: transcript.toolsHash,
249
+ effort: effort ?? '',
250
+ cwd,
251
+ contextWindow: model.contextWindow,
252
+ maxTokens
253
+ }
254
+
255
+ const sessionId = options?.sessionId ?? sha(key.systemPrompt)
256
+ await Promise.all(allSessions(runtime.sessions).map((candidate) => candidate.settling))
257
+ let attached = attachSession(runtime.sessions, transcript, key)
258
+ if (attached && attached.session.spec.mcpUrl !== mcp.urlFor(attached.session.token)) {
259
+ dropSession(runtime.sessions, attached.session)
260
+ attached = undefined
261
+ }
262
+ if (attached?.session.proc?.isDead()) {
263
+ dropSession(runtime.sessions, attached.session)
264
+ attached = undefined
265
+ }
266
+
267
+ if (attached) {
268
+ const active = attached.session
269
+ session = active
270
+ active.busy = true
271
+ if (!active.proc) throw new Error('Claude Code process is missing')
272
+ mcp.register(active.token, {
273
+ listTools: () => mcpToolDefs(active.tools),
274
+ callTool: (name, args, meta) => parkToolCall(active, name, args, meta)
275
+ })
276
+
277
+ const tail = transcript.messages.slice(attached.prefix)
278
+ const results = tail.filter((message): message is ToolResultMessage => message.role === 'toolResult')
279
+ const rest = tail.filter((message) => message.role !== 'toolResult')
280
+ const content = userContent(rest)
281
+
282
+ if (active.awaiting.size > 0) {
283
+ if (content.length > 0) active.proc.sendUser(content)
284
+ const seen = new Set<string>()
285
+ for (const result of results) {
286
+ if (!active.awaiting.has(result.toolCallId)) continue
287
+ deliverToolResult(active, result)
288
+ seen.add(result.toolCallId)
289
+ }
290
+ failMissingResults(active, seen)
291
+ active.awaiting.clear()
292
+ } else {
293
+ if (content.length === 0) throw new Error('the transcript has no new user message')
294
+ active.pendingTools.length = 0
295
+ active.proc.sendUser(content)
296
+ }
297
+ } else {
298
+ const previous = runtime.sessions.sessions.get(sessionId)
299
+ if (previous?.busy) throw new Error('this Pi session already has a request in flight')
300
+ if (previous) dropSession(runtime.sessions, previous)
301
+
302
+ const live = await runtime.ensureMcp()
303
+ const token = newSessionToken()
304
+ const spec: ClaudeSpec = {
305
+ bin: claudeBin(),
306
+ model: model.id,
307
+ systemPrompt,
308
+ effort,
309
+ cwd,
310
+ mcpUrl: live.urlFor(token),
311
+ contextWindow: model.contextWindow,
312
+ maxTokens
313
+ }
314
+ const created = sessionFor(runtime.sessions, sessionId, token, key, spec, transcript.tools, transcript)
315
+ session = created
316
+ created.busy = true
317
+ live.register(created.token, {
318
+ listTools: () => mcpToolDefs(created.tools),
319
+ callTool: (name, args, meta) => parkToolCall(created, name, args, meta)
320
+ })
321
+ created.proc = new ClaudeProcess(spec)
322
+ await created.proc.start(replayContent(transcript.messages))
323
+ }
324
+
325
+ const active = session
326
+ const step = await readStep(active, stream, output, model, options)
327
+
328
+ if (step === 'aborted') {
329
+ active.settling = settleAbort(runtime.sessions, active, transcript)
330
+ fail('aborted', 'Request aborted')
331
+ return
332
+ }
333
+ if (!step.ok) {
334
+ dropSession(runtime.sessions, active)
335
+ fail('error', step.error)
336
+ return
337
+ }
338
+
339
+ updateChain(active, transcript, output)
340
+ const callIds = new Set<string>()
341
+ for (const block of output.content) if (block.type === 'toolCall') callIds.add(block.id)
342
+ active.awaiting = step.reason === 'toolUse' ? callIds : new Set()
343
+
344
+ stream.push({ type: 'done', reason: step.reason, message: output })
345
+ stream.end()
346
+ } catch (error) {
347
+ if (session) dropSession(runtime.sessions, session)
348
+ fail(options?.signal?.aborted ? 'aborted' : 'error', error instanceof Error ? error.message : String(error))
349
+ } finally {
350
+ if (session && !session.settling) {
351
+ session.busy = false
352
+ session.lastUsed = Date.now()
353
+ }
354
+ }
355
+ }
356
+
357
+ async function settleAbort(store: ClaudeSessionStore, session: ClaudeSession, transcript: Transcript): Promise<void> {
358
+ const proc = session.proc
359
+ proc?.interrupt()
360
+ const deadline = AbortSignal.timeout(INTERRUPT_MS)
361
+ for (;;) {
362
+ const event = await proc?.next(deadline).catch(() => undefined)
363
+ if (!event) {
364
+ dropSession(store, session)
365
+ break
366
+ }
367
+ if (jsonText(event, 'type') !== 'result') continue
368
+ session.chain = transcript.chain[transcript.messages.length] ?? session.chain
369
+ session.count = transcript.messages.length
370
+ session.awaiting = new Set()
371
+ session.pendingTools.length = 0
372
+ break
373
+ }
374
+ session.settling = undefined
375
+ session.busy = false
376
+ session.lastUsed = Date.now()
377
+ }
378
+
379
+ async function readStep(
380
+ session: ClaudeSession,
381
+ stream: AssistantMessageEventStream,
382
+ output: AssistantMessage,
383
+ model: Model<Api>,
384
+ options?: SimpleStreamOptions
385
+ ): Promise<StepResult> {
386
+ const proc = session.proc
387
+ if (!proc) return { ok: false, error: 'Claude Code process is missing' }
388
+
389
+ const blocks = output.content
390
+ const positions = new Map<number, number>()
391
+ const partialJson = new Map<number, string>()
392
+ const finished = emptyTotals()
393
+ let current = emptyTotals()
394
+ let responseStart = 0
395
+ let inResponse = false
396
+ let started = false
397
+ let stopReason: string | undefined
398
+ let turnStop: TurnReason | undefined
399
+ let lastTotal = 0
400
+
401
+ const start = (): void => {
402
+ if (started) return
403
+ started = true
404
+ stream.push({ type: 'start', partial: output })
405
+ }
406
+
407
+ const setUsage = (raw: JsonObject | undefined): void => {
408
+ if (!raw || !isUsageSchema(raw)) return
409
+ const usage = raw
410
+ if (usage.input_tokens !== undefined) current.input = usage.input_tokens
411
+ if (usage.output_tokens !== undefined) current.output = usage.output_tokens
412
+ if (usage.cache_read_input_tokens !== undefined) current.cacheRead = usage.cache_read_input_tokens
413
+ if (usage.cache_creation_input_tokens !== undefined) current.cacheWrite = usage.cache_creation_input_tokens
414
+ const oneHour = usage.cache_creation?.ephemeral_1h_input_tokens
415
+ if (oneHour !== undefined && oneHour > 0) current.cacheWrite1h = oneHour
416
+ output.usage.input = finished.input + current.input
417
+ output.usage.output = finished.output + current.output
418
+ output.usage.cacheRead = finished.cacheRead + current.cacheRead
419
+ output.usage.cacheWrite = finished.cacheWrite + current.cacheWrite
420
+ const oneHourTotal = finished.cacheWrite1h + current.cacheWrite1h
421
+ if (oneHourTotal > 0) output.usage.cacheWrite1h = oneHourTotal
422
+ output.usage.totalTokens = current.input + current.cacheRead + current.cacheWrite + current.output
423
+ output.usage.cost = calculateCost(model, output.usage)
424
+ }
425
+
426
+ const foldUsage = (): void => {
427
+ lastTotal = current.input + current.cacheRead + current.cacheWrite + current.output
428
+ finished.input += current.input
429
+ finished.output += current.output
430
+ finished.cacheRead += current.cacheRead
431
+ finished.cacheWrite += current.cacheWrite
432
+ finished.cacheWrite1h += current.cacheWrite1h
433
+ current = emptyTotals()
434
+ output.usage.totalTokens = lastTotal
435
+ output.usage.cost = calculateCost(model, output.usage)
436
+ }
437
+
438
+ for (;;) {
439
+ let event: JsonObject | undefined
440
+ try {
441
+ event = await proc.next(options?.signal)
442
+ } catch {
443
+ return 'aborted'
444
+ }
445
+ if (!event)
446
+ return { ok: false, error: `Claude Code exited${proc.stderrTail() ? `\n${proc.stderrTail()}` : ''}` }
447
+ session.lastUsed = Date.now()
448
+
449
+ const type = jsonText(event, 'type')
450
+ if (type === 'stream_event') {
451
+ const parent = event['parent_tool_use_id']
452
+ if (parent !== undefined && parent !== null) continue
453
+ const raw = jsonObject(event['event'])
454
+ if (!raw || !isStreamEventSchema(raw)) continue
455
+ const streamEvent = raw
456
+ await options?.onProviderStreamEvent?.(streamEvent, model)
457
+
458
+ if (streamEvent.type === 'message_start') {
459
+ if (inResponse) {
460
+ blocks.length = responseStart
461
+ positions.clear()
462
+ partialJson.clear()
463
+ }
464
+ responseStart = blocks.length
465
+ inResponse = true
466
+ current = emptyTotals()
467
+ stopReason = undefined
468
+ if (streamEvent.message.id) output.responseId = streamEvent.message.id
469
+ if (streamEvent.message.model) output.responseModel = streamEvent.message.model
470
+ setUsage(streamEvent.message.usage)
471
+ start()
472
+ continue
473
+ }
474
+
475
+ if (streamEvent.type === 'content_block_start') {
476
+ start()
477
+ const block = streamEvent.content_block
478
+ const index = blocks.length
479
+ positions.set(streamEvent.index, index)
480
+ if (block.type === 'text') {
481
+ blocks.push({ type: 'text', text: '' })
482
+ stream.push({ type: 'text_start', contentIndex: index, partial: output })
483
+ } else if (block.type === 'thinking') {
484
+ blocks.push({ type: 'thinking', thinking: '', thinkingSignature: '' })
485
+ stream.push({ type: 'thinking_start', contentIndex: index, partial: output })
486
+ } else if (block.type === 'redacted_thinking') {
487
+ blocks.push({
488
+ type: 'thinking',
489
+ thinking: '[Reasoning redacted]',
490
+ thinkingSignature: block.data,
491
+ redacted: true
492
+ })
493
+ stream.push({ type: 'thinking_start', contentIndex: index, partial: output })
494
+ } else {
495
+ blocks.push({
496
+ type: 'toolCall',
497
+ id: block.id,
498
+ name: toolNameFromMcp(block.name),
499
+ arguments: {}
500
+ })
501
+ partialJson.set(streamEvent.index, '')
502
+ stream.push({ type: 'toolcall_start', contentIndex: index, partial: output })
503
+ }
504
+ continue
505
+ }
506
+
507
+ if (streamEvent.type === 'content_block_delta') {
508
+ const index = positions.get(streamEvent.index)
509
+ const block = index === undefined ? undefined : blocks[index]
510
+ if (index === undefined || !block) continue
511
+ const delta = streamEvent.delta
512
+ if (delta.type === 'text_delta' && block.type === 'text') {
513
+ block.text += delta.text
514
+ stream.push({ type: 'text_delta', contentIndex: index, delta: delta.text, partial: output })
515
+ } else if (delta.type === 'thinking_delta' && block.type === 'thinking') {
516
+ block.thinking += delta.thinking
517
+ stream.push({
518
+ type: 'thinking_delta',
519
+ contentIndex: index,
520
+ delta: delta.thinking,
521
+ partial: output
522
+ })
523
+ } else if (delta.type === 'signature_delta' && block.type === 'thinking') {
524
+ block.thinkingSignature = (block.thinkingSignature ?? '') + delta.signature
525
+ } else if (delta.type === 'input_json_delta' && block.type === 'toolCall') {
526
+ partialJson.set(streamEvent.index, (partialJson.get(streamEvent.index) ?? '') + delta.partial_json)
527
+ stream.push({
528
+ type: 'toolcall_delta',
529
+ contentIndex: index,
530
+ delta: delta.partial_json,
531
+ partial: output
532
+ })
533
+ }
534
+ continue
535
+ }
536
+
537
+ if (streamEvent.type === 'content_block_stop') {
538
+ const index = positions.get(streamEvent.index)
539
+ const block = index === undefined ? undefined : blocks[index]
540
+ if (index === undefined || !block) continue
541
+ positions.delete(streamEvent.index)
542
+ if (block.type === 'text') {
543
+ stream.push({ type: 'text_end', contentIndex: index, content: block.text, partial: output })
544
+ } else if (block.type === 'thinking') {
545
+ stream.push({
546
+ type: 'thinking_end',
547
+ contentIndex: index,
548
+ content: block.thinking,
549
+ partial: output
550
+ })
551
+ } else {
552
+ const rawJson = partialJson.get(streamEvent.index) ?? ''
553
+ partialJson.delete(streamEvent.index)
554
+ const parsed = rawJson.trim().length > 0 ? safeObject(rawJson) : undefined
555
+ if (parsed) block.arguments = parsed
556
+ else if (rawJson.trim().length > 0) block.argumentsError = `Invalid JSON arguments: ${rawJson}`
557
+ const args: JsonObject = JSON.parse(JSON.stringify(block.arguments))
558
+ session.pendingTools.push({ id: block.id, name: block.name, args })
559
+ stream.push({ type: 'toolcall_end', contentIndex: index, toolCall: block, partial: output })
560
+ }
561
+ continue
562
+ }
563
+
564
+ if (streamEvent.type === 'message_delta') {
565
+ const reason = streamEvent.delta?.stop_reason
566
+ if (reason) stopReason = reason
567
+ setUsage(streamEvent.usage)
568
+ continue
569
+ }
570
+
571
+ if (streamEvent.type === 'message_stop') {
572
+ foldUsage()
573
+ inResponse = false
574
+ turnStop = mapStop(stopReason)
575
+ output.stopReason = turnStop
576
+ if (turnStop === 'toolUse') return { ok: true, reason: turnStop }
577
+ }
578
+ continue
579
+ }
580
+
581
+ if (type === 'assistant') {
582
+ const error = jsonText(event, 'error')
583
+ if (!error) continue
584
+ const message = jsonObject(event['message'])
585
+ const content = message ? textFromContent(message['content']) : ''
586
+ return { ok: false, error: content || error }
587
+ }
588
+
589
+ if (type === 'user') {
590
+ markServed(session, event)
591
+ continue
592
+ }
593
+
594
+ if (type === 'rate_limit_event' || jsonText(event, 'subtype') === 'rate_limit_event') {
595
+ if (jsonFlag(event, 'isUsingOverage')) {
596
+ return {
597
+ ok: false,
598
+ error: 'Claude Code charged this turn to usage credits instead of the plan limits'
599
+ }
600
+ }
601
+ continue
602
+ }
603
+
604
+ if (type === 'result') {
605
+ const subtype = jsonText(event, 'subtype') ?? 'success'
606
+ if (jsonFlag(event, 'is_error') || subtype !== 'success') {
607
+ const errors = jsonStrings(event['errors']) ?? []
608
+ const text = errors.join('\n') || jsonText(event, 'result') || `Claude Code ${subtype}`
609
+ return { ok: false, error: text }
610
+ }
611
+ start()
612
+ output.stopReason = turnStop ?? 'stop'
613
+ if (output.usage.totalTokens === 0) {
614
+ output.usage.totalTokens = finished.input + finished.cacheRead + finished.cacheWrite + finished.output
615
+ }
616
+ output.usage.cost = calculateCost(model, output.usage)
617
+ return { ok: true, reason: turnStop ?? 'stop' }
618
+ }
619
+ }
620
+ }
621
+
622
+ function safeObject(raw: string): JsonObject | undefined {
623
+ try {
624
+ const parsed = JSON.parse(raw)
625
+ return jsonObject(parsed)
626
+ } catch {
627
+ return undefined
628
+ }
629
+ }
630
+
631
+ function textFromContent(value: JsonValue | undefined): string {
632
+ const blocks = jsonArray(value)
633
+ if (!blocks) return ''
634
+ const parts: string[] = []
635
+ for (const item of blocks) {
636
+ const block = jsonObject(item)
637
+ if (!block) continue
638
+ const text = jsonText(block, 'text')
639
+ if (text) parts.push(text)
640
+ }
641
+ return parts.join('')
642
+ }
643
+
644
+ function markServed(session: ClaudeSession, event: JsonObject): void {
645
+ const message = jsonObject(event['message'])
646
+ if (!message) return
647
+ const content = jsonArray(message['content'])
648
+ if (!content) return
649
+ for (const item of content) {
650
+ const block = jsonObject(item)
651
+ if (!block || jsonText(block, 'type') !== 'tool_result') continue
652
+ const id = jsonText(block, 'tool_use_id')
653
+ if (!id) continue
654
+ if (session.parked.has(id) || session.ready.has(id) || session.awaiting.has(id)) continue
655
+ session.servedByClaude.add(id)
656
+ }
657
+ }