@tduyng/pi-opencode-z 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/stream.ts ADDED
@@ -0,0 +1,1013 @@
1
+ import { writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ import { Validator } from 'ata-validator'
5
+ import { t } from 'ata-validator/t'
6
+
7
+ import {
8
+ calculateCost,
9
+ createAssistantMessageEventStream,
10
+ type Api,
11
+ type AssistantMessage,
12
+ type AssistantMessageEventStream,
13
+ type ImageContent,
14
+ type Message,
15
+ type Model,
16
+ type SimpleStreamOptions,
17
+ type TextContent,
18
+ type Tool,
19
+ type ToolCall
20
+ } from '@earendil-works/pi-ai'
21
+ import type { ToolAnnotations } from '@earendil-works/pi-coding-agent'
22
+
23
+ import { api, type OpenCodeEvent } from './client.js'
24
+ import type {
25
+ JsonObject,
26
+ JsonValue,
27
+ McpContent,
28
+ McpResult,
29
+ McpServerHandle,
30
+ McpToolAnnotations,
31
+ McpToolDef
32
+ } from './mcp.js'
33
+ import { variantFor } from './models.js'
34
+ import {
35
+ BRIDGE_AGENT,
36
+ bridgeSession,
37
+ createSessionStore,
38
+ ensureProjectDir,
39
+ findByOpencodeId,
40
+ messageFingerprint,
41
+ releaseParked,
42
+ resetOpencodeSession,
43
+ TOOL_PREFIX,
44
+ toolsKey,
45
+ type BridgeSession,
46
+ type BridgeSessionStore,
47
+ type TokenTotals
48
+ } from './session.js'
49
+ const isString = new Validator(t.string()).isValidObject
50
+ const isNumber = new Validator(t.number()).isValidObject
51
+
52
+ const DEFAULT_TURN_TIMEOUT_MS = 300_000
53
+ const PARK_GRACE_MS = 15_000
54
+ const TOOLS_RELOAD_MS = 250
55
+ const MAX_NUDGES = 2
56
+
57
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
58
+
59
+ export type BridgeContext = { messages: Message[] }
60
+
61
+ export type TranscriptState = {
62
+ systemPrompt: string
63
+ tools: Tool[]
64
+ conversation: Message[]
65
+ }
66
+
67
+ export function replayTranscript(messages: Message[]): TranscriptState {
68
+ const sections = new Map<string, string>()
69
+ const tools = new Map<string, Tool>()
70
+ const conversation: Message[] = []
71
+ const additions: string[] = []
72
+ let base = ''
73
+ let leading = true
74
+
75
+ for (const message of messages) {
76
+ if (message.role !== 'system') {
77
+ conversation.push(message)
78
+ continue
79
+ }
80
+ const text = contentText(message.content)
81
+ if (leading) {
82
+ base = text
83
+ leading = false
84
+ } else if (text) {
85
+ additions.push(text)
86
+ }
87
+ for (const [name, value] of Object.entries(message.sections ?? {})) {
88
+ if (value === null) sections.delete(name)
89
+ else sections.set(name, value)
90
+ }
91
+ for (const tool of message.toolsAdded ?? []) tools.set(tool.name, tool)
92
+ for (const reference of message.toolsRemoved ?? []) tools.delete(reference.name)
93
+ }
94
+
95
+ return {
96
+ systemPrompt: [base, ...sections.values(), ...additions].filter(Boolean).join('\n\n'),
97
+ tools: [...tools.values()],
98
+ conversation
99
+ }
100
+ }
101
+ const PROVIDER_ID = 'opencode'
102
+
103
+ type SessionListener = (event: OpenCodeEvent) => void
104
+ type RecoveryReason = 'refused' | 'empty'
105
+
106
+ export type BridgeRuntime = {
107
+ sessions: BridgeSessionStore
108
+ listeners: Map<string, SessionListener>
109
+ mcp?: McpServerHandle
110
+ toolHints?: ToolHints
111
+ }
112
+
113
+ export type ToolHints = (name: string) => McpToolAnnotations | undefined
114
+
115
+ export const NO_TOOL_HINTS: ToolHints = () => undefined
116
+
117
+ export function createBridgeRuntime(): BridgeRuntime {
118
+ return { sessions: createSessionStore(), listeners: new Map() }
119
+ }
120
+
121
+ export function toolHintsFrom(getAllTools: () => { name: string; annotations?: ToolAnnotations }[]): ToolHints {
122
+ return (name) => getAllTools().find((tool) => tool.name === name)?.annotations
123
+ }
124
+
125
+ export function attachMcpServer(runtime: BridgeRuntime, handle: McpServerHandle): void {
126
+ runtime.mcp = handle
127
+ }
128
+
129
+ type BridgeEvent =
130
+ | { type: 'session.step.started'; sessionID: string }
131
+ | { type: 'session.step.streamed'; sessionID: string }
132
+ | { type: 'session.execution.succeeded'; sessionID: string }
133
+ | { type: 'session.reasoning.delta' | 'session.text.delta'; sessionID: string; delta: string }
134
+ | { type: 'session.reasoning.ended' | 'session.text.ended'; sessionID: string; text: string }
135
+ | { type: 'session.tool.input.started'; sessionID: string; name: string; id?: string }
136
+ | { type: 'session.tool.success' | 'session.tool.failed'; sessionID: string; id: string }
137
+ | {
138
+ type: 'session.step.failed' | 'session.execution.interrupted' | 'session.execution.failed'
139
+ sessionID: string
140
+ failure?: string
141
+ }
142
+ | { type: 'session.step.ended'; sessionID: string; tokens?: TokenTotals }
143
+ | { type: 'permission.asked'; sessionID: string; id: string; action: string }
144
+ | { type: 'bridge.tool.parked'; sessionID: string; callId: string }
145
+ | { type: 'other'; sessionID: string; failure?: string }
146
+
147
+ function decodeEvent(event: OpenCodeEvent): BridgeEvent {
148
+ const cached = decodedEvents.get(event)
149
+ if (cached !== undefined) return cached
150
+ const decoded = decodeUncached(event)
151
+ decodedEvents.set(event, decoded)
152
+ return decoded
153
+ }
154
+
155
+ const decodedEvents = new WeakMap<OpenCodeEvent, BridgeEvent>()
156
+
157
+ function decodeUncached(event: OpenCodeEvent): BridgeEvent {
158
+ const data = event.data ?? {}
159
+ const sessionID = isString(data['sessionID']) ? data['sessionID'] : ''
160
+ switch (event.type) {
161
+ case 'session.step.started':
162
+ case 'session.step.streamed':
163
+ case 'session.execution.succeeded':
164
+ return { type: event.type, sessionID }
165
+ case 'session.reasoning.delta':
166
+ case 'session.text.delta': {
167
+ const delta = data['delta']
168
+ if (isString(delta)) return { type: event.type, sessionID, delta }
169
+ break
170
+ }
171
+ case 'session.reasoning.ended':
172
+ case 'session.text.ended': {
173
+ const text = data['text']
174
+ return { type: event.type, sessionID, text: isString(text) ? text : '' }
175
+ }
176
+ case 'session.tool.input.started': {
177
+ const name = data['name']
178
+ if (!isString(name)) break
179
+ const id = data['id']
180
+ return { type: event.type, sessionID, name, id: isString(id) ? id : undefined }
181
+ }
182
+ case 'session.tool.success':
183
+ case 'session.tool.failed': {
184
+ const id = data['id']
185
+ if (isString(id)) return { type: event.type, sessionID, id }
186
+ break
187
+ }
188
+ case 'session.step.failed':
189
+ case 'session.execution.interrupted':
190
+ case 'session.execution.failed':
191
+ return { type: event.type, sessionID, failure: failureOf(event) }
192
+ case 'session.step.ended':
193
+ return { type: event.type, sessionID, tokens: decodeStepTokens(event) }
194
+ case 'permission.asked': {
195
+ const id = data['id']
196
+ if (!isString(id)) break
197
+ const action = data['action']
198
+ return { type: event.type, sessionID, id, action: isString(action) ? action : 'unknown' }
199
+ }
200
+ case 'bridge.tool.parked': {
201
+ const callId = data['callId']
202
+ if (isString(callId)) return { type: event.type, sessionID, callId }
203
+ break
204
+ }
205
+ }
206
+ return { type: 'other', sessionID, failure: failureOf(event) }
207
+ }
208
+
209
+ function failureOf(event: OpenCodeEvent): string | undefined {
210
+ const data = event.data
211
+ const errorMessage = data?.['errorMessage']
212
+ const message = data?.['message']
213
+ const error = data?.['error']
214
+ const direct =
215
+ (isString(errorMessage) && errorMessage.trim()) ||
216
+ (isString(message) && message.trim()) ||
217
+ (isString(error) && error.trim())
218
+ if (direct) return direct
219
+ if (!isErrorObjectSchema(error)) return undefined
220
+ const parsed = error
221
+ return parsed.message?.trim() || parsed.errorMessage?.trim() || parsed.reason?.trim() || undefined
222
+ }
223
+
224
+ function isObject(value: unknown): value is JsonObject {
225
+ return isJsonObjectSchema(value)
226
+ }
227
+
228
+ function decodeStepTokens(event: OpenCodeEvent): TokenTotals | undefined {
229
+ const tokens = event.data?.['tokens']
230
+ if (!tokens) return undefined
231
+ if (Array.isArray(tokens)) return emptyTokenTotals()
232
+ if (!isObject(tokens)) return undefined
233
+ const cache = isObject(tokens['cache']) ? tokens['cache'] : undefined
234
+ return {
235
+ input: tokenCount(tokens['input']),
236
+ output: tokenCount(tokens['output']) + tokenCount(tokens['reasoning']),
237
+ cacheRead: tokenCount(cache?.['read']),
238
+ cacheWrite: tokenCount(cache?.['write'])
239
+ }
240
+ }
241
+
242
+ function tokenCount(value: JsonValue | undefined): number {
243
+ return isNumber(value) && Number.isFinite(value) && value > 0 ? value : 0
244
+ }
245
+
246
+ function emptyTokenTotals(): TokenTotals {
247
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
248
+ }
249
+
250
+ export function routeEvent(runtime: BridgeRuntime, event: OpenCodeEvent): void {
251
+ const decoded = decodeEvent(event)
252
+ if (!decoded.sessionID) return
253
+
254
+ if (decoded.type === 'session.step.ended') {
255
+ const session = findByOpencodeId(runtime.sessions, decoded.sessionID)
256
+ if (session) parkLateUsage(session, decoded.tokens)
257
+ return
258
+ }
259
+
260
+ if (decoded.type === 'permission.asked') {
261
+ const { sessionID, id, action } = decoded
262
+ void answerPermission(runtime.sessions, sessionID, id, action).catch(() => undefined)
263
+ return
264
+ }
265
+ runtime.listeners.get(decoded.sessionID)?.(event)
266
+ }
267
+
268
+ async function answerPermission(
269
+ sessions: BridgeSessionStore,
270
+ sessionId: string,
271
+ requestId: string,
272
+ action: string
273
+ ): Promise<void> {
274
+ const session = findByOpencodeId(sessions, sessionId)
275
+ if (!session) return
276
+ if (session.answeredPermissions.has(requestId)) return
277
+
278
+ const reply: { decision: 'once' | 'reject'; message?: string } = action.startsWith(TOOL_PREFIX)
279
+ ? { decision: 'once' }
280
+ : {
281
+ decision: 'reject',
282
+ message: `Your built-in ${action} tool is disabled here. Call the matching pi_ tool instead.`
283
+ }
284
+ session.answeredPermissions.add(requestId)
285
+ if (reply.decision === 'reject') session.refusedTools.push(action)
286
+ const send = () => api('POST', `/api/session/${sessionId}/permission/${requestId}/reply`, reply)
287
+ try {
288
+ await send()
289
+ } catch {
290
+ try {
291
+ await send()
292
+ } catch {
293
+ session.answeredPermissions.delete(requestId)
294
+ }
295
+ }
296
+ }
297
+
298
+ function parkLateUsage(session: BridgeSession, step: TokenTotals | undefined): void {
299
+ if (!step) return
300
+ session.lateUsage.input += step.input
301
+ session.lateUsage.output += step.output
302
+ session.lateUsage.cacheRead += step.cacheRead
303
+ session.lateUsage.cacheWrite += step.cacheWrite
304
+ session.latestStep = step
305
+ }
306
+
307
+ function takeLateUsage(session: BridgeSession | undefined, usage: AssistantMessage['usage']): void {
308
+ if (!session) return
309
+ usage.input += session.lateUsage.input
310
+ usage.output += session.lateUsage.output
311
+ usage.cacheRead += session.lateUsage.cacheRead
312
+ usage.cacheWrite += session.lateUsage.cacheWrite
313
+ const step = session.latestStep
314
+ usage.totalTokens = step.input + step.output + step.cacheRead + step.cacheWrite
315
+ session.lateUsage = emptyTokenTotals()
316
+ session.latestStep = emptyTokenTotals()
317
+ }
318
+
319
+ function emptyUsage(): AssistantMessage['usage'] {
320
+ return {
321
+ input: 0,
322
+ output: 0,
323
+ cacheRead: 0,
324
+ cacheWrite: 0,
325
+ totalTokens: 0,
326
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
327
+ }
328
+ }
329
+
330
+ function contentText(content: string | (TextContent | ImageContent)[]): string {
331
+ if (Array.isArray(content)) {
332
+ return content
333
+ .map((part) => (part.type === 'text' ? part.text : `[image: ${part.mimeType}]`))
334
+ .join('\n')
335
+ .trim()
336
+ }
337
+ return content
338
+ }
339
+
340
+ function imagesIn(messages: Message[]): ImageContent[] {
341
+ const images: ImageContent[] = []
342
+ for (const message of messages) {
343
+ if (message.role === 'assistant' || !Array.isArray(message.content)) continue
344
+ for (const part of message.content) if (part.type === 'image') images.push(part)
345
+ }
346
+ return images
347
+ }
348
+
349
+ export function serializeHistory(messages: Message[]): string {
350
+ const parts: string[] = []
351
+ for (const message of messages) {
352
+ if (message.role === 'user') {
353
+ parts.push(`USER:\n${contentText(message.content)}`)
354
+ continue
355
+ }
356
+ if (message.role === 'toolResult') {
357
+ parts.push(
358
+ `RESULT of ${TOOL_PREFIX}${message.toolName}${message.isError ? ' (failed)' : ''}:\n${contentText(message.content)}`
359
+ )
360
+ continue
361
+ }
362
+ const rendered = Array.isArray(message.content)
363
+ ? message.content
364
+ .map((part) => {
365
+ if (part.type === 'text') return part.text
366
+ if (part.type === 'thinking') return ''
367
+ return `CALLED ${TOOL_PREFIX}${part.name}(${JSON.stringify(part.arguments)})`
368
+ })
369
+ .filter(Boolean)
370
+ .join('\n')
371
+ : message.content
372
+ if (rendered) parts.push(`ASSISTANT:\n${rendered}`)
373
+ }
374
+ return parts.join('\n\n')
375
+ }
376
+
377
+ export function preamble(systemPrompt: string): string {
378
+ return systemPrompt.trim() ? `<system>\n${systemPrompt.trim()}\n</system>` : ''
379
+ }
380
+
381
+ const LEAKED_CALL_MARKER = /<\/?(?:invoke|parameter|tool_call)\b/
382
+
383
+ function leakedToolCall(text: string): boolean {
384
+ return LEAKED_CALL_MARKER.test(text.replaceAll('\u200b', '')) && text.includes(TOOL_PREFIX)
385
+ }
386
+
387
+ function refusalNudge(refused: string[]): string {
388
+ const names = [...new Set(refused)].join(', ')
389
+ return [
390
+ `Your built-in ${names} call was refused and opencode stopped the step. Built-in tools are disabled here.`,
391
+ 'Redo that work with the matching pi_ tool, such as pi_read, pi_bash, pi_edit or pi_write.'
392
+ ].join('\n')
393
+ }
394
+
395
+ const EMPTY_TURN_NUDGE = [
396
+ 'Your turn ended after reasoning without a tool call or final answer.',
397
+ 'Call one of the declared pi_ tools now. A functions. namespace label is not part of the tool name.'
398
+ ].join('\n')
399
+
400
+ export function mcpToolDefs(tools: Tool[], hints: ToolHints): McpToolDef[] {
401
+ return tools.map((tool) => {
402
+ const annotations = hints(tool.name)
403
+ const def: McpToolDef = {
404
+ name: tool.name,
405
+ description: tool.description,
406
+ inputSchema: mcpInputSchema(tool.parameters as JsonObject)
407
+ }
408
+ if (annotations) def.annotations = annotations
409
+ return def
410
+ })
411
+ }
412
+
413
+ const JsonObjectSchema = t.object({}, { additionalProperties: true })
414
+ const isJsonObjectSchema = new Validator(JsonObjectSchema).isValidObject
415
+ const RequiredArraySchema = t.array(t.string())
416
+ const isRequiredArraySchema = new Validator(RequiredArraySchema).isValidObject
417
+ const RequiredObjectSchema = t.object({}, { additionalProperties: true })
418
+ const isRequiredObjectSchema = new Validator(RequiredObjectSchema).isValidObject
419
+
420
+ export function mcpInputSchema(parameters: JsonObject | undefined): JsonObject {
421
+ if (!isJsonObjectSchema(parameters)) return { type: 'object' }
422
+ const parsed = parameters
423
+ const required = parsed['required']
424
+ if (isRequiredArraySchema(required)) {
425
+ return parsed as JsonObject
426
+ }
427
+ if (isRequiredObjectSchema(required)) {
428
+ return { ...parsed, required: Object.keys(required) } as JsonObject
429
+ }
430
+ return { ...parsed, required: [] } as JsonObject
431
+ }
432
+
433
+ function piToolResult(message: { content: (TextContent | ImageContent)[]; isError: boolean }): McpResult {
434
+ const content: McpContent[] = message.content.map((part) =>
435
+ part.type === 'text'
436
+ ? { type: 'text', text: part.text }
437
+ : { type: 'image', data: part.data, mimeType: part.mimeType }
438
+ )
439
+ return {
440
+ content: content.length > 0 ? content : [{ type: 'text', text: '(no output)' }],
441
+ isError: message.isError
442
+ }
443
+ }
444
+
445
+ function turnTimeoutMs(value: number | undefined): number {
446
+ return value !== undefined && Number.isFinite(value) && value > 0 ? value : DEFAULT_TURN_TIMEOUT_MS
447
+ }
448
+
449
+ function transcriptDrifted(session: BridgeSession, messages: Message[]): boolean {
450
+ if (messages.length < session.fingerprints.length) return true
451
+ for (let index = 0; index < session.fingerprints.length; index++) {
452
+ const message = messages[index]
453
+ if (!message || session.fingerprints[index] !== messageFingerprint(message)) return true
454
+ }
455
+ return false
456
+ }
457
+
458
+ export function streamOpenCodeBridge(
459
+ runtime: BridgeRuntime,
460
+ model: Model<Api>,
461
+ context: BridgeContext,
462
+ options?: SimpleStreamOptions
463
+ ): AssistantMessageEventStream {
464
+ const stream = createAssistantMessageEventStream()
465
+ void runTurn(runtime, stream, model, context, options)
466
+ return stream
467
+ }
468
+
469
+ async function runTurn(
470
+ runtime: BridgeRuntime,
471
+ stream: AssistantMessageEventStream,
472
+ model: Model<Api>,
473
+ context: BridgeContext,
474
+ options?: SimpleStreamOptions
475
+ ): Promise<void> {
476
+ const output: AssistantMessage = {
477
+ role: 'assistant',
478
+ content: [],
479
+ api: model.api,
480
+ provider: model.provider,
481
+ model: model.id,
482
+ usage: emptyUsage(),
483
+ stopReason: 'stop',
484
+ timestamp: Date.now()
485
+ }
486
+
487
+ const piSessionId = options?.sessionId || `ephemeral-${Math.random().toString(36).slice(2)}`
488
+ const session = bridgeSession(runtime.sessions, piSessionId)
489
+ let listenerKey: string | undefined
490
+ let timer: ReturnType<typeof setTimeout> | undefined
491
+ let onAbort: (() => void) | undefined
492
+
493
+ try {
494
+ if (options?.signal?.aborted) {
495
+ output.stopReason = 'aborted'
496
+ output.errorMessage = 'opencode request aborted before dispatch'
497
+ stream.push({ type: 'error', reason: 'aborted', error: output })
498
+ stream.end()
499
+ return
500
+ }
501
+ if (!runtime.mcp) throw new Error('the opencode bridge MCP server is not running')
502
+ stream.push({ type: 'start', partial: output })
503
+
504
+ const transcript = replayTranscript(context.messages)
505
+ session.tools = transcript.tools
506
+ runtime.mcp.register(session.token, {
507
+ listTools: () => mcpToolDefs(session.tools, runtime.toolHints ?? NO_TOOL_HINTS),
508
+ callTool: (name, args) => parkToolCall(runtime, session, name, args)
509
+ })
510
+
511
+ const variant = variantFor(model.thinkingLevelMap, options?.reasoning)
512
+ const messages = transcript.conversation
513
+
514
+ if (session.opencodeSessionId && (session.modelId !== model.id || transcriptDrifted(session, messages))) {
515
+ await resetOpencodeSession(session)
516
+ }
517
+
518
+ if (session.opencodeSessionId && session.toolsKey !== toolsKey(transcript.tools)) {
519
+ const refreshed = await runtime.mcp.notifyToolsChanged(session.token)
520
+ if (refreshed) await sleep(TOOLS_RELOAD_MS)
521
+ else await resetOpencodeSession(session)
522
+ }
523
+
524
+ const tail = messages.slice(session.syncedCount)
525
+ const resuming = canResume(session, tail)
526
+
527
+ if (!resuming && session.opencodeSessionId && tail.some((message) => message.role !== 'user')) {
528
+ releaseParked(session, 'Pi did not complete the previous call. Rebuilding the opencode session.')
529
+ await api('POST', `/api/session/${session.opencodeSessionId}/interrupt`).catch(() => undefined)
530
+ await resetOpencodeSession(session)
531
+ }
532
+
533
+ if (!resuming && session.parked.size > 0) {
534
+ releaseParked(session, 'Pi did not run this call. Ask again if you still need it.')
535
+ await api('POST', `/api/session/${session.opencodeSessionId}/interrupt`).catch(() => undefined)
536
+ await resetOpencodeSession(session)
537
+ }
538
+
539
+ const opencodeSessionId = resuming
540
+ ? session.opencodeSessionId
541
+ : await ensureOpencodeSession(runtime, session, model, variant)
542
+ if (!opencodeSessionId) throw new Error('could not open an opencode session')
543
+
544
+ listenerKey = opencodeSessionId
545
+ let nudges = 0
546
+ const turn = createTurn(
547
+ runtime.sessions,
548
+ stream,
549
+ output,
550
+ model,
551
+ (reason) => {
552
+ if (nudges >= MAX_NUDGES || options?.signal?.aborted) return false
553
+ const refused = session.refusedTools
554
+ if (reason === 'refused' && refused.length === 0) return false
555
+ nudges++
556
+ session.refusedTools = []
557
+ if (reason === 'refused') {
558
+ releaseParked(session, 'opencode stopped this step; the call was dropped.')
559
+ }
560
+ const text = reason === 'empty' ? EMPTY_TURN_NUDGE : refusalNudge(refused)
561
+ void (async () => {
562
+ try {
563
+ await api('POST', `/api/session/${opencodeSessionId}/prompt`, { text })
564
+ } catch (nudgeError) {
565
+ turn.fail(nudgeError instanceof Error ? nudgeError.message : String(nudgeError))
566
+ }
567
+ })()
568
+ return true
569
+ },
570
+ options?.signal
571
+ )
572
+ runtime.listeners.set(listenerKey, turn.handle)
573
+
574
+ const onAbortTurn = () => {
575
+ const sessionId = session.opencodeSessionId
576
+ if (sessionId) {
577
+ void api('POST', `/api/session/${sessionId}/interrupt`).catch(() => undefined)
578
+ }
579
+ turn.fail('opencode request aborted')
580
+ }
581
+ onAbort = onAbortTurn
582
+ if (options?.signal?.aborted) {
583
+ onAbortTurn()
584
+ } else {
585
+ options?.signal?.addEventListener('abort', onAbortTurn, { once: true })
586
+ }
587
+ const limit = turnTimeoutMs(options?.timeoutMs)
588
+ timer = setTimeout(() => {
589
+ if (session.opencodeSessionId) {
590
+ void api('POST', `/api/session/${session.opencodeSessionId}/interrupt`).catch(() => undefined)
591
+ }
592
+ turn.fail(
593
+ `opencode did not answer within ${Math.round(limit / 1000)}s (model ${model.id}). ` +
594
+ 'The bridge dropped the stuck session, so just retry the prompt.'
595
+ )
596
+ }, limit)
597
+
598
+ if (resuming) {
599
+ for (const [callId, parked] of session.parked) {
600
+ const result = tail.find((message) => message.role === 'toolResult' && message.toolCallId === callId)
601
+ parked.resolve(
602
+ result && result.role === 'toolResult'
603
+ ? piToolResult(result)
604
+ : { content: [{ type: 'text', text: '(no result)' }], isError: true }
605
+ )
606
+ }
607
+ session.parked.clear()
608
+ } else {
609
+ if (variant && session.variant !== variant) {
610
+ await api('POST', `/api/session/${opencodeSessionId}/model`, {
611
+ model: { providerID: PROVIDER_ID, id: model.id, variant }
612
+ }).catch(() => undefined)
613
+ session.variant = variant
614
+ }
615
+ await sendPrompt(session, transcript, tail, opencodeSessionId)
616
+ }
617
+
618
+ await turn.done
619
+
620
+ if (output.stopReason === 'error' || output.stopReason === 'aborted') {
621
+ releaseParked(session, 'The bridge stopped before this call completed.')
622
+ if (session.opencodeSessionId) {
623
+ await api('POST', `/api/session/${session.opencodeSessionId}/interrupt`).catch(() => undefined)
624
+ }
625
+ } else {
626
+ session.syncedCount = syncedCountAfter(messages, output)
627
+ session.fingerprints = messages.map(messageFingerprint)
628
+ session.modelId = model.id
629
+ session.toolsKey = toolsKey(transcript.tools)
630
+ }
631
+ } catch (error) {
632
+ output.stopReason = options?.signal?.aborted ? 'aborted' : 'error'
633
+ output.errorMessage = error instanceof Error ? error.message : String(error)
634
+ if (output.content.length === 0) output.content.push({ type: 'text', text: output.errorMessage })
635
+ releaseParked(session, 'The bridge turn failed.')
636
+ if (session.opencodeSessionId) {
637
+ await api('POST', `/api/session/${session.opencodeSessionId}/interrupt`).catch(() => undefined)
638
+ }
639
+ stream.push({ type: 'error', reason: output.stopReason, error: output })
640
+ stream.end()
641
+ } finally {
642
+ if (timer) clearTimeout(timer)
643
+ if (listenerKey) runtime.listeners.delete(listenerKey)
644
+ if (onAbort) options?.signal?.removeEventListener('abort', onAbort)
645
+ }
646
+ }
647
+
648
+ export function syncedCountAfter(messages: Message[], output: AssistantMessage): number {
649
+ return output.content.length > 0 ? messages.length + 1 : messages.length
650
+ }
651
+
652
+ function canResume(session: BridgeSession, tail: Message[]): boolean {
653
+ if (!session.opencodeSessionId || session.parked.size === 0) return false
654
+ if (tail.some((message) => message.role === 'user')) return false
655
+ const answered = new Set(
656
+ tail.filter((message) => message.role === 'toolResult').map((message) => message.toolCallId)
657
+ )
658
+ for (const callId of session.parked.keys()) if (!answered.has(callId)) return false
659
+ return true
660
+ }
661
+
662
+ async function ensureOpencodeSession(
663
+ runtime: BridgeRuntime,
664
+ session: BridgeSession,
665
+ model: Model<Api>,
666
+ variant: string | undefined
667
+ ): Promise<string> {
668
+ if (session.opencodeSessionId) return session.opencodeSessionId
669
+ if (!runtime.mcp) throw new Error('the opencode bridge MCP server is not running')
670
+
671
+ const dir = await ensureProjectDir(session, runtime.mcp.urlFor(session.token))
672
+ const created = await api<{ data?: { id?: string } }>('POST', '/api/session', {
673
+ title: 'pi bridge',
674
+ agent: BRIDGE_AGENT,
675
+ model: { providerID: PROVIDER_ID, id: model.id, variant },
676
+ location: { directory: dir }
677
+ })
678
+ const id = created?.data?.id
679
+ if (!id) throw new Error('opencode did not return a session id')
680
+ session.opencodeSessionId = id
681
+ session.variant = variant
682
+ return id
683
+ }
684
+
685
+ async function sendPrompt(
686
+ session: BridgeSession,
687
+ transcript: TranscriptState,
688
+ tail: Message[],
689
+ opencodeSessionId: string
690
+ ): Promise<void> {
691
+ const firstPrompt = session.syncedCount === 0
692
+ const blocks: string[] = []
693
+
694
+ if (firstPrompt) {
695
+ blocks.push(preamble(transcript.systemPrompt))
696
+ const history = serializeHistory(transcript.conversation)
697
+ if (history) blocks.push(history)
698
+ } else {
699
+ if (transcript.systemPrompt !== (session.systemPrompt ?? '')) {
700
+ blocks.push(`<system-update>\n${transcript.systemPrompt.trim()}\n</system-update>`)
701
+ }
702
+ const fresh = tail
703
+ .filter((message) => message.role === 'user')
704
+ .map((message) => (message.role === 'user' ? contentText(message.content) : ''))
705
+ blocks.push(...fresh.filter(Boolean))
706
+ }
707
+ session.refusedTools = []
708
+
709
+ const files = await writeImages(session, imagesIn(tail))
710
+ const text = blocks.filter(Boolean).join('\n\n') || '(continue)'
711
+ const payload = { text, files: files.length > 0 ? files : undefined }
712
+ await api('POST', `/api/session/${opencodeSessionId}/prompt`, payload)
713
+ session.systemPrompt = transcript.systemPrompt
714
+ }
715
+
716
+ async function writeImages(session: BridgeSession, images: ImageContent[]): Promise<{ uri: string }[]> {
717
+ if (images.length === 0 || !session.dir) return []
718
+ const written: { uri: string }[] = []
719
+ for (const [index, image] of images.entries()) {
720
+ const extension = image.mimeType.split('/')[1]?.replace(/[^a-z0-9]/gi, '') || 'bin'
721
+ const path = join(session.dir, `image-${Date.now()}-${index}.${extension}`)
722
+ await writeFile(path, Buffer.from(image.data, 'base64'))
723
+ written.push({ uri: `file://${path}` })
724
+ }
725
+ return written
726
+ }
727
+
728
+ function parkToolCall(
729
+ runtime: BridgeRuntime,
730
+ session: BridgeSession,
731
+ prefixed: string,
732
+ args: JsonObject
733
+ ): Promise<McpResult> {
734
+ const name = prefixed.startsWith(TOOL_PREFIX) ? prefixed.slice(TOOL_PREFIX.length) : prefixed
735
+ if (!session.tools.some((tool) => tool.name === name)) {
736
+ return Promise.resolve({
737
+ content: [{ type: 'text', text: `${name} is not available in this session anymore.` }],
738
+ isError: true
739
+ })
740
+ }
741
+ const callId = `oc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
742
+ return new Promise<McpResult>((resolve) => {
743
+ session.parked.set(callId, { name, args, resolve })
744
+ const owner = session.opencodeSessionId ?? ''
745
+ const notify = runtime.listeners.get(owner)
746
+ if (!notify) {
747
+ session.parked.delete(callId)
748
+ resolve({
749
+ content: [{ type: 'text', text: 'The bridge turn ended before this call arrived.' }],
750
+ isError: true
751
+ })
752
+ return
753
+ }
754
+ notify({ type: 'bridge.tool.parked', data: { callId, sessionID: owner } })
755
+ })
756
+ }
757
+
758
+ type Turn = {
759
+ handle: SessionListener
760
+ done: Promise<void>
761
+ fail(message: string): void
762
+ }
763
+
764
+ export function createTurn(
765
+ sessions: BridgeSessionStore,
766
+ stream: AssistantMessageEventStream,
767
+ output: AssistantMessage,
768
+ model: Model<Api>,
769
+ recover: (reason: RecoveryReason) => boolean = () => false,
770
+ signal?: AbortSignal
771
+ ): Turn {
772
+ let settle: () => void = () => undefined
773
+ const done = new Promise<void>((resolve) => {
774
+ settle = resolve
775
+ })
776
+
777
+ let textIndex: number | undefined
778
+ let thinkingIndex: number | undefined
779
+ let finished = false
780
+ let stepStreamed = false
781
+ let owner: BridgeSession | undefined
782
+ const piCalls = new Map<string, string>()
783
+ const parkedCallIds: string[] = []
784
+ const ready = () => stepStreamed && piCalls.size > 0 && parkedCallIds.length >= piCalls.size
785
+ let failure: string | undefined
786
+ let parkTimer: ReturnType<typeof setTimeout> | undefined
787
+ const clearPark = () => {
788
+ if (!parkTimer) return
789
+ clearTimeout(parkTimer)
790
+ parkTimer = undefined
791
+ }
792
+ const resetExecution = () => {
793
+ stepStreamed = false
794
+ piCalls.clear()
795
+ parkedCallIds.length = 0
796
+ failure = undefined
797
+ clearPark()
798
+ }
799
+
800
+ const openText = () => {
801
+ if (textIndex !== undefined) return textIndex
802
+ textIndex = output.content.length
803
+ output.content.push({ type: 'text', text: '' })
804
+ stream.push({ type: 'text_start', contentIndex: textIndex, partial: output })
805
+ return textIndex
806
+ }
807
+ const closeText = (text: string) => {
808
+ if (textIndex === undefined) return
809
+ const block = output.content[textIndex]
810
+ if (block?.type === 'text') block.text = text
811
+ stream.push({ type: 'text_end', contentIndex: textIndex, content: text, partial: output })
812
+ textIndex = undefined
813
+ }
814
+ const openThinking = () => {
815
+ if (thinkingIndex !== undefined) return thinkingIndex
816
+ thinkingIndex = output.content.length
817
+ output.content.push({ type: 'thinking', thinking: '' })
818
+ stream.push({ type: 'thinking_start', contentIndex: thinkingIndex, partial: output })
819
+ return thinkingIndex
820
+ }
821
+ const closeThinking = (text: string) => {
822
+ if (thinkingIndex === undefined) return
823
+ const block = output.content[thinkingIndex]
824
+ if (block?.type === 'thinking') block.thinking = text
825
+ stream.push({ type: 'thinking_end', contentIndex: thinkingIndex, content: text, partial: output })
826
+ thinkingIndex = undefined
827
+ }
828
+
829
+ const finish = (reason: 'stop' | 'toolUse') => {
830
+ if (finished) return
831
+ finished = true
832
+ clearPark()
833
+ takeLateUsage(owner, output.usage)
834
+ closeText(textIndex !== undefined ? textOf(output, textIndex) : '')
835
+ closeThinking(thinkingIndex !== undefined ? thinkingOf(output, thinkingIndex) : '')
836
+ calculateCost(model, output.usage)
837
+ output.stopReason = reason
838
+ stream.push({ type: 'done', reason, message: output })
839
+ stream.end()
840
+ settle()
841
+ }
842
+
843
+ const fail = (message: string) => {
844
+ if (finished) return
845
+ finished = true
846
+ clearPark()
847
+ takeLateUsage(owner, output.usage)
848
+ const reason = signal?.aborted ? 'aborted' : 'error'
849
+ output.stopReason = reason
850
+ output.errorMessage = message
851
+ if (output.content.length === 0) output.content.push({ type: 'text', text: message })
852
+ stream.push({ type: 'error', reason, error: output })
853
+ stream.end()
854
+ settle()
855
+ }
856
+
857
+ const emitToolCalls = (session: BridgeSession) => {
858
+ clearPark()
859
+ for (const callId of parkedCallIds) {
860
+ const parked = session.parked.get(callId)
861
+ if (!parked) continue
862
+ const toolCall: ToolCall = {
863
+ type: 'toolCall',
864
+ id: callId,
865
+ name: parked.name,
866
+ arguments: parked.args
867
+ }
868
+ const contentIndex = output.content.length
869
+ output.content.push(toolCall)
870
+ stream.push({ type: 'toolcall_start', contentIndex, partial: output })
871
+ stream.push({
872
+ type: 'toolcall_delta',
873
+ contentIndex,
874
+ delta: JSON.stringify(toolCall.arguments),
875
+ partial: output
876
+ })
877
+ stream.push({ type: 'toolcall_end', contentIndex, toolCall, partial: output })
878
+ }
879
+ finish('toolUse')
880
+ }
881
+
882
+ const handle: SessionListener = (event) => {
883
+ if (finished) return
884
+ const decoded = decodeEvent(event)
885
+ const session = findByOpencodeId(sessions, decoded.sessionID)
886
+ if (session) owner = session
887
+
888
+ switch (decoded.type) {
889
+ case 'session.step.started':
890
+ stepStreamed = false
891
+ piCalls.clear()
892
+ clearPark()
893
+ return
894
+ case 'session.reasoning.delta': {
895
+ const index = openThinking()
896
+ const block = output.content[index]
897
+ if (block?.type === 'thinking') block.thinking += decoded.delta
898
+ stream.push({ type: 'thinking_delta', contentIndex: index, delta: decoded.delta, partial: output })
899
+ return
900
+ }
901
+ case 'session.reasoning.ended':
902
+ closeThinking(decoded.text)
903
+ return
904
+ case 'session.text.delta': {
905
+ const index = openText()
906
+ const block = output.content[index]
907
+ if (block?.type === 'text') block.text += decoded.delta
908
+ stream.push({ type: 'text_delta', contentIndex: index, delta: decoded.delta, partial: output })
909
+ return
910
+ }
911
+ case 'session.text.ended':
912
+ closeText(decoded.text)
913
+ return
914
+ case 'session.tool.input.started': {
915
+ if (decoded.name.startsWith(TOOL_PREFIX)) {
916
+ piCalls.set(decoded.id ?? `${decoded.name}#${piCalls.size}`, decoded.name)
917
+ }
918
+ return
919
+ }
920
+ case 'session.tool.success':
921
+ case 'session.tool.failed': {
922
+ if (!piCalls.delete(decoded.id)) return
923
+ if (parkedCallIds.length >= piCalls.size) clearPark()
924
+ if (session && ready()) emitToolCalls(session)
925
+ return
926
+ }
927
+ case 'session.step.failed': {
928
+ if (decoded.failure) failure = decoded.failure
929
+ piCalls.clear()
930
+ clearPark()
931
+ return
932
+ }
933
+ case 'session.step.streamed':
934
+ stepStreamed = true
935
+ if (session && ready()) {
936
+ emitToolCalls(session)
937
+ return
938
+ }
939
+ if (piCalls.size > parkedCallIds.length && !parkTimer) {
940
+ const missing = piCalls.size - parkedCallIds.length
941
+ const total = piCalls.size
942
+ const names = [...piCalls.values()].join(', ')
943
+ parkTimer = setTimeout(
944
+ () =>
945
+ fail(
946
+ `opencode started ${names || 'a pi_ tool'} but ${missing} of ${total} call(s) ` +
947
+ `never reached the bridge within ${PARK_GRACE_MS / 1000}s`
948
+ ),
949
+ PARK_GRACE_MS
950
+ )
951
+ }
952
+ return
953
+ case 'bridge.tool.parked':
954
+ parkedCallIds.push(decoded.callId)
955
+ if (session && ready()) emitToolCalls(session)
956
+ return
957
+ case 'session.execution.succeeded': {
958
+ const hasResult = output.content.some(
959
+ (block) =>
960
+ block.type === 'toolCall' ||
961
+ (block.type === 'text' && block.text.trim().length > 0 && !leakedToolCall(block.text))
962
+ )
963
+ if (!hasResult && recover('empty')) {
964
+ const leaked = textIndex === undefined ? undefined : output.content[textIndex]
965
+ closeText(leaked?.type === 'text' ? leaked.text : '')
966
+ resetExecution()
967
+ return
968
+ }
969
+ finish('stop')
970
+ return
971
+ }
972
+ case 'session.execution.interrupted':
973
+ case 'session.execution.failed': {
974
+ if (decoded.failure) failure = decoded.failure
975
+ if (recover('refused')) {
976
+ resetExecution()
977
+ return
978
+ }
979
+ fail(
980
+ failure ??
981
+ (decoded.type === 'session.execution.failed'
982
+ ? 'opencode could not finish the request'
983
+ : 'opencode interrupted the request')
984
+ )
985
+ return
986
+ }
987
+ case 'session.step.ended':
988
+ case 'permission.asked':
989
+ return
990
+ case 'other':
991
+ if (decoded.failure) failure = decoded.failure
992
+ }
993
+ }
994
+
995
+ return { handle, done, fail }
996
+ }
997
+
998
+ const ErrorObjectSchema = t.object({
999
+ message: t.optional(t.string()),
1000
+ errorMessage: t.optional(t.string()),
1001
+ reason: t.optional(t.string())
1002
+ })
1003
+ const isErrorObjectSchema = new Validator(ErrorObjectSchema).isValidObject
1004
+
1005
+ function textOf(output: AssistantMessage, index: number): string {
1006
+ const block = output.content[index]
1007
+ return block?.type === 'text' ? block.text : ''
1008
+ }
1009
+
1010
+ function thinkingOf(output: AssistantMessage, index: number): string {
1011
+ const block = output.content[index]
1012
+ return block?.type === 'thinking' ? block.thinking : ''
1013
+ }