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.
@@ -2,7 +2,8 @@
2
2
  // alive" epic: coding CLIs each narrate their work in a different, unstable
3
3
  // wire format (Claude Code emits `--output-format stream-json` NDJSON, Codex
4
4
  // emits `--json` item events, Cursor emits its own `--output-format
5
- // stream-json` NDJSON — 0573). This module turns any of them into ONE small,
5
+ // stream-json` NDJSON — 0573, opencode emits `--format json` part events —
6
+ // 0608). This module turns any of them into ONE small,
6
7
  // typed `AgentEvent` stream the UI can render as a live "what the agent is
7
8
  // doing right now" card.
8
9
  //
@@ -218,6 +219,15 @@ function parseCodexLine(line) {
218
219
  * `shellToolCall`, each with an `args` object), and a terminal `result` with
219
220
  * `is_error` + the full text in `result`. `thinking` deltas, the `user` echo,
220
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.
221
231
  */
222
232
  function parseCursorLine(line) {
223
233
  const obj = tryParse(line);
@@ -272,12 +282,119 @@ function cursorToolEvent(toolCall) {
272
282
  return null;
273
283
  }
274
284
 
285
+ /** One opencode `tool_use` part → an AgentEvent, or null. Tool names are
286
+ * lowercase (`read`/`write`/`edit`/`patch`/`bash`); the arguments live under
287
+ * `state.input` (`filePath` for file tools, `command` for bash). Anything else
288
+ * (glob/grep/webfetch/task) carries less "alive" signal and is skipped, mirroring
289
+ * toolKind()'s v1 restraint — an unknown tool never crashes the parser. */
290
+ function opencodeToolEvent(part) {
291
+ if (!part || typeof part !== "object") return null;
292
+ const tool = typeof part.tool === "string" ? part.tool.toLowerCase() : "";
293
+ const state = part.state && typeof part.state === "object" ? part.state : {};
294
+ const input = state.input && typeof state.input === "object" ? state.input : {};
295
+ if (tool === "bash") {
296
+ const raw = input.command;
297
+ const cmd = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : "";
298
+ return { t: "run", cmd: sanitizeText(cmd) };
299
+ }
300
+ if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
301
+ const raw = input.filePath ?? input.file_path ?? input.path;
302
+ return { t: "edit", path: typeof raw === "string" ? sanitizeText(raw) : "" };
303
+ }
304
+ if (tool === "read") {
305
+ const raw = input.filePath ?? input.file_path ?? input.path;
306
+ return { t: "read", path: typeof raw === "string" ? sanitizeText(raw) : "" };
307
+ }
308
+ return null;
309
+ }
310
+
311
+ /**
312
+ * A stateful line parser for opencode `run --format json` NDJSON (0608).
313
+ *
314
+ * Shapes captured LIVE against opencode 1.18.5: EVERY event carries a top-level
315
+ * `sessionID` (`ses_…`) — including the very first `step_start` and the terminal
316
+ * `error` — which is exactly the id `--session` resumes. `text` events carry a
317
+ * WHOLE text part (`part.text`), not a delta, so a part is emitted once and a
318
+ * repeat of the same `part.id` with identical text is dropped (a growing part
319
+ * re-emits, so the room still sees the latest wording). `tool_use` events carry
320
+ * the tool + its input; they're deduped by `callID` so a state update can't
321
+ * double a step. `step_finish` closes an assistant turn (`part.reason`): `stop`
322
+ * ends the run, so it becomes the `result` carrying the last text as the summary
323
+ * (opencode has no separate result event, and the folder-run report reads that
324
+ * summary). `error` (`{ error: { name, data: { message } } }`) → a failed result.
325
+ *
326
+ * Stateful (last text, seen part/call ids) — hence a factory, not a bare fn.
327
+ */
328
+ function makeOpencodeLineParser() {
329
+ const seenText = new Map(); // part.id → last emitted text
330
+ const seenCalls = new Set(); // tool callID
331
+ let sessionSeen = false;
332
+ let lastText = "";
333
+ return function parseOpencodeLine(line) {
334
+ const obj = tryParse(line);
335
+ if (!obj) return [];
336
+ const out = [];
337
+ // The session id rides on every event; emit it once, off whichever lands first.
338
+ if (!sessionSeen && typeof obj.sessionID === "string" && obj.sessionID) {
339
+ sessionSeen = true;
340
+ out.push({ t: "session", sessionId: sanitizeText(obj.sessionID) });
341
+ }
342
+ const part = obj.part && typeof obj.part === "object" ? obj.part : {};
343
+ switch (obj.type) {
344
+ case "text": {
345
+ const text = typeof part.text === "string" ? sanitizeText(part.text.replace(/\s+/g, " ").trim()) : "";
346
+ if (!text) break;
347
+ const id = typeof part.id === "string" ? part.id : "";
348
+ if (id && seenText.get(id) === text) break; // same part, same text → not news
349
+ if (id) {
350
+ seenText.set(id, text);
351
+ if (seenText.size > 200) seenText.delete(seenText.keys().next().value); // bound
352
+ }
353
+ lastText = text;
354
+ out.push({ t: "note", text });
355
+ break;
356
+ }
357
+ case "tool_use": {
358
+ const callId = typeof part.callID === "string" ? part.callID : "";
359
+ if (callId && seenCalls.has(callId)) break; // status updates repeat the call
360
+ if (callId) {
361
+ seenCalls.add(callId);
362
+ if (seenCalls.size > 500) seenCalls.delete(seenCalls.values().next().value); // bound
363
+ }
364
+ const ev = opencodeToolEvent(part);
365
+ if (ev) out.push(ev);
366
+ break;
367
+ }
368
+ case "step_finish": {
369
+ // Only the turn that stops ends the RUN; `tool-calls` means another
370
+ // assistant turn follows.
371
+ if (part.reason === "stop") {
372
+ out.push(lastText ? { t: "result", ok: true, summary: lastText } : { t: "result", ok: true });
373
+ }
374
+ break;
375
+ }
376
+ case "error": {
377
+ const err = obj.error && typeof obj.error === "object" ? obj.error : {};
378
+ const data = err.data && typeof err.data === "object" ? err.data : {};
379
+ const raw = typeof data.message === "string" ? data.message : typeof err.name === "string" ? err.name : "";
380
+ const summary = sanitizeText(raw);
381
+ out.push(summary ? { t: "result", ok: false, summary } : { t: "result", ok: false });
382
+ break;
383
+ }
384
+ default:
385
+ break;
386
+ }
387
+ return out;
388
+ };
389
+ }
390
+
275
391
  /** claude / claude_code / claude-code all mean the Claude parser. */
276
392
  function normalizeVendor(vendor) {
277
393
  const v = String(vendor || "").toLowerCase();
278
394
  if (v === "claude" || v === "claude_code" || v === "claude-code") return "claude";
279
395
  if (v === "codex") return "codex";
280
396
  if (v === "cursor") return "cursor"; // structured stream-json since 0573
397
+ if (v === "opencode") return "opencode"; // structured `--format json` since 0608
281
398
  return "text"; // ANY unknown vendor → lastLine text-tail fallback
282
399
  }
283
400
 
@@ -345,7 +462,7 @@ function makeTextTailParser() {
345
462
 
346
463
  /**
347
464
  * Build a stateful stream parser for `vendor`.
348
- * @param {'claude'|'claude_code'|'codex'|'cursor'|string} vendor
465
+ * @param {'claude'|'claude_code'|'codex'|'cursor'|'opencode'|string} vendor
349
466
  * @returns {{ push: (chunk: string) => AgentEvent[], flush: () => AgentEvent[] }}
350
467
  */
351
468
  export function makeStreamParser(vendor) {
@@ -353,6 +470,9 @@ export function makeStreamParser(vendor) {
353
470
  if (v === "claude") return makeLineBufferedParser(parseClaudeLine);
354
471
  if (v === "codex") return makeLineBufferedParser(parseCodexLine);
355
472
  if (v === "cursor") return makeLineBufferedParser(parseCursorLine);
473
+ // opencode's line parser carries per-stream state (dedupe + last text), so
474
+ // each parser instance gets its own.
475
+ if (v === "opencode") return makeLineBufferedParser(makeOpencodeLineParser());
356
476
  return makeTextTailParser();
357
477
  }
358
478
 
@@ -437,3 +557,113 @@ export function summarizeSteps(events, limit = 8) {
437
557
  for (const ev of events || []) ring.push(ev);
438
558
  return ring.labels();
439
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
+ }
package/src/cli.mjs CHANGED
@@ -89,6 +89,33 @@ export function minimalEnv(base = process.env, extraAllow = []) {
89
89
  return out;
90
90
  }
91
91
 
92
+ // ── PWD must match the directory we actually run in (0615) ───────────────────
93
+ // A child spawned with `cwd` still inherits the PARENT's `PWD`, which names
94
+ // wherever the daemon was launched. Most tools call getcwd() and never notice,
95
+ // but some resolve their working project from the environment instead: live-
96
+ // verified on opencode 1.18.5, which read, edited, and shelled in the daemon's
97
+ // launch directory while hilos staged the diff in the repo clone ("no changes
98
+ // produced"). An env that contradicts the real cwd is simply wrong, so every
99
+ // spawn that sets `cwd` also sets `PWD` to it — and drops the inherited
100
+ // `OLDPWD`, which is both meaningless to the child and a leak of where the
101
+ // daemon lives. No cwd means the child inherits ours, so PWD is left alone.
102
+
103
+ /**
104
+ * `base` with `PWD` pinned to `cwd` (and any inherited `OLDPWD` removed).
105
+ * Returns a copy; `base` is never mutated. A missing/blank `cwd` is a no-op.
106
+ * @param {Record<string, string | undefined>} base
107
+ * @param {string} [cwd]
108
+ * @returns {Record<string, string>}
109
+ */
110
+ export function envForCwd(base, cwd) {
111
+ const out = { ...(base || {}) };
112
+ if (typeof cwd === "string" && cwd.trim()) {
113
+ out.PWD = cwd;
114
+ delete out.OLDPWD;
115
+ }
116
+ return out;
117
+ }
118
+
92
119
  /** Human-readable elapsed time: "45s", "2m 3s". */
93
120
  export function fmtElapsed(ms) {
94
121
  const total = Math.max(0, Math.round(ms / 1000));
@@ -145,8 +172,9 @@ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
145
172
  * @property {(chunk: string) => void} [onData] - called with each stdout chunk as
146
173
  * it arrives (lets a caller track the latest output line for a heartbeat)
147
174
  * @property {Record<string, string>} [env] - base environment for the child. Any
148
- * hilos-owned var (HILOS_*) is stripped from it regardless. Omit to inherit the
149
- * daemon's environment minus HILOS_* (the safe default).
175
+ * hilos-owned var (HILOS_*) is stripped from it regardless, and `PWD` is pinned
176
+ * to `cwd` when one is set (0615). Omit to inherit the daemon's environment
177
+ * minus HILOS_* (the safe default).
150
178
  */
151
179
 
152
180
  /**
@@ -188,12 +216,13 @@ function runCliOnce(opts) {
188
216
  // `codex exec` appends piped stdin to its prompt and blocks until EOF, so
189
217
  // an open pipe hangs it until the run timeout ("Reading additional input
190
218
  // from stdin…"). Nothing we spawn is ever fed via stdin.
191
- // Always strip hilos's own token from the child's env (see scrubHilosEnv).
219
+ // Always strip hilos's own token from the child's env (see scrubHilosEnv),
220
+ // and keep PWD honest about the directory we run in (see envForCwd).
192
221
  child = spawn(cmd, args, {
193
222
  cwd,
194
223
  detached: true,
195
224
  stdio: ["ignore", "pipe", "pipe"],
196
- env: scrubHilosEnv(env || process.env),
225
+ env: envForCwd(scrubHilosEnv(env || process.env), cwd),
197
226
  });
198
227
  } catch (error) {
199
228
  resolve({ status: null, stdout: "", stderr: "", error });
package/src/config.mjs CHANGED
@@ -57,6 +57,12 @@ const DEFAULTS = {
57
57
  // it still won't run arbitrary commands. Override in hilos-agent.json if you
58
58
  // want a stricter (or `--dangerously-skip-permissions`) command.
59
59
  codingCmd: "claude -p --permission-mode acceptEdits",
60
+ // ACP transport opt-in (0759): drive the coding CLI over the Agent Client
61
+ // Protocol (JSON-RPC on stdio) instead of argv + stdout scraping. Covers
62
+ // opencode (`opencode acp`) and cursor (`cursor-agent acp`), and only for
63
+ // runs the workspace already gates with runtime permissions; every other
64
+ // run keeps the existing paths. Also enabled by HILOS_ACP=1.
65
+ acpTransport: false,
60
66
  // Environment handed to the coding/chat CLI. hilos's own token (HILOS_*) is
61
67
  // ALWAYS stripped either way. "inherit" (default) passes the rest of your
62
68
  // shell env so the coding tool behaves exactly as if you ran it yourself.
@@ -124,6 +130,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
124
130
  chatTimeoutMs: process.env.HILOS_CHAT_TIMEOUT_MS ? Number(process.env.HILOS_CHAT_TIMEOUT_MS) : undefined,
125
131
  backfill: process.env.HILOS_BACKFILL === "1" ? true : undefined,
126
132
  once: process.env.HILOS_ONCE === "1" ? true : undefined,
133
+ acpTransport: process.env.HILOS_ACP === "1" ? true : undefined,
127
134
  };
128
135
  const merged = { ...DEFAULTS, ...file };
129
136
  for (const [k, v] of Object.entries(env)) if (v !== undefined && v !== "") merged[k] = v;
@@ -155,6 +162,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
155
162
  // edited file can NEVER drop the daemon's connection.
156
163
  const LIVE_FIELDS = [
157
164
  "codingCmd",
165
+ "acpTransport",
158
166
  "codingModel",
159
167
  "chatCmd",
160
168
  "codingEnv",
package/src/daemon.mjs CHANGED
@@ -16,6 +16,29 @@ export function branchSlug(text, suffix) {
16
16
  return `hilos/${base}-${suffix}`;
17
17
  }
18
18
 
19
+ /**
20
+ * How to push work when the coding child committed instead of leaving a diff.
21
+ *
22
+ * The daemon owns `intendedBranch`. A child that changes branches must not also
23
+ * change the PR head: if it switches to the configured base branch, using the
24
+ * child's current branch would ask GitHub to open `main` against `main`. Push
25
+ * the current HEAD commit under the daemon-owned branch name instead. Omitting
26
+ * `-u` on recovery also avoids rewriting the accidental branch's upstream.
27
+ */
28
+ export function selfDrivenShipPlan(currentBranch, intendedBranch) {
29
+ const current = String(currentBranch || "").trim();
30
+ const intended = String(intendedBranch || "").trim();
31
+ if (!intended) throw new Error("An intended task branch is required");
32
+ const recovered = !current || current !== intended;
33
+ return {
34
+ headBranch: intended,
35
+ pushArgs: recovered
36
+ ? ["push", "origin", `HEAD:refs/heads/${intended}`]
37
+ : ["push", "-u", "origin", intended],
38
+ recoveredFrom: recovered ? current || "detached HEAD" : null,
39
+ };
40
+ }
41
+
19
42
  /** Truncate a diff to a byte budget at a line boundary. */
20
43
  export function truncateDiff(diff, maxBytes = 12000) {
21
44
  if (diff.length <= maxBytes) return { text: diff, truncated: false, omittedLines: 0 };