hilos-agent 0.7.0 → 0.9.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.
@@ -0,0 +1,553 @@
1
+ // ACP (Agent Client Protocol) session runner (0759).
2
+ //
3
+ // Drives an agent subprocess over ACP — JSON-RPC 2.0, newline-delimited, on
4
+ // stdio — instead of argv + stdout scraping. The 0596 spike proved the loop
5
+ // live against `opencode acp`: permissions arrive as blocking JSON-RPC
6
+ // requests with allow-once/always/reject options, and streaming arrives as
7
+ // structured session/update notifications.
8
+ //
9
+ // The permission callback contract is EXACTLY the HTTP bridge's
10
+ // (requestPermission / getPermissionDecision, see opencode-permissions.mjs),
11
+ // so neither transport can become the ungated exception; the decision
12
+ // vocabulary is shared via mapOpenCodePermissionDecision. Every failure mode
13
+ // answers the agent with a rejection — fail closed, never fail open.
14
+ //
15
+ // The public runner returns the same small shape as runCli /
16
+ // runOpenCodeHttpSession so handler.mjs keeps one integration seam:
17
+ // { status, stdout, stderr, error?, sessionId?, aborted? }
18
+
19
+ import { spawn } from "node:child_process";
20
+ import { createAcpEventMapper } from "./agent-events.mjs";
21
+ import { resolveHilosPermissionReply } from "./permission-gate.mjs";
22
+
23
+ const DEFAULT_TIMEOUT_MS = 30 * 60_000;
24
+ const DEFAULT_POLL_MS = 1_000;
25
+ const SHUTDOWN_GRACE_MS = 750;
26
+ const PROTOCOL_VERSION = 1;
27
+ const MAX_LINE_BYTES = 4 * 1024 * 1024;
28
+
29
+ function isObject(value) {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
31
+ }
32
+
33
+ function abortError(reason = "cancelled") {
34
+ const error = new Error(String(reason || "cancelled"));
35
+ error.name = "AbortError";
36
+ return error;
37
+ }
38
+
39
+ function raceWithAbort(promise, signal) {
40
+ if (!signal) return promise;
41
+ if (signal.aborted) return Promise.reject(abortError(signal.reason));
42
+ return new Promise((resolve, reject) => {
43
+ const onAbort = () => reject(abortError(signal.reason));
44
+ signal.addEventListener("abort", onAbort, { once: true });
45
+ Promise.resolve(promise).then(
46
+ (value) => {
47
+ signal.removeEventListener("abort", onAbort);
48
+ resolve(value);
49
+ },
50
+ (error) => {
51
+ signal.removeEventListener("abort", onAbort);
52
+ reject(error);
53
+ },
54
+ );
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Choose the agent's option id for a hilos reply ("once"|"always"|"reject").
60
+ * Matches on the ACP option `kind` first (allow_once / allow_always /
61
+ * reject_once / reject_always), then falls back to id/name heuristics. A
62
+ * reject reply with no recognizable option returns null — the caller answers
63
+ * with a protocol-level cancel, which the agent must treat as not-allowed.
64
+ */
65
+ export function pickAcpPermissionOption(options, reply) {
66
+ const list = Array.isArray(options) ? options.filter(isObject) : [];
67
+ const byKind = (kind) => list.find((o) => o.kind === kind);
68
+ const byPattern = (re) =>
69
+ list.find((o) => re.test(`${o.optionId ?? ""} ${o.name ?? ""}`));
70
+ if (reply === "always") {
71
+ // The name fallback must never land on "Reject always" — a human's
72
+ // allow-always answered with a rejection would invert the decision.
73
+ return (
74
+ byKind("allow_always") ??
75
+ list.find(
76
+ (o) =>
77
+ !String(o.kind ?? "").startsWith("reject") &&
78
+ /always/i.test(`${o.optionId ?? ""} ${o.name ?? ""}`) &&
79
+ !/reject|deny|\bno\b/i.test(`${o.optionId ?? ""} ${o.name ?? ""}`),
80
+ ) ??
81
+ null
82
+ );
83
+ }
84
+ if (reply === "once") {
85
+ // Never widen a single-use approval into a persistent one: an allow_once
86
+ // option is required; allow_always is NOT an acceptable stand-in.
87
+ return (
88
+ byKind("allow_once") ??
89
+ list.find(
90
+ (o) =>
91
+ o.kind !== "allow_always" &&
92
+ /\bonce\b|allow/i.test(`${o.optionId ?? ""} ${o.name ?? ""}`) &&
93
+ !/always/i.test(`${o.optionId ?? ""} ${o.name ?? ""}`),
94
+ ) ??
95
+ null
96
+ );
97
+ }
98
+ return (
99
+ byKind("reject_once") ??
100
+ byKind("reject_always") ??
101
+ byPattern(/reject|deny|no\b/i) ??
102
+ null
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Shape an ACP session/request_permission into the vendor-neutral request the
108
+ * hilos permission callbacks expect (the same fields the SSE relay produces).
109
+ */
110
+ export function normalizeAcpPermissionRequest(params, fallbackId, vendor = "opencode") {
111
+ const toolCall = isObject(params?.toolCall) ? params.toolCall : {};
112
+ const rawInput = isObject(toolCall.rawInput) ? toolCall.rawInput : {};
113
+ const locations = Array.isArray(toolCall.locations)
114
+ ? toolCall.locations
115
+ .map((l) => (isObject(l) && typeof l.path === "string" ? l.path : null))
116
+ .filter(Boolean)
117
+ : [];
118
+ const command = typeof rawInput.command === "string" ? rawInput.command : null;
119
+ const resources = locations.length ? locations : command ? [command] : [];
120
+ const vendorRequestId =
121
+ typeof toolCall.toolCallId === "string" && toolCall.toolCallId
122
+ ? toolCall.toolCallId
123
+ : `acp_${fallbackId}`;
124
+ return {
125
+ vendor,
126
+ vendorRequestId,
127
+ sessionId: typeof params?.sessionId === "string" ? params.sessionId : "",
128
+ action: typeof toolCall.kind === "string" && toolCall.kind ? toolCall.kind : "tool",
129
+ resources,
130
+ suggestedSave: undefined,
131
+ metadata: {
132
+ ...(typeof toolCall.title === "string" && toolCall.title
133
+ ? { title: toolCall.title }
134
+ : {}),
135
+ ...(command ? { command } : {}),
136
+ transport: "acp",
137
+ },
138
+ source: { type: "tool", ...(typeof toolCall.title === "string" ? { name: toolCall.title } : {}) },
139
+ };
140
+ }
141
+
142
+ /** Split a stdout stream into newline-delimited JSON-RPC messages. */
143
+ export function createNdjsonParser() {
144
+ let buffer = "";
145
+ return {
146
+ push(chunk) {
147
+ buffer += String(chunk);
148
+ if (buffer.length > MAX_LINE_BYTES) {
149
+ // A frame this large is not a protocol message; drop it rather than
150
+ // letting a runaway agent grow the daemon's heap without bound.
151
+ buffer = "";
152
+ return [];
153
+ }
154
+ const messages = [];
155
+ let idx;
156
+ while ((idx = buffer.indexOf("\n")) >= 0) {
157
+ const line = buffer.slice(0, idx).trim();
158
+ buffer = buffer.slice(idx + 1);
159
+ if (!line) continue;
160
+ try {
161
+ const parsed = JSON.parse(line);
162
+ if (isObject(parsed)) messages.push(parsed);
163
+ } catch {
164
+ // Non-JSON stdout noise (banners, stray logs) is not a frame.
165
+ }
166
+ }
167
+ return messages;
168
+ },
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Run one prompt against an ACP agent subprocess.
174
+ *
175
+ * Slice 1 (0759) intentionally mirrors the HTTP runner's text mode: stdout
176
+ * collects the agent's completed message text, tool activity stays off the
177
+ * transcript, and the session id is returned for the caller's records.
178
+ *
179
+ * @param {{
180
+ * cmd?: string,
181
+ * acpArgs?: string[],
182
+ * vendor?: string,
183
+ * cwd?: string,
184
+ * prompt?: string,
185
+ * env?: Record<string, string | undefined>,
186
+ * timeoutMs?: number,
187
+ * pollIntervalMs?: number,
188
+ * signal?: AbortSignal,
189
+ * onData?: (chunk: string) => void,
190
+ * onEvent?: (event: object) => void,
191
+ * requestPermission?: (request: object, context: object) => Promise<unknown>,
192
+ * getPermissionDecision?: (handle: unknown, context: object) => Promise<unknown>,
193
+ * mcpServers?: object[],
194
+ * spawnImpl?: (cmd: string, args: string[], options: object) => import("node:child_process").ChildProcess,
195
+ * setTimer?: typeof setTimeout,
196
+ * clearTimer?: typeof clearTimeout,
197
+ * sleep?: (ms: number) => Promise<void>,
198
+ * now?: () => number,
199
+ * log?: { error?: (message: string) => void },
200
+ * }} [options]
201
+ * @returns {Promise<{ status: number | null, stdout: string, stderr: string, error?: Error | null, sessionId?: string | null, aborted?: boolean }>}
202
+ */
203
+ export async function runAcpSession({
204
+ cmd = "opencode",
205
+ acpArgs = ["acp"],
206
+ vendor = "opencode",
207
+ cwd,
208
+ prompt,
209
+ env,
210
+ timeoutMs = DEFAULT_TIMEOUT_MS,
211
+ pollIntervalMs = DEFAULT_POLL_MS,
212
+ signal,
213
+ onData,
214
+ onEvent,
215
+ requestPermission,
216
+ getPermissionDecision,
217
+ /** Session to continue (0778); null starts a fresh one. */
218
+ resumeSessionId = null,
219
+ mcpServers = [],
220
+ spawnImpl = spawn,
221
+ setTimer = setTimeout,
222
+ clearTimer = clearTimeout,
223
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
224
+ now = () => Date.now(),
225
+ log = console,
226
+ } = {}) {
227
+ if (!cwd) {
228
+ return { status: null, stdout: "", stderr: "", error: new Error("ACP requires cwd") };
229
+ }
230
+ if (typeof requestPermission !== "function" || typeof getPermissionDecision !== "function") {
231
+ // Without the hilos gate there is no one to answer asks; refuse to start
232
+ // rather than run a session whose permissions would dead-end.
233
+ return {
234
+ status: null,
235
+ stdout: "",
236
+ stderr: "",
237
+ error: new Error("ACP transport requires the hilos permission callbacks"),
238
+ };
239
+ }
240
+
241
+ const controller = new AbortController();
242
+ let abortKind = null;
243
+ const abort = (kind, reason) => {
244
+ if (controller.signal.aborted) return;
245
+ abortKind = kind;
246
+ controller.abort(reason);
247
+ };
248
+ const onParentAbort = () => abort("cancelled", signal?.reason ?? "cancelled");
249
+ if (signal?.aborted) onParentAbort();
250
+ else signal?.addEventListener?.("abort", onParentAbort, { once: true });
251
+ const timeout =
252
+ timeoutMs > 0
253
+ ? setTimer(() => abort("timeout", `ACP session timed out after ${timeoutMs}ms`), timeoutMs)
254
+ : null;
255
+ timeout?.unref?.();
256
+
257
+ let child = null;
258
+ let sessionId = null;
259
+ let stdout = "";
260
+ let stderr = "";
261
+ let nextId = 0;
262
+ const pending = new Map();
263
+ const permissionTasks = new Set();
264
+ let currentMessageId = null;
265
+ let messageBuffer = "";
266
+ const eventMapper = createAcpEventMapper();
267
+
268
+ const emitOutput = (text) => {
269
+ try {
270
+ onData?.(`${text}\n`);
271
+ } catch {
272
+ // Output observers never own session correctness.
273
+ }
274
+ };
275
+ const flushMessage = () => {
276
+ const text = messageBuffer.trim();
277
+ messageBuffer = "";
278
+ currentMessageId = null;
279
+ if (!text) return;
280
+ stdout += `${text}\n`;
281
+ emitOutput(text);
282
+ };
283
+
284
+ const writeFrame = (frame) => {
285
+ if (!child || child.stdin.destroyed) return false;
286
+ try {
287
+ child.stdin.write(`${JSON.stringify(frame)}\n`);
288
+ return true;
289
+ } catch {
290
+ return false;
291
+ }
292
+ };
293
+ const rpc = (method, params) => {
294
+ const id = ++nextId;
295
+ return new Promise((resolve, reject) => {
296
+ pending.set(id, { resolve, reject, method });
297
+ if (!writeFrame({ jsonrpc: "2.0", id, method, params })) {
298
+ pending.delete(id);
299
+ reject(new Error(`ACP agent is not accepting frames (${method})`));
300
+ }
301
+ });
302
+ };
303
+ const respond = (id, result, error) => {
304
+ writeFrame(
305
+ error
306
+ ? { jsonrpc: "2.0", id, error }
307
+ : { jsonrpc: "2.0", id, result },
308
+ );
309
+ };
310
+ const failPending = (reason) => {
311
+ for (const [, entry] of pending) entry.reject(new Error(reason));
312
+ pending.clear();
313
+ };
314
+
315
+ async function settlePermission(msg) {
316
+ const request = normalizeAcpPermissionRequest(msg.params, msg.id, vendor);
317
+ const receivedAt = now();
318
+ const deadlineAt = receivedAt + Math.max(1, timeoutMs || DEFAULT_TIMEOUT_MS);
319
+ // The shared gate (0777): the identical raise → poll → fail-closed loop
320
+ // claude_code and codex now run, so no transport can drift into being the
321
+ // lenient one.
322
+ const { reply } = await resolveHilosPermissionReply({
323
+ request,
324
+ requestPermission,
325
+ getPermissionDecision,
326
+ signal: controller.signal,
327
+ deadlineAt,
328
+ pollIntervalMs,
329
+ sleep,
330
+ now,
331
+ log,
332
+ label: "acp permission",
333
+ });
334
+ const option = pickAcpPermissionOption(msg.params?.options, reply);
335
+ if (option && (reply !== "reject" || option.kind?.startsWith("reject"))) {
336
+ respond(msg.id, { outcome: { outcome: "selected", optionId: option.optionId } });
337
+ } else if (reply === "reject") {
338
+ // No recognizable reject option: cancel the ask at the protocol level.
339
+ respond(msg.id, { outcome: { outcome: "cancelled" } });
340
+ } else {
341
+ // An approval we cannot express in the agent's options must not become
342
+ // an implicit rejection card-side; the wire still gets a cancel.
343
+ log?.error?.("acp permission: no matching option for reply, cancelling");
344
+ respond(msg.id, { outcome: { outcome: "cancelled" } });
345
+ }
346
+ }
347
+
348
+ function handleUpdate(update) {
349
+ if (!isObject(update)) return;
350
+ const kind = update.sessionUpdate;
351
+ if (kind === "agent_message_chunk") {
352
+ const messageId = typeof update.messageId === "string" ? update.messageId : null;
353
+ if (currentMessageId !== null && messageId !== currentMessageId) flushMessage();
354
+ currentMessageId = messageId;
355
+ const text =
356
+ isObject(update.content) && typeof update.content.text === "string"
357
+ ? update.content.text
358
+ : "";
359
+ messageBuffer += text;
360
+ return;
361
+ }
362
+ // Tool calls narrate the activity feed (0759 slice 3): the mapper's
363
+ // emit-once discipline turns the raw update stream into AgentEvents.
364
+ if (onEvent && (kind === "tool_call" || kind === "tool_call_update")) {
365
+ try {
366
+ const event = eventMapper.push(update);
367
+ if (event) onEvent(event);
368
+ } catch {
369
+ // Narration must never break the run.
370
+ }
371
+ return;
372
+ }
373
+ // Thoughts, usage, command lists: structurally received, not activity.
374
+ }
375
+
376
+ function handleMessage(msg) {
377
+ if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
378
+ const entry = pending.get(msg.id);
379
+ if (!entry) return;
380
+ pending.delete(msg.id);
381
+ if (msg.error) {
382
+ entry.reject(
383
+ new Error(
384
+ `ACP ${entry.method} failed: ${msg.error.message ?? JSON.stringify(msg.error)}`,
385
+ ),
386
+ );
387
+ } else {
388
+ entry.resolve(msg.result);
389
+ }
390
+ return;
391
+ }
392
+ if (msg.method === "session/update") {
393
+ if (isObject(msg.params) && msg.params.sessionId === sessionId) {
394
+ handleUpdate(msg.params.update);
395
+ }
396
+ return;
397
+ }
398
+ if (msg.method === "session/request_permission" && msg.id !== undefined) {
399
+ const task = settlePermission(msg).finally(() => permissionTasks.delete(task));
400
+ permissionTasks.add(task);
401
+ return;
402
+ }
403
+ if (msg.id !== undefined && msg.method) {
404
+ // fs/terminal requests should never arrive (capabilities declared off);
405
+ // refuse anything unexpected instead of guessing.
406
+ respond(msg.id, undefined, {
407
+ code: -32601,
408
+ message: `hilos does not implement ${msg.method}`,
409
+ });
410
+ }
411
+ }
412
+
413
+ try {
414
+ child = spawnImpl(cmd, acpArgs, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
415
+ const spawned = new Promise((resolve, reject) => {
416
+ child.once("spawn", resolve);
417
+ child.once("error", reject);
418
+ });
419
+ child.once("exit", (code) => {
420
+ failPending(`ACP agent exited (${code ?? "signal"}) before replying`);
421
+ });
422
+ const parser = createNdjsonParser();
423
+ child.stdout.on("data", (chunk) => {
424
+ for (const msg of parser.push(chunk)) {
425
+ try {
426
+ handleMessage(msg);
427
+ } catch (error) {
428
+ log?.error?.(`acp frame handling: ${error?.message ?? error}`);
429
+ }
430
+ }
431
+ });
432
+ child.stderr.on("data", (chunk) => {
433
+ stderr += String(chunk);
434
+ if (stderr.length > MAX_LINE_BYTES) stderr = stderr.slice(-MAX_LINE_BYTES);
435
+ });
436
+ await raceWithAbort(spawned, controller.signal);
437
+
438
+ const init = await raceWithAbort(
439
+ rpc("initialize", {
440
+ protocolVersion: PROTOCOL_VERSION,
441
+ clientCapabilities: {
442
+ fs: { readTextFile: false, writeTextFile: false },
443
+ terminal: false,
444
+ },
445
+ }),
446
+ controller.signal,
447
+ );
448
+ if (isObject(init) && init.protocolVersion !== undefined && init.protocolVersion !== PROTOCOL_VERSION) {
449
+ throw new Error(`ACP agent speaks protocol ${init.protocolVersion}, expected ${PROTOCOL_VERSION}`);
450
+ }
451
+
452
+ // Resume over ACP (0778). The 0759 slice-1 exclusion — ACP runs could never
453
+ // be resumed runs — was deferred work, not an incompatibility: BOTH live
454
+ // vendors advertise `loadSession: true` in this very handshake (verified
455
+ // against opencode 1.18.5 and cursor-agent). So a resumed run now loads its
456
+ // session and keeps raising cards, instead of trading approvals for
457
+ // continuity. A load that fails (an id from another machine, vendor, or
458
+ // transport) falls back to a fresh session — never-worse, the same rule the
459
+ // argv `--resume` retry follows — and says so.
460
+ const canLoad = isObject(init) && init.agentCapabilities?.loadSession === true;
461
+ if (resumeSessionId && canLoad) {
462
+ try {
463
+ await raceWithAbort(
464
+ rpc("session/load", { sessionId: resumeSessionId, cwd, mcpServers }),
465
+ controller.signal,
466
+ );
467
+ sessionId = resumeSessionId;
468
+ } catch (error) {
469
+ if (controller.signal.aborted) throw error;
470
+ log?.error?.(
471
+ `acp session/load failed for ${resumeSessionId}; starting a fresh session: ${error?.message ?? error}`,
472
+ );
473
+ }
474
+ } else if (resumeSessionId) {
475
+ log?.error?.(
476
+ `acp agent does not advertise loadSession; starting a fresh session instead of resuming ${resumeSessionId}`,
477
+ );
478
+ }
479
+ if (!sessionId) {
480
+ const session = await raceWithAbort(
481
+ rpc("session/new", { cwd, mcpServers }),
482
+ controller.signal,
483
+ );
484
+ sessionId = isObject(session) && typeof session.sessionId === "string" ? session.sessionId : null;
485
+ if (!sessionId) throw new Error("ACP agent created a session without an id");
486
+ }
487
+
488
+ const turn = await raceWithAbort(
489
+ rpc("session/prompt", {
490
+ sessionId,
491
+ prompt: [{ type: "text", text: String(prompt ?? "") }],
492
+ }),
493
+ controller.signal,
494
+ );
495
+ flushMessage();
496
+ // Every in-flight permission has been answered or is being failed closed by
497
+ // its own error path; give those settlements a bounded chance to finish.
498
+ await Promise.allSettled([...permissionTasks]);
499
+
500
+ const stopReason = isObject(turn) && typeof turn.stopReason === "string" ? turn.stopReason : null;
501
+ const clean = stopReason === "end_turn" || stopReason == null;
502
+ return {
503
+ status: clean ? 0 : 1,
504
+ stdout: stdout.trimEnd(),
505
+ stderr: stderr.trimEnd(),
506
+ error: clean ? null : new Error(`ACP turn stopped: ${stopReason}`),
507
+ sessionId,
508
+ };
509
+ } catch (error) {
510
+ flushMessage();
511
+ const aborted = abortKind === "cancelled";
512
+ const timedOut = abortKind === "timeout";
513
+ return {
514
+ status: null,
515
+ stdout: stdout.trimEnd(),
516
+ stderr: stderr.trimEnd(),
517
+ ...(aborted ? { aborted: true } : {}),
518
+ ...(sessionId ? { sessionId } : {}),
519
+ error:
520
+ error instanceof Error && !timedOut
521
+ ? error
522
+ : new Error(timedOut ? "ACP session timed out" : String(error)),
523
+ };
524
+ } finally {
525
+ if (timeout != null) clearTimer(timeout);
526
+ signal?.removeEventListener?.("abort", onParentAbort);
527
+ if (child && child.exitCode === null && !child.killed) {
528
+ // Ask for a graceful stop first (the agent may flush state), then kill.
529
+ if (sessionId) {
530
+ writeFrame({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
531
+ }
532
+ try {
533
+ child.stdin.end();
534
+ } catch {
535
+ // Already gone.
536
+ }
537
+ const grace = setTimer(() => {
538
+ try {
539
+ child.kill("SIGKILL");
540
+ } catch {
541
+ // Already gone.
542
+ }
543
+ }, SHUTDOWN_GRACE_MS);
544
+ grace?.unref?.();
545
+ try {
546
+ child.kill("SIGTERM");
547
+ } catch {
548
+ // Already gone.
549
+ }
550
+ }
551
+ failPending("ACP session finished");
552
+ }
553
+ }