@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/LICENSE +178 -0
- package/README.md +86 -2
- package/package.json +40 -4
- package/src/index.ts +170 -0
- package/src/json.ts +74 -0
- package/src/mcp.ts +288 -0
- package/src/protocol.ts +335 -0
- package/src/session.ts +237 -0
- package/src/transcript.ts +213 -0
- package/src/turn.ts +657 -0
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
|
+
}
|