hilos-agent 0.4.0 → 0.6.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, or any command — as
3
+ Run **your own** coding agent — Claude Code, Codex, Cursor, 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,8 +42,9 @@ 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", any command
46
- "chatCmd": "claude -p --model claude-haiku-4-5", // FAST command for chat replies + the plan-ack (set if your stack isn't Claude)
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
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
+ "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
49
50
  "heartbeatMs": 180000, // long runs post one "still working…" thread reply this often (0 = off, min 15s)
@@ -60,7 +61,8 @@ then edits it in place with elapsed time + the CLI's latest line — so the chan
60
61
  shows it's alive without thread spam. When the run ends, that message is retired
61
62
  to a short "done" line. A run that **times out or errors** says so honestly (with
62
63
  a stderr tail) instead of claiming "no changes". Chat replies use the faster
63
- `chatCmd` (default Haiku; falls back to `codingCmd` if unset) bounded by
64
+ `chatCmd` (when unset, derived from `codingCmd`'s tool a Claude daemon chats
65
+ with Haiku, a Codex daemon with `codex exec`, and so on) bounded by
64
66
  `chatTimeoutMs`. The responsive surface needs a hilos server new enough to expose
65
67
  `edit_message`; older servers just skip the live edits.
66
68
 
@@ -125,6 +127,26 @@ It's your own machine, so this is the same trust as running the CLI yourself.
125
127
  startup, so a `folders` entry added while the daemon is running needs a restart
126
128
  to appear.
127
129
 
130
+ ### Deploy the folder with your own hosting CLI
131
+
132
+ Add an optional per-channel deploy target next to `folders`:
133
+
134
+ ```jsonc
135
+ {
136
+ "folders": { "<channelId>": "/Users/you/notes-site" },
137
+ "deploy": { "<channelId>": { "provider": "vercel", "prod": false } }
138
+ }
139
+ ```
140
+
141
+ `prod:false` means a preview deployment; `prod:true` means production. If the
142
+ setting is absent, the daemon detects `.vercel/` / `vercel.json` or `.netlify/`
143
+ / `netlify.toml`; with no marker, deploy stays off. Install and sign in to the
144
+ matching CLI yourself (`vercel login` or `netlify login`). hilos stores no host
145
+ credential and never deploys silently: ask explicitly to put the folder live,
146
+ or use the report card's clearly labeled deploy action. The live URL comes back
147
+ on the same report card. CLI output is secret-redacted, the child receives no
148
+ `HILOS_*` variables, and failures remain caveats rather than false successes.
149
+
128
150
  ## Embedding the daemon
129
151
 
130
152
  `run(cfg, opts)` is the poll loop, and it's embeddable. Beyond `handler`/`log` it
@@ -31,6 +31,7 @@ function parseArgs(argv) {
31
31
  else if (a === "--url") flags.url = argv[++i];
32
32
  else if (a === "--token") flags.token = argv[++i];
33
33
  else if (a === "--coding-cmd") flags.codingCmd = argv[++i];
34
+ else if (a === "--coding-model") flags.codingModel = argv[++i];
34
35
  else if (a === "--chat-cmd") flags.chatCmd = argv[++i];
35
36
  else if (a === "--once") flags.once = true;
36
37
  else if (a === "--backfill") flags.backfill = true;
@@ -55,9 +56,15 @@ const HELP = `hilos-agent — your coding agent as a teammate in hilos
55
56
  Options:
56
57
  --channel <id> watch only one channel (per-channel override)
57
58
  --config <path> use a specific config file
58
- --coding-cmd <cmd> the coding agent to run (default: "claude -p")
59
- --chat-cmd <cmd> fast command for chat replies + the plan-ack
60
- (default: "claude -p --model claude-haiku-4-5")
59
+ --coding-cmd <cmd> the coding agent to run claude -p, codex exec,
60
+ cursor-agent -p --trust, agy -p, hermes, or any command
61
+ that takes a prompt as its last arg (default: "claude -p")
62
+ --coding-model <tier> model preset tier (opus | sonnet | haiku) resolved at
63
+ run time against the CLI's own model list — never a baked
64
+ id (Cursor only today; default: the tool's own model)
65
+ --chat-cmd <cmd> fast command for chat replies + the plan-ack (default:
66
+ derived from the coding command, so a Codex or Cursor
67
+ daemon chats with its own tool)
61
68
  --once one poll then exit (cron-friendly)
62
69
  --backfill also act on mentions that predate startup
63
70
  --no-gate propose only; don't wait for approval / push
@@ -91,7 +98,12 @@ async function main() {
91
98
  }
92
99
 
93
100
  if (cmd === "init") {
94
- const path = writeStarterConfig(joinPayload ? GLOBAL_CONFIG : flags.config, joinPayload || {});
101
+ // Carry an explicit --coding-cmd into the starter file so an init run from a
102
+ // non-Claude connect command doesn't write the Claude default over it.
103
+ const starter = { ...(joinPayload || {}) };
104
+ if (flags.codingCmd) starter.codingCmd = flags.codingCmd;
105
+ if (flags.codingModel) starter.codingModel = flags.codingModel;
106
+ const path = writeStarterConfig(joinPayload ? GLOBAL_CONFIG : flags.config, starter);
95
107
  console.log(`Wrote ${path}.`);
96
108
  console.log(joinPayload ? "Token + endpoint set from your link." : "Fill in token + repos, then run `hilos-agent`.");
97
109
  console.log('Map your repos: "repos": { "owner/name": "/abs/path/to/checkout" }');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.4.0",
3
+ "version": "0.6.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,10 @@
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). This module turns any of them into ONE small,
6
+ // typed `AgentEvent` stream the UI can render as a live "what the agent is
7
+ // doing right now" card.
7
8
  //
8
9
  // Design rules that make this safe to point at an untrusted, evolving CLI:
9
10
  // - PURE + dependency-free (node builtins only) so it stands alone and is
@@ -207,12 +208,77 @@ function parseCodexLine(line) {
207
208
  }
208
209
  }
209
210
 
211
+ /**
212
+ * Parse ONE Cursor `--output-format stream-json` NDJSON line → AgentEvent[].
213
+ * Shapes captured LIVE against cursor-agent 2026.07.23 (ticket 0572's audit):
214
+ * a `system`/`init` envelope with `session_id` (the `--resume` id — 0573),
215
+ * `assistant` turns whose message.content carries text blocks (tool calls
216
+ * arrive as separate events, unlike Claude's inline tool_use), `tool_call`
217
+ * started/completed envelopes keyed by kind (`editToolCall`/`readToolCall`/
218
+ * `shellToolCall`, each with an `args` object), and a terminal `result` with
219
+ * `is_error` + the full text in `result`. `thinking` deltas, the `user` echo,
220
+ * and `tool_call` completions are deliberately not steps.
221
+ */
222
+ function parseCursorLine(line) {
223
+ const obj = tryParse(line);
224
+ if (!obj) return [];
225
+ if (obj.type === "system" && typeof obj.session_id === "string") {
226
+ return [{ t: "session", sessionId: sanitizeText(obj.session_id) }];
227
+ }
228
+ if (obj.type === "result") {
229
+ const ok = obj.is_error !== true && obj.subtype !== "error";
230
+ const summary = typeof obj.result === "string" ? sanitizeText(obj.result) : undefined;
231
+ return [summary ? { t: "result", ok, summary } : { t: "result", ok }];
232
+ }
233
+ // Only `started` — the `completed` twin repeats the same call and would
234
+ // double every step.
235
+ if (obj.type === "tool_call" && obj.subtype === "started") {
236
+ const ev = cursorToolEvent(obj.tool_call);
237
+ return ev ? [ev] : [];
238
+ }
239
+ if (obj.type === "assistant" && obj.message && Array.isArray(obj.message.content)) {
240
+ const out = [];
241
+ for (const block of obj.message.content) {
242
+ if (block && block.type === "text" && typeof block.text === "string") {
243
+ const text = sanitizeText(block.text.replace(/\s+/g, " ").trim());
244
+ if (text) out.push({ t: "note", text });
245
+ }
246
+ }
247
+ return out;
248
+ }
249
+ return [];
250
+ }
251
+
252
+ /** One Cursor tool_call envelope → an AgentEvent, or null. The envelope holds a
253
+ * single `<kind>ToolCall` key; grep/ls/etc. carry less "alive" signal and are
254
+ * skipped, mirroring toolKind()'s v1 restraint — an unknown kind never crashes. */
255
+ function cursorToolEvent(toolCall) {
256
+ if (!toolCall || typeof toolCall !== "object") return null;
257
+ for (const [key, val] of Object.entries(toolCall)) {
258
+ if (!key.endsWith("ToolCall") || !val || typeof val !== "object") continue;
259
+ const args = val.args && typeof val.args === "object" ? val.args : {};
260
+ if (key === "shellToolCall" || key === "terminalToolCall") {
261
+ const cmd = typeof args.command === "string" ? args.command.replace(/\s+/g, " ").trim() : "";
262
+ return { t: "run", cmd: sanitizeText(cmd) };
263
+ }
264
+ if (key === "editToolCall" || key === "writeToolCall") {
265
+ return { t: "edit", path: typeof args.path === "string" ? sanitizeText(args.path) : "" };
266
+ }
267
+ if (key === "readToolCall") {
268
+ return { t: "read", path: typeof args.path === "string" ? sanitizeText(args.path) : "" };
269
+ }
270
+ return null;
271
+ }
272
+ return null;
273
+ }
274
+
210
275
  /** claude / claude_code / claude-code all mean the Claude parser. */
211
276
  function normalizeVendor(vendor) {
212
277
  const v = String(vendor || "").toLowerCase();
213
278
  if (v === "claude" || v === "claude_code" || v === "claude-code") return "claude";
214
279
  if (v === "codex") return "codex";
215
- return "cursor"; // cursor + ANY unknown vendor → lastLine fallback
280
+ if (v === "cursor") return "cursor"; // structured stream-json since 0573
281
+ return "text"; // ANY unknown vendor → lastLine text-tail fallback
216
282
  }
217
283
 
218
284
  // --- Streaming parsers -----------------------------------------------------
@@ -249,12 +315,13 @@ function makeLineBufferedParser(parseLine) {
249
315
  }
250
316
 
251
317
  /**
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.
318
+ * Fallback for any vendor with no structured stream (cursor graduated to a
319
+ * real parser in 0573). There's nothing to parse, so we only remember the last
320
+ * non-empty line seen matching exactly what the daemon does today
321
+ * (handler.mjs onData) so we NEVER regress below it — and emit at most one
322
+ * sparse note on flush.
256
323
  */
257
- function makeCursorParser() {
324
+ function makeTextTailParser() {
258
325
  let lastLine = "";
259
326
  return {
260
327
  /** @returns {AgentEvent[]} */
@@ -285,7 +352,8 @@ export function makeStreamParser(vendor) {
285
352
  const v = normalizeVendor(vendor);
286
353
  if (v === "claude") return makeLineBufferedParser(parseClaudeLine);
287
354
  if (v === "codex") return makeLineBufferedParser(parseCodexLine);
288
- return makeCursorParser();
355
+ if (v === "cursor") return makeLineBufferedParser(parseCursorLine);
356
+ return makeTextTailParser();
289
357
  }
290
358
 
291
359
  // --- Human step labels (what the LiveRunCard shows) ------------------------
package/src/cli.mjs CHANGED
@@ -7,6 +7,88 @@
7
7
 
8
8
  import { spawn } from "node:child_process";
9
9
 
10
+ // ── Environment isolation ────────────────────────────────────────────────────
11
+ // The coding/chat CLI we spawn (`claude -p`, `codex`, …) is a model with tool
12
+ // use: it can run `env`/`printenv` and echo whatever it sees into the channel.
13
+ // So hilos's OWN workspace bearer token (HILOS_TOKEN and the rest of the HILOS_*
14
+ // connection vars) must NEVER be in the child's environment — the coding tool
15
+ // never needs it (the DAEMON talks to hilos, the child only edits files), and
16
+ // leaking it would hand a reader the keys to the workspace. We strip HILOS_* by
17
+ // default on every spawn. Note the common `--join` paste-one-command flow keeps
18
+ // the token in daemon memory (never in env), so this is a no-op there and only
19
+ // bites the env-auth path (`HILOS_TOKEN=… hilos-agent`) — which is exactly where
20
+ // the leak was. Hooks read the token from the config file, so they're unaffected.
21
+
22
+ // Control flags (not secrets) that must survive the scrub: the documented
23
+ // HILOS_HOOKS=off kill switch is read by the hook helper INSIDE the spawned
24
+ // CLI's process tree, so stripping it would silently re-enable hook sends
25
+ // from daemon-spawned sessions. Nothing here carries workspace access.
26
+ const HILOS_CONTROL_KEYS = new Set(["HILOS_HOOKS"]);
27
+
28
+ /**
29
+ * True for a hilos-owned connection var the coding tool must never inherit.
30
+ * @param {string} key
31
+ */
32
+ function isHilosSecretKey(key) {
33
+ return /^HILOS_/i.test(key) && !HILOS_CONTROL_KEYS.has(key.toUpperCase());
34
+ }
35
+
36
+ /**
37
+ * A shallow copy of `base` (default `process.env`) with every hilos-owned env
38
+ * var (HILOS_*) removed. Applied to every child by runCli so the model can't
39
+ * read — and therefore can't echo — hilos's workspace token.
40
+ * @param {Record<string, string | undefined>} [base]
41
+ * @returns {Record<string, string>}
42
+ */
43
+ export function scrubHilosEnv(base = process.env) {
44
+ const out = {};
45
+ for (const [k, v] of Object.entries(base)) {
46
+ if (v === undefined) continue;
47
+ if (isHilosSecretKey(k)) continue;
48
+ out[k] = v;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ // Strict-isolation allowlist: what a coding CLI genuinely needs to start and
54
+ // authenticate to ITS OWN provider — nothing else. Everything outside this list
55
+ // (AWS/GCP/DB creds, PATs, SSH-agent sockets, arbitrary secrets in the user's
56
+ // shell) is dropped so a task can't sweep them up. Opt-in via `codingEnv:
57
+ // "minimal"`; users can widen it with `codingEnvAllow: ["FOO", …]`.
58
+ const MINIMAL_ENV_EXACT = new Set([
59
+ "PATH", "HOME", "USER", "LOGNAME", "SHELL", "TERM", "TMPDIR", "TMP", "TEMP",
60
+ "TZ", "PWD", "LANG", "LC_ALL", "LC_CTYPE", "COLUMNS", "LINES", "COLORTERM",
61
+ ]);
62
+ // Prefixes the coding tools use for their own config/auth (never hilos's).
63
+ const MINIMAL_ENV_PREFIXES = [
64
+ "LC_", "XDG_", "NODE_", "NPM_", "npm_", "NVM_", "VOLTA_", "FNM_",
65
+ "ANTHROPIC_", "CLAUDE_", "OPENAI_", "CODEX_", "CURSOR_", "QWEN_", "DASHSCOPE_",
66
+ "SSL_", "NODE_EXTRA_CA_",
67
+ ];
68
+
69
+ /**
70
+ * A curated child environment for strict isolation: only the vars a coding CLI
71
+ * needs to run and reach its own model provider, plus any `extraAllow` names the
72
+ * user opted into. HILOS_* is always excluded. Use for `codingEnv: "minimal"`.
73
+ * @param {Record<string, string | undefined>} [base]
74
+ * @param {string[]} [extraAllow]
75
+ * @returns {Record<string, string>}
76
+ */
77
+ export function minimalEnv(base = process.env, extraAllow = []) {
78
+ const allowExtra = new Set(extraAllow || []);
79
+ const out = {};
80
+ for (const [k, v] of Object.entries(base)) {
81
+ if (v === undefined || isHilosSecretKey(k)) continue;
82
+ const ok =
83
+ MINIMAL_ENV_EXACT.has(k) ||
84
+ HILOS_CONTROL_KEYS.has(k.toUpperCase()) ||
85
+ allowExtra.has(k) ||
86
+ MINIMAL_ENV_PREFIXES.some((p) => k.startsWith(p));
87
+ if (ok) out[k] = v;
88
+ }
89
+ return out;
90
+ }
91
+
10
92
  /** Human-readable elapsed time: "45s", "2m 3s". */
11
93
  export function fmtElapsed(ms) {
12
94
  const total = Math.max(0, Math.round(ms / 1000));
@@ -62,6 +144,9 @@ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
62
144
  * whole process group: SIGTERM, then SIGKILL after a short grace)
63
145
  * @property {(chunk: string) => void} [onData] - called with each stdout chunk as
64
146
  * it arrives (lets a caller track the latest output line for a heartbeat)
147
+ * @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).
65
150
  */
66
151
 
67
152
  /**
@@ -74,7 +159,7 @@ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
74
159
  *
75
160
  * @param {RunCliOptions} opts
76
161
  */
77
- export function runCli(opts) {
162
+ function runCliOnce(opts) {
78
163
  const {
79
164
  cmd,
80
165
  args = [],
@@ -86,6 +171,7 @@ export function runCli(opts) {
86
171
  log = console,
87
172
  signal,
88
173
  onData,
174
+ env,
89
175
  } = opts || {};
90
176
  return new Promise((resolve) => {
91
177
  // Already cancelled before we even start.
@@ -98,7 +184,17 @@ export function runCli(opts) {
98
184
  // detached:true makes the child its own process-group leader, so we can
99
185
  // kill the WHOLE tree (claude → node → git …) with process.kill(-pid) on
100
186
  // cancel/timeout instead of orphaning its subprocesses.
101
- child = spawn(cmd, args, { cwd, detached: true });
187
+ // stdin MUST be 'ignore' (/dev/null instant EOF), never a dangling pipe:
188
+ // `codex exec` appends piped stdin to its prompt and blocks until EOF, so
189
+ // an open pipe hangs it until the run timeout ("Reading additional input
190
+ // from stdin…"). Nothing we spawn is ever fed via stdin.
191
+ // Always strip hilos's own token from the child's env (see scrubHilosEnv).
192
+ child = spawn(cmd, args, {
193
+ cwd,
194
+ detached: true,
195
+ stdio: ["ignore", "pipe", "pipe"],
196
+ env: scrubHilosEnv(env || process.env),
197
+ });
102
198
  } catch (error) {
103
199
  resolve({ status: null, stdout: "", stderr: "", error });
104
200
  return;
@@ -193,3 +289,27 @@ export function runCli(opts) {
193
289
  child.on("close", (code) => finish(code, null));
194
290
  });
195
291
  }
292
+
293
+ // Compat retry (0572): Cursor CLIs older than Jan 2026 predate the workspace-
294
+ // trust gate and reject the `--trust` flag we now bake into cursor commands
295
+ // (`error: unknown option '--trust'`). Those CLIs don't NEED the flag (no gate
296
+ // existed), so on that exact failure runCli retries ONCE with it stripped. The
297
+ // failed attempt writes only the commander error to stderr — stdout stays
298
+ // empty — so progress streaming (onData) never sees the false start. Lives
299
+ // inside runCli (not a wrapper) so every call site, present and future, gets it.
300
+ const UNKNOWN_TRUST_RE = /unknown option '--trust'/;
301
+
302
+ /** @param {RunCliOptions} opts */
303
+ export async function runCli(opts) {
304
+ const first = await runCliOnce(opts);
305
+ const args = Array.isArray(opts?.args) ? opts.args : [];
306
+ if (
307
+ first.status !== 0 &&
308
+ !first.aborted &&
309
+ args.includes("--trust") &&
310
+ UNKNOWN_TRUST_RE.test(first.stderr || "")
311
+ ) {
312
+ return runCliOnce({ ...opts, args: args.filter((a) => a !== "--trust") });
313
+ }
314
+ return first;
315
+ }
package/src/config.mjs CHANGED
@@ -48,15 +48,36 @@ const DEFAULTS = {
48
48
  // folder on this machine. Changes apply directly (no branch/PR). Live-reloadable
49
49
  // like `repos`. Shape: { "<channelId>": "/abs/path" }.
50
50
  folders: {},
51
+ // Optional local-folder deploy target. Outward deploys are always triggered
52
+ // explicitly by a request or report-card action; this only chooses provider
53
+ // and preview (default) vs production. Shape:
54
+ // { "<channelId>": { provider: "vercel" | "netlify", prod: false } }.
55
+ deploy: {},
51
56
  // acceptEdits lets the CLI make file edits without prompting (bias to action);
52
57
  // it still won't run arbitrary commands. Override in hilos-agent.json if you
53
58
  // want a stricter (or `--dangerously-skip-permissions`) command.
54
59
  codingCmd: "claude -p --permission-mode acceptEdits",
55
- // Chat replies + the code-task plan-ack use a FAST one-shot model so a casual
60
+ // Environment handed to the coding/chat CLI. hilos's own token (HILOS_*) is
61
+ // ALWAYS stripped either way. "inherit" (default) passes the rest of your
62
+ // shell env so the coding tool behaves exactly as if you ran it yourself.
63
+ // "minimal" hands the child only what it needs to run + reach its own model
64
+ // provider (PATH/HOME/locale + ANTHROPIC_*/OPENAI_*/… ), dropping unrelated
65
+ // secrets (AWS/DB/SSH-agent/etc.) so a task can't sweep them up. Widen minimal
66
+ // mode with codingEnvAllow: ["MY_VAR", …].
67
+ codingEnv: "inherit",
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: "",
74
+ // Chat replies + the code-task plan-ack use a FAST one-shot command so a casual
56
75
  // reply (or "I see it, here's my plan") comes back in seconds, not minutes.
57
76
  // Bounded by chatTimeoutMs with a template fallback so it can never dead-air.
58
- // If your coding stack isn't Claude, set this to your own fast reply command.
59
- chatCmd: "claude -p --model claude-haiku-4-5",
77
+ // Empty = derive from codingCmd's vendor (fastChatCmd in progress-emitter.mjs),
78
+ // so a Codex/Cursor daemon chats with ITS OWN tool — Claude Code is never
79
+ // required just because it's hilos's default. Set explicitly to override.
80
+ chatCmd: "",
60
81
  defaultBranch: "main",
61
82
  // Bias to action: open a PR directly for review (approve = merge on the card).
62
83
  // Set gate:true for the older approve-before-push flow (propose a diff, wait).
@@ -96,6 +117,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
96
117
  token: process.env.HILOS_TOKEN,
97
118
  channelId: process.env.HILOS_CHANNEL,
98
119
  codingCmd: process.env.CODING_CMD,
120
+ codingModel: process.env.HILOS_CODING_MODEL,
99
121
  chatCmd: process.env.HILOS_CHAT_CMD,
100
122
  heartbeatMs: process.env.HILOS_HEARTBEAT_MS ? Number(process.env.HILOS_HEARTBEAT_MS) : undefined,
101
123
  progressMs: process.env.HILOS_PROGRESS_MS ? Number(process.env.HILOS_PROGRESS_MS) : undefined,
@@ -118,8 +140,13 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
118
140
  merged.repos = { ...DEFAULTS.repos, ...(file.repos || {}) };
119
141
  // Local-folder map, merged like `repos` (an object, not a scalar overlay).
120
142
  merged.folders = { ...DEFAULTS.folders, ...(file.folders || {}) };
121
- // Remember where the file lives so reloadConfig can re-read it live.
143
+ merged.deploy = { ...DEFAULTS.deploy, ...(file.deploy || {}) };
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.
122
148
  merged.configPath = findConfigPath(flags.config) || null;
149
+ merged.fileSnapshot = file;
123
150
  return merged;
124
151
  }
125
152
 
@@ -128,7 +155,10 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
128
155
  // edited file can NEVER drop the daemon's connection.
129
156
  const LIVE_FIELDS = [
130
157
  "codingCmd",
158
+ "codingModel",
131
159
  "chatCmd",
160
+ "codingEnv",
161
+ "codingEnvAllow",
132
162
  "defaultBranch",
133
163
  "gate",
134
164
  "maxRounds",
@@ -146,6 +176,7 @@ const LIVE_FIELDS = [
146
176
  // live merge (with DEFAULTS) happens in the special-cased block below, exactly
147
177
  // like `repos`, so a partial edit doesn't drop the defaults.
148
178
  "folders",
179
+ "deploy",
149
180
  ];
150
181
 
151
182
  /**
@@ -162,8 +193,17 @@ export function reloadConfig(prev) {
162
193
  const path = prev.configPath;
163
194
  const file = (path && readJson(path)) || {};
164
195
  const next = { ...prev };
165
- 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;
166
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;
167
207
  if (process.env.HILOS_CHAT_CMD) next.chatCmd = process.env.HILOS_CHAT_CMD;
168
208
  if (process.env.HILOS_HEARTBEAT_MS) next.heartbeatMs = Number(process.env.HILOS_HEARTBEAT_MS);
169
209
  if (process.env.HILOS_PROGRESS_MS) next.progressMs = Number(process.env.HILOS_PROGRESS_MS);
@@ -174,6 +214,7 @@ export function reloadConfig(prev) {
174
214
  // Merge the folder map like repos (overriding the raw scalar-loop assignment
175
215
  // above with a proper DEFAULTS-merged object).
176
216
  if (file.folders) next.folders = { ...DEFAULTS.folders, ...file.folders };
217
+ if (file.deploy) next.deploy = { ...DEFAULTS.deploy, ...file.deploy };
177
218
  return next;
178
219
  }
179
220
 
@@ -187,6 +228,7 @@ export function writeStarterConfig(path, partial = {}) {
187
228
  channelId: partial.channelId || "",
188
229
  repos: partial.repos || { "owner/name": "/absolute/path/to/checkout" },
189
230
  codingCmd: partial.codingCmd || DEFAULTS.codingCmd,
231
+ ...(partial.codingModel ? { codingModel: partial.codingModel } : {}),
190
232
  defaultBranch: DEFAULTS.defaultBranch,
191
233
  // false = open a PR directly (bias to action); true = approve-before-push.
192
234
  gate: false,