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 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.7.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
+ }
@@ -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
+ }