orizu 0.4.1 → 0.5.1
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/README.md +22 -13
- package/dist/artifact-archive.js +152 -0
- package/dist/connectors-cli.js +80 -0
- package/dist/credentials.js +4 -1
- package/dist/daytona-slice-rehearsal.js +255 -0
- package/dist/daytona-slice.js +441 -0
- package/dist/egress-allowlist-cli.js +132 -0
- package/dist/egress-policy.js +159 -0
- package/dist/git-credential.js +131 -0
- package/dist/github-setup.js +248 -0
- package/dist/help.js +271 -16
- package/dist/hosted-bootstrap-rehearsal.js +235 -0
- package/dist/hosted-bootstrap.js +365 -0
- package/dist/hosted-broker-server.js +220 -0
- package/dist/hosted-git-http-server.js +136 -0
- package/dist/hosted-harness-claude.js +493 -0
- package/dist/hosted-harness-opencode.js +729 -0
- package/dist/hosted-harness.js +60 -0
- package/dist/hosted-loop.js +477 -0
- package/dist/hosted-run-event-sink.js +499 -0
- package/dist/hosted-runtime-assets.js +216 -0
- package/dist/hosted-session-cli.js +642 -0
- package/dist/index.js +855 -237
- package/dist/kill-switch-cli.js +89 -0
- package/dist/manifests-cli.js +143 -0
- package/dist/preview-runtime.js +4 -1
- package/dist/sandbox-provider.js +238 -0
- package/dist/secret-redaction.js +112 -0
- package/dist/session-timeline.js +103 -0
- package/dist/setup-prompt.js +4 -2
- package/dist/skill-installer.js +9 -1
- package/dist/smoke.js +250 -0
- package/dist/task-assignment-manifest.js +39 -2
- package/dist/vercel-sandbox-provider.js +260 -0
- package/dist/workbench-cli.js +493 -0
- package/dist/workspace-sync.js +924 -0
- package/dist/workspace.js +868 -57
- package/package.json +4 -1
- package/src/artifact-archive.ts +182 -0
- package/src/connectors-cli.ts +115 -0
- package/src/credentials.ts +6 -2
- package/src/daytona-slice-rehearsal.ts +320 -0
- package/src/daytona-slice.ts +598 -0
- package/src/egress-allowlist-cli.ts +147 -0
- package/src/egress-policy.ts +188 -0
- package/src/git-credential.ts +155 -0
- package/src/github-setup.ts +396 -0
- package/src/help.ts +272 -16
- package/src/hosted-bootstrap-rehearsal.ts +312 -0
- package/src/hosted-bootstrap.ts +571 -0
- package/src/hosted-broker-server.ts +278 -0
- package/src/hosted-git-http-server.ts +164 -0
- package/src/hosted-harness-claude.ts +621 -0
- package/src/hosted-harness-opencode.ts +823 -0
- package/src/hosted-harness.ts +124 -0
- package/src/hosted-loop.ts +680 -0
- package/src/hosted-run-event-sink.ts +632 -0
- package/src/hosted-runtime-assets.ts +272 -0
- package/src/hosted-session-cli.ts +844 -0
- package/src/index.ts +987 -296
- package/src/kill-switch-cli.ts +113 -0
- package/src/manifests-cli.ts +168 -0
- package/src/preview-runtime.ts +14 -9
- package/src/sandbox-provider.ts +440 -0
- package/src/secret-redaction.ts +124 -0
- package/src/session-timeline.ts +152 -0
- package/src/setup-prompt.ts +4 -2
- package/src/skill-installer.ts +13 -1
- package/src/smoke.ts +342 -0
- package/src/task-assignment-manifest.ts +70 -2
- package/src/vercel-sandbox-provider.ts +388 -0
- package/src/workbench-cli.ts +701 -0
- package/src/workspace-sync.ts +1148 -0
- package/src/workspace.ts +1051 -64
- package/vendor/skills/orizu-cli/SKILL.md +3 -1
- 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
|
+
}
|