hilos-agent 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -3
- package/bin/hilos-agent.mjs +16 -1
- package/package.json +1 -1
- package/src/agent-events.mjs +113 -2
- package/src/cli.mjs +33 -4
- package/src/handler.mjs +259 -69
- package/src/opencode-permissions.mjs +654 -0
- package/src/opencode-session.mjs +770 -0
- package/src/progress-emitter.mjs +92 -8
- package/src/resume.mjs +85 -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,7 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
|
|
|
42
42
|
"url": "https://hilos.sh/api/mcp",
|
|
43
43
|
"token": "mgo_…",
|
|
44
44
|
"repos": { "your-org/your-repo": "/Users/you/code/your-repo" },
|
|
45
|
-
"codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent -p --output-format text --trust", "agy -p", any command
|
|
45
|
+
"codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent -p --output-format text --trust", "opencode run", "agy -p", any command
|
|
46
46
|
"codingModel": "", // model preset tier ("opus" | "sonnet" | "haiku") resolved at run time against the CLI's own model list (Cursor only today); "" = the tool's default
|
|
47
47
|
"chatCmd": "", // FAST command for chat replies + the plan-ack. Empty = derived from codingCmd's tool (codex daemons chat with codex, etc.); set to override
|
|
48
48
|
"defaultBranch": "main",
|
|
@@ -187,6 +187,14 @@ explains what each permission level means.
|
|
|
187
187
|
`codingCmd` decides how much the coding agent can do on its own. Three levels,
|
|
188
188
|
safest first:
|
|
189
189
|
|
|
190
|
+
- **`opencode run` (runtime-gated).** When the connected hilos server advertises
|
|
191
|
+
runtime permissions, the daemon runs OpenCode through an authenticated
|
|
192
|
+
loopback server and becomes its sole permission responder. A tool ask pauses
|
|
193
|
+
mechanically, posts a card in the run thread, and resumes only after a channel
|
|
194
|
+
member chooses **Allow once**, an exact harness-suggested **Always** rule, or
|
|
195
|
+
**Deny**. Missing transport, expiry, and cancellation all reject the tool
|
|
196
|
+
call. `opencode run --auto` deliberately bypasses these cards and keeps
|
|
197
|
+
OpenCode's dangerous auto-approve behavior.
|
|
190
198
|
- **`--permission-mode acceptEdits` (default).** The agent edits files without
|
|
191
199
|
prompting, but in headless `claude -p` a step that needs bash — run the tests,
|
|
192
200
|
install a dep — has no interactive prompt to grant, so the task can **stall**.
|
|
@@ -208,7 +216,10 @@ safest first:
|
|
|
208
216
|
|
|
209
217
|
The default stays `acceptEdits`. Reach for `--dangerously-skip-permissions` when
|
|
210
218
|
you want a truly hands-off teammate, and keep `gate:true` if you'd rather review
|
|
211
|
-
before anything is pushed.
|
|
219
|
+
before anything is pushed. OpenCode is the first harness with the runtime-card
|
|
220
|
+
bridge; Claude Code, Codex, Cursor, and other adapters still follow their own
|
|
221
|
+
CLI permission modes until their native approval hooks join the same
|
|
222
|
+
vendor-neutral hilos substrate.
|
|
212
223
|
|
|
213
224
|
## Hooks — stream a raw Claude Code session
|
|
214
225
|
|
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 = [];
|
|
@@ -38,6 +46,7 @@ function parseArgs(argv) {
|
|
|
38
46
|
else if (a === "--no-gate") flags.gate = false;
|
|
39
47
|
else if (a === "--global") flags.global = true;
|
|
40
48
|
else if (a === "-h" || a === "--help") flags.help = true;
|
|
49
|
+
else if (a === "-v" || a === "--version") flags.version = true;
|
|
41
50
|
else positional.push(a);
|
|
42
51
|
}
|
|
43
52
|
return { cmd: positional[0] || "run", flags, positional };
|
|
@@ -57,7 +66,8 @@ Options:
|
|
|
57
66
|
--channel <id> watch only one channel (per-channel override)
|
|
58
67
|
--config <path> use a specific config file
|
|
59
68
|
--coding-cmd <cmd> the coding agent to run — claude -p, codex exec,
|
|
60
|
-
cursor-agent -p --trust, agy -p, hermes,
|
|
69
|
+
cursor-agent -p --trust, opencode run, agy -p, hermes,
|
|
70
|
+
or any command
|
|
61
71
|
that takes a prompt as its last arg (default: "claude -p")
|
|
62
72
|
--coding-model <tier> model preset tier (opus | sonnet | haiku) resolved at
|
|
63
73
|
run time against the CLI's own model list — never a baked
|
|
@@ -68,12 +78,17 @@ Options:
|
|
|
68
78
|
--once one poll then exit (cron-friendly)
|
|
69
79
|
--backfill also act on mentions that predate startup
|
|
70
80
|
--no-gate propose only; don't wait for approval / push
|
|
81
|
+
-v, --version print the installed version
|
|
71
82
|
-h, --help this help
|
|
72
83
|
|
|
73
84
|
Docs: https://hilos.sh · https://www.npmjs.com/package/hilos-agent`;
|
|
74
85
|
|
|
75
86
|
async function main() {
|
|
76
87
|
const { cmd, flags, positional } = parseArgs(process.argv.slice(2));
|
|
88
|
+
if (flags.version || cmd === "version") {
|
|
89
|
+
console.log(packageVersion());
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
77
92
|
if (flags.help || cmd === "help") {
|
|
78
93
|
console.log(HELP);
|
|
79
94
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.
|
|
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
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// alive" epic: coding CLIs each narrate their work in a different, unstable
|
|
3
3
|
// wire format (Claude Code emits `--output-format stream-json` NDJSON, Codex
|
|
4
4
|
// emits `--json` item events, Cursor emits its own `--output-format
|
|
5
|
-
// stream-json` NDJSON — 0573
|
|
5
|
+
// stream-json` NDJSON — 0573, opencode emits `--format json` part events —
|
|
6
|
+
// 0608). This module turns any of them into ONE small,
|
|
6
7
|
// typed `AgentEvent` stream the UI can render as a live "what the agent is
|
|
7
8
|
// doing right now" card.
|
|
8
9
|
//
|
|
@@ -272,12 +273,119 @@ function cursorToolEvent(toolCall) {
|
|
|
272
273
|
return null;
|
|
273
274
|
}
|
|
274
275
|
|
|
276
|
+
/** One opencode `tool_use` part → an AgentEvent, or null. Tool names are
|
|
277
|
+
* lowercase (`read`/`write`/`edit`/`patch`/`bash`); the arguments live under
|
|
278
|
+
* `state.input` (`filePath` for file tools, `command` for bash). Anything else
|
|
279
|
+
* (glob/grep/webfetch/task) carries less "alive" signal and is skipped, mirroring
|
|
280
|
+
* toolKind()'s v1 restraint — an unknown tool never crashes the parser. */
|
|
281
|
+
function opencodeToolEvent(part) {
|
|
282
|
+
if (!part || typeof part !== "object") return null;
|
|
283
|
+
const tool = typeof part.tool === "string" ? part.tool.toLowerCase() : "";
|
|
284
|
+
const state = part.state && typeof part.state === "object" ? part.state : {};
|
|
285
|
+
const input = state.input && typeof state.input === "object" ? state.input : {};
|
|
286
|
+
if (tool === "bash") {
|
|
287
|
+
const raw = input.command;
|
|
288
|
+
const cmd = typeof raw === "string" ? raw.replace(/\s+/g, " ").trim() : "";
|
|
289
|
+
return { t: "run", cmd: sanitizeText(cmd) };
|
|
290
|
+
}
|
|
291
|
+
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
|
292
|
+
const raw = input.filePath ?? input.file_path ?? input.path;
|
|
293
|
+
return { t: "edit", path: typeof raw === "string" ? sanitizeText(raw) : "" };
|
|
294
|
+
}
|
|
295
|
+
if (tool === "read") {
|
|
296
|
+
const raw = input.filePath ?? input.file_path ?? input.path;
|
|
297
|
+
return { t: "read", path: typeof raw === "string" ? sanitizeText(raw) : "" };
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A stateful line parser for opencode `run --format json` NDJSON (0608).
|
|
304
|
+
*
|
|
305
|
+
* Shapes captured LIVE against opencode 1.18.5: EVERY event carries a top-level
|
|
306
|
+
* `sessionID` (`ses_…`) — including the very first `step_start` and the terminal
|
|
307
|
+
* `error` — which is exactly the id `--session` resumes. `text` events carry a
|
|
308
|
+
* WHOLE text part (`part.text`), not a delta, so a part is emitted once and a
|
|
309
|
+
* repeat of the same `part.id` with identical text is dropped (a growing part
|
|
310
|
+
* re-emits, so the room still sees the latest wording). `tool_use` events carry
|
|
311
|
+
* the tool + its input; they're deduped by `callID` so a state update can't
|
|
312
|
+
* double a step. `step_finish` closes an assistant turn (`part.reason`): `stop`
|
|
313
|
+
* ends the run, so it becomes the `result` carrying the last text as the summary
|
|
314
|
+
* (opencode has no separate result event, and the folder-run report reads that
|
|
315
|
+
* summary). `error` (`{ error: { name, data: { message } } }`) → a failed result.
|
|
316
|
+
*
|
|
317
|
+
* Stateful (last text, seen part/call ids) — hence a factory, not a bare fn.
|
|
318
|
+
*/
|
|
319
|
+
function makeOpencodeLineParser() {
|
|
320
|
+
const seenText = new Map(); // part.id → last emitted text
|
|
321
|
+
const seenCalls = new Set(); // tool callID
|
|
322
|
+
let sessionSeen = false;
|
|
323
|
+
let lastText = "";
|
|
324
|
+
return function parseOpencodeLine(line) {
|
|
325
|
+
const obj = tryParse(line);
|
|
326
|
+
if (!obj) return [];
|
|
327
|
+
const out = [];
|
|
328
|
+
// The session id rides on every event; emit it once, off whichever lands first.
|
|
329
|
+
if (!sessionSeen && typeof obj.sessionID === "string" && obj.sessionID) {
|
|
330
|
+
sessionSeen = true;
|
|
331
|
+
out.push({ t: "session", sessionId: sanitizeText(obj.sessionID) });
|
|
332
|
+
}
|
|
333
|
+
const part = obj.part && typeof obj.part === "object" ? obj.part : {};
|
|
334
|
+
switch (obj.type) {
|
|
335
|
+
case "text": {
|
|
336
|
+
const text = typeof part.text === "string" ? sanitizeText(part.text.replace(/\s+/g, " ").trim()) : "";
|
|
337
|
+
if (!text) break;
|
|
338
|
+
const id = typeof part.id === "string" ? part.id : "";
|
|
339
|
+
if (id && seenText.get(id) === text) break; // same part, same text → not news
|
|
340
|
+
if (id) {
|
|
341
|
+
seenText.set(id, text);
|
|
342
|
+
if (seenText.size > 200) seenText.delete(seenText.keys().next().value); // bound
|
|
343
|
+
}
|
|
344
|
+
lastText = text;
|
|
345
|
+
out.push({ t: "note", text });
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
case "tool_use": {
|
|
349
|
+
const callId = typeof part.callID === "string" ? part.callID : "";
|
|
350
|
+
if (callId && seenCalls.has(callId)) break; // status updates repeat the call
|
|
351
|
+
if (callId) {
|
|
352
|
+
seenCalls.add(callId);
|
|
353
|
+
if (seenCalls.size > 500) seenCalls.delete(seenCalls.values().next().value); // bound
|
|
354
|
+
}
|
|
355
|
+
const ev = opencodeToolEvent(part);
|
|
356
|
+
if (ev) out.push(ev);
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
case "step_finish": {
|
|
360
|
+
// Only the turn that stops ends the RUN; `tool-calls` means another
|
|
361
|
+
// assistant turn follows.
|
|
362
|
+
if (part.reason === "stop") {
|
|
363
|
+
out.push(lastText ? { t: "result", ok: true, summary: lastText } : { t: "result", ok: true });
|
|
364
|
+
}
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
case "error": {
|
|
368
|
+
const err = obj.error && typeof obj.error === "object" ? obj.error : {};
|
|
369
|
+
const data = err.data && typeof err.data === "object" ? err.data : {};
|
|
370
|
+
const raw = typeof data.message === "string" ? data.message : typeof err.name === "string" ? err.name : "";
|
|
371
|
+
const summary = sanitizeText(raw);
|
|
372
|
+
out.push(summary ? { t: "result", ok: false, summary } : { t: "result", ok: false });
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
default:
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
275
382
|
/** claude / claude_code / claude-code all mean the Claude parser. */
|
|
276
383
|
function normalizeVendor(vendor) {
|
|
277
384
|
const v = String(vendor || "").toLowerCase();
|
|
278
385
|
if (v === "claude" || v === "claude_code" || v === "claude-code") return "claude";
|
|
279
386
|
if (v === "codex") return "codex";
|
|
280
387
|
if (v === "cursor") return "cursor"; // structured stream-json since 0573
|
|
388
|
+
if (v === "opencode") return "opencode"; // structured `--format json` since 0608
|
|
281
389
|
return "text"; // ANY unknown vendor → lastLine text-tail fallback
|
|
282
390
|
}
|
|
283
391
|
|
|
@@ -345,7 +453,7 @@ function makeTextTailParser() {
|
|
|
345
453
|
|
|
346
454
|
/**
|
|
347
455
|
* Build a stateful stream parser for `vendor`.
|
|
348
|
-
* @param {'claude'|'claude_code'|'codex'|'cursor'|string} vendor
|
|
456
|
+
* @param {'claude'|'claude_code'|'codex'|'cursor'|'opencode'|string} vendor
|
|
349
457
|
* @returns {{ push: (chunk: string) => AgentEvent[], flush: () => AgentEvent[] }}
|
|
350
458
|
*/
|
|
351
459
|
export function makeStreamParser(vendor) {
|
|
@@ -353,6 +461,9 @@ export function makeStreamParser(vendor) {
|
|
|
353
461
|
if (v === "claude") return makeLineBufferedParser(parseClaudeLine);
|
|
354
462
|
if (v === "codex") return makeLineBufferedParser(parseCodexLine);
|
|
355
463
|
if (v === "cursor") return makeLineBufferedParser(parseCursorLine);
|
|
464
|
+
// opencode's line parser carries per-stream state (dedupe + last text), so
|
|
465
|
+
// each parser instance gets its own.
|
|
466
|
+
if (v === "opencode") return makeLineBufferedParser(makeOpencodeLineParser());
|
|
356
467
|
return makeTextTailParser();
|
|
357
468
|
}
|
|
358
469
|
|
package/src/cli.mjs
CHANGED
|
@@ -89,6 +89,33 @@ export function minimalEnv(base = process.env, extraAllow = []) {
|
|
|
89
89
|
return out;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// ── PWD must match the directory we actually run in (0615) ───────────────────
|
|
93
|
+
// A child spawned with `cwd` still inherits the PARENT's `PWD`, which names
|
|
94
|
+
// wherever the daemon was launched. Most tools call getcwd() and never notice,
|
|
95
|
+
// but some resolve their working project from the environment instead: live-
|
|
96
|
+
// verified on opencode 1.18.5, which read, edited, and shelled in the daemon's
|
|
97
|
+
// launch directory while hilos staged the diff in the repo clone ("no changes
|
|
98
|
+
// produced"). An env that contradicts the real cwd is simply wrong, so every
|
|
99
|
+
// spawn that sets `cwd` also sets `PWD` to it — and drops the inherited
|
|
100
|
+
// `OLDPWD`, which is both meaningless to the child and a leak of where the
|
|
101
|
+
// daemon lives. No cwd means the child inherits ours, so PWD is left alone.
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `base` with `PWD` pinned to `cwd` (and any inherited `OLDPWD` removed).
|
|
105
|
+
* Returns a copy; `base` is never mutated. A missing/blank `cwd` is a no-op.
|
|
106
|
+
* @param {Record<string, string | undefined>} base
|
|
107
|
+
* @param {string} [cwd]
|
|
108
|
+
* @returns {Record<string, string>}
|
|
109
|
+
*/
|
|
110
|
+
export function envForCwd(base, cwd) {
|
|
111
|
+
const out = { ...(base || {}) };
|
|
112
|
+
if (typeof cwd === "string" && cwd.trim()) {
|
|
113
|
+
out.PWD = cwd;
|
|
114
|
+
delete out.OLDPWD;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
92
119
|
/** Human-readable elapsed time: "45s", "2m 3s". */
|
|
93
120
|
export function fmtElapsed(ms) {
|
|
94
121
|
const total = Math.max(0, Math.round(ms / 1000));
|
|
@@ -145,8 +172,9 @@ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
|
|
|
145
172
|
* @property {(chunk: string) => void} [onData] - called with each stdout chunk as
|
|
146
173
|
* it arrives (lets a caller track the latest output line for a heartbeat)
|
|
147
174
|
* @property {Record<string, string>} [env] - base environment for the child. Any
|
|
148
|
-
* hilos-owned var (HILOS_*) is stripped from it regardless
|
|
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
|
/**
|
|
@@ -188,12 +216,13 @@ function runCliOnce(opts) {
|
|
|
188
216
|
// `codex exec` appends piped stdin to its prompt and blocks until EOF, so
|
|
189
217
|
// an open pipe hangs it until the run timeout ("Reading additional input
|
|
190
218
|
// from stdin…"). Nothing we spawn is ever fed via stdin.
|
|
191
|
-
// Always strip hilos's own token from the child's env (see scrubHilosEnv)
|
|
219
|
+
// Always strip hilos's own token from the child's env (see scrubHilosEnv),
|
|
220
|
+
// and keep PWD honest about the directory we run in (see envForCwd).
|
|
192
221
|
child = spawn(cmd, args, {
|
|
193
222
|
cwd,
|
|
194
223
|
detached: true,
|
|
195
224
|
stdio: ["ignore", "pipe", "pipe"],
|
|
196
|
-
env: scrubHilosEnv(env || process.env),
|
|
225
|
+
env: envForCwd(scrubHilosEnv(env || process.env), cwd),
|
|
197
226
|
});
|
|
198
227
|
} catch (error) {
|
|
199
228
|
resolve({ status: null, stdout: "", stderr: "", error });
|