hilos-agent 0.6.0 → 0.7.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.
- package/README.md +14 -3
- package/bin/hilos-agent.mjs +16 -1
- package/package.json +1 -1
- package/src/agent-events.mjs +113 -2
- package/src/cli.mjs +33 -4
- package/src/handler.mjs +259 -69
- package/src/opencode-permissions.mjs +654 -0
- package/src/opencode-session.mjs +770 -0
- package/src/progress-emitter.mjs +92 -8
- package/src/resume.mjs +85 -12
- package/src/run.mjs +5 -0
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
// Authenticated OpenCode HTTP session runner (0593).
|
|
2
|
+
//
|
|
3
|
+
// `opencode run --attach` cannot be used for hilos runtime approvals: that CLI
|
|
4
|
+
// subscribes to `permission.asked` itself and immediately replies `reject`
|
|
5
|
+
// unless `--auto` is enabled. A second hilos subscriber would race a built-in
|
|
6
|
+
// responder, so this module owns the one event stream and is the only component
|
|
7
|
+
// allowed to answer permission asks.
|
|
8
|
+
//
|
|
9
|
+
// The public runner returns the same small shape as runCli so handler.mjs can
|
|
10
|
+
// preserve its report/progress/error paths:
|
|
11
|
+
// { status, stdout, stderr, error?, sessionId?, aborted? }
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
import { realpathSync } from "node:fs";
|
|
16
|
+
import {
|
|
17
|
+
createOpenCodePermissionRelay,
|
|
18
|
+
createSseParser,
|
|
19
|
+
} from "./opencode-permissions.mjs";
|
|
20
|
+
import { envForCwd } from "./cli.mjs";
|
|
21
|
+
|
|
22
|
+
const STARTUP_TIMEOUT_MS = 15_000;
|
|
23
|
+
const SHUTDOWN_GRACE_MS = 750;
|
|
24
|
+
const OPENCODE_SESSION_RE = /^ses_[A-Za-z0-9_-]+$/;
|
|
25
|
+
|
|
26
|
+
function abortError(reason = "cancelled") {
|
|
27
|
+
const error = new Error(String(reason || "cancelled"));
|
|
28
|
+
error.name = "AbortError";
|
|
29
|
+
return error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function basicAuthorization(username, password) {
|
|
33
|
+
return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function withDirectory(baseUrl, pathname, directory) {
|
|
37
|
+
const url = new URL(pathname, baseUrl);
|
|
38
|
+
url.searchParams.set("directory", directory);
|
|
39
|
+
return url;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function optionValue(args, index, long, short) {
|
|
43
|
+
const value = args[index];
|
|
44
|
+
if (value === long || value === short) {
|
|
45
|
+
if (typeof args[index + 1] !== "string" || !args[index + 1]) {
|
|
46
|
+
throw new Error(`${long} requires a value`);
|
|
47
|
+
}
|
|
48
|
+
return { value: args[index + 1], consumed: 2 };
|
|
49
|
+
}
|
|
50
|
+
if (value.startsWith(`${long}=`)) {
|
|
51
|
+
return { value: value.slice(long.length + 1), consumed: 1 };
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse only the OpenCode run options the HTTP API can preserve exactly.
|
|
58
|
+
* Unknown options fail closed instead of silently dropping a user's runtime
|
|
59
|
+
* policy or falling back to the CLI that auto-rejects permission asks.
|
|
60
|
+
*/
|
|
61
|
+
export function parseOpenCodeRunArgs(rawArgs = []) {
|
|
62
|
+
const args = [...rawArgs];
|
|
63
|
+
if (args[0] === "run") args.shift();
|
|
64
|
+
const parsed = {
|
|
65
|
+
auto: false,
|
|
66
|
+
serverArgs: [],
|
|
67
|
+
model: null,
|
|
68
|
+
agent: null,
|
|
69
|
+
variant: null,
|
|
70
|
+
title: null,
|
|
71
|
+
directory: null,
|
|
72
|
+
format: "default",
|
|
73
|
+
sessionId: null,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < args.length; ) {
|
|
77
|
+
const arg = args[i];
|
|
78
|
+
if (arg === "--auto") {
|
|
79
|
+
parsed.auto = true;
|
|
80
|
+
i++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (arg === "--pure" || arg === "--print-logs") {
|
|
84
|
+
parsed.serverArgs.push(arg);
|
|
85
|
+
i++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const logLevel = optionValue(args, i, "--log-level", "");
|
|
89
|
+
if (logLevel) {
|
|
90
|
+
parsed.serverArgs.push("--log-level", logLevel.value);
|
|
91
|
+
i += logLevel.consumed;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const model = optionValue(args, i, "--model", "-m");
|
|
95
|
+
if (model) {
|
|
96
|
+
parsed.model = model.value;
|
|
97
|
+
i += model.consumed;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const agent = optionValue(args, i, "--agent", "");
|
|
101
|
+
if (agent) {
|
|
102
|
+
parsed.agent = agent.value;
|
|
103
|
+
i += agent.consumed;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const variant = optionValue(args, i, "--variant", "");
|
|
107
|
+
if (variant) {
|
|
108
|
+
parsed.variant = variant.value;
|
|
109
|
+
i += variant.consumed;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const title = optionValue(args, i, "--title", "");
|
|
113
|
+
if (title) {
|
|
114
|
+
parsed.title = title.value;
|
|
115
|
+
i += title.consumed;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const directory = optionValue(args, i, "--dir", "");
|
|
119
|
+
if (directory) {
|
|
120
|
+
parsed.directory = directory.value;
|
|
121
|
+
i += directory.consumed;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const session = optionValue(args, i, "--session", "-s");
|
|
125
|
+
if (session) {
|
|
126
|
+
if (!OPENCODE_SESSION_RE.test(session.value)) {
|
|
127
|
+
throw new Error("OpenCode --session must use a ses_ identifier");
|
|
128
|
+
}
|
|
129
|
+
parsed.sessionId = session.value;
|
|
130
|
+
i += session.consumed;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const format = optionValue(args, i, "--format", "");
|
|
134
|
+
if (format) {
|
|
135
|
+
if (format.value !== "default" && format.value !== "json") {
|
|
136
|
+
throw new Error("OpenCode --format must be default or json");
|
|
137
|
+
}
|
|
138
|
+
parsed.format = format.value;
|
|
139
|
+
i += format.consumed;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (arg === "--format=default" || arg === "--format=json") {
|
|
143
|
+
parsed.format = arg.slice("--format=".length);
|
|
144
|
+
i++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
// These affect display only; hilos already consumes the event stream.
|
|
148
|
+
if (arg === "--thinking" || arg === "--share") {
|
|
149
|
+
i++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
throw new Error(
|
|
153
|
+
`OpenCode runtime permissions do not support ${arg || "an empty run argument"} yet`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return parsed;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function openCodeModel(value) {
|
|
160
|
+
if (!value) return null;
|
|
161
|
+
const slash = value.indexOf("/");
|
|
162
|
+
if (slash <= 0 || slash === value.length - 1) {
|
|
163
|
+
throw new Error("OpenCode --model must use provider/model");
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
providerID: value.slice(0, slash),
|
|
167
|
+
modelID: value.slice(slash + 1),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function messageError(event) {
|
|
172
|
+
const error = event?.properties?.error;
|
|
173
|
+
if (!error) return "OpenCode session failed";
|
|
174
|
+
if (typeof error === "string") return error;
|
|
175
|
+
if (typeof error?.data?.message === "string") return error.data.message;
|
|
176
|
+
if (typeof error?.message === "string") return error.message;
|
|
177
|
+
if (typeof error?.name === "string") return error.name;
|
|
178
|
+
return "OpenCode session failed";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function terminateProcessGroup(child, {
|
|
182
|
+
setTimer = setTimeout,
|
|
183
|
+
clearTimer = clearTimeout,
|
|
184
|
+
kill = process.kill,
|
|
185
|
+
} = {}) {
|
|
186
|
+
if (!child || child.exitCode != null || child.signalCode != null) return Promise.resolve();
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
let settled = false;
|
|
189
|
+
let timer = null;
|
|
190
|
+
const done = () => {
|
|
191
|
+
if (settled) return;
|
|
192
|
+
settled = true;
|
|
193
|
+
if (timer != null) clearTimer(timer);
|
|
194
|
+
child.removeListener?.("exit", done);
|
|
195
|
+
resolve();
|
|
196
|
+
};
|
|
197
|
+
child.once?.("exit", done);
|
|
198
|
+
try {
|
|
199
|
+
if (child.pid) kill(-child.pid, "SIGTERM");
|
|
200
|
+
else child.kill?.("SIGTERM");
|
|
201
|
+
} catch {
|
|
202
|
+
child.kill?.("SIGTERM");
|
|
203
|
+
}
|
|
204
|
+
timer = setTimer(() => {
|
|
205
|
+
try {
|
|
206
|
+
if (child.pid) kill(-child.pid, "SIGKILL");
|
|
207
|
+
else child.kill?.("SIGKILL");
|
|
208
|
+
} catch {
|
|
209
|
+
child.kill?.("SIGKILL");
|
|
210
|
+
}
|
|
211
|
+
done();
|
|
212
|
+
}, SHUTDOWN_GRACE_MS);
|
|
213
|
+
timer?.unref?.();
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Launch a loopback-only, password-protected OpenCode server and resolve only
|
|
219
|
+
* after its exact listening line is observed.
|
|
220
|
+
* @param {any} [options]
|
|
221
|
+
* @returns {Promise<any>}
|
|
222
|
+
*/
|
|
223
|
+
export function startOpenCodeServer({
|
|
224
|
+
cmd = "opencode",
|
|
225
|
+
cwd,
|
|
226
|
+
env,
|
|
227
|
+
serverArgs = [],
|
|
228
|
+
password = randomBytes(24).toString("base64url"),
|
|
229
|
+
username = "opencode",
|
|
230
|
+
signal,
|
|
231
|
+
spawnImpl = spawn,
|
|
232
|
+
kill = process.kill,
|
|
233
|
+
setTimer = setTimeout,
|
|
234
|
+
clearTimer = clearTimeout,
|
|
235
|
+
startupTimeoutMs = STARTUP_TIMEOUT_MS,
|
|
236
|
+
} = {}) {
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
if (signal?.aborted) return reject(abortError(signal.reason));
|
|
239
|
+
let child;
|
|
240
|
+
try {
|
|
241
|
+
child = spawnImpl(
|
|
242
|
+
cmd,
|
|
243
|
+
[
|
|
244
|
+
"serve",
|
|
245
|
+
"--hostname",
|
|
246
|
+
"127.0.0.1",
|
|
247
|
+
"--port",
|
|
248
|
+
"0",
|
|
249
|
+
...serverArgs,
|
|
250
|
+
],
|
|
251
|
+
{
|
|
252
|
+
cwd,
|
|
253
|
+
detached: true,
|
|
254
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
255
|
+
// PWD follows cwd (0615): opencode resolves its project from the
|
|
256
|
+
// environment, so an inherited PWD would serve a project from the
|
|
257
|
+
// daemon's launch directory instead of the repo we're running on.
|
|
258
|
+
env: {
|
|
259
|
+
...envForCwd(env ?? {}, cwd),
|
|
260
|
+
OPENCODE_SERVER_PASSWORD: password,
|
|
261
|
+
OPENCODE_SERVER_USERNAME: username,
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
reject(error);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let settled = false;
|
|
271
|
+
let timer = null;
|
|
272
|
+
let stdout = "";
|
|
273
|
+
let stderr = "";
|
|
274
|
+
child.stdout?.setEncoding?.("utf8");
|
|
275
|
+
child.stderr?.setEncoding?.("utf8");
|
|
276
|
+
const finish = (error, value) => {
|
|
277
|
+
if (settled) return;
|
|
278
|
+
settled = true;
|
|
279
|
+
if (timer != null) clearTimer(timer);
|
|
280
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
281
|
+
child.removeListener?.("error", onError);
|
|
282
|
+
child.removeListener?.("exit", onExit);
|
|
283
|
+
if (error) {
|
|
284
|
+
void terminateProcessGroup(child, { setTimer, clearTimer, kill });
|
|
285
|
+
reject(error);
|
|
286
|
+
} else {
|
|
287
|
+
resolve(value);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
const inspect = () => {
|
|
291
|
+
const match = /opencode server listening on (http:\/\/127\.0\.0\.1:\d+)/.exec(stdout);
|
|
292
|
+
if (!match) return;
|
|
293
|
+
finish(null, {
|
|
294
|
+
child,
|
|
295
|
+
baseUrl: match[1],
|
|
296
|
+
password,
|
|
297
|
+
username,
|
|
298
|
+
stderr: () => stderr,
|
|
299
|
+
close: () => terminateProcessGroup(child, { setTimer, clearTimer, kill }),
|
|
300
|
+
});
|
|
301
|
+
};
|
|
302
|
+
const onError = (error) => finish(error);
|
|
303
|
+
const onExit = (code, exitSignal) =>
|
|
304
|
+
finish(
|
|
305
|
+
new Error(
|
|
306
|
+
`OpenCode server exited before startup (${code ?? exitSignal ?? "unknown"}): ${stderr.trim().slice(-500)}`,
|
|
307
|
+
),
|
|
308
|
+
);
|
|
309
|
+
const onAbort = () => finish(abortError(signal?.reason));
|
|
310
|
+
child.stdout?.on?.("data", (chunk) => {
|
|
311
|
+
stdout = (stdout + String(chunk)).slice(-8_192);
|
|
312
|
+
inspect();
|
|
313
|
+
});
|
|
314
|
+
child.stderr?.on?.("data", (chunk) => {
|
|
315
|
+
stderr = (stderr + String(chunk)).slice(-32_768);
|
|
316
|
+
});
|
|
317
|
+
child.once?.("error", onError);
|
|
318
|
+
child.once?.("exit", onExit);
|
|
319
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
320
|
+
timer = setTimer(
|
|
321
|
+
() => finish(new Error("OpenCode server did not become ready in time")),
|
|
322
|
+
startupTimeoutMs,
|
|
323
|
+
);
|
|
324
|
+
timer?.unref?.();
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function jsonRequest({
|
|
329
|
+
baseUrl,
|
|
330
|
+
directory,
|
|
331
|
+
pathname,
|
|
332
|
+
method = "GET",
|
|
333
|
+
body,
|
|
334
|
+
password,
|
|
335
|
+
username,
|
|
336
|
+
fetchImpl,
|
|
337
|
+
signal,
|
|
338
|
+
expected = [200],
|
|
339
|
+
}) {
|
|
340
|
+
const response = await fetchImpl(withDirectory(baseUrl, pathname, directory), {
|
|
341
|
+
method,
|
|
342
|
+
headers: {
|
|
343
|
+
authorization: basicAuthorization(username, password),
|
|
344
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
345
|
+
},
|
|
346
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
347
|
+
signal,
|
|
348
|
+
});
|
|
349
|
+
if (!expected.includes(response.status)) {
|
|
350
|
+
const detail = await response.text().catch(() => "");
|
|
351
|
+
throw new Error(
|
|
352
|
+
`OpenCode ${method} ${pathname} failed (${response.status}): ${detail.slice(0, 300)}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if (response.status === 204) return null;
|
|
356
|
+
return response.json();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Execute one OpenCode prompt while hilos owns every permission response.
|
|
361
|
+
* @param {any} [options]
|
|
362
|
+
* @returns {Promise<{
|
|
363
|
+
* status: number | null,
|
|
364
|
+
* stdout: string,
|
|
365
|
+
* stderr: string,
|
|
366
|
+
* error: Error | null,
|
|
367
|
+
* sessionId?: string,
|
|
368
|
+
* aborted?: boolean,
|
|
369
|
+
* }>}
|
|
370
|
+
*/
|
|
371
|
+
export async function runOpenCodeHttpSession({
|
|
372
|
+
cmd = "opencode",
|
|
373
|
+
args = ["run"],
|
|
374
|
+
cwd,
|
|
375
|
+
prompt,
|
|
376
|
+
env,
|
|
377
|
+
timeoutMs = 30 * 60_000,
|
|
378
|
+
signal,
|
|
379
|
+
onData,
|
|
380
|
+
requestPermission,
|
|
381
|
+
getPermissionDecision,
|
|
382
|
+
fetchImpl = globalThis.fetch,
|
|
383
|
+
spawnImpl = spawn,
|
|
384
|
+
kill = process.kill,
|
|
385
|
+
randomPassword = () => randomBytes(24).toString("base64url"),
|
|
386
|
+
realpathImpl = realpathSync,
|
|
387
|
+
setTimer = setTimeout,
|
|
388
|
+
clearTimer = clearTimeout,
|
|
389
|
+
log = console,
|
|
390
|
+
} = {}) {
|
|
391
|
+
if (!cwd) {
|
|
392
|
+
return { status: null, stdout: "", stderr: "", error: new Error("OpenCode requires cwd") };
|
|
393
|
+
}
|
|
394
|
+
if (typeof fetchImpl !== "function") {
|
|
395
|
+
return { status: null, stdout: "", stderr: "", error: new Error("OpenCode requires fetch") };
|
|
396
|
+
}
|
|
397
|
+
let parsed;
|
|
398
|
+
let canonicalCwd;
|
|
399
|
+
try {
|
|
400
|
+
parsed = parseOpenCodeRunArgs(args);
|
|
401
|
+
if (parsed.auto) {
|
|
402
|
+
throw new Error("The HTTP permission bridge must not run with --auto");
|
|
403
|
+
}
|
|
404
|
+
canonicalCwd = realpathImpl(cwd);
|
|
405
|
+
if (
|
|
406
|
+
parsed.directory != null &&
|
|
407
|
+
realpathImpl(parsed.directory) !== canonicalCwd
|
|
408
|
+
) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
"OpenCode --dir must match the runtime permission checkout",
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
} catch (error) {
|
|
414
|
+
return {
|
|
415
|
+
status: null,
|
|
416
|
+
stdout: "",
|
|
417
|
+
stderr: "",
|
|
418
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const controller = new AbortController();
|
|
423
|
+
let abortKind = null;
|
|
424
|
+
const abort = (kind, reason) => {
|
|
425
|
+
if (controller.signal.aborted) return;
|
|
426
|
+
abortKind = kind;
|
|
427
|
+
controller.abort(reason);
|
|
428
|
+
};
|
|
429
|
+
const onParentAbort = () => abort("cancelled", signal?.reason ?? "cancelled");
|
|
430
|
+
if (signal?.aborted) onParentAbort();
|
|
431
|
+
else signal?.addEventListener?.("abort", onParentAbort, { once: true });
|
|
432
|
+
const timeout =
|
|
433
|
+
timeoutMs > 0
|
|
434
|
+
? setTimer(() => abort("timeout", `OpenCode timed out after ${timeoutMs}ms`), timeoutMs)
|
|
435
|
+
: null;
|
|
436
|
+
timeout?.unref?.();
|
|
437
|
+
|
|
438
|
+
let server = null;
|
|
439
|
+
let relay = null;
|
|
440
|
+
let reader = null;
|
|
441
|
+
let sessionId = null;
|
|
442
|
+
let stdout = "";
|
|
443
|
+
let stderr = "";
|
|
444
|
+
let streamTask = null;
|
|
445
|
+
const completedTextParts = new Set();
|
|
446
|
+
let sawStopPart = false;
|
|
447
|
+
/** @type {(outcome: "idle" | "error") => void} */
|
|
448
|
+
let resolveDone = () => {};
|
|
449
|
+
const donePromise = new Promise((resolve) => {
|
|
450
|
+
resolveDone = resolve;
|
|
451
|
+
});
|
|
452
|
+
let promptSent = false;
|
|
453
|
+
const emitOutput = (value) => {
|
|
454
|
+
try {
|
|
455
|
+
onData?.(
|
|
456
|
+
parsed.format === "json"
|
|
457
|
+
? `${JSON.stringify(value)}\n`
|
|
458
|
+
: `${String(value ?? "")}\n`,
|
|
459
|
+
);
|
|
460
|
+
} catch {
|
|
461
|
+
// Output observers never own session correctness.
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
server = await startOpenCodeServer({
|
|
467
|
+
cmd,
|
|
468
|
+
cwd,
|
|
469
|
+
env,
|
|
470
|
+
serverArgs: parsed.serverArgs,
|
|
471
|
+
password: randomPassword(),
|
|
472
|
+
signal: controller.signal,
|
|
473
|
+
spawnImpl,
|
|
474
|
+
kill,
|
|
475
|
+
setTimer,
|
|
476
|
+
clearTimer,
|
|
477
|
+
});
|
|
478
|
+
const auth = {
|
|
479
|
+
baseUrl: server.baseUrl,
|
|
480
|
+
directory: cwd,
|
|
481
|
+
password: server.password,
|
|
482
|
+
username: server.username,
|
|
483
|
+
fetchImpl,
|
|
484
|
+
signal: controller.signal,
|
|
485
|
+
};
|
|
486
|
+
if (parsed.sessionId) {
|
|
487
|
+
const candidateSessionId = parsed.sessionId;
|
|
488
|
+
const session = await jsonRequest({
|
|
489
|
+
...auth,
|
|
490
|
+
pathname: `/session/${encodeURIComponent(candidateSessionId)}`,
|
|
491
|
+
method: "GET",
|
|
492
|
+
expected: [200],
|
|
493
|
+
});
|
|
494
|
+
if (session?.id !== candidateSessionId) {
|
|
495
|
+
throw new Error("OpenCode resumed a different session than requested");
|
|
496
|
+
}
|
|
497
|
+
if (
|
|
498
|
+
typeof session?.directory !== "string" ||
|
|
499
|
+
realpathImpl(session.directory) !== canonicalCwd
|
|
500
|
+
) {
|
|
501
|
+
throw new Error(
|
|
502
|
+
"OpenCode refused to resume a session from another project directory",
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
sessionId = candidateSessionId;
|
|
506
|
+
} else {
|
|
507
|
+
const session = await jsonRequest({
|
|
508
|
+
...auth,
|
|
509
|
+
pathname: "/session",
|
|
510
|
+
method: "POST",
|
|
511
|
+
body: parsed.title ? { title: parsed.title } : {},
|
|
512
|
+
expected: [200],
|
|
513
|
+
});
|
|
514
|
+
const candidateSessionId = session?.id;
|
|
515
|
+
if (!candidateSessionId) {
|
|
516
|
+
throw new Error("OpenCode created a session without an id");
|
|
517
|
+
}
|
|
518
|
+
if (
|
|
519
|
+
typeof session?.directory !== "string" ||
|
|
520
|
+
realpathImpl(session.directory) !== canonicalCwd
|
|
521
|
+
) {
|
|
522
|
+
throw new Error(
|
|
523
|
+
"OpenCode created the session in another project directory",
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
sessionId = candidateSessionId;
|
|
527
|
+
}
|
|
528
|
+
if (parsed.format === "json") {
|
|
529
|
+
emitOutput({
|
|
530
|
+
type: "step_start",
|
|
531
|
+
sessionID: sessionId,
|
|
532
|
+
part: {
|
|
533
|
+
id: `hilos_${sessionId}`,
|
|
534
|
+
sessionID: sessionId,
|
|
535
|
+
type: "step-start",
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
relay = createOpenCodePermissionRelay({
|
|
541
|
+
baseUrl: server.baseUrl,
|
|
542
|
+
directory: cwd,
|
|
543
|
+
password: server.password,
|
|
544
|
+
username: server.username,
|
|
545
|
+
requestPermission,
|
|
546
|
+
getPermissionDecision,
|
|
547
|
+
fetchImpl,
|
|
548
|
+
timeoutMs: Math.max(1, timeoutMs || 30 * 60_000),
|
|
549
|
+
signal: controller.signal,
|
|
550
|
+
onError: (error, context) => {
|
|
551
|
+
log?.error?.(`opencode permission ${context?.phase ?? "relay"}: ${error?.message ?? error}`);
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
const eventResponse = await fetchImpl(withDirectory(server.baseUrl, "/event", cwd), {
|
|
556
|
+
method: "GET",
|
|
557
|
+
headers: {
|
|
558
|
+
accept: "text/event-stream",
|
|
559
|
+
authorization: basicAuthorization(server.username, server.password),
|
|
560
|
+
},
|
|
561
|
+
signal: controller.signal,
|
|
562
|
+
});
|
|
563
|
+
if (!eventResponse.ok || !eventResponse.body) {
|
|
564
|
+
throw new Error(`OpenCode event subscription failed (${eventResponse.status})`);
|
|
565
|
+
}
|
|
566
|
+
reader = eventResponse.body.getReader();
|
|
567
|
+
const outputParser = createSseParser();
|
|
568
|
+
streamTask = (async () => {
|
|
569
|
+
for (;;) {
|
|
570
|
+
const { done: ended, value } = await reader.read();
|
|
571
|
+
if (ended) throw new Error("OpenCode event stream ended before the session became idle");
|
|
572
|
+
relay.push(value);
|
|
573
|
+
for (const message of outputParser.push(value)) {
|
|
574
|
+
let event;
|
|
575
|
+
try {
|
|
576
|
+
event = JSON.parse(message.data);
|
|
577
|
+
} catch {
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
const props = event?.properties;
|
|
581
|
+
if (event?.type === "message.part.updated") {
|
|
582
|
+
const part = props?.part;
|
|
583
|
+
if (part?.sessionID !== sessionId) continue;
|
|
584
|
+
const partType =
|
|
585
|
+
part?.type === "tool"
|
|
586
|
+
? "tool_use"
|
|
587
|
+
: typeof part?.type === "string"
|
|
588
|
+
? part.type.replaceAll("-", "_")
|
|
589
|
+
: null;
|
|
590
|
+
if (part?.type === "text") {
|
|
591
|
+
if (
|
|
592
|
+
!part?.time?.end ||
|
|
593
|
+
part?.synthetic ||
|
|
594
|
+
completedTextParts.has(part.id)
|
|
595
|
+
) {
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
completedTextParts.add(part.id);
|
|
599
|
+
const text = String(part.text ?? "").trim();
|
|
600
|
+
if (text) {
|
|
601
|
+
stdout += `${text}\n`;
|
|
602
|
+
if (parsed.format === "json") {
|
|
603
|
+
emitOutput({
|
|
604
|
+
type: "text",
|
|
605
|
+
sessionID: sessionId,
|
|
606
|
+
part,
|
|
607
|
+
});
|
|
608
|
+
} else {
|
|
609
|
+
emitOutput(text);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
} else if (
|
|
613
|
+
parsed.format === "json" &&
|
|
614
|
+
["tool_use", "step_start", "step_finish"].includes(partType)
|
|
615
|
+
) {
|
|
616
|
+
if (partType === "step_finish" && part?.reason === "stop") {
|
|
617
|
+
sawStopPart = true;
|
|
618
|
+
}
|
|
619
|
+
emitOutput({
|
|
620
|
+
type: partType,
|
|
621
|
+
sessionID: sessionId,
|
|
622
|
+
part,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
} else if (event?.type === "session.error" && props?.sessionID === sessionId) {
|
|
626
|
+
const errorMessage = messageError(event);
|
|
627
|
+
stderr += `${errorMessage}\n`;
|
|
628
|
+
if (parsed.format === "json") {
|
|
629
|
+
emitOutput({
|
|
630
|
+
type: "error",
|
|
631
|
+
sessionID: sessionId,
|
|
632
|
+
error: {
|
|
633
|
+
name: "SessionError",
|
|
634
|
+
data: { message: errorMessage },
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
if (promptSent) resolveDone("error");
|
|
639
|
+
} else if (
|
|
640
|
+
event?.type === "session.status" &&
|
|
641
|
+
props?.sessionID === sessionId &&
|
|
642
|
+
props?.status?.type === "idle" &&
|
|
643
|
+
promptSent
|
|
644
|
+
) {
|
|
645
|
+
if (parsed.format === "json" && !sawStopPart) {
|
|
646
|
+
sawStopPart = true;
|
|
647
|
+
emitOutput({
|
|
648
|
+
type: "step_finish",
|
|
649
|
+
sessionID: sessionId,
|
|
650
|
+
part: {
|
|
651
|
+
id: `hilos_stop_${sessionId}`,
|
|
652
|
+
sessionID: sessionId,
|
|
653
|
+
type: "step-finish",
|
|
654
|
+
reason: "stop",
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
resolveDone("idle");
|
|
659
|
+
} else if (
|
|
660
|
+
event?.type === "session.idle" &&
|
|
661
|
+
props?.sessionID === sessionId &&
|
|
662
|
+
promptSent
|
|
663
|
+
) {
|
|
664
|
+
if (parsed.format === "json" && !sawStopPart) {
|
|
665
|
+
sawStopPart = true;
|
|
666
|
+
emitOutput({
|
|
667
|
+
type: "step_finish",
|
|
668
|
+
sessionID: sessionId,
|
|
669
|
+
part: {
|
|
670
|
+
id: `hilos_stop_${sessionId}`,
|
|
671
|
+
sessionID: sessionId,
|
|
672
|
+
type: "step-finish",
|
|
673
|
+
reason: "stop",
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
resolveDone("idle");
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
})().catch((error) => {
|
|
682
|
+
if (!controller.signal.aborted) {
|
|
683
|
+
stderr += `${error?.message ?? error}\n`;
|
|
684
|
+
resolveDone("error");
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
const model = openCodeModel(parsed.model);
|
|
689
|
+
promptSent = true;
|
|
690
|
+
await jsonRequest({
|
|
691
|
+
...auth,
|
|
692
|
+
pathname: `/session/${encodeURIComponent(sessionId)}/prompt_async`,
|
|
693
|
+
method: "POST",
|
|
694
|
+
body: {
|
|
695
|
+
parts: [{ type: "text", text: String(prompt ?? "") }],
|
|
696
|
+
...(model ? { model } : {}),
|
|
697
|
+
...(parsed.agent ? { agent: parsed.agent } : {}),
|
|
698
|
+
...(parsed.variant ? { variant: parsed.variant } : {}),
|
|
699
|
+
},
|
|
700
|
+
expected: [204],
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const outcome = await Promise.race([
|
|
704
|
+
donePromise,
|
|
705
|
+
new Promise((_, reject) => {
|
|
706
|
+
if (controller.signal.aborted) reject(abortError(controller.signal.reason));
|
|
707
|
+
else {
|
|
708
|
+
controller.signal.addEventListener(
|
|
709
|
+
"abort",
|
|
710
|
+
() => reject(abortError(controller.signal.reason)),
|
|
711
|
+
{ once: true },
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
}),
|
|
715
|
+
]);
|
|
716
|
+
if (outcome === "error") {
|
|
717
|
+
// A terminal session/stream failure cannot wait behind a human decision:
|
|
718
|
+
// stop the run first, then close the relay so every pending ask receives a
|
|
719
|
+
// bounded reject and its durable hilos card settles fail closed.
|
|
720
|
+
abort("session-error", stderr.trim() || "OpenCode session failed");
|
|
721
|
+
await relay.close("session-error");
|
|
722
|
+
} else {
|
|
723
|
+
await relay.idle();
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
status: outcome === "idle" && !stderr.trim() ? 0 : 1,
|
|
727
|
+
stdout: stdout.trimEnd(),
|
|
728
|
+
stderr: stderr.trimEnd(),
|
|
729
|
+
error: outcome === "error" ? new Error(stderr.trim() || "OpenCode session failed") : null,
|
|
730
|
+
sessionId,
|
|
731
|
+
};
|
|
732
|
+
} catch (error) {
|
|
733
|
+
const aborted = abortKind === "cancelled";
|
|
734
|
+
const timedOut = abortKind === "timeout";
|
|
735
|
+
return {
|
|
736
|
+
status: null,
|
|
737
|
+
stdout: stdout.trimEnd(),
|
|
738
|
+
stderr: stderr.trimEnd(),
|
|
739
|
+
...(aborted ? { aborted: true } : {}),
|
|
740
|
+
...(sessionId ? { sessionId } : {}),
|
|
741
|
+
error:
|
|
742
|
+
error instanceof Error
|
|
743
|
+
? error
|
|
744
|
+
: new Error(timedOut ? "OpenCode timed out" : String(error)),
|
|
745
|
+
};
|
|
746
|
+
} finally {
|
|
747
|
+
if (timeout != null) clearTimer(timeout);
|
|
748
|
+
signal?.removeEventListener?.("abort", onParentAbort);
|
|
749
|
+
if (sessionId && server && controller.signal.aborted) {
|
|
750
|
+
await jsonRequest({
|
|
751
|
+
baseUrl: server.baseUrl,
|
|
752
|
+
directory: cwd,
|
|
753
|
+
pathname: `/session/${encodeURIComponent(sessionId)}/abort`,
|
|
754
|
+
method: "POST",
|
|
755
|
+
password: server.password,
|
|
756
|
+
username: server.username,
|
|
757
|
+
fetchImpl,
|
|
758
|
+
signal: undefined,
|
|
759
|
+
expected: [200],
|
|
760
|
+
}).catch(() => {});
|
|
761
|
+
}
|
|
762
|
+
// Closing the relay fails any unresolved permission ask closed, then the
|
|
763
|
+
// stream and local server are torn down. No child survives this function.
|
|
764
|
+
await relay?.close("session-finished").catch(() => {});
|
|
765
|
+
controller.abort("session-finished");
|
|
766
|
+
await reader?.cancel("session-finished").catch(() => {});
|
|
767
|
+
await streamTask?.catch(() => {});
|
|
768
|
+
await server?.close().catch(() => {});
|
|
769
|
+
}
|
|
770
|
+
}
|