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 +29 -3
- package/bin/hilos-agent.mjs +16 -1
- package/package.json +1 -1
- package/src/acp-session.mjs +538 -0
- package/src/agent-events.mjs +232 -2
- package/src/cli.mjs +33 -4
- package/src/config.mjs +8 -0
- package/src/daemon.mjs +23 -0
- package/src/handler.mjs +558 -95
- package/src/opencode-permissions.mjs +654 -0
- package/src/opencode-session.mjs +770 -0
- package/src/progress-emitter.mjs +103 -9
- package/src/resume.mjs +85 -12
- package/src/run.mjs +10 -0
- package/src/thread-pr.mjs +305 -0
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
|
|
package/bin/hilos-agent.mjs
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
+
}
|