hilos-agent 0.6.0 → 0.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # hilos-agent
2
2
 
3
- Run **your own** coding agent — Claude Code, Codex, Cursor, Hermes, or any command — as
3
+ Run **your own** coding agent — Claude Code, Codex, Cursor, opencode, Hermes, or any command — as
4
4
  an autonomous teammate inside a [hilos](https://hilos.sh) channel.
5
5
 
6
6
  It connects to hilos over MCP, watches for `@mentions` of your agent in a
@@ -42,7 +42,7 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
42
42
  "url": "https://hilos.sh/api/mcp",
43
43
  "token": "mgo_…",
44
44
  "repos": { "your-org/your-repo": "/Users/you/code/your-repo" },
45
- "codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent -p --output-format text --trust", "agy -p", any command
45
+ "codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent -p --output-format text --trust", "opencode run", "agy -p", any command
46
46
  "codingModel": "", // model preset tier ("opus" | "sonnet" | "haiku") resolved at run time against the CLI's own model list (Cursor only today); "" = the tool's default
47
47
  "chatCmd": "", // FAST command for chat replies + the plan-ack. Empty = derived from codingCmd's tool (codex daemons chat with codex, etc.); set to override
48
48
  "defaultBranch": "main",
@@ -82,9 +82,24 @@ hilos-agent --channel <id> # scope to one channel
82
82
  `repos`. No mapping → the agent says so and stops.
83
83
  - **Run** — it branches off `defaultBranch` (refuses a dirty tree), runs
84
84
  `codingCmd` with the task, and stages the result.
85
+ - **Continue the thread's PR** — when the mention lands in a thread hilos says is
86
+ about a pull request, the daemon works on *that* PR instead of opening a second
87
+ one: it fetches the PR's head branch, commits there, and the same PR updates.
88
+ It confirms with `gh` that the PR is still open first — a merged, closed, or
89
+ fork PR gets a fresh branch, and the run says so.
90
+ - **Merge / close on request** — "merge it" from a workspace owner or admin in a
91
+ PR thread is executed, not described. The daemon relays the request to hilos
92
+ with the id of the message that asked; hilos verifies the person's role and
93
+ that their message really asks for it, then acts with the workspace's GitHub
94
+ App. The daemon never merges on its own judgment and holds no merge rights.
85
95
  - **Open a PR** (default) — it commits, pushes with *your* `git`/`gh`, opens a PR,
86
96
  and posts a report card with the link. Review on the card: **Approve** merges,
87
97
  **Reject** closes, **Request changes** re-works.
98
+ - **Recover an over-eager coding CLI** — if the child commits or switches
99
+ branches despite the edit-only prompt, the daemon pushes that HEAD under the
100
+ task branch it owns. It never asks GitHub to open the default branch against
101
+ itself, and a rejected PR creation includes GitHub's actual error in the
102
+ report.
88
103
  - **Approve-before-push** (`gate:true`) — instead, it posts the staged diff as a
89
104
  card and polls for your decision; **Approve** pushes + opens the PR, **Reject**
90
105
  discards the branch, **Request changes** re-runs with your note (bounded rounds).
@@ -187,6 +202,14 @@ explains what each permission level means.
187
202
  `codingCmd` decides how much the coding agent can do on its own. Three levels,
188
203
  safest first:
189
204
 
205
+ - **`opencode run` (runtime-gated).** When the connected hilos server advertises
206
+ runtime permissions, the daemon runs OpenCode through an authenticated
207
+ loopback server and becomes its sole permission responder. A tool ask pauses
208
+ mechanically, posts a card in the run thread, and resumes only after a channel
209
+ member chooses **Allow once**, an exact harness-suggested **Always** rule, or
210
+ **Deny**. Missing transport, expiry, and cancellation all reject the tool
211
+ call. `opencode run --auto` deliberately bypasses these cards and keeps
212
+ OpenCode's dangerous auto-approve behavior.
190
213
  - **`--permission-mode acceptEdits` (default).** The agent edits files without
191
214
  prompting, but in headless `claude -p` a step that needs bash — run the tests,
192
215
  install a dep — has no interactive prompt to grant, so the task can **stall**.
@@ -208,7 +231,10 @@ safest first:
208
231
 
209
232
  The default stays `acceptEdits`. Reach for `--dangerously-skip-permissions` when
210
233
  you want a truly hands-off teammate, and keep `gate:true` if you'd rather review
211
- before anything is pushed.
234
+ before anything is pushed. OpenCode is the first harness with the runtime-card
235
+ bridge; Claude Code, Codex, Cursor, and other adapters still follow their own
236
+ CLI permission modes until their native approval hooks join the same
237
+ vendor-neutral hilos substrate.
212
238
 
213
239
  ## Hooks — stream a raw Claude Code session
214
240
 
@@ -16,10 +16,18 @@
16
16
  // codingCmd in hilos-agent.json to change it and the daemon picks it up on its
17
17
  // next poll — no restart needed.
18
18
 
19
+ import { readFileSync } from "node:fs";
20
+ import { fileURLToPath } from "node:url";
21
+
19
22
  import { resolveConfig, decodeJoin, writeStarterConfig, GLOBAL_CONFIG } from "../src/config.mjs";
20
23
  import { run } from "../src/run.mjs";
21
24
  import { hookMain, hooksMain } from "../src/hook.mjs";
22
25
 
26
+ function packageVersion() {
27
+ const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
28
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version;
29
+ }
30
+
23
31
  function parseArgs(argv) {
24
32
  const flags = {};
25
33
  const positional = [];
@@ -38,6 +46,7 @@ function parseArgs(argv) {
38
46
  else if (a === "--no-gate") flags.gate = false;
39
47
  else if (a === "--global") flags.global = true;
40
48
  else if (a === "-h" || a === "--help") flags.help = true;
49
+ else if (a === "-v" || a === "--version") flags.version = true;
41
50
  else positional.push(a);
42
51
  }
43
52
  return { cmd: positional[0] || "run", flags, positional };
@@ -57,7 +66,8 @@ Options:
57
66
  --channel <id> watch only one channel (per-channel override)
58
67
  --config <path> use a specific config file
59
68
  --coding-cmd <cmd> the coding agent to run — claude -p, codex exec,
60
- cursor-agent -p --trust, agy -p, hermes, or any command
69
+ cursor-agent -p --trust, opencode run, agy -p, hermes,
70
+ or any command
61
71
  that takes a prompt as its last arg (default: "claude -p")
62
72
  --coding-model <tier> model preset tier (opus | sonnet | haiku) resolved at
63
73
  run time against the CLI's own model list — never a baked
@@ -68,12 +78,17 @@ Options:
68
78
  --once one poll then exit (cron-friendly)
69
79
  --backfill also act on mentions that predate startup
70
80
  --no-gate propose only; don't wait for approval / push
81
+ -v, --version print the installed version
71
82
  -h, --help this help
72
83
 
73
84
  Docs: https://hilos.sh · https://www.npmjs.com/package/hilos-agent`;
74
85
 
75
86
  async function main() {
76
87
  const { cmd, flags, positional } = parseArgs(process.argv.slice(2));
88
+ if (flags.version || cmd === "version") {
89
+ console.log(packageVersion());
90
+ return;
91
+ }
77
92
  if (flags.help || cmd === "help") {
78
93
  console.log(HELP);
79
94
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.6.0",
3
+ "version": "0.9.0",
4
4
  "description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions in channels and threads, makes the change, and opens a PR for review — your code and credentials never leave your machine. (Approve-before-push is available via gate:true.)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,538 @@
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 { mapOpenCodePermissionDecision } from "./opencode-permissions.mjs";
21
+
22
+ const DEFAULT_TIMEOUT_MS = 30 * 60_000;
23
+ const DEFAULT_POLL_MS = 1_000;
24
+ const SHUTDOWN_GRACE_MS = 750;
25
+ const PROTOCOL_VERSION = 1;
26
+ const MAX_LINE_BYTES = 4 * 1024 * 1024;
27
+
28
+ function isObject(value) {
29
+ return typeof value === "object" && value !== null && !Array.isArray(value);
30
+ }
31
+
32
+ function abortError(reason = "cancelled") {
33
+ const error = new Error(String(reason || "cancelled"));
34
+ error.name = "AbortError";
35
+ return error;
36
+ }
37
+
38
+ function raceWithAbort(promise, signal) {
39
+ if (!signal) return promise;
40
+ if (signal.aborted) return Promise.reject(abortError(signal.reason));
41
+ return new Promise((resolve, reject) => {
42
+ const onAbort = () => reject(abortError(signal.reason));
43
+ signal.addEventListener("abort", onAbort, { once: true });
44
+ Promise.resolve(promise).then(
45
+ (value) => {
46
+ signal.removeEventListener("abort", onAbort);
47
+ resolve(value);
48
+ },
49
+ (error) => {
50
+ signal.removeEventListener("abort", onAbort);
51
+ reject(error);
52
+ },
53
+ );
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Choose the agent's option id for a hilos reply ("once"|"always"|"reject").
59
+ * Matches on the ACP option `kind` first (allow_once / allow_always /
60
+ * reject_once / reject_always), then falls back to id/name heuristics. A
61
+ * reject reply with no recognizable option returns null — the caller answers
62
+ * with a protocol-level cancel, which the agent must treat as not-allowed.
63
+ */
64
+ export function pickAcpPermissionOption(options, reply) {
65
+ const list = Array.isArray(options) ? options.filter(isObject) : [];
66
+ const byKind = (kind) => list.find((o) => o.kind === kind);
67
+ const byPattern = (re) =>
68
+ list.find((o) => re.test(`${o.optionId ?? ""} ${o.name ?? ""}`));
69
+ if (reply === "always") {
70
+ // The name fallback must never land on "Reject always" — a human's
71
+ // allow-always answered with a rejection would invert the decision.
72
+ return (
73
+ byKind("allow_always") ??
74
+ list.find(
75
+ (o) =>
76
+ !String(o.kind ?? "").startsWith("reject") &&
77
+ /always/i.test(`${o.optionId ?? ""} ${o.name ?? ""}`) &&
78
+ !/reject|deny|\bno\b/i.test(`${o.optionId ?? ""} ${o.name ?? ""}`),
79
+ ) ??
80
+ null
81
+ );
82
+ }
83
+ if (reply === "once") {
84
+ // Never widen a single-use approval into a persistent one: an allow_once
85
+ // option is required; allow_always is NOT an acceptable stand-in.
86
+ return (
87
+ byKind("allow_once") ??
88
+ list.find(
89
+ (o) =>
90
+ o.kind !== "allow_always" &&
91
+ /\bonce\b|allow/i.test(`${o.optionId ?? ""} ${o.name ?? ""}`) &&
92
+ !/always/i.test(`${o.optionId ?? ""} ${o.name ?? ""}`),
93
+ ) ??
94
+ null
95
+ );
96
+ }
97
+ return (
98
+ byKind("reject_once") ??
99
+ byKind("reject_always") ??
100
+ byPattern(/reject|deny|no\b/i) ??
101
+ null
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Shape an ACP session/request_permission into the vendor-neutral request the
107
+ * hilos permission callbacks expect (the same fields the SSE relay produces).
108
+ */
109
+ export function normalizeAcpPermissionRequest(params, fallbackId, vendor = "opencode") {
110
+ const toolCall = isObject(params?.toolCall) ? params.toolCall : {};
111
+ const rawInput = isObject(toolCall.rawInput) ? toolCall.rawInput : {};
112
+ const locations = Array.isArray(toolCall.locations)
113
+ ? toolCall.locations
114
+ .map((l) => (isObject(l) && typeof l.path === "string" ? l.path : null))
115
+ .filter(Boolean)
116
+ : [];
117
+ const command = typeof rawInput.command === "string" ? rawInput.command : null;
118
+ const resources = locations.length ? locations : command ? [command] : [];
119
+ const vendorRequestId =
120
+ typeof toolCall.toolCallId === "string" && toolCall.toolCallId
121
+ ? toolCall.toolCallId
122
+ : `acp_${fallbackId}`;
123
+ return {
124
+ vendor,
125
+ vendorRequestId,
126
+ sessionId: typeof params?.sessionId === "string" ? params.sessionId : "",
127
+ action: typeof toolCall.kind === "string" && toolCall.kind ? toolCall.kind : "tool",
128
+ resources,
129
+ suggestedSave: undefined,
130
+ metadata: {
131
+ ...(typeof toolCall.title === "string" && toolCall.title
132
+ ? { title: toolCall.title }
133
+ : {}),
134
+ ...(command ? { command } : {}),
135
+ transport: "acp",
136
+ },
137
+ source: { type: "tool", ...(typeof toolCall.title === "string" ? { name: toolCall.title } : {}) },
138
+ };
139
+ }
140
+
141
+ /** Split a stdout stream into newline-delimited JSON-RPC messages. */
142
+ export function createNdjsonParser() {
143
+ let buffer = "";
144
+ return {
145
+ push(chunk) {
146
+ buffer += String(chunk);
147
+ if (buffer.length > MAX_LINE_BYTES) {
148
+ // A frame this large is not a protocol message; drop it rather than
149
+ // letting a runaway agent grow the daemon's heap without bound.
150
+ buffer = "";
151
+ return [];
152
+ }
153
+ const messages = [];
154
+ let idx;
155
+ while ((idx = buffer.indexOf("\n")) >= 0) {
156
+ const line = buffer.slice(0, idx).trim();
157
+ buffer = buffer.slice(idx + 1);
158
+ if (!line) continue;
159
+ try {
160
+ const parsed = JSON.parse(line);
161
+ if (isObject(parsed)) messages.push(parsed);
162
+ } catch {
163
+ // Non-JSON stdout noise (banners, stray logs) is not a frame.
164
+ }
165
+ }
166
+ return messages;
167
+ },
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Run one prompt against an ACP agent subprocess.
173
+ *
174
+ * Slice 1 (0759) intentionally mirrors the HTTP runner's text mode: stdout
175
+ * collects the agent's completed message text, tool activity stays off the
176
+ * transcript, and the session id is returned for the caller's records.
177
+ *
178
+ * @param {{
179
+ * cmd?: string,
180
+ * acpArgs?: string[],
181
+ * vendor?: string,
182
+ * cwd?: string,
183
+ * prompt?: string,
184
+ * env?: Record<string, string | undefined>,
185
+ * timeoutMs?: number,
186
+ * pollIntervalMs?: number,
187
+ * signal?: AbortSignal,
188
+ * onData?: (chunk: string) => void,
189
+ * requestPermission?: (request: object, context: object) => Promise<unknown>,
190
+ * getPermissionDecision?: (handle: unknown, context: object) => Promise<unknown>,
191
+ * mcpServers?: object[],
192
+ * spawnImpl?: (cmd: string, args: string[], options: object) => import("node:child_process").ChildProcess,
193
+ * setTimer?: typeof setTimeout,
194
+ * clearTimer?: typeof clearTimeout,
195
+ * sleep?: (ms: number) => Promise<void>,
196
+ * now?: () => number,
197
+ * log?: { error?: (message: string) => void },
198
+ * }} [options]
199
+ * @returns {Promise<{ status: number | null, stdout: string, stderr: string, error?: Error | null, sessionId?: string | null, aborted?: boolean }>}
200
+ */
201
+ export async function runAcpSession({
202
+ cmd = "opencode",
203
+ acpArgs = ["acp"],
204
+ vendor = "opencode",
205
+ cwd,
206
+ prompt,
207
+ env,
208
+ timeoutMs = DEFAULT_TIMEOUT_MS,
209
+ pollIntervalMs = DEFAULT_POLL_MS,
210
+ signal,
211
+ onData,
212
+ requestPermission,
213
+ getPermissionDecision,
214
+ mcpServers = [],
215
+ spawnImpl = spawn,
216
+ setTimer = setTimeout,
217
+ clearTimer = clearTimeout,
218
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
219
+ now = () => Date.now(),
220
+ log = console,
221
+ } = {}) {
222
+ if (!cwd) {
223
+ return { status: null, stdout: "", stderr: "", error: new Error("ACP requires cwd") };
224
+ }
225
+ if (typeof requestPermission !== "function" || typeof getPermissionDecision !== "function") {
226
+ // Without the hilos gate there is no one to answer asks; refuse to start
227
+ // rather than run a session whose permissions would dead-end.
228
+ return {
229
+ status: null,
230
+ stdout: "",
231
+ stderr: "",
232
+ error: new Error("ACP transport requires the hilos permission callbacks"),
233
+ };
234
+ }
235
+
236
+ const controller = new AbortController();
237
+ let abortKind = null;
238
+ const abort = (kind, reason) => {
239
+ if (controller.signal.aborted) return;
240
+ abortKind = kind;
241
+ controller.abort(reason);
242
+ };
243
+ const onParentAbort = () => abort("cancelled", signal?.reason ?? "cancelled");
244
+ if (signal?.aborted) onParentAbort();
245
+ else signal?.addEventListener?.("abort", onParentAbort, { once: true });
246
+ const timeout =
247
+ timeoutMs > 0
248
+ ? setTimer(() => abort("timeout", `ACP session timed out after ${timeoutMs}ms`), timeoutMs)
249
+ : null;
250
+ timeout?.unref?.();
251
+
252
+ let child = null;
253
+ let sessionId = null;
254
+ let stdout = "";
255
+ let stderr = "";
256
+ let nextId = 0;
257
+ const pending = new Map();
258
+ const permissionTasks = new Set();
259
+ let currentMessageId = null;
260
+ let messageBuffer = "";
261
+
262
+ const emitOutput = (text) => {
263
+ try {
264
+ onData?.(`${text}\n`);
265
+ } catch {
266
+ // Output observers never own session correctness.
267
+ }
268
+ };
269
+ const flushMessage = () => {
270
+ const text = messageBuffer.trim();
271
+ messageBuffer = "";
272
+ currentMessageId = null;
273
+ if (!text) return;
274
+ stdout += `${text}\n`;
275
+ emitOutput(text);
276
+ };
277
+
278
+ const writeFrame = (frame) => {
279
+ if (!child || child.stdin.destroyed) return false;
280
+ try {
281
+ child.stdin.write(`${JSON.stringify(frame)}\n`);
282
+ return true;
283
+ } catch {
284
+ return false;
285
+ }
286
+ };
287
+ const rpc = (method, params) => {
288
+ const id = ++nextId;
289
+ return new Promise((resolve, reject) => {
290
+ pending.set(id, { resolve, reject, method });
291
+ if (!writeFrame({ jsonrpc: "2.0", id, method, params })) {
292
+ pending.delete(id);
293
+ reject(new Error(`ACP agent is not accepting frames (${method})`));
294
+ }
295
+ });
296
+ };
297
+ const respond = (id, result, error) => {
298
+ writeFrame(
299
+ error
300
+ ? { jsonrpc: "2.0", id, error }
301
+ : { jsonrpc: "2.0", id, result },
302
+ );
303
+ };
304
+ const failPending = (reason) => {
305
+ for (const [, entry] of pending) entry.reject(new Error(reason));
306
+ pending.clear();
307
+ };
308
+
309
+ async function settlePermission(msg) {
310
+ const request = normalizeAcpPermissionRequest(msg.params, msg.id, vendor);
311
+ const receivedAt = now();
312
+ const deadlineAt = receivedAt + Math.max(1, timeoutMs || DEFAULT_TIMEOUT_MS);
313
+ let reply = "reject";
314
+ let handle = null;
315
+ try {
316
+ if (controller.signal.aborted) throw abortError(controller.signal.reason);
317
+ handle = await raceWithAbort(
318
+ requestPermission(request, { signal: controller.signal, deadlineAt }),
319
+ controller.signal,
320
+ );
321
+ let decision = handle;
322
+ let mapped = mapOpenCodePermissionDecision(decision);
323
+ while (!mapped) {
324
+ if (now() >= deadlineAt) throw abortError("timeout");
325
+ decision = await raceWithAbort(
326
+ getPermissionDecision(handle, {
327
+ request,
328
+ signal: controller.signal,
329
+ deadlineAt,
330
+ }),
331
+ controller.signal,
332
+ );
333
+ mapped = mapOpenCodePermissionDecision(decision);
334
+ if (mapped) break;
335
+ const waitMs = Math.min(Math.max(1, pollIntervalMs), Math.max(1, deadlineAt - now()));
336
+ await raceWithAbort(sleep(waitMs), controller.signal);
337
+ }
338
+ reply = mapped;
339
+ } catch (error) {
340
+ reply = "reject";
341
+ log?.error?.(`acp permission decision: ${error?.message ?? error}`);
342
+ // The agent is about to be failed closed. Settle the durable hilos card
343
+ // too, on a bounded one-shot call — the run signal may already be gone.
344
+ if (handle) {
345
+ try {
346
+ await getPermissionDecision(handle, {
347
+ request,
348
+ deadlineAt,
349
+ failClosed: true,
350
+ });
351
+ } catch (settlementError) {
352
+ log?.error?.(
353
+ `acp permission settlement: ${settlementError?.message ?? settlementError}`,
354
+ );
355
+ }
356
+ }
357
+ }
358
+ const option = pickAcpPermissionOption(msg.params?.options, reply);
359
+ if (option && (reply !== "reject" || option.kind?.startsWith("reject"))) {
360
+ respond(msg.id, { outcome: { outcome: "selected", optionId: option.optionId } });
361
+ } else if (reply === "reject") {
362
+ // No recognizable reject option: cancel the ask at the protocol level.
363
+ respond(msg.id, { outcome: { outcome: "cancelled" } });
364
+ } else {
365
+ // An approval we cannot express in the agent's options must not become
366
+ // an implicit rejection card-side; the wire still gets a cancel.
367
+ log?.error?.("acp permission: no matching option for reply, cancelling");
368
+ respond(msg.id, { outcome: { outcome: "cancelled" } });
369
+ }
370
+ }
371
+
372
+ function handleUpdate(update) {
373
+ if (!isObject(update)) return;
374
+ const kind = update.sessionUpdate;
375
+ if (kind === "agent_message_chunk") {
376
+ const messageId = typeof update.messageId === "string" ? update.messageId : null;
377
+ if (currentMessageId !== null && messageId !== currentMessageId) flushMessage();
378
+ currentMessageId = messageId;
379
+ const text =
380
+ isObject(update.content) && typeof update.content.text === "string"
381
+ ? update.content.text
382
+ : "";
383
+ messageBuffer += text;
384
+ return;
385
+ }
386
+ // Thoughts, tool calls, usage, command lists: structurally received, not
387
+ // part of slice 1's transcript (parity with the HTTP runner's text mode).
388
+ }
389
+
390
+ function handleMessage(msg) {
391
+ if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
392
+ const entry = pending.get(msg.id);
393
+ if (!entry) return;
394
+ pending.delete(msg.id);
395
+ if (msg.error) {
396
+ entry.reject(
397
+ new Error(
398
+ `ACP ${entry.method} failed: ${msg.error.message ?? JSON.stringify(msg.error)}`,
399
+ ),
400
+ );
401
+ } else {
402
+ entry.resolve(msg.result);
403
+ }
404
+ return;
405
+ }
406
+ if (msg.method === "session/update") {
407
+ if (isObject(msg.params) && msg.params.sessionId === sessionId) {
408
+ handleUpdate(msg.params.update);
409
+ }
410
+ return;
411
+ }
412
+ if (msg.method === "session/request_permission" && msg.id !== undefined) {
413
+ const task = settlePermission(msg).finally(() => permissionTasks.delete(task));
414
+ permissionTasks.add(task);
415
+ return;
416
+ }
417
+ if (msg.id !== undefined && msg.method) {
418
+ // fs/terminal requests should never arrive (capabilities declared off);
419
+ // refuse anything unexpected instead of guessing.
420
+ respond(msg.id, undefined, {
421
+ code: -32601,
422
+ message: `hilos does not implement ${msg.method}`,
423
+ });
424
+ }
425
+ }
426
+
427
+ try {
428
+ child = spawnImpl(cmd, acpArgs, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
429
+ const spawned = new Promise((resolve, reject) => {
430
+ child.once("spawn", resolve);
431
+ child.once("error", reject);
432
+ });
433
+ child.once("exit", (code) => {
434
+ failPending(`ACP agent exited (${code ?? "signal"}) before replying`);
435
+ });
436
+ const parser = createNdjsonParser();
437
+ child.stdout.on("data", (chunk) => {
438
+ for (const msg of parser.push(chunk)) {
439
+ try {
440
+ handleMessage(msg);
441
+ } catch (error) {
442
+ log?.error?.(`acp frame handling: ${error?.message ?? error}`);
443
+ }
444
+ }
445
+ });
446
+ child.stderr.on("data", (chunk) => {
447
+ stderr += String(chunk);
448
+ if (stderr.length > MAX_LINE_BYTES) stderr = stderr.slice(-MAX_LINE_BYTES);
449
+ });
450
+ await raceWithAbort(spawned, controller.signal);
451
+
452
+ const init = await raceWithAbort(
453
+ rpc("initialize", {
454
+ protocolVersion: PROTOCOL_VERSION,
455
+ clientCapabilities: {
456
+ fs: { readTextFile: false, writeTextFile: false },
457
+ terminal: false,
458
+ },
459
+ }),
460
+ controller.signal,
461
+ );
462
+ if (isObject(init) && init.protocolVersion !== undefined && init.protocolVersion !== PROTOCOL_VERSION) {
463
+ throw new Error(`ACP agent speaks protocol ${init.protocolVersion}, expected ${PROTOCOL_VERSION}`);
464
+ }
465
+
466
+ const session = await raceWithAbort(
467
+ rpc("session/new", { cwd, mcpServers }),
468
+ controller.signal,
469
+ );
470
+ sessionId = isObject(session) && typeof session.sessionId === "string" ? session.sessionId : null;
471
+ if (!sessionId) throw new Error("ACP agent created a session without an id");
472
+
473
+ const turn = await raceWithAbort(
474
+ rpc("session/prompt", {
475
+ sessionId,
476
+ prompt: [{ type: "text", text: String(prompt ?? "") }],
477
+ }),
478
+ controller.signal,
479
+ );
480
+ flushMessage();
481
+ // Every in-flight permission has been answered or is being failed closed by
482
+ // its own error path; give those settlements a bounded chance to finish.
483
+ await Promise.allSettled([...permissionTasks]);
484
+
485
+ const stopReason = isObject(turn) && typeof turn.stopReason === "string" ? turn.stopReason : null;
486
+ const clean = stopReason === "end_turn" || stopReason == null;
487
+ return {
488
+ status: clean ? 0 : 1,
489
+ stdout: stdout.trimEnd(),
490
+ stderr: stderr.trimEnd(),
491
+ error: clean ? null : new Error(`ACP turn stopped: ${stopReason}`),
492
+ sessionId,
493
+ };
494
+ } catch (error) {
495
+ flushMessage();
496
+ const aborted = abortKind === "cancelled";
497
+ const timedOut = abortKind === "timeout";
498
+ return {
499
+ status: null,
500
+ stdout: stdout.trimEnd(),
501
+ stderr: stderr.trimEnd(),
502
+ ...(aborted ? { aborted: true } : {}),
503
+ ...(sessionId ? { sessionId } : {}),
504
+ error:
505
+ error instanceof Error && !timedOut
506
+ ? error
507
+ : new Error(timedOut ? "ACP session timed out" : String(error)),
508
+ };
509
+ } finally {
510
+ if (timeout != null) clearTimer(timeout);
511
+ signal?.removeEventListener?.("abort", onParentAbort);
512
+ if (child && child.exitCode === null && !child.killed) {
513
+ // Ask for a graceful stop first (the agent may flush state), then kill.
514
+ if (sessionId) {
515
+ writeFrame({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
516
+ }
517
+ try {
518
+ child.stdin.end();
519
+ } catch {
520
+ // Already gone.
521
+ }
522
+ const grace = setTimer(() => {
523
+ try {
524
+ child.kill("SIGKILL");
525
+ } catch {
526
+ // Already gone.
527
+ }
528
+ }, SHUTDOWN_GRACE_MS);
529
+ grace?.unref?.();
530
+ try {
531
+ child.kill("SIGTERM");
532
+ } catch {
533
+ // Already gone.
534
+ }
535
+ }
536
+ failPending("ACP session finished");
537
+ }
538
+ }