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/src/progress-emitter.mjs
CHANGED
|
@@ -21,11 +21,12 @@ import { createStepRing, sanitizeText } from "./agent-events.mjs";
|
|
|
21
21
|
/**
|
|
22
22
|
* Which parser/stream-flags a coding command wants, from its FIRST token.
|
|
23
23
|
* `claude`/`claude-code` → claude_code, `codex` → codex,
|
|
24
|
-
* `cursor`/`cursor-agent` → cursor, `
|
|
25
|
-
* `hermes` → hermes, everything else →
|
|
26
|
-
* absolute path (`/usr/local/bin/claude`) by taking the
|
|
24
|
+
* `cursor`/`cursor-agent` → cursor, `opencode` → opencode,
|
|
25
|
+
* `agy`/`antigravity` → antigravity, `hermes` → hermes, everything else →
|
|
26
|
+
* unknown. Handles an absolute path (`/usr/local/bin/claude`) by taking the
|
|
27
|
+
* basename.
|
|
27
28
|
* @param {string} codingCmd
|
|
28
|
-
* @returns {'claude_code'|'codex'|'cursor'|'antigravity'|'hermes'|'unknown'}
|
|
29
|
+
* @returns {'claude_code'|'codex'|'cursor'|'opencode'|'antigravity'|'hermes'|'unknown'}
|
|
29
30
|
*/
|
|
30
31
|
export function detectVendor(codingCmd) {
|
|
31
32
|
const first = String(codingCmd || "").trim().split(/\s+/)[0] || "";
|
|
@@ -35,6 +36,8 @@ export function detectVendor(codingCmd) {
|
|
|
35
36
|
// `agent` is Cursor's canonical binary name since Jan 2026 (the installer
|
|
36
37
|
// symlinks both; `cursor-agent` remains an alias) — 0572.
|
|
37
38
|
if (base === "cursor" || base === "cursor-agent" || base === "agent") return "cursor";
|
|
39
|
+
// opencode installs a single `opencode` binary (~/.opencode/bin) — 0592.
|
|
40
|
+
if (base === "opencode") return "opencode";
|
|
38
41
|
if (base === "agy" || base === "antigravity") return "antigravity";
|
|
39
42
|
if (base === "hermes") return "hermes";
|
|
40
43
|
return "unknown";
|
|
@@ -50,15 +53,18 @@ export function detectVendor(codingCmd) {
|
|
|
50
53
|
* --output-format text (explicit, so a CLI default change can never post raw
|
|
51
54
|
* JSONL into the channel) and --trust (its Jan-2026 workspace-trust gate fails
|
|
52
55
|
* headless runs at spawn in untrusted directories — 0572; pre-2026 CLIs reject
|
|
53
|
-
* the flag and runCli retries without it).
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
+
* the flag and runCli retries without it). opencode carries no flags: `opencode
|
|
57
|
+
* run` IS its non-interactive mode (plain `opencode` opens the TUI), and its
|
|
58
|
+
* model comes from the user's own provider config (0592). Returns "" for
|
|
59
|
+
* unknown (caller falls back to codingCmd).
|
|
60
|
+
* @param {'claude_code'|'codex'|'cursor'|'opencode'|'antigravity'|'hermes'|'unknown'} vendor
|
|
56
61
|
* @returns {string}
|
|
57
62
|
*/
|
|
58
63
|
export function fastChatCmd(vendor) {
|
|
59
64
|
if (vendor === "claude_code") return "claude -p --model claude-haiku-4-5";
|
|
60
65
|
if (vendor === "codex") return "codex exec --skip-git-repo-check";
|
|
61
66
|
if (vendor === "cursor") return "cursor-agent -p --output-format text --trust";
|
|
67
|
+
if (vendor === "opencode") return "opencode run";
|
|
62
68
|
if (vendor === "antigravity") return "agy -p";
|
|
63
69
|
if (vendor === "hermes") return "hermes -z";
|
|
64
70
|
return "";
|
|
@@ -73,15 +79,93 @@ export function fastChatCmd(vendor) {
|
|
|
73
79
|
* cursor-agent 2026.07.23 that the LAST occurrence wins, so the code run
|
|
74
80
|
* streams NDJSON while chat keeps text. codex returns [] — its stream flags
|
|
75
81
|
* are deferred to 0278 rather than guessed (an unproven flag could break the run).
|
|
76
|
-
*
|
|
82
|
+
* opencode (0608): `--format json`. 0592 left it OFF because nothing parsed its
|
|
83
|
+
* event shape and the rawTail fallback drops JSON-looking lines; the
|
|
84
|
+
* agent-events parser now reads those events (steps, the final summary, and the
|
|
85
|
+
* `sessionID` that thread pinning needs), so it's a strict upgrade, not a
|
|
86
|
+
* liveness trade. The FAST chat path stays plain `opencode run` (prose, not NDJSON).
|
|
87
|
+
* @param {'claude_code'|'codex'|'cursor'|'opencode'|'antigravity'|'hermes'|'unknown'} vendor
|
|
77
88
|
* @returns {string[]}
|
|
78
89
|
*/
|
|
79
90
|
export function codeStreamArgs(vendor) {
|
|
80
91
|
if (vendor === "claude_code") return ["--output-format", "stream-json", "--verbose"];
|
|
81
92
|
if (vendor === "cursor") return ["--output-format", "stream-json"];
|
|
93
|
+
if (vendor === "opencode") return ["--format", "json"];
|
|
82
94
|
return [];
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The server an `opencode run --attach <url>` command targets, or null for a
|
|
99
|
+
* normal local run. Read off the TOKENIZED command (never a substring match —
|
|
100
|
+
* the prompt is a separate argv entry, but a user's own `--attach` could
|
|
101
|
+
* otherwise be faked by prompt text). Supports both `--attach <url>` and
|
|
102
|
+
* `--attach=<url>`; an `--attach` with no value still counts as attached (the
|
|
103
|
+
* mode is what matters here, not the address).
|
|
104
|
+
* @param {string} codingCmd
|
|
105
|
+
* @returns {string|null}
|
|
106
|
+
*/
|
|
107
|
+
export function attachTarget(codingCmd) {
|
|
108
|
+
const tokens = String(codingCmd || "").trim().split(/\s+/).filter(Boolean);
|
|
109
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
110
|
+
if (tokens[i] === "--attach") return tokens[i + 1] && !tokens[i + 1].startsWith("-") ? tokens[i + 1] : "";
|
|
111
|
+
if (tokens[i].startsWith("--attach=")) return tokens[i].slice("--attach=".length);
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Extra args that PIN the code run to a project directory, appended to the code
|
|
118
|
+
* run's argv when the vendor needs it. opencode only (0608): live-verified on
|
|
119
|
+
* opencode 1.18.5 that it resolves its project from the environment's `PWD`
|
|
120
|
+
* rather than from the spawned process's cwd, so a daemon launched anywhere
|
|
121
|
+
* else runs the CLI against the WRONG directory — and its sessions are scoped
|
|
122
|
+
* per project, so a `--session` created under another directory HANGS the CLI
|
|
123
|
+
* instead of erroring. `--dir <cwd>` makes the project deterministic and equal
|
|
124
|
+
* to the directory we actually run in, which is what makes thread-to-session
|
|
125
|
+
* pinning sound. Every other vendor honors cwd → [].
|
|
126
|
+
*
|
|
127
|
+
* 0615 made the spawned env's `PWD` match the cwd on every path, so this is no
|
|
128
|
+
* longer the only thing holding opencode to the right directory. It stays: it
|
|
129
|
+
* is the CLI's own explicit contract for the project (an env var is a side
|
|
130
|
+
* channel), and it keeps the attach-mode distinction below meaningful.
|
|
131
|
+
*
|
|
132
|
+
* EXCEPTION — `--attach`: when the command attaches to a running opencode
|
|
133
|
+
* server, `--dir` means "a path on the REMOTE server", so handing it our local
|
|
134
|
+
* repo path would point the run at a directory that may not exist there. An
|
|
135
|
+
* attached server owns its own project, so we send nothing and let it decide.
|
|
136
|
+
* @param {'claude_code'|'codex'|'cursor'|'opencode'|'antigravity'|'hermes'|'unknown'} vendor
|
|
137
|
+
* @param {string} cwd
|
|
138
|
+
* @param {string} [codingCmd] — the full coding command, for the --attach check
|
|
139
|
+
* @returns {string[]}
|
|
140
|
+
*/
|
|
141
|
+
export function codeDirArgs(vendor, cwd, codingCmd = "") {
|
|
142
|
+
if (vendor !== "opencode") return [];
|
|
143
|
+
if (attachTarget(codingCmd) !== null) return []; // remote project — not ours to set
|
|
144
|
+
if (typeof cwd !== "string" || !cwd.trim()) return [];
|
|
145
|
+
return ["--dir", cwd];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The identity of the PROJECT a captured session belongs to — recorded with the
|
|
150
|
+
* session (0608) and required to match before a resume, because an opencode
|
|
151
|
+
* `--session` from a different project hangs the CLI. Locally that identity is
|
|
152
|
+
* the directory we run in; under `--attach` the project lives on the remote
|
|
153
|
+
* server, so the identity is that server (`attach:<url>`) — two threads pointed
|
|
154
|
+
* at different servers still can't cross sessions, and switching a command from
|
|
155
|
+
* local to attached (or between servers) correctly refuses to resume.
|
|
156
|
+
* Non-opencode vendors just record the directory.
|
|
157
|
+
* @param {'claude_code'|'codex'|'cursor'|'opencode'|'antigravity'|'hermes'|'unknown'} vendor
|
|
158
|
+
* @param {string} cwd
|
|
159
|
+
* @param {string} [codingCmd]
|
|
160
|
+
* @returns {string}
|
|
161
|
+
*/
|
|
162
|
+
export function codeProjectKey(vendor, cwd, codingCmd = "") {
|
|
163
|
+
const dir = typeof cwd === "string" ? cwd : "";
|
|
164
|
+
if (vendor !== "opencode") return dir;
|
|
165
|
+
const attached = attachTarget(codingCmd);
|
|
166
|
+
return attached === null ? dir : `attach:${attached}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
85
169
|
// A raw stdout line that starts like JSON is the structured NDJSON we already
|
|
86
170
|
// parse into steps — keep it OUT of the `lastLine` text fallback (which exists
|
|
87
171
|
// for text-only CLIs), so a Claude run never surfaces raw JSON as its status.
|
package/src/resume.mjs
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// with full context.
|
|
8
8
|
//
|
|
9
9
|
// Two pieces, both PURE where it matters + node-builtins-only:
|
|
10
|
-
// 1. buildResumeArgs(vendor, sessionId) — the vendor-gated
|
|
10
|
+
// 1. buildResumeArgs(vendor, sessionId) — the vendor-gated resume flags
|
|
11
|
+
// (`--resume` for claude_code/cursor, `--session` for opencode — 0608).
|
|
11
12
|
// 2. a tiny best-effort state store keyed by thread_root_msg_id at
|
|
12
13
|
// ~/.hilos/state.json. The LOCAL record proves a session exists on THIS
|
|
13
14
|
// machine — the confidence signal the handler gates `--resume` on (a
|
|
@@ -17,8 +18,9 @@
|
|
|
17
18
|
// - The pure transforms (parse/merge/prune/serialize) take an injected clock
|
|
18
19
|
// (nowMs) and never call Date.now/os, so they unit-test with a fixed time.
|
|
19
20
|
// - Every fs read/write is guarded: a state IO failure NEVER breaks a run.
|
|
20
|
-
// - We NEVER guess a vendor's resume flag.
|
|
21
|
-
//
|
|
21
|
+
// - We NEVER guess a vendor's resume flag. claude_code, cursor and opencode
|
|
22
|
+
// have proven ones (0282/0573/0608); codex degrades to today's
|
|
23
|
+
// branch+feedback iterate (deferred to 0278+).
|
|
22
24
|
|
|
23
25
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
24
26
|
import { homedir } from "node:os";
|
|
@@ -32,28 +34,95 @@ export const STATE_FILE = "state.json";
|
|
|
32
34
|
* and a genuinely stale session can never be resurrected for a resume. */
|
|
33
35
|
export const STATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
34
36
|
|
|
37
|
+
/** opencode session ids are `ses_` + an opaque id (live-captured off
|
|
38
|
+
* `opencode run --format json`, 1.18.5). The prefix is the cheap shape guard
|
|
39
|
+
* that keeps a foreign id (a claude/cursor uuid inherited from an older state
|
|
40
|
+
* entry) from ever reaching `--session`. */
|
|
41
|
+
const OPENCODE_SESSION_RE = /^ses_[A-Za-z0-9_-]+$/;
|
|
42
|
+
|
|
35
43
|
/**
|
|
36
|
-
* The
|
|
44
|
+
* The resume flags for a coding vendor, or [] when resume isn't safe/known.
|
|
37
45
|
*
|
|
38
46
|
* claude_code: `--resume <id>`, proven and compatible with
|
|
39
47
|
* `--output-format stream-json`. cursor (0573): `--resume <chatId>` —
|
|
40
48
|
* live-verified against cursor-agent 2026.07.23 that a resumed `-p` run
|
|
41
49
|
* answers from the prior session's context; the id is the `session_id` its
|
|
42
|
-
* stream-json init/result events carry.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
50
|
+
* stream-json init/result events carry. opencode (0608): `--session <ses_…>`
|
|
51
|
+
* — live-verified against opencode 1.18.5 that `opencode run -s <id> "…"`
|
|
52
|
+
* continues the pinned session non-interactively (a follow-up recalled what
|
|
53
|
+
* the first run did); the id is the `sessionID` every `--format json` event
|
|
54
|
+
* carries. Its `--fork` twin is deliberately NOT emitted: the daemon has no
|
|
55
|
+
* thread-branching semantics to map onto it today. codex has an UNCONFIRMED
|
|
56
|
+
* resume flag, so we emit NOTHING rather than guess — a wrong flag could break
|
|
57
|
+
* the run; it degrades to today's branch+feedback iterate. A falsy or
|
|
58
|
+
* non-string sessionId also returns [] (nothing to resume).
|
|
46
59
|
*
|
|
47
|
-
* @param {'claude_code'|'codex'|'cursor'|'hermes'|'unknown'} vendor
|
|
60
|
+
* @param {'claude_code'|'codex'|'cursor'|'opencode'|'hermes'|'unknown'} vendor
|
|
48
61
|
* @param {string|null|undefined} sessionId
|
|
49
62
|
* @returns {string[]}
|
|
50
63
|
*/
|
|
51
64
|
export function buildResumeArgs(vendor, sessionId) {
|
|
52
|
-
if (vendor !== "claude_code" && vendor !== "cursor") return [];
|
|
53
65
|
if (typeof sessionId !== "string" || !sessionId.trim()) return [];
|
|
66
|
+
if (vendor === "opencode") {
|
|
67
|
+
// A wrong id is not free here: an unknown id exits 1 ("Session not found"),
|
|
68
|
+
// and an id from ANOTHER project directory HANGS the CLI — so only pass a
|
|
69
|
+
// well-shaped one. The handler's project-dir gate covers the rest (0608).
|
|
70
|
+
return OPENCODE_SESSION_RE.test(sessionId.trim()) ? ["--session", sessionId.trim()] : [];
|
|
71
|
+
}
|
|
72
|
+
if (vendor !== "claude_code" && vendor !== "cursor") return [];
|
|
54
73
|
return ["--resume", sessionId];
|
|
55
74
|
}
|
|
56
75
|
|
|
76
|
+
/** Vendors whose resume flag is proven (see buildResumeArgs). */
|
|
77
|
+
const RESUMABLE_VENDORS = new Set(["claude_code", "cursor", "opencode"]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Should this iterate resume a recorded session? PURE — the handler passes what
|
|
81
|
+
* it read, this decides, so the whole gate is unit-testable.
|
|
82
|
+
*
|
|
83
|
+
* Confidence, in order: a resumable vendor, a LOCAL record (the proof the
|
|
84
|
+
* session lives on this machine — a providerSessionId that came only from the
|
|
85
|
+
* server likely belongs to another machine/instance), the same hostname, the
|
|
86
|
+
* same VENDOR, the same PROJECT, an id, and agreement with any server-side id.
|
|
87
|
+
* Anything short of that degrades to the branch+feedback iterate, which is only
|
|
88
|
+
* ever slower — never wrong.
|
|
89
|
+
*
|
|
90
|
+
* - vendor: an entry written by a different CLI holds a foreign id shape. Swap
|
|
91
|
+
* `codingCmd` from opencode to claude between runs (the daemon hot-reloads
|
|
92
|
+
* config) and a `ses_…` would reach `--resume`: a guaranteed failed
|
|
93
|
+
* invocation. An entry written before 0608 has no vendor → degrade once, then
|
|
94
|
+
* the next capture rewrites the entry with one.
|
|
95
|
+
* - project: opencode sessions are scoped per project, and a `--session` from
|
|
96
|
+
* another project HANGS its CLI rather than erroring (0608). Only enforced
|
|
97
|
+
* for opencode, whose entries carry the key; other vendors resume as before.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} o
|
|
100
|
+
* @param {string} o.vendor — the CURRENT command's vendor
|
|
101
|
+
* @param {object|null} o.entry — the local state entry for this thread root
|
|
102
|
+
* @param {string|null} [o.serverSessionId] — activeRun.providerSessionId
|
|
103
|
+
* @param {string} o.machine — this hostname
|
|
104
|
+
* @param {string} [o.projectKey] — codeProjectKey() for the run we're about to do
|
|
105
|
+
* @returns {{ sessionId: string|null, reason: string }}
|
|
106
|
+
*/
|
|
107
|
+
export function resumeDecision({ vendor, entry, serverSessionId = null, machine, projectKey = "" }) {
|
|
108
|
+
if (!RESUMABLE_VENDORS.has(vendor)) return { sessionId: null, reason: "vendor-has-no-resume-flag" };
|
|
109
|
+
const local = entry && typeof entry === "object" ? entry : null;
|
|
110
|
+
if (!local || !local.sessionId) {
|
|
111
|
+
return { sessionId: null, reason: serverSessionId ? "no-local-record" : "nothing-recorded" };
|
|
112
|
+
}
|
|
113
|
+
if (local.machine !== machine) return { sessionId: null, reason: "recorded-on-another-machine" };
|
|
114
|
+
if (local.vendor !== vendor) {
|
|
115
|
+
return { sessionId: null, reason: local.vendor ? "recorded-by-another-vendor" : "vendor-not-recorded" };
|
|
116
|
+
}
|
|
117
|
+
if (vendor === "opencode" && local.cwd !== projectKey) {
|
|
118
|
+
return { sessionId: null, reason: local.cwd ? "recorded-in-another-project" : "project-not-recorded" };
|
|
119
|
+
}
|
|
120
|
+
if (serverSessionId && serverSessionId !== local.sessionId) {
|
|
121
|
+
return { sessionId: null, reason: "server-records-a-different-session" };
|
|
122
|
+
}
|
|
123
|
+
return { sessionId: local.sessionId, reason: "ok" };
|
|
124
|
+
}
|
|
125
|
+
|
|
57
126
|
// ---------------------------------------------------------------------------
|
|
58
127
|
// Pure state transforms (no fs, no clock, no os — the caller injects nowMs).
|
|
59
128
|
// ---------------------------------------------------------------------------
|
|
@@ -122,8 +191,12 @@ export function readStateEntry(dir, threadRoot) {
|
|
|
122
191
|
/**
|
|
123
192
|
* Write/overwrite the entry for `threadRoot`, pruning stale entries. Best-effort:
|
|
124
193
|
* returns true on success, false on any failure (a state write NEVER breaks a run).
|
|
125
|
-
* `entry` should carry { runId, branch, prUrl, sessionId, machine,
|
|
126
|
-
*
|
|
194
|
+
* `entry` should carry { runId, branch, prUrl, sessionId, machine, vendor, cwd,
|
|
195
|
+
* updatedAt }; `vendor` is the CLI that created the session (an id is only valid
|
|
196
|
+
* for its own vendor) and `cwd` is the project key it belongs to — opencode
|
|
197
|
+
* sessions are scoped to a project, so its resume gate requires the match
|
|
198
|
+
* (0608). Both are read back by resumeDecision(). The prune clock derives from
|
|
199
|
+
* entry.updatedAt (falling back to the injected now()).
|
|
127
200
|
*
|
|
128
201
|
* @param {string} dir
|
|
129
202
|
* @param {string} threadRoot
|
package/src/run.mjs
CHANGED
|
@@ -99,6 +99,11 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
99
99
|
// Semantic local-folder intent (0515): the server guarantees a forced-tool
|
|
100
100
|
// ask/ship/deploy decision. Older servers fall back to the local router.
|
|
101
101
|
agentIntent: toolNames.includes("classify_agent_intent"),
|
|
102
|
+
// Harness-enforced runtime permission bridge (0593). Both tools are
|
|
103
|
+
// required: a request without a durable reply path must fail closed.
|
|
104
|
+
runtimePermissions:
|
|
105
|
+
toolNames.includes("request_permission") &&
|
|
106
|
+
toolNames.includes("get_permission_decision"),
|
|
102
107
|
};
|
|
103
108
|
|
|
104
109
|
// Register local folders (0324/0325): a folder-mode daemon announces each
|