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