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 +26 -4
- package/bin/hilos-agent.mjs +16 -4
- package/package.json +1 -1
- package/src/agent-events.mjs +78 -10
- package/src/cli.mjs +122 -2
- package/src/config.mjs +47 -5
- package/src/deploy.mjs +234 -0
- package/src/handler.mjs +426 -28
- package/src/model-resolve.mjs +99 -0
- package/src/progress-emitter.mjs +40 -7
- package/src/redact.mjs +54 -0
- package/src/resume.mjs +10 -7
- package/src/run.mjs +13 -3
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
|
-
"
|
|
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` (
|
|
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
|
package/bin/hilos-agent.mjs
CHANGED
|
@@ -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
|
|
59
|
-
|
|
60
|
-
(default: "claude -p
|
|
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
|
-
|
|
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.
|
|
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": {
|
package/src/agent-events.mjs
CHANGED
|
@@ -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
|
|
5
|
-
// any of them into ONE small,
|
|
6
|
-
// live "what the agent is
|
|
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"; //
|
|
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
|
|
253
|
-
* to parse, so we only remember the last
|
|
254
|
-
*
|
|
255
|
-
* and emit at most one
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|