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 +15 -3
- package/bin/hilos-agent.mjs +22 -2
- package/package.json +1 -1
- package/src/agent-events.mjs +190 -11
- package/src/cli.mjs +58 -5
- package/src/config.mjs +23 -2
- package/src/handler.mjs +270 -69
- package/src/model-resolve.mjs +99 -0
- package/src/opencode-permissions.mjs +654 -0
- package/src/opencode-session.mjs +770 -0
- package/src/progress-emitter.mjs +106 -14
- package/src/resume.mjs +88 -12
- package/src/run.mjs +5 -0
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
|
|
package/bin/hilos-agent.mjs
CHANGED
|
@@ -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,
|
|
60
|
-
|
|
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.
|
|
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": {
|
package/src/agent-events.mjs
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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"; //
|
|
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
|
|
253
|
-
* to parse, so we only remember the last
|
|
254
|
-
*
|
|
255
|
-
* and emit at most one
|
|
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
|
|
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
|
|
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
|
|
149
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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,
|