hilos-agent 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # hilos-agent
2
2
 
3
- Run **your own** coding agent — Claude Code, Codex, Cursor, Hermes, or any command — as
3
+ Run **your own** coding agent — Claude Code, Codex, Cursor, opencode, Hermes, or any command — as
4
4
  an autonomous teammate inside a [hilos](https://hilos.sh) channel.
5
5
 
6
6
  It connects to hilos over MCP, watches for `@mentions` of your agent in a
@@ -42,7 +42,7 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
42
42
  "url": "https://hilos.sh/api/mcp",
43
43
  "token": "mgo_…",
44
44
  "repos": { "your-org/your-repo": "/Users/you/code/your-repo" },
45
- "codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent -p --output-format text --trust", "agy -p", any command
45
+ "codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent -p --output-format text --trust", "opencode run", "agy -p", any command
46
46
  "codingModel": "", // model preset tier ("opus" | "sonnet" | "haiku") resolved at run time against the CLI's own model list (Cursor only today); "" = the tool's default
47
47
  "chatCmd": "", // FAST command for chat replies + the plan-ack. Empty = derived from codingCmd's tool (codex daemons chat with codex, etc.); set to override
48
48
  "defaultBranch": "main",
@@ -187,6 +187,14 @@ explains what each permission level means.
187
187
  `codingCmd` decides how much the coding agent can do on its own. Three levels,
188
188
  safest first:
189
189
 
190
+ - **`opencode run` (runtime-gated).** When the connected hilos server advertises
191
+ runtime permissions, the daemon runs OpenCode through an authenticated
192
+ loopback server and becomes its sole permission responder. A tool ask pauses
193
+ mechanically, posts a card in the run thread, and resumes only after a channel
194
+ member chooses **Allow once**, an exact harness-suggested **Always** rule, or
195
+ **Deny**. Missing transport, expiry, and cancellation all reject the tool
196
+ call. `opencode run --auto` deliberately bypasses these cards and keeps
197
+ OpenCode's dangerous auto-approve behavior.
190
198
  - **`--permission-mode acceptEdits` (default).** The agent edits files without
191
199
  prompting, but in headless `claude -p` a step that needs bash — run the tests,
192
200
  install a dep — has no interactive prompt to grant, so the task can **stall**.
@@ -208,7 +216,10 @@ safest first:
208
216
 
209
217
  The default stays `acceptEdits`. Reach for `--dangerously-skip-permissions` when
210
218
  you want a truly hands-off teammate, and keep `gate:true` if you'd rather review
211
- before anything is pushed.
219
+ before anything is pushed. OpenCode is the first harness with the runtime-card
220
+ bridge; Claude Code, Codex, Cursor, and other adapters still follow their own
221
+ CLI permission modes until their native approval hooks join the same
222
+ vendor-neutral hilos substrate.
212
223
 
213
224
  ## Hooks — stream a raw Claude Code session
214
225
 
@@ -16,10 +16,18 @@
16
16
  // codingCmd in hilos-agent.json to change it and the daemon picks it up on its
17
17
  // next poll — no restart needed.
18
18
 
19
+ import { readFileSync } from "node:fs";
20
+ import { fileURLToPath } from "node:url";
21
+
19
22
  import { resolveConfig, decodeJoin, writeStarterConfig, GLOBAL_CONFIG } from "../src/config.mjs";
20
23
  import { run } from "../src/run.mjs";
21
24
  import { hookMain, hooksMain } from "../src/hook.mjs";
22
25
 
26
+ function packageVersion() {
27
+ const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
28
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version;
29
+ }
30
+
23
31
  function parseArgs(argv) {
24
32
  const flags = {};
25
33
  const positional = [];
@@ -38,6 +46,7 @@ function parseArgs(argv) {
38
46
  else if (a === "--no-gate") flags.gate = false;
39
47
  else if (a === "--global") flags.global = true;
40
48
  else if (a === "-h" || a === "--help") flags.help = true;
49
+ else if (a === "-v" || a === "--version") flags.version = true;
41
50
  else positional.push(a);
42
51
  }
43
52
  return { cmd: positional[0] || "run", flags, positional };
@@ -57,7 +66,8 @@ Options:
57
66
  --channel <id> watch only one channel (per-channel override)
58
67
  --config <path> use a specific config file
59
68
  --coding-cmd <cmd> the coding agent to run — claude -p, codex exec,
60
- cursor-agent -p --trust, agy -p, hermes, or any command
69
+ cursor-agent -p --trust, opencode run, agy -p, hermes,
70
+ or any command
61
71
  that takes a prompt as its last arg (default: "claude -p")
62
72
  --coding-model <tier> model preset tier (opus | sonnet | haiku) resolved at
63
73
  run time against the CLI's own model list — never a baked
@@ -68,12 +78,17 @@ Options:
68
78
  --once one poll then exit (cron-friendly)
69
79
  --backfill also act on mentions that predate startup
70
80
  --no-gate propose only; don't wait for approval / push
81
+ -v, --version print the installed version
71
82
  -h, --help this help
72
83
 
73
84
  Docs: https://hilos.sh · https://www.npmjs.com/package/hilos-agent`;
74
85
 
75
86
  async function main() {
76
87
  const { cmd, flags, positional } = parseArgs(process.argv.slice(2));
88
+ if (flags.version || cmd === "version") {
89
+ console.log(packageVersion());
90
+ return;
91
+ }
77
92
  if (flags.help || cmd === "help") {
78
93
  console.log(HELP);
79
94
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.6.0",
3
+ "version": "0.7.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": {
@@ -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
  //
@@ -272,12 +273,119 @@ function cursorToolEvent(toolCall) {
272
273
  return null;
273
274
  }
274
275
 
276
+ /** One opencode `tool_use` part → an AgentEvent, or null. Tool names are
277
+ * lowercase (`read`/`write`/`edit`/`patch`/`bash`); the arguments live under
278
+ * `state.input` (`filePath` for file tools, `command` for bash). Anything else
279
+ * (glob/grep/webfetch/task) carries less "alive" signal and is skipped, mirroring
280
+ * toolKind()'s v1 restraint — an unknown tool never crashes the parser. */
281
+ function opencodeToolEvent(part) {
282
+ if (!part || typeof part !== "object") return null;
283
+ const tool = typeof part.tool === "string" ? part.tool.toLowerCase() : "";
284
+ const state = part.state && typeof part.state === "object" ? part.state : {};
285
+ const input = state.input && typeof state.input === "object" ? state.input : {};
286
+ if (tool === "bash") {
287
+ const raw = input.command;
288
+ const cmd = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : "";
289
+ return { t: "run", cmd: sanitizeText(cmd) };
290
+ }
291
+ if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
292
+ const raw = input.filePath ?? input.file_path ?? input.path;
293
+ return { t: "edit", path: typeof raw === "string" ? sanitizeText(raw) : "" };
294
+ }
295
+ if (tool === "read") {
296
+ const raw = input.filePath ?? input.file_path ?? input.path;
297
+ return { t: "read", path: typeof raw === "string" ? sanitizeText(raw) : "" };
298
+ }
299
+ return null;
300
+ }
301
+
302
+ /**
303
+ * A stateful line parser for opencode `run --format json` NDJSON (0608).
304
+ *
305
+ * Shapes captured LIVE against opencode 1.18.5: EVERY event carries a top-level
306
+ * `sessionID` (`ses_…`) — including the very first `step_start` and the terminal
307
+ * `error` — which is exactly the id `--session` resumes. `text` events carry a
308
+ * WHOLE text part (`part.text`), not a delta, so a part is emitted once and a
309
+ * repeat of the same `part.id` with identical text is dropped (a growing part
310
+ * re-emits, so the room still sees the latest wording). `tool_use` events carry
311
+ * the tool + its input; they're deduped by `callID` so a state update can't
312
+ * double a step. `step_finish` closes an assistant turn (`part.reason`): `stop`
313
+ * ends the run, so it becomes the `result` carrying the last text as the summary
314
+ * (opencode has no separate result event, and the folder-run report reads that
315
+ * summary). `error` (`{ error: { name, data: { message } } }`) → a failed result.
316
+ *
317
+ * Stateful (last text, seen part/call ids) — hence a factory, not a bare fn.
318
+ */
319
+ function makeOpencodeLineParser() {
320
+ const seenText = new Map(); // part.id → last emitted text
321
+ const seenCalls = new Set(); // tool callID
322
+ let sessionSeen = false;
323
+ let lastText = "";
324
+ return function parseOpencodeLine(line) {
325
+ const obj = tryParse(line);
326
+ if (!obj) return [];
327
+ const out = [];
328
+ // The session id rides on every event; emit it once, off whichever lands first.
329
+ if (!sessionSeen && typeof obj.sessionID === "string" && obj.sessionID) {
330
+ sessionSeen = true;
331
+ out.push({ t: "session", sessionId: sanitizeText(obj.sessionID) });
332
+ }
333
+ const part = obj.part && typeof obj.part === "object" ? obj.part : {};
334
+ switch (obj.type) {
335
+ case "text": {
336
+ const text = typeof part.text === "string" ? sanitizeText(part.text.replace(/\s+/g, " ").trim()) : "";
337
+ if (!text) break;
338
+ const id = typeof part.id === "string" ? part.id : "";
339
+ if (id && seenText.get(id) === text) break; // same part, same text → not news
340
+ if (id) {
341
+ seenText.set(id, text);
342
+ if (seenText.size > 200) seenText.delete(seenText.keys().next().value); // bound
343
+ }
344
+ lastText = text;
345
+ out.push({ t: "note", text });
346
+ break;
347
+ }
348
+ case "tool_use": {
349
+ const callId = typeof part.callID === "string" ? part.callID : "";
350
+ if (callId && seenCalls.has(callId)) break; // status updates repeat the call
351
+ if (callId) {
352
+ seenCalls.add(callId);
353
+ if (seenCalls.size > 500) seenCalls.delete(seenCalls.values().next().value); // bound
354
+ }
355
+ const ev = opencodeToolEvent(part);
356
+ if (ev) out.push(ev);
357
+ break;
358
+ }
359
+ case "step_finish": {
360
+ // Only the turn that stops ends the RUN; `tool-calls` means another
361
+ // assistant turn follows.
362
+ if (part.reason === "stop") {
363
+ out.push(lastText ? { t: "result", ok: true, summary: lastText } : { t: "result", ok: true });
364
+ }
365
+ break;
366
+ }
367
+ case "error": {
368
+ const err = obj.error && typeof obj.error === "object" ? obj.error : {};
369
+ const data = err.data && typeof err.data === "object" ? err.data : {};
370
+ const raw = typeof data.message === "string" ? data.message : typeof err.name === "string" ? err.name : "";
371
+ const summary = sanitizeText(raw);
372
+ out.push(summary ? { t: "result", ok: false, summary } : { t: "result", ok: false });
373
+ break;
374
+ }
375
+ default:
376
+ break;
377
+ }
378
+ return out;
379
+ };
380
+ }
381
+
275
382
  /** claude / claude_code / claude-code all mean the Claude parser. */
276
383
  function normalizeVendor(vendor) {
277
384
  const v = String(vendor || "").toLowerCase();
278
385
  if (v === "claude" || v === "claude_code" || v === "claude-code") return "claude";
279
386
  if (v === "codex") return "codex";
280
387
  if (v === "cursor") return "cursor"; // structured stream-json since 0573
388
+ if (v === "opencode") return "opencode"; // structured `--format json` since 0608
281
389
  return "text"; // ANY unknown vendor → lastLine text-tail fallback
282
390
  }
283
391
 
@@ -345,7 +453,7 @@ function makeTextTailParser() {
345
453
 
346
454
  /**
347
455
  * Build a stateful stream parser for `vendor`.
348
- * @param {'claude'|'claude_code'|'codex'|'cursor'|string} vendor
456
+ * @param {'claude'|'claude_code'|'codex'|'cursor'|'opencode'|string} vendor
349
457
  * @returns {{ push: (chunk: string) => AgentEvent[], flush: () => AgentEvent[] }}
350
458
  */
351
459
  export function makeStreamParser(vendor) {
@@ -353,6 +461,9 @@ export function makeStreamParser(vendor) {
353
461
  if (v === "claude") return makeLineBufferedParser(parseClaudeLine);
354
462
  if (v === "codex") return makeLineBufferedParser(parseCodexLine);
355
463
  if (v === "cursor") return makeLineBufferedParser(parseCursorLine);
464
+ // opencode's line parser carries per-stream state (dedupe + last text), so
465
+ // each parser instance gets its own.
466
+ if (v === "opencode") return makeLineBufferedParser(makeOpencodeLineParser());
356
467
  return makeTextTailParser();
357
468
  }
358
469
 
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 });