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.
@@ -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, `agy`/`antigravity` → antigravity,
25
- * `hermes` → hermes, everything else → unknown. Handles an
26
- * absolute path (`/usr/local/bin/claude`) by taking the basename.
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). Returns "" for unknown (caller
54
- * falls back to codingCmd).
55
- * @param {'claude_code'|'codex'|'cursor'|'antigravity'|'hermes'|'unknown'} vendor
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
- * @param {'claude_code'|'codex'|'cursor'|'antigravity'|'hermes'|'unknown'} vendor
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 `--resume` flags.
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. Only claude_code has a proven one;
21
- // codex/cursor degrade to today's branch+feedback iterate (deferred to 0278+).
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 `--resume` flags for a coding vendor, or [] when resume isn't safe/known.
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. codex has an UNCONFIRMED resume flag,
43
- * so we emit NOTHING rather than guess — a wrong flag could break the run; it
44
- * degrades to today's branch+feedback iterate. A falsy or non-string sessionId
45
- * also returns [] (nothing to resume).
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, updatedAt }; the
126
- * prune clock derives from entry.updatedAt (falling back to the injected now()).
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