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.
@@ -21,18 +21,23 @@ 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] || "";
32
33
  const base = (first.split(/[/\\]/).pop() || "").toLowerCase();
33
34
  if (base === "claude" || base === "claude-code" || base === "claude_code") return "claude_code";
34
35
  if (base === "codex") return "codex";
35
- if (base === "cursor" || base === "cursor-agent") return "cursor";
36
+ // `agent` is Cursor's canonical binary name since Jan 2026 (the installer
37
+ // symlinks both; `cursor-agent` remains an alias) — 0572.
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";
36
41
  if (base === "agy" || base === "antigravity") return "antigravity";
37
42
  if (base === "hermes") return "hermes";
38
43
  return "unknown";
@@ -45,16 +50,21 @@ export function detectVendor(codingCmd) {
45
50
  * verified non-interactive print mode; the daemon appends the prompt as the last
46
51
  * arg. codex carries --skip-git-repo-check because chat (and the read-only
47
52
  * review sandbox) can run outside a git checkout. cursor carries
48
- * --output-format text because its -p default is stream-json, which would post
49
- * raw JSONL into the channel. Returns "" for unknown (caller falls back to
50
- * codingCmd).
51
- * @param {'claude_code'|'codex'|'cursor'|'antigravity'|'hermes'|'unknown'} vendor
53
+ * --output-format text (explicit, so a CLI default change can never post raw
54
+ * JSONL into the channel) and --trust (its Jan-2026 workspace-trust gate fails
55
+ * headless runs at spawn in untrusted directories — 0572; pre-2026 CLIs reject
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
52
61
  * @returns {string}
53
62
  */
54
63
  export function fastChatCmd(vendor) {
55
64
  if (vendor === "claude_code") return "claude -p --model claude-haiku-4-5";
56
65
  if (vendor === "codex") return "codex exec --skip-git-repo-check";
57
- if (vendor === "cursor") return "cursor-agent -p --output-format text";
66
+ if (vendor === "cursor") return "cursor-agent -p --output-format text --trust";
67
+ if (vendor === "opencode") return "opencode run";
58
68
  if (vendor === "antigravity") return "agy -p";
59
69
  if (vendor === "hermes") return "hermes -z";
60
70
  return "";
@@ -62,18 +72,100 @@ export function fastChatCmd(vendor) {
62
72
 
63
73
  /**
64
74
  * Extra args to make the code run EMIT a structured stream, appended to the code
65
- * run's argv (NOT the display string) and ONLY for the code run. Only claude_code
66
- * has a proven flag (`--output-format stream-json --verbose`, per lib/agent-cli.ts
67
- * + scripts/verify-sandbox-mcp.mjs). codex/cursor return [] — their stream flags
75
+ * run's argv (NOT the display string) and ONLY for the code run. claude_code:
76
+ * `--output-format stream-json --verbose` (per lib/agent-cli.ts +
77
+ * scripts/verify-sandbox-mcp.mjs). cursor (0573): `--output-format stream-json`
78
+ * — appended AFTER the base's `--output-format text`, and live-verified on
79
+ * cursor-agent 2026.07.23 that the LAST occurrence wins, so the code run
80
+ * streams NDJSON while chat keeps text. codex returns [] — its stream flags
68
81
  * are deferred to 0278 rather than guessed (an unproven flag could break the run).
69
- * @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
70
88
  * @returns {string[]}
71
89
  */
72
90
  export function codeStreamArgs(vendor) {
73
91
  if (vendor === "claude_code") return ["--output-format", "stream-json", "--verbose"];
92
+ if (vendor === "cursor") return ["--output-format", "stream-json"];
93
+ if (vendor === "opencode") return ["--format", "json"];
74
94
  return [];
75
95
  }
76
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
+
77
169
  // A raw stdout line that starts like JSON is the structured NDJSON we already
78
170
  // parse into steps — keep it OUT of the `lastLine` text fallback (which exists
79
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,25 +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
- * ONLY claude_code has a proven, confirmed resume flag (`--resume <id>`, compatible
39
- * with `--output-format stream-json`). codex/cursor have UNCONFIRMED resume flags,
40
- * so we emit NOTHING rather than guess — a wrong flag could break the run; they
41
- * degrade to today's branch+feedback iterate (deferred to 0278/later). A falsy or
46
+ * claude_code: `--resume <id>`, proven and compatible with
47
+ * `--output-format stream-json`. cursor (0573): `--resume <chatId>` —
48
+ * live-verified against cursor-agent 2026.07.23 that a resumed `-p` run
49
+ * answers from the prior session's context; the id is the `session_id` its
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
42
58
  * non-string sessionId also returns [] (nothing to resume).
43
59
  *
44
- * @param {'claude_code'|'codex'|'cursor'|'hermes'|'unknown'} vendor
60
+ * @param {'claude_code'|'codex'|'cursor'|'opencode'|'hermes'|'unknown'} vendor
45
61
  * @param {string|null|undefined} sessionId
46
62
  * @returns {string[]}
47
63
  */
48
64
  export function buildResumeArgs(vendor, sessionId) {
49
- if (vendor !== "claude_code") return [];
50
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 [];
51
73
  return ["--resume", sessionId];
52
74
  }
53
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
+
54
126
  // ---------------------------------------------------------------------------
55
127
  // Pure state transforms (no fs, no clock, no os — the caller injects nowMs).
56
128
  // ---------------------------------------------------------------------------
@@ -119,8 +191,12 @@ export function readStateEntry(dir, threadRoot) {
119
191
  /**
120
192
  * Write/overwrite the entry for `threadRoot`, pruning stale entries. Best-effort:
121
193
  * returns true on success, false on any failure (a state write NEVER breaks a run).
122
- * `entry` should carry { runId, branch, prUrl, sessionId, machine, updatedAt }; the
123
- * 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()).
124
200
  *
125
201
  * @param {string} dir
126
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