orizu 0.4.1 → 0.5.0

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 (76) hide show
  1. package/README.md +22 -13
  2. package/dist/artifact-archive.js +152 -0
  3. package/dist/connectors-cli.js +80 -0
  4. package/dist/credentials.js +4 -1
  5. package/dist/daytona-slice-rehearsal.js +255 -0
  6. package/dist/daytona-slice.js +441 -0
  7. package/dist/egress-allowlist-cli.js +132 -0
  8. package/dist/egress-policy.js +159 -0
  9. package/dist/git-credential.js +131 -0
  10. package/dist/github-setup.js +226 -0
  11. package/dist/help.js +271 -16
  12. package/dist/hosted-bootstrap-rehearsal.js +235 -0
  13. package/dist/hosted-bootstrap.js +365 -0
  14. package/dist/hosted-broker-server.js +220 -0
  15. package/dist/hosted-git-http-server.js +136 -0
  16. package/dist/hosted-harness-claude.js +493 -0
  17. package/dist/hosted-harness-opencode.js +729 -0
  18. package/dist/hosted-harness.js +60 -0
  19. package/dist/hosted-loop.js +477 -0
  20. package/dist/hosted-run-event-sink.js +499 -0
  21. package/dist/hosted-runtime-assets.js +216 -0
  22. package/dist/hosted-session-cli.js +642 -0
  23. package/dist/index.js +855 -237
  24. package/dist/kill-switch-cli.js +89 -0
  25. package/dist/manifests-cli.js +143 -0
  26. package/dist/preview-runtime.js +4 -1
  27. package/dist/sandbox-provider.js +238 -0
  28. package/dist/secret-redaction.js +112 -0
  29. package/dist/session-timeline.js +103 -0
  30. package/dist/setup-prompt.js +4 -2
  31. package/dist/skill-installer.js +9 -1
  32. package/dist/smoke.js +250 -0
  33. package/dist/task-assignment-manifest.js +39 -2
  34. package/dist/vercel-sandbox-provider.js +260 -0
  35. package/dist/workbench-cli.js +493 -0
  36. package/dist/workspace-sync.js +924 -0
  37. package/dist/workspace.js +868 -57
  38. package/package.json +4 -1
  39. package/src/artifact-archive.ts +182 -0
  40. package/src/connectors-cli.ts +115 -0
  41. package/src/credentials.ts +6 -2
  42. package/src/daytona-slice-rehearsal.ts +320 -0
  43. package/src/daytona-slice.ts +598 -0
  44. package/src/egress-allowlist-cli.ts +147 -0
  45. package/src/egress-policy.ts +188 -0
  46. package/src/git-credential.ts +155 -0
  47. package/src/github-setup.ts +366 -0
  48. package/src/help.ts +272 -16
  49. package/src/hosted-bootstrap-rehearsal.ts +312 -0
  50. package/src/hosted-bootstrap.ts +571 -0
  51. package/src/hosted-broker-server.ts +278 -0
  52. package/src/hosted-git-http-server.ts +164 -0
  53. package/src/hosted-harness-claude.ts +621 -0
  54. package/src/hosted-harness-opencode.ts +823 -0
  55. package/src/hosted-harness.ts +124 -0
  56. package/src/hosted-loop.ts +680 -0
  57. package/src/hosted-run-event-sink.ts +632 -0
  58. package/src/hosted-runtime-assets.ts +272 -0
  59. package/src/hosted-session-cli.ts +844 -0
  60. package/src/index.ts +987 -296
  61. package/src/kill-switch-cli.ts +113 -0
  62. package/src/manifests-cli.ts +168 -0
  63. package/src/preview-runtime.ts +14 -9
  64. package/src/sandbox-provider.ts +440 -0
  65. package/src/secret-redaction.ts +124 -0
  66. package/src/session-timeline.ts +152 -0
  67. package/src/setup-prompt.ts +4 -2
  68. package/src/skill-installer.ts +13 -1
  69. package/src/smoke.ts +342 -0
  70. package/src/task-assignment-manifest.ts +70 -2
  71. package/src/vercel-sandbox-provider.ts +388 -0
  72. package/src/workbench-cli.ts +701 -0
  73. package/src/workspace-sync.ts +1148 -0
  74. package/src/workspace.ts +1051 -64
  75. package/vendor/skills/orizu-cli/SKILL.md +3 -1
  76. package/vendor/skills/orizu-cli/references/cli-reference.md +3 -3
@@ -0,0 +1,823 @@
1
+ /**
2
+ * OpenCode driver for the AgentHarness seam (ALI-926 / P3.4, per ADR-005 §4a).
3
+ *
4
+ * The `opencode` implementation of `AgentHarness`. Drives a long-running
5
+ * `opencode serve` process over localhost HTTP/SSE, exactly as OpenInspect's
6
+ * `bridge.py` does — this module is a DESIGN port of `bridge.py`'s
7
+ * `_stream_opencode_response_sse` + `_transform_part_to_event` into idiomatic
8
+ * TypeScript. The WebSocket half of `bridge.py` is NOT ported: the transport is
9
+ * the RunAPI event bridge (`hosted-run-event-sink.ts`), not a Durable Object.
10
+ *
11
+ * DRIVING PROTOCOL (all localhost):
12
+ * - POST /session → create an OpenCode session
13
+ * - GET /session/{id} → validate a resumed session
14
+ * - GET /event → SSE stream of message parts
15
+ * - POST /session/{id}/prompt_async → enqueue a prompt (non-blocking)
16
+ * - GET /session/{id}/message → final-state reconciliation
17
+ * - POST /session/{id}/abort → stop the in-flight prompt
18
+ *
19
+ * The stream is opened BEFORE the prompt is POSTed (mirrors bridge.py) so no
20
+ * early parts are missed. Termination is driven by `session.idle` /
21
+ * `session.status{idle}` (→ `execution_complete`) or `session.error` (→
22
+ * `error`). Sub-task / child-session fan-out is intentionally NOT ported —
23
+ * ADR-005 keeps the RunAPI single-writer-per-run, so `spawn-task` is out of P3.
24
+ *
25
+ * TESTABILITY: the driver takes a `baseUrl` (localhost:port) and an injectable
26
+ * `fetchImpl`, so it runs against a fake in-process OpenCode SSE server with no
27
+ * real `opencode` binary. `spawnOpenCode` (below) is the P3.5 in-sandbox launch
28
+ * helper; the driver itself never spawns a process.
29
+ *
30
+ * ── Deliberate deltas from bridge.py (kept in ONE place) ─────────────────────
31
+ * This driver is a faithful port EXCEPT for these consciously-chosen
32
+ * differences — read them before diffing behavior against `bridge.py`:
33
+ * • Sub-task / child-session fan-out is OUT OF SCOPE. bridge.py tracks
34
+ * `tracked_child_session_ids`, authorizes child assistant messages, and
35
+ * emits `isSubtask` events; ADR-005 keeps the RunAPI single-writer-per-run,
36
+ * so `task`-tool child sessions are neither tracked nor forwarded here.
37
+ * • Compaction IS NOW PORTED (P2-1). `session.compacted` sets a
38
+ * `compactionOccurred` flag; thereafter assistant messages whose parentID
39
+ * no longer matches the user message are accepted (except the compaction
40
+ * summary itself), in both the live stream and `fetchFinalState` — mirrors
41
+ * bridge.py lines ~1300-1307 / 1153-1160 / 1421-1425.
42
+ * • Max prompt duration IS NOW PORTED (P3-1). `PROMPT_MAX_DURATION`
43
+ * (default 5400s, injectable) runs ALONGSIDE the SSE inactivity deadline;
44
+ * on breach the prompt is aborted and a terminal `error` is emitted.
45
+ * • SSE framing assumes LF `\n\n` event separators and `data:` lines, exactly
46
+ * as OpenCode 1.14.41 emits them (bridge.py `_parse_sse_stream` makes the
47
+ * same assumption — it splits on `"\n\n"`, never CRLF). This is pinned to
48
+ * `OPENCODE_PINNED_VERSION`; a bump that switches OpenCode to CRLF framing
49
+ * would break both ports and must be re-verified.
50
+ */
51
+
52
+ import type {
53
+ AgentHarness,
54
+ HarnessEvent,
55
+ HarnessPrompt,
56
+ HarnessStartOptions,
57
+ } from './hosted-harness.js'
58
+
59
+ // OpenCode is HARD-PINNED to this version everywhere it is installed/launched.
60
+ // AUDIT RISK #1 (SSE fragility): the entire event model rides OpenCode's
61
+ // undocumented `/event` SSE part schema (`message.part.updated`, `session.idle`,
62
+ // `session.error`, `session.updated`, ...). Versions newer than 1.14.41 broke
63
+ // that stream upstream, so the pin is load-bearing and must be re-verified
64
+ // before any bump. The AgentHarness seam is the mitigation of last resort: swap
65
+ // to a Claude-Agent-SDK loop (ALI-929) rather than chase OpenCode's SSE.
66
+ export const OPENCODE_PINNED_VERSION = '1.14.41'
67
+
68
+ const DEFAULT_OPENCODE_PORT = 4096
69
+ const DEFAULT_SSE_INACTIVITY_MS = 120_000
70
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
71
+ // Wall-clock ceiling on a single prompt (port of bridge.py PROMPT_MAX_DURATION,
72
+ // 5400s). Runs alongside the inactivity deadline: inactivity catches a stalled
73
+ // stream, this catches a stream that stays busy forever.
74
+ const DEFAULT_PROMPT_MAX_DURATION_MS = 5_400_000
75
+
76
+ // Placeholder titles OpenCode auto-assigns ("New session - <ISO8601>Z" /
77
+ // "Child session - <ISO8601>Z"); noise, never forwarded (port of bridge.py
78
+ // OPENCODE_DEFAULT_TITLE_RE).
79
+ const OPENCODE_DEFAULT_TITLE_RE =
80
+ /^(new session|child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/i
81
+
82
+ export type HarnessFetch = (url: string, init?: RequestInit) => Promise<Response>
83
+
84
+ export interface OpenCodeHarnessOptions {
85
+ /** Base URL of the local `opencode serve`, e.g. "http://localhost:4096". */
86
+ baseUrl: string
87
+ fetchImpl?: HarnessFetch
88
+ /** Inactivity ceiling on the SSE stream (ms); reset on every chunk. */
89
+ sseInactivityMs?: number
90
+ /** Timeout for the discrete (non-streaming) OpenCode requests (ms). */
91
+ requestTimeoutMs?: number
92
+ /** Wall-clock ceiling on a single prompt (ms); breach aborts + errors out. */
93
+ promptMaxDurationMs?: number
94
+ now?: () => number
95
+ }
96
+
97
+ /** Distinct terminal reason emitted when a prompt breaches PROMPT_MAX_DURATION,
98
+ * so the bridge/tests can tell a duration abort from a model error. */
99
+ export const PROMPT_MAX_DURATION_ERROR = 'prompt exceeded max duration'
100
+
101
+ class SseInactivityError extends Error {
102
+ constructor(ms: number) {
103
+ super(`OpenCode SSE stream inactive for ${ms}ms (no data received)`)
104
+ this.name = 'SseInactivityError'
105
+ }
106
+ }
107
+
108
+ class PromptAbortedError extends Error {
109
+ constructor() {
110
+ super('prompt aborted')
111
+ this.name = 'PromptAbortedError'
112
+ }
113
+ }
114
+
115
+ // -- OpenCode ascending id generator (port of OpenCode's id.ts) --------------
116
+ // Monotonic so our user-message id always sorts AFTER any prior assistant
117
+ // message id — this is what prevents OpenCode's prompt loop from early-exiting
118
+ // on `lastUser.id < lastAssistant.id`.
119
+ const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
120
+ let idLastTimestamp = 0
121
+ let idCounter = 0
122
+
123
+ function randomBase62(length: number): string {
124
+ let out = ''
125
+ for (let i = 0; i < length; i += 1) {
126
+ out += BASE62[Math.floor(Math.random() * 62)]
127
+ }
128
+ return out
129
+ }
130
+
131
+ function ascendingId(prefix: 'ses' | 'msg' | 'prt'): string {
132
+ const nowMs = Date.now()
133
+ if (nowMs !== idLastTimestamp) {
134
+ idLastTimestamp = nowMs
135
+ idCounter = 0
136
+ }
137
+ idCounter += 1
138
+ const encoded = (nowMs * 0x1000 + idCounter) & 0xffffffffffff
139
+ const hex = encoded.toString(16).padStart(12, '0')
140
+ return `${prefix}_${hex}${randomBase62(14)}`
141
+ }
142
+
143
+ // -- Anthropic / OpenAI reasoning-effort mapping (port of bridge.py) ---------
144
+ const ANTHROPIC_THINKING_BUDGETS: Record<string, number> = { high: 16_000, max: 31_999 }
145
+ const ANTHROPIC_ADAPTIVE_THINKING_MODELS = new Set([
146
+ 'claude-fable-5',
147
+ 'claude-opus-4-6',
148
+ 'claude-opus-4-7',
149
+ 'claude-opus-4-8',
150
+ 'claude-sonnet-4-6',
151
+ ])
152
+ const ANTHROPIC_ADAPTIVE_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
153
+
154
+ export function buildPromptRequestBody(
155
+ content: string,
156
+ model: string | undefined,
157
+ opencodeMessageId: string,
158
+ reasoningEffort: string | undefined
159
+ ): Record<string, unknown> {
160
+ const body: Record<string, unknown> = {
161
+ parts: [{ type: 'text', text: content }],
162
+ messageID: opencodeMessageId,
163
+ }
164
+ if (!model) return body
165
+
166
+ const [providerId, modelId] = model.includes('/') ? model.split('/', 2) : ['anthropic', model]
167
+ const modelSpec: Record<string, unknown> = { providerID: providerId, modelID: modelId }
168
+
169
+ if (reasoningEffort) {
170
+ if (providerId === 'anthropic') {
171
+ if (ANTHROPIC_ADAPTIVE_THINKING_MODELS.has(modelId)) {
172
+ const options: Record<string, unknown> = { thinking: { type: 'adaptive' } }
173
+ if (ANTHROPIC_ADAPTIVE_EFFORTS.has(reasoningEffort)) {
174
+ options.outputConfig = { effort: reasoningEffort }
175
+ }
176
+ modelSpec.options = options
177
+ } else {
178
+ const budget = ANTHROPIC_THINKING_BUDGETS[reasoningEffort]
179
+ if (budget !== undefined) {
180
+ modelSpec.options = { thinking: { type: 'enabled', budgetTokens: budget } }
181
+ }
182
+ }
183
+ } else if (providerId === 'openai') {
184
+ modelSpec.options = { reasoningEffort, reasoningSummary: 'auto' }
185
+ }
186
+ }
187
+ body.model = modelSpec
188
+ return body
189
+ }
190
+
191
+ // -- OpenCode part → HarnessEvent transform (port of _transform_part_to_event) -
192
+
193
+ function asRecord(value: unknown): Record<string, unknown> {
194
+ return value && typeof value === 'object' && !Array.isArray(value)
195
+ ? (value as Record<string, unknown>)
196
+ : {}
197
+ }
198
+
199
+ function extractErrorMessage(error: unknown): string {
200
+ const rec = asRecord(error)
201
+ const data = asRecord(rec.data)
202
+ if (typeof data.message === 'string') return data.message
203
+ if (typeof rec.message === 'string') return rec.message
204
+ if (typeof rec.name === 'string') return rec.name
205
+ return typeof error === 'string' && error ? error : 'Unknown error'
206
+ }
207
+
208
+ // -- SSE parsing over a fetch ReadableStream ---------------------------------
209
+
210
+ async function readWithDeadline(
211
+ reader: ReadableStreamDefaultReader<Uint8Array>,
212
+ signal: AbortSignal,
213
+ inactivityMs: number
214
+ ): Promise<ReadableStreamReadResult<Uint8Array>> {
215
+ return new Promise((resolve, reject) => {
216
+ if (signal.aborted) {
217
+ reject(new PromptAbortedError())
218
+ return
219
+ }
220
+ const timer = setTimeout(() => {
221
+ cleanup()
222
+ reject(new SseInactivityError(inactivityMs))
223
+ }, inactivityMs)
224
+ const onAbort = (): void => {
225
+ cleanup()
226
+ reject(new PromptAbortedError())
227
+ }
228
+ const cleanup = (): void => {
229
+ clearTimeout(timer)
230
+ signal.removeEventListener('abort', onAbort)
231
+ }
232
+ signal.addEventListener('abort', onAbort, { once: true })
233
+ reader.read().then(
234
+ result => {
235
+ cleanup()
236
+ resolve(result)
237
+ },
238
+ error => {
239
+ cleanup()
240
+ reject(error)
241
+ }
242
+ )
243
+ })
244
+ }
245
+
246
+ async function* parseSseEvents(
247
+ body: ReadableStream<Uint8Array>,
248
+ signal: AbortSignal,
249
+ inactivityMs: number
250
+ ): AsyncGenerator<Record<string, unknown>> {
251
+ const reader = body.getReader()
252
+ const decoder = new TextDecoder()
253
+ let buffer = ''
254
+ try {
255
+ for (;;) {
256
+ const { done, value } = await readWithDeadline(reader, signal, inactivityMs)
257
+ if (done) break
258
+ if (value) buffer += decoder.decode(value, { stream: true })
259
+ let boundary = buffer.indexOf('\n\n')
260
+ while (boundary !== -1) {
261
+ const rawEvent = buffer.slice(0, boundary)
262
+ buffer = buffer.slice(boundary + 2)
263
+ const dataLines: string[] = []
264
+ for (const line of rawEvent.split('\n')) {
265
+ if (line.startsWith('data:')) {
266
+ const trimmed = line.slice(5).replace(/^ /, '')
267
+ if (trimmed) dataLines.push(trimmed)
268
+ }
269
+ }
270
+ if (dataLines.length > 0) {
271
+ try {
272
+ yield asRecord(JSON.parse(dataLines.join('\n')))
273
+ } catch {
274
+ // Malformed SSE payload — skip rather than crash the stream.
275
+ }
276
+ }
277
+ boundary = buffer.indexOf('\n\n')
278
+ }
279
+ }
280
+ } finally {
281
+ try {
282
+ reader.releaseLock()
283
+ } catch {
284
+ // reader already released
285
+ }
286
+ try {
287
+ await body.cancel()
288
+ } catch {
289
+ // stream already closed
290
+ }
291
+ }
292
+ }
293
+
294
+ // -- The driver --------------------------------------------------------------
295
+
296
+ export function createOpenCodeHarness(options: OpenCodeHarnessOptions): AgentHarness {
297
+ const fetchImpl = options.fetchImpl ?? (globalThis.fetch as HarnessFetch)
298
+ const base = options.baseUrl.replace(/\/$/, '')
299
+ const inactivityMs = options.sseInactivityMs ?? DEFAULT_SSE_INACTIVITY_MS
300
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
301
+ const promptMaxDurationMs = options.promptMaxDurationMs ?? DEFAULT_PROMPT_MAX_DURATION_MS
302
+ const now = options.now ?? ((): number => Date.now())
303
+
304
+ let opencodeSessionId: string | null = null
305
+ let startModel: string | undefined
306
+ let startReasoningEffort: string | undefined
307
+ let readyEmitted = false
308
+ // Set true by the public stop(); makes the next idle terminal resolve as a
309
+ // cancellation rather than a success (P2-2).
310
+ let stopRequested = false
311
+ // Last title actually forwarded, deduped across the whole harness lifetime so
312
+ // a repeated identical title is emitted once (P3-6).
313
+ let lastForwardedTitle: string | null = null
314
+
315
+ /** Drop placeholder/default titles and de-dupe against the last one forwarded;
316
+ * returns the title to emit, or null to suppress (port of bridge.py
317
+ * _normalize_forwardable_session_title + _session_title_event_once). */
318
+ function forwardableTitle(rawTitle: unknown): string | null {
319
+ if (typeof rawTitle !== 'string') return null
320
+ const trimmed = rawTitle.trim()
321
+ if (!trimmed || OPENCODE_DEFAULT_TITLE_RE.test(trimmed)) return null
322
+ if (trimmed === lastForwardedTitle) return null
323
+ lastForwardedTitle = trimmed
324
+ return trimmed
325
+ }
326
+
327
+ async function timedFetch(url: string, init: RequestInit = {}): Promise<Response> {
328
+ const controller = new AbortController()
329
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs)
330
+ try {
331
+ return await fetchImpl(url, { ...init, signal: init.signal ?? controller.signal })
332
+ } finally {
333
+ clearTimeout(timer)
334
+ }
335
+ }
336
+
337
+ async function createSession(): Promise<string> {
338
+ const res = await timedFetch(`${base}/session`, {
339
+ method: 'POST',
340
+ headers: { 'Content-Type': 'application/json' },
341
+ body: JSON.stringify({}),
342
+ })
343
+ if (!res.ok) throw new Error(`OpenCode session create failed (${res.status})`)
344
+ const data = asRecord(await res.json())
345
+ const id = typeof data.id === 'string' ? data.id : null
346
+ if (!id) throw new Error('OpenCode session create returned no id')
347
+ return id
348
+ }
349
+
350
+ async function validateSession(id: string): Promise<boolean> {
351
+ try {
352
+ const res = await timedFetch(`${base}/session/${encodeURIComponent(id)}`)
353
+ return res.ok
354
+ } catch {
355
+ return false
356
+ }
357
+ }
358
+
359
+ async function requestStop(): Promise<void> {
360
+ if (!opencodeSessionId) return
361
+ try {
362
+ await timedFetch(`${base}/session/${encodeURIComponent(opencodeSessionId)}/abort`, {
363
+ method: 'POST',
364
+ })
365
+ } catch {
366
+ // best-effort: the stream teardown is the real cancellation
367
+ }
368
+ }
369
+
370
+ async function* fetchFinalState(
371
+ sessionId: string,
372
+ userMessageIds: Set<string>,
373
+ allowedAssistantMsgIds: Set<string>,
374
+ messageText: Map<string, string>,
375
+ compactionOccurred: boolean
376
+ ): AsyncGenerator<HarnessEvent> {
377
+ // After idle, re-read the message list to capture any assistant text the SSE
378
+ // stream may have missed due to part-ordering (bridge.py does the same). Any
379
+ // text LONGER than what we already emitted is re-emitted as a token snapshot.
380
+ let messages: unknown
381
+ try {
382
+ const res = await timedFetch(`${base}/session/${encodeURIComponent(sessionId)}/message`)
383
+ if (!res.ok) return
384
+ messages = await res.json()
385
+ } catch {
386
+ return
387
+ }
388
+ if (!Array.isArray(messages)) return
389
+ for (const msg of messages) {
390
+ const info = asRecord(asRecord(msg).info)
391
+ if (info.role !== 'assistant') continue
392
+ const msgId = typeof info.id === 'string' ? info.id : ''
393
+ const parentId = typeof info.parentID === 'string' ? info.parentID : ''
394
+ const isCompactionSummary = info.summary === true
395
+ // Accept if parentID matches our user message, OR it was tracked live, OR
396
+ // compaction re-chained the messages and this isn't the summary itself
397
+ // (port of bridge.py _fetch_final_message_state, lines ~1420-1426).
398
+ const accept =
399
+ userMessageIds.has(parentId) ||
400
+ (msgId !== '' && allowedAssistantMsgIds.has(msgId)) ||
401
+ (compactionOccurred && !isCompactionSummary)
402
+ if (!accept) continue
403
+ const parts = Array.isArray(asRecord(msg).parts) ? (asRecord(msg).parts as unknown[]) : []
404
+ const combined = parts
405
+ .map(p => asRecord(p))
406
+ .filter(p => p.type === 'text')
407
+ .map(p => (typeof p.text === 'string' ? p.text : ''))
408
+ .join('')
409
+ const previous = messageText.get(msgId) ?? ''
410
+ if (combined.length > previous.length) {
411
+ messageText.set(msgId, combined)
412
+ yield { kind: 'token', messageId: msgId, payload: { text: combined } }
413
+ }
414
+ }
415
+ }
416
+
417
+ return {
418
+ name: 'opencode',
419
+
420
+ async start(opts: HarnessStartOptions): Promise<{ agentSessionId: string }> {
421
+ startModel = opts.model
422
+ startReasoningEffort = opts.reasoningEffort
423
+ if (opts.resumeAgentSessionId && (await validateSession(opts.resumeAgentSessionId))) {
424
+ opencodeSessionId = opts.resumeAgentSessionId
425
+ } else {
426
+ opencodeSessionId = await createSession()
427
+ }
428
+ return { agentSessionId: opencodeSessionId }
429
+ },
430
+
431
+ async *runPrompt(prompt: HarnessPrompt, signal: AbortSignal): AsyncIterable<HarnessEvent> {
432
+ if (!opencodeSessionId) opencodeSessionId = await createSession()
433
+ const sessionId = opencodeSessionId
434
+
435
+ // Per-prompt state: a fresh stop intent and compaction flag each run.
436
+ stopRequested = false
437
+ let compactionOccurred = false
438
+ const promptStart = now()
439
+
440
+ if (!readyEmitted) {
441
+ readyEmitted = true
442
+ yield {
443
+ kind: 'ready',
444
+ messageId: prompt.messageId,
445
+ payload: { agentSessionId: sessionId },
446
+ }
447
+ }
448
+
449
+ const opencodeMessageId = ascendingId('msg')
450
+ const requestBody = buildPromptRequestBody(
451
+ prompt.content,
452
+ startModel,
453
+ opencodeMessageId,
454
+ startReasoningEffort
455
+ )
456
+
457
+ // Per-part cumulative text, ordered per assistant message so a coalesced
458
+ // token snapshot carries the whole assistant turn (not a single part).
459
+ const partText = new Map<string, string>()
460
+ const messagePartOrder = new Map<string, string[]>()
461
+ const messageText = (msgId: string): string =>
462
+ (messagePartOrder.get(msgId) ?? []).map(pid => partText.get(pid) ?? '').join('')
463
+
464
+ const allowedAssistantMsgIds = new Set<string>()
465
+ const userMessageIds = new Set<string>([opencodeMessageId])
466
+ const pendingParts = new Map<string, Array<{ part: Record<string, unknown>; delta: unknown }>>()
467
+ const emittedToolStates = new Set<string>()
468
+
469
+ const handlePart = (part: Record<string, unknown>, delta: unknown): HarnessEvent[] => {
470
+ const partType = typeof part.type === 'string' ? part.type : ''
471
+ const partId = typeof part.id === 'string' ? part.id : ''
472
+ const msgId = typeof part.messageID === 'string' ? part.messageID : ''
473
+ const events: HarnessEvent[] = []
474
+
475
+ if (partType === 'text') {
476
+ const full = typeof part.text === 'string' ? part.text : ''
477
+ if (typeof delta === 'string' && delta) {
478
+ partText.set(partId, (partText.get(partId) ?? '') + delta)
479
+ } else {
480
+ partText.set(partId, full)
481
+ }
482
+ const order = messagePartOrder.get(msgId) ?? []
483
+ if (!order.includes(partId)) {
484
+ order.push(partId)
485
+ messagePartOrder.set(msgId, order)
486
+ }
487
+ const text = messageText(msgId)
488
+ if (text) events.push({ kind: 'token', messageId: msgId, payload: { text } })
489
+ } else if (partType === 'tool') {
490
+ const state = asRecord(part.state)
491
+ const status = typeof state.status === 'string' ? state.status : ''
492
+ const input = asRecord(state.input)
493
+ if ((status === 'pending' || status === '') && Object.keys(input).length === 0) {
494
+ return events
495
+ }
496
+ const callId = typeof part.callID === 'string' ? part.callID : ''
497
+ const partSid = typeof part.sessionID === 'string' ? part.sessionID : ''
498
+ const toolKey = `tool:${partSid}:${callId}:${status}`
499
+ if (emittedToolStates.has(toolKey)) return events
500
+ emittedToolStates.add(toolKey)
501
+ const terminal = status === 'completed' || status === 'error'
502
+ const payload: Record<string, unknown> = {
503
+ tool: typeof part.tool === 'string' ? part.tool : '',
504
+ args: input,
505
+ callId,
506
+ status,
507
+ }
508
+ if (terminal) payload.output = typeof state.output === 'string' ? state.output : ''
509
+ events.push({ kind: terminal ? 'tool_result' : 'tool_call', messageId: msgId, payload })
510
+ } else if (partType === 'step-start') {
511
+ events.push({ kind: 'step_start', messageId: msgId, payload: {} })
512
+ } else if (partType === 'step-finish') {
513
+ events.push({
514
+ kind: 'step_finish',
515
+ messageId: msgId,
516
+ payload: { cost: part.cost ?? null, tokens: part.tokens ?? null, reason: part.reason ?? null },
517
+ })
518
+ }
519
+ return events
520
+ }
521
+
522
+ let sseResponse: Response
523
+ try {
524
+ sseResponse = await fetchImpl(`${base}/event`, {
525
+ headers: { Accept: 'text/event-stream' },
526
+ signal,
527
+ })
528
+ } catch (error) {
529
+ yield errorEvent(prompt.messageId, error)
530
+ return
531
+ }
532
+ if (!sseResponse.ok || !sseResponse.body) {
533
+ // Cancel the body on the failure path too — a non-2xx response can still
534
+ // carry an (unconsumed) body that would otherwise leak (P3-7).
535
+ await cancelBody(sseResponse.body)
536
+ yield errorEvent(prompt.messageId, new Error(`SSE connection failed (${sseResponse.status})`))
537
+ return
538
+ }
539
+ const sseBody = sseResponse.body
540
+
541
+ let sawError = false
542
+ let errorText: string | null = null
543
+ let terminalYielded = false
544
+ try {
545
+ const promptRes = await timedFetch(
546
+ `${base}/session/${encodeURIComponent(sessionId)}/prompt_async`,
547
+ {
548
+ method: 'POST',
549
+ headers: { 'Content-Type': 'application/json' },
550
+ body: JSON.stringify(requestBody),
551
+ }
552
+ )
553
+ if (promptRes.status !== 200 && promptRes.status !== 204) {
554
+ throw new Error(`Async prompt failed (${promptRes.status})`)
555
+ }
556
+
557
+ for await (const event of parseSseEvents(sseBody, signal, inactivityMs)) {
558
+ // Wall-clock max-duration guard (P3-1): a stream that stays busy (or
559
+ // heartbeats) forever never trips the inactivity deadline, so bound it
560
+ // here — checked on EVERY event, ahead of the heartbeat/continue paths,
561
+ // so no event class can starve it (port of bridge.py PROMPT_MAX_DURATION).
562
+ if (now() - promptStart > promptMaxDurationMs) {
563
+ await requestStop()
564
+ const durationTextMap = new Map<string, string>()
565
+ for (const [msgId, order] of messagePartOrder.entries()) {
566
+ durationTextMap.set(msgId, order.map(pid => partText.get(pid) ?? '').join(''))
567
+ }
568
+ for await (const ev of fetchFinalState(
569
+ sessionId,
570
+ userMessageIds,
571
+ allowedAssistantMsgIds,
572
+ durationTextMap,
573
+ compactionOccurred
574
+ )) {
575
+ yield ev
576
+ }
577
+ terminalYielded = true
578
+ yield {
579
+ kind: 'error',
580
+ messageId: prompt.messageId,
581
+ critical: true,
582
+ payload: {
583
+ error: `${PROMPT_MAX_DURATION_ERROR} of ${Math.round(promptMaxDurationMs / 1000)}s`,
584
+ },
585
+ }
586
+ return
587
+ }
588
+
589
+ const eventType = event.type
590
+ if (eventType === 'server.heartbeat' || eventType === 'server.connected') continue
591
+
592
+ const props = asRecord(event.properties)
593
+
594
+ // session_title (session.updated → session_title). Placeholder/default
595
+ // titles are dropped and identical repeats are deduped (P3-6).
596
+ if (eventType === 'session.updated') {
597
+ const info = asRecord(props.info)
598
+ const sid = props.sessionID ?? info.id
599
+ if (sid === sessionId) {
600
+ const title = forwardableTitle(info.title)
601
+ if (title) yield { kind: 'session_title', messageId: prompt.messageId, payload: { title } }
602
+ }
603
+ continue
604
+ }
605
+
606
+ const eventSessionId =
607
+ (props.sessionID as string | undefined) ?? (asRecord(props.part).sessionID as string | undefined)
608
+ if (eventSessionId && eventSessionId !== sessionId) continue
609
+
610
+ if (eventType === 'message.updated') {
611
+ const info = asRecord(props.info)
612
+ if (info.sessionID !== sessionId) continue
613
+ const ocMsgId = typeof info.id === 'string' ? info.id : ''
614
+ const parentId = typeof info.parentID === 'string' ? info.parentID : ''
615
+ const role = info.role
616
+ const isCompactionSummary = info.summary === true
617
+ if (role === 'user' && ocMsgId) userMessageIds.add(ocMsgId)
618
+ // Accept an assistant message when its parentID matches our user
619
+ // message, OR compaction has re-chained the conversation and this is
620
+ // not the compaction summary itself (P2-1; port of bridge.py
621
+ // lines ~1153-1160).
622
+ const acceptAssistant =
623
+ userMessageIds.has(parentId) || (compactionOccurred && !isCompactionSummary)
624
+ if (role === 'assistant' && ocMsgId && acceptAssistant) {
625
+ allowedAssistantMsgIds.add(ocMsgId)
626
+ const buffered = pendingParts.get(ocMsgId)
627
+ if (buffered) {
628
+ pendingParts.delete(ocMsgId)
629
+ for (const { part, delta } of buffered) {
630
+ for (const ev of handlePart(part, delta)) yield ev
631
+ }
632
+ }
633
+ }
634
+ } else if (eventType === 'message.part.updated') {
635
+ const part = asRecord(props.part)
636
+ const delta = props.delta
637
+ const ocMsgId = typeof part.messageID === 'string' ? part.messageID : ''
638
+ if (allowedAssistantMsgIds.has(ocMsgId)) {
639
+ for (const ev of handlePart(part, delta)) yield ev
640
+ } else if (ocMsgId) {
641
+ const list = pendingParts.get(ocMsgId) ?? []
642
+ list.push({ part, delta })
643
+ pendingParts.set(ocMsgId, list)
644
+ }
645
+ } else if (eventType === 'session.compacted') {
646
+ // Compaction re-chains messages so subsequent assistant parentIDs no
647
+ // longer point at our user message; flip the flag so acceptance falls
648
+ // through to the compaction branch above (P2-1).
649
+ const compactedSid = props.sessionID ?? asRecord(props.info).id
650
+ if (compactedSid === sessionId) compactionOccurred = true
651
+ continue
652
+ } else if (
653
+ eventType === 'session.idle' ||
654
+ (eventType === 'session.status' && asRecord(props.status).type === 'idle')
655
+ ) {
656
+ const messageTextMap = new Map<string, string>()
657
+ for (const [msgId, order] of messagePartOrder.entries()) {
658
+ messageTextMap.set(msgId, order.map(pid => partText.get(pid) ?? '').join(''))
659
+ }
660
+ for await (const ev of fetchFinalState(
661
+ sessionId,
662
+ userMessageIds,
663
+ allowedAssistantMsgIds,
664
+ messageTextMap,
665
+ compactionOccurred
666
+ )) {
667
+ yield ev
668
+ }
669
+ terminalYielded = true
670
+ // A stop()-initiated idle is a cancellation, not a success (P2-2).
671
+ yield {
672
+ kind: 'execution_complete',
673
+ messageId: prompt.messageId,
674
+ critical: true,
675
+ payload: stopRequested ? { success: false, aborted: true } : { success: true },
676
+ }
677
+ return
678
+ } else if (eventType === 'session.error') {
679
+ sawError = true
680
+ errorText = extractErrorMessage(props.error)
681
+ terminalYielded = true
682
+ yield {
683
+ kind: 'error',
684
+ messageId: prompt.messageId,
685
+ critical: true,
686
+ payload: { error: errorText },
687
+ }
688
+ return
689
+ }
690
+ }
691
+
692
+ // Stream ended without an explicit idle/error terminal (server closed).
693
+ if (!terminalYielded) {
694
+ yield {
695
+ kind: 'execution_complete',
696
+ messageId: prompt.messageId,
697
+ critical: true,
698
+ payload: sawError ? { success: false, error: errorText } : { success: true },
699
+ }
700
+ }
701
+ } catch (error) {
702
+ if (error instanceof PromptAbortedError) {
703
+ await requestStop()
704
+ if (!terminalYielded) {
705
+ // Signal-abort resolves as a cancellation (P2-2): `aborted: true` is
706
+ // the bridge's cue to finish('cancelled'), never 'failed'.
707
+ yield {
708
+ kind: 'execution_complete',
709
+ messageId: prompt.messageId,
710
+ critical: true,
711
+ payload: { success: false, aborted: true },
712
+ }
713
+ }
714
+ return
715
+ }
716
+ await requestStop()
717
+ if (!terminalYielded) yield errorEvent(prompt.messageId, error)
718
+ } finally {
719
+ // Guaranteed body teardown on EVERY exit — including a prompt POST that
720
+ // throws before parseSseEvents (whose own finally would otherwise be the
721
+ // only canceller) — so the /event response never leaks (P3-7).
722
+ await cancelBody(sseBody)
723
+ }
724
+ },
725
+
726
+ async stop(): Promise<void> {
727
+ // Mark intent so the resulting idle terminal is reported as cancelled, not
728
+ // succeeded (P2-2), then best-effort ask OpenCode to abort.
729
+ stopRequested = true
730
+ await requestStop()
731
+ },
732
+
733
+ async shutdown(): Promise<void> {
734
+ opencodeSessionId = null
735
+ readyEmitted = false
736
+ stopRequested = false
737
+ },
738
+ }
739
+ }
740
+
741
+ /** Best-effort cancel of a fetch response body; never throws. */
742
+ async function cancelBody(body: ReadableStream<Uint8Array> | null): Promise<void> {
743
+ if (!body) return
744
+ try {
745
+ await body.cancel()
746
+ } catch {
747
+ // already released/closed
748
+ }
749
+ }
750
+
751
+ function errorEvent(messageId: string, error: unknown): HarnessEvent {
752
+ const message = error instanceof Error ? error.message : String(error)
753
+ return { kind: 'error', messageId, critical: true, payload: { error: message } }
754
+ }
755
+
756
+ // -- spawnOpenCode: the P3.5 in-sandbox launch helper ------------------------
757
+
758
+ export interface OpenCodeConfigOptions {
759
+ /** Provider-qualified model string, e.g. "anthropic/claude-opus-4-8". */
760
+ model: string
761
+ /** OpenCode permission map; defaults to allow-all (sandbox is the boundary). */
762
+ permission?: Record<string, unknown>
763
+ }
764
+
765
+ /**
766
+ * Build the `OPENCODE_CONFIG_CONTENT` payload OpenCode reads at boot (mirrors
767
+ * OpenInspect's `start_opencode`). Kept pure so the launch command and the
768
+ * config are independently testable.
769
+ */
770
+ export function buildOpenCodeConfigContent(opts: OpenCodeConfigOptions): string {
771
+ return JSON.stringify({
772
+ model: opts.model,
773
+ permission: opts.permission ?? { '*': { '*': 'allow' } },
774
+ })
775
+ }
776
+
777
+ export interface SpawnOpenCodeOptions extends OpenCodeConfigOptions {
778
+ port?: number
779
+ /** Directory OpenCode runs in (the cloned workspace). */
780
+ cwd?: string
781
+ /** Extra env merged over the config-content injection. */
782
+ env?: Record<string, string>
783
+ /** Injectable spawner (defaults to Bun.spawn) so this stays unit-testable. */
784
+ spawn?: (cmd: string[], opts: { cwd?: string; env: Record<string, string> }) => { kill: () => void }
785
+ }
786
+
787
+ export interface SpawnedOpenCode {
788
+ baseUrl: string
789
+ port: number
790
+ stop: () => void
791
+ }
792
+
793
+ /**
794
+ * Launch `opencode serve` locally. Process supervision is intentionally minimal
795
+ * for this slice — P3.5 runs this INSIDE the Daytona sandbox via the CLI. The
796
+ * pinned version (`OPENCODE_PINNED_VERSION`) is the one the sandbox image must
797
+ * install (see the SSE-fragility note on that constant). Returns the localhost
798
+ * base URL the driver connects to; callers still `start()`/`runPrompt()` the
799
+ * harness against it.
800
+ */
801
+ export function spawnOpenCode(opts: SpawnOpenCodeOptions): SpawnedOpenCode {
802
+ const port = opts.port ?? DEFAULT_OPENCODE_PORT
803
+ const env: Record<string, string> = {
804
+ OPENCODE_CONFIG_CONTENT: buildOpenCodeConfigContent(opts),
805
+ ...(opts.env ?? {}),
806
+ }
807
+ const cmd = ['opencode', 'serve', '--port', String(port)]
808
+ const spawnImpl =
809
+ opts.spawn ??
810
+ ((command: string[], spawnOpts: { cwd?: string; env: Record<string, string> }) => {
811
+ const bun = (globalThis as { Bun?: { spawn: (o: unknown) => { kill: () => void } } }).Bun
812
+ if (!bun?.spawn) throw new Error('spawnOpenCode requires Bun.spawn or an injected spawner')
813
+ return bun.spawn({
814
+ cmd: command,
815
+ cwd: spawnOpts.cwd,
816
+ env: { ...process.env, ...spawnOpts.env },
817
+ stdout: 'ignore',
818
+ stderr: 'ignore',
819
+ })
820
+ })
821
+ const child = spawnImpl(cmd, { cwd: opts.cwd, env })
822
+ return { baseUrl: `http://localhost:${port}`, port, stop: () => child.kill() }
823
+ }