hilos-agent 0.5.1 → 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,8 @@ 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", "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
+ "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
46
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
47
48
  "defaultBranch": "main",
48
49
  "gate": false, // default: open a PR directly. true = approve-before-push
@@ -186,6 +187,14 @@ explains what each permission level means.
186
187
  `codingCmd` decides how much the coding agent can do on its own. Three levels,
187
188
  safest first:
188
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.
189
198
  - **`--permission-mode acceptEdits` (default).** The agent edits files without
190
199
  prompting, but in headless `claude -p` a step that needs bash — run the tests,
191
200
  install a dep — has no interactive prompt to grant, so the task can **stall**.
@@ -207,7 +216,10 @@ safest first:
207
216
 
208
217
  The default stays `acceptEdits`. Reach for `--dangerously-skip-permissions` when
209
218
  you want a truly hands-off teammate, and keep `gate:true` if you'd rather review
210
- 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.
211
223
 
212
224
  ## Hooks — stream a raw Claude Code session
213
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 = [];
@@ -31,12 +39,14 @@ function parseArgs(argv) {
31
39
  else if (a === "--url") flags.url = argv[++i];
32
40
  else if (a === "--token") flags.token = argv[++i];
33
41
  else if (a === "--coding-cmd") flags.codingCmd = argv[++i];
42
+ else if (a === "--coding-model") flags.codingModel = argv[++i];
34
43
  else if (a === "--chat-cmd") flags.chatCmd = argv[++i];
35
44
  else if (a === "--once") flags.once = true;
36
45
  else if (a === "--backfill") flags.backfill = true;
37
46
  else if (a === "--no-gate") flags.gate = false;
38
47
  else if (a === "--global") flags.global = true;
39
48
  else if (a === "-h" || a === "--help") flags.help = true;
49
+ else if (a === "-v" || a === "--version") flags.version = true;
40
50
  else positional.push(a);
41
51
  }
42
52
  return { cmd: positional[0] || "run", flags, positional };
@@ -56,20 +66,29 @@ Options:
56
66
  --channel <id> watch only one channel (per-channel override)
57
67
  --config <path> use a specific config file
58
68
  --coding-cmd <cmd> the coding agent to run — claude -p, codex exec,
59
- cursor-agent -p, agy -p, hermes, or any command that
60
- takes a prompt as its last arg (default: "claude -p")
69
+ cursor-agent -p --trust, opencode run, agy -p, hermes,
70
+ or any command
71
+ that takes a prompt as its last arg (default: "claude -p")
72
+ --coding-model <tier> model preset tier (opus | sonnet | haiku) resolved at
73
+ run time against the CLI's own model list — never a baked
74
+ id (Cursor only today; default: the tool's own model)
61
75
  --chat-cmd <cmd> fast command for chat replies + the plan-ack (default:
62
76
  derived from the coding command, so a Codex or Cursor
63
77
  daemon chats with its own tool)
64
78
  --once one poll then exit (cron-friendly)
65
79
  --backfill also act on mentions that predate startup
66
80
  --no-gate propose only; don't wait for approval / push
81
+ -v, --version print the installed version
67
82
  -h, --help this help
68
83
 
69
84
  Docs: https://hilos.sh · https://www.npmjs.com/package/hilos-agent`;
70
85
 
71
86
  async function main() {
72
87
  const { cmd, flags, positional } = parseArgs(process.argv.slice(2));
88
+ if (flags.version || cmd === "version") {
89
+ console.log(packageVersion());
90
+ return;
91
+ }
73
92
  if (flags.help || cmd === "help") {
74
93
  console.log(HELP);
75
94
  return;
@@ -98,6 +117,7 @@ async function main() {
98
117
  // non-Claude connect command doesn't write the Claude default over it.
99
118
  const starter = { ...(joinPayload || {}) };
100
119
  if (flags.codingCmd) starter.codingCmd = flags.codingCmd;
120
+ if (flags.codingModel) starter.codingModel = flags.codingModel;
101
121
  const path = writeStarterConfig(joinPayload ? GLOBAL_CONFIG : flags.config, starter);
102
122
  console.log(`Wrote ${path}.`);
103
123
  console.log(joinPayload ? "Token + endpoint set from your link." : "Fill in token + repos, then run `hilos-agent`.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.5.1",
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": {
@@ -1,9 +1,11 @@
1
1
  // Normalized vendor stream parser (0272). The KEYSTONE of the "Agents feel
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
- // emits `--json` item events, Cursor emits unstructured text). This module turns
5
- // any of them into ONE small, typed `AgentEvent` stream the UI can render as a
6
- // live "what the agent is doing right now" card.
4
+ // emits `--json` item events, Cursor emits its own `--output-format
5
+ // stream-json` NDJSON 0573, opencode emits `--format json` part events
6
+ // 0608). This module turns any of them into ONE small,
7
+ // typed `AgentEvent` stream the UI can render as a live "what the agent is
8
+ // doing right now" card.
7
9
  //
8
10
  // Design rules that make this safe to point at an untrusted, evolving CLI:
9
11
  // - PURE + dependency-free (node builtins only) so it stands alone and is
@@ -207,12 +209,184 @@ function parseCodexLine(line) {
207
209
  }
208
210
  }
209
211
 
212
+ /**
213
+ * Parse ONE Cursor `--output-format stream-json` NDJSON line → AgentEvent[].
214
+ * Shapes captured LIVE against cursor-agent 2026.07.23 (ticket 0572's audit):
215
+ * a `system`/`init` envelope with `session_id` (the `--resume` id — 0573),
216
+ * `assistant` turns whose message.content carries text blocks (tool calls
217
+ * arrive as separate events, unlike Claude's inline tool_use), `tool_call`
218
+ * started/completed envelopes keyed by kind (`editToolCall`/`readToolCall`/
219
+ * `shellToolCall`, each with an `args` object), and a terminal `result` with
220
+ * `is_error` + the full text in `result`. `thinking` deltas, the `user` echo,
221
+ * and `tool_call` completions are deliberately not steps.
222
+ */
223
+ function parseCursorLine(line) {
224
+ const obj = tryParse(line);
225
+ if (!obj) return [];
226
+ if (obj.type === "system" && typeof obj.session_id === "string") {
227
+ return [{ t: "session", sessionId: sanitizeText(obj.session_id) }];
228
+ }
229
+ if (obj.type === "result") {
230
+ const ok = obj.is_error !== true && obj.subtype !== "error";
231
+ const summary = typeof obj.result === "string" ? sanitizeText(obj.result) : undefined;
232
+ return [summary ? { t: "result", ok, summary } : { t: "result", ok }];
233
+ }
234
+ // Only `started` — the `completed` twin repeats the same call and would
235
+ // double every step.
236
+ if (obj.type === "tool_call" && obj.subtype === "started") {
237
+ const ev = cursorToolEvent(obj.tool_call);
238
+ return ev ? [ev] : [];
239
+ }
240
+ if (obj.type === "assistant" && obj.message && Array.isArray(obj.message.content)) {
241
+ const out = [];
242
+ for (const block of obj.message.content) {
243
+ if (block && block.type === "text" && typeof block.text === "string") {
244
+ const text = sanitizeText(block.text.replace(/\s+/g, " ").trim());
245
+ if (text) out.push({ t: "note", text });
246
+ }
247
+ }
248
+ return out;
249
+ }
250
+ return [];
251
+ }
252
+
253
+ /** One Cursor tool_call envelope → an AgentEvent, or null. The envelope holds a
254
+ * single `<kind>ToolCall` key; grep/ls/etc. carry less "alive" signal and are
255
+ * skipped, mirroring toolKind()'s v1 restraint — an unknown kind never crashes. */
256
+ function cursorToolEvent(toolCall) {
257
+ if (!toolCall || typeof toolCall !== "object") return null;
258
+ for (const [key, val] of Object.entries(toolCall)) {
259
+ if (!key.endsWith("ToolCall") || !val || typeof val !== "object") continue;
260
+ const args = val.args && typeof val.args === "object" ? val.args : {};
261
+ if (key === "shellToolCall" || key === "terminalToolCall") {
262
+ const cmd = typeof args.command === "string" ? args.command.replace(/\s+/g, " ").trim() : "";
263
+ return { t: "run", cmd: sanitizeText(cmd) };
264
+ }
265
+ if (key === "editToolCall" || key === "writeToolCall") {
266
+ return { t: "edit", path: typeof args.path === "string" ? sanitizeText(args.path) : "" };
267
+ }
268
+ if (key === "readToolCall") {
269
+ return { t: "read", path: typeof args.path === "string" ? sanitizeText(args.path) : "" };
270
+ }
271
+ return null;
272
+ }
273
+ return null;
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
+
210
382
  /** claude / claude_code / claude-code all mean the Claude parser. */
211
383
  function normalizeVendor(vendor) {
212
384
  const v = String(vendor || "").toLowerCase();
213
385
  if (v === "claude" || v === "claude_code" || v === "claude-code") return "claude";
214
386
  if (v === "codex") return "codex";
215
- return "cursor"; // cursor + ANY unknown vendor → lastLine fallback
387
+ if (v === "cursor") return "cursor"; // structured stream-json since 0573
388
+ if (v === "opencode") return "opencode"; // structured `--format json` since 0608
389
+ return "text"; // ANY unknown vendor → lastLine text-tail fallback
216
390
  }
217
391
 
218
392
  // --- Streaming parsers -----------------------------------------------------
@@ -249,12 +423,13 @@ function makeLineBufferedParser(parseLine) {
249
423
  }
250
424
 
251
425
  /**
252
- * Fallback for Cursor and any vendor with no structured stream. There's nothing
253
- * to parse, so we only remember the last non-empty line seen — matching exactly
254
- * what the daemon does today (handler.mjs onData) so we NEVER regress below it —
255
- * and emit at most one sparse note on flush.
426
+ * Fallback for any vendor with no structured stream (cursor graduated to a
427
+ * real parser in 0573). There's nothing to parse, so we only remember the last
428
+ * non-empty line seen matching exactly what the daemon does today
429
+ * (handler.mjs onData) so we NEVER regress below it — and emit at most one
430
+ * sparse note on flush.
256
431
  */
257
- function makeCursorParser() {
432
+ function makeTextTailParser() {
258
433
  let lastLine = "";
259
434
  return {
260
435
  /** @returns {AgentEvent[]} */
@@ -278,14 +453,18 @@ function makeCursorParser() {
278
453
 
279
454
  /**
280
455
  * Build a stateful stream parser for `vendor`.
281
- * @param {'claude'|'claude_code'|'codex'|'cursor'|string} vendor
456
+ * @param {'claude'|'claude_code'|'codex'|'cursor'|'opencode'|string} vendor
282
457
  * @returns {{ push: (chunk: string) => AgentEvent[], flush: () => AgentEvent[] }}
283
458
  */
284
459
  export function makeStreamParser(vendor) {
285
460
  const v = normalizeVendor(vendor);
286
461
  if (v === "claude") return makeLineBufferedParser(parseClaudeLine);
287
462
  if (v === "codex") return makeLineBufferedParser(parseCodexLine);
288
- return makeCursorParser();
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());
467
+ return makeTextTailParser();
289
468
  }
290
469
 
291
470
  // --- Human step labels (what the LiveRunCard shows) ------------------------
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
  /**
@@ -159,7 +187,7 @@ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
159
187
  *
160
188
  * @param {RunCliOptions} opts
161
189
  */
162
- export function runCli(opts) {
190
+ function runCliOnce(opts) {
163
191
  const {
164
192
  cmd,
165
193
  args = [],
@@ -188,12 +216,13 @@ export function runCli(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 });
@@ -289,3 +318,27 @@ export function runCli(opts) {
289
318
  child.on("close", (code) => finish(code, null));
290
319
  });
291
320
  }
321
+
322
+ // Compat retry (0572): Cursor CLIs older than Jan 2026 predate the workspace-
323
+ // trust gate and reject the `--trust` flag we now bake into cursor commands
324
+ // (`error: unknown option '--trust'`). Those CLIs don't NEED the flag (no gate
325
+ // existed), so on that exact failure runCli retries ONCE with it stripped. The
326
+ // failed attempt writes only the commander error to stderr — stdout stays
327
+ // empty — so progress streaming (onData) never sees the false start. Lives
328
+ // inside runCli (not a wrapper) so every call site, present and future, gets it.
329
+ const UNKNOWN_TRUST_RE = /unknown option '--trust'/;
330
+
331
+ /** @param {RunCliOptions} opts */
332
+ export async function runCli(opts) {
333
+ const first = await runCliOnce(opts);
334
+ const args = Array.isArray(opts?.args) ? opts.args : [];
335
+ if (
336
+ first.status !== 0 &&
337
+ !first.aborted &&
338
+ args.includes("--trust") &&
339
+ UNKNOWN_TRUST_RE.test(first.stderr || "")
340
+ ) {
341
+ return runCliOnce({ ...opts, args: args.filter((a) => a !== "--trust") });
342
+ }
343
+ return first;
344
+ }
package/src/config.mjs CHANGED
@@ -66,6 +66,11 @@ const DEFAULTS = {
66
66
  // mode with codingEnvAllow: ["MY_VAR", …].
67
67
  codingEnv: "inherit",
68
68
  codingEnvAllow: [],
69
+ // Model preset TIER for the coding run ("" / "default" = the tool's own
70
+ // default). "opus" | "sonnet" | "haiku" resolve at RUN time against the
71
+ // CLI's own model list — never a baked id that could 404 on another account
72
+ // (0504; cursor only today, see model-resolve.mjs).
73
+ codingModel: "",
69
74
  // Chat replies + the code-task plan-ack use a FAST one-shot command so a casual
70
75
  // reply (or "I see it, here's my plan") comes back in seconds, not minutes.
71
76
  // Bounded by chatTimeoutMs with a template fallback so it can never dead-air.
@@ -112,6 +117,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
112
117
  token: process.env.HILOS_TOKEN,
113
118
  channelId: process.env.HILOS_CHANNEL,
114
119
  codingCmd: process.env.CODING_CMD,
120
+ codingModel: process.env.HILOS_CODING_MODEL,
115
121
  chatCmd: process.env.HILOS_CHAT_CMD,
116
122
  heartbeatMs: process.env.HILOS_HEARTBEAT_MS ? Number(process.env.HILOS_HEARTBEAT_MS) : undefined,
117
123
  progressMs: process.env.HILOS_PROGRESS_MS ? Number(process.env.HILOS_PROGRESS_MS) : undefined,
@@ -135,8 +141,12 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
135
141
  // Local-folder map, merged like `repos` (an object, not a scalar overlay).
136
142
  merged.folders = { ...DEFAULTS.folders, ...(file.folders || {}) };
137
143
  merged.deploy = { ...DEFAULTS.deploy, ...(file.deploy || {}) };
138
- // Remember where the file lives so reloadConfig can re-read it live.
144
+ // Remember where the file lives so reloadConfig can re-read it live, and
145
+ // what it said AT LAUNCH so reload only applies fields the user actually
146
+ // edited afterwards (0576) — a pre-existing file value must not claw back
147
+ // an explicit launch flag (--coding-cmd / --coding-model) on the first poll.
139
148
  merged.configPath = findConfigPath(flags.config) || null;
149
+ merged.fileSnapshot = file;
140
150
  return merged;
141
151
  }
142
152
 
@@ -145,6 +155,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
145
155
  // edited file can NEVER drop the daemon's connection.
146
156
  const LIVE_FIELDS = [
147
157
  "codingCmd",
158
+ "codingModel",
148
159
  "chatCmd",
149
160
  "codingEnv",
150
161
  "codingEnvAllow",
@@ -182,8 +193,17 @@ export function reloadConfig(prev) {
182
193
  const path = prev.configPath;
183
194
  const file = (path && readJson(path)) || {};
184
195
  const next = { ...prev };
185
- for (const k of LIVE_FIELDS) if (file[k] !== undefined) next[k] = file[k];
196
+ // Apply only fields that CHANGED since the launch snapshot (or the last
197
+ // reload): an EDIT to hilos-agent.json wins live, as documented, but a value
198
+ // that merely pre-existed at launch can't override the flags the user just
199
+ // pasted (`--coding-cmd`/`--coding-model` on a machine with an older config
200
+ // silently flipped back on the first poll — 0576 review finding).
201
+ const snap = prev.fileSnapshot || {};
202
+ const changed = (k) => JSON.stringify(file[k]) !== JSON.stringify(snap[k]);
203
+ for (const k of LIVE_FIELDS) if (file[k] !== undefined && changed(k)) next[k] = file[k];
204
+ next.fileSnapshot = file;
186
205
  if (process.env.CODING_CMD) next.codingCmd = process.env.CODING_CMD;
206
+ if (process.env.HILOS_CODING_MODEL) next.codingModel = process.env.HILOS_CODING_MODEL;
187
207
  if (process.env.HILOS_CHAT_CMD) next.chatCmd = process.env.HILOS_CHAT_CMD;
188
208
  if (process.env.HILOS_HEARTBEAT_MS) next.heartbeatMs = Number(process.env.HILOS_HEARTBEAT_MS);
189
209
  if (process.env.HILOS_PROGRESS_MS) next.progressMs = Number(process.env.HILOS_PROGRESS_MS);
@@ -208,6 +228,7 @@ export function writeStarterConfig(path, partial = {}) {
208
228
  channelId: partial.channelId || "",
209
229
  repos: partial.repos || { "owner/name": "/absolute/path/to/checkout" },
210
230
  codingCmd: partial.codingCmd || DEFAULTS.codingCmd,
231
+ ...(partial.codingModel ? { codingModel: partial.codingModel } : {}),
211
232
  defaultBranch: DEFAULTS.defaultBranch,
212
233
  // false = open a PR directly (bias to action); true = approve-before-push.
213
234
  gate: false,