hilos-agent 0.7.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 +15 -0
- package/package.json +1 -1
- package/src/acp-session.mjs +538 -0
- package/src/agent-events.mjs +119 -0
- package/src/config.mjs +8 -0
- package/src/daemon.mjs +23 -0
- package/src/handler.mjs +306 -33
- package/src/progress-emitter.mjs +11 -1
- package/src/run.mjs +5 -0
- package/src/thread-pr.mjs +305 -0
package/README.md
CHANGED
|
@@ -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).
|
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
|
+
}
|
package/src/agent-events.mjs
CHANGED
|
@@ -219,6 +219,15 @@ function parseCodexLine(line) {
|
|
|
219
219
|
* `shellToolCall`, each with an `args` object), and a terminal `result` with
|
|
220
220
|
* `is_error` + the full text in `result`. `thinking` deltas, the `user` echo,
|
|
221
221
|
* and `tool_call` completions are deliberately not steps.
|
|
222
|
+
*
|
|
223
|
+
* LOCKSTEP (0574 phase 2 / 0749): `parseCursorCloudEvent` in
|
|
224
|
+
* lib/hosted-progress.ts is this function's CLOUD twin — same field knowledge,
|
|
225
|
+
* same restraint (`started` only, unknown tools skipped, never throws) — for
|
|
226
|
+
* Cursor's Cloud Agents SSE stream, whose event NAMES differ from this CLI
|
|
227
|
+
* stream-json (`tool_call` with a flat `name` + `args`, not `<kind>ToolCall`
|
|
228
|
+
* envelopes). Change one, look at the other. The cloud side's mappings are
|
|
229
|
+
* WIRE (unconfirmed) until a real cloud transcript is captured; the shapes
|
|
230
|
+
* below were captured live and are not.
|
|
222
231
|
*/
|
|
223
232
|
function parseCursorLine(line) {
|
|
224
233
|
const obj = tryParse(line);
|
|
@@ -548,3 +557,113 @@ export function summarizeSteps(events, limit = 8) {
|
|
|
548
557
|
for (const ev of events || []) ring.push(ev);
|
|
549
558
|
return ring.labels();
|
|
550
559
|
}
|
|
560
|
+
|
|
561
|
+
// ── Activity fold (0537/0750) ────────────────────────────────────────────────
|
|
562
|
+
//
|
|
563
|
+
// The step ring's richer sibling: structured feed rows the run card renders as
|
|
564
|
+
// sentences that MUTATE IN PLACE. LOCKSTEP with the server's fold in
|
|
565
|
+
// `lib/hosted-progress.ts` (the hosted lanes) — same tense rule (the sentence
|
|
566
|
+
// is formatted at read time so "Editing lib/x.ts" flips to "Edited lib/x.ts"
|
|
567
|
+
// once the next action starts), same aggregation (repeat edits count up, read
|
|
568
|
+
// sweeps recede into one row), same loud failures. Change one, look at the
|
|
569
|
+
// other. The server re-validates every row (sanitizeProgress), so this side
|
|
570
|
+
// only has to be honest, not paranoid.
|
|
571
|
+
|
|
572
|
+
const MAX_ACTIVITY = 30;
|
|
573
|
+
|
|
574
|
+
/** @param {{kind: string, subject: string, status: string, n: number}} row */
|
|
575
|
+
function activitySentence(row) {
|
|
576
|
+
const running = row.status === "running";
|
|
577
|
+
switch (row.kind) {
|
|
578
|
+
case "edit":
|
|
579
|
+
return `${running ? "Editing" : "Edited"} ${row.subject || "files"}`;
|
|
580
|
+
case "read":
|
|
581
|
+
if (row.n > 1) return `${running ? "Reading" : "Read"} ${row.n} files`;
|
|
582
|
+
return `${running ? "Reading" : "Read"} ${row.subject || "a file"}`;
|
|
583
|
+
case "run": {
|
|
584
|
+
const label = describeRun(row.subject);
|
|
585
|
+
return running ? label : label.replace(/^Running/, "Ran");
|
|
586
|
+
}
|
|
587
|
+
default:
|
|
588
|
+
return row.subject; // note/result carry their own sentence
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* A bounded fold of AgentEvents into activity rows. Starts are the only ground
|
|
594
|
+
* truth a CLI stream carries, so "done" is the sequential-execution inference:
|
|
595
|
+
* a new action starting settles the one in flight. `settle("failed")` is for a
|
|
596
|
+
* dying run — leaving "Editing…" forever is the dishonesty this feed exists to
|
|
597
|
+
* avoid. session/phase events are metadata, not rows (same as stepLabel).
|
|
598
|
+
* @param {number} [limit]
|
|
599
|
+
*/
|
|
600
|
+
export function createActivityFold(limit = MAX_ACTIVITY) {
|
|
601
|
+
/** @type {{kind: string, subject: string, status: string, n: number}[]} */
|
|
602
|
+
const rows = [];
|
|
603
|
+
|
|
604
|
+
function settle(as = "done") {
|
|
605
|
+
const last = rows[rows.length - 1];
|
|
606
|
+
if (last && last.status === "running") last.status = as;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function add(row) {
|
|
610
|
+
settle();
|
|
611
|
+
rows.push(row);
|
|
612
|
+
if (rows.length > limit) rows.shift();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** @param {AgentEvent} event */
|
|
616
|
+
function push(event) {
|
|
617
|
+
if (!event || typeof event !== "object") return;
|
|
618
|
+
const last = rows[rows.length - 1];
|
|
619
|
+
switch (event.t) {
|
|
620
|
+
case "edit":
|
|
621
|
+
if (last && last.status === "running" && last.kind === "edit" && last.subject === event.path) {
|
|
622
|
+
last.n += 1;
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
add({ kind: "edit", subject: event.path || "", status: "running", n: 1 });
|
|
626
|
+
return;
|
|
627
|
+
case "read":
|
|
628
|
+
if (last && last.status === "running" && last.kind === "read") {
|
|
629
|
+
last.n += 1;
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
add({ kind: "read", subject: event.path || "", status: "running", n: 1 });
|
|
633
|
+
return;
|
|
634
|
+
case "run":
|
|
635
|
+
add({ kind: "run", subject: event.cmd || "", status: "running", n: 1 });
|
|
636
|
+
return;
|
|
637
|
+
case "note":
|
|
638
|
+
if (!event.text) return;
|
|
639
|
+
add({ kind: "note", subject: event.text, status: "done", n: 1 });
|
|
640
|
+
return;
|
|
641
|
+
case "result":
|
|
642
|
+
add({
|
|
643
|
+
kind: "result",
|
|
644
|
+
subject: event.ok ? "Done" : (event.summary && event.summary.trim()) || "Finished with errors",
|
|
645
|
+
status: event.ok ? "done" : "failed",
|
|
646
|
+
n: 1,
|
|
647
|
+
});
|
|
648
|
+
return;
|
|
649
|
+
default:
|
|
650
|
+
return; // session / phase / unknown — metadata, not activity
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** The wire rows, sentences formatted for the CURRENT statuses, sanitized.
|
|
655
|
+
* @returns {{kind: string, text: string, status: string, n?: number}[]} */
|
|
656
|
+
function list() {
|
|
657
|
+
const out = [];
|
|
658
|
+
for (const row of rows) {
|
|
659
|
+
const text = sanitizeText(activitySentence(row));
|
|
660
|
+
if (!text) continue;
|
|
661
|
+
const wire = { kind: row.kind, text, status: row.status };
|
|
662
|
+
if (row.kind === "edit" && row.n > 1) wire.n = row.n;
|
|
663
|
+
out.push(wire);
|
|
664
|
+
}
|
|
665
|
+
return out;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return { push, settle, list };
|
|
669
|
+
}
|