hilos-agent 0.6.0 → 0.9.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.
@@ -16,16 +16,17 @@
16
16
  // - Defense-in-depth: every string is re-sanitized here (the parser already
17
17
  // redacts, the server will redact again) and arrays are clamped.
18
18
 
19
- import { createStepRing, sanitizeText } from "./agent-events.mjs";
19
+ import { createActivityFold, createStepRing, sanitizeText } from "./agent-events.mjs";
20
20
 
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.
@@ -141,6 +225,9 @@ export function createProgressEmitter({
141
225
  clearTimer = defaultClearTimer,
142
226
  } = {}) {
143
227
  const ring = createStepRing(stepLimit);
228
+ // The ring's richer sibling (0750): structured rows for the activity feed.
229
+ // Older servers drop the unknown field; newer ones render it (0537).
230
+ const activity = createActivityFold();
144
231
  const files = [];
145
232
  const fileSet = new Set();
146
233
  const startedAt = now();
@@ -166,6 +253,7 @@ export function createProgressEmitter({
166
253
  function fold(ev) {
167
254
  if (!ev || typeof ev !== "object") return;
168
255
  ring.push(ev); // steps[] (coalesces same-file edits + dup labels)
256
+ activity.push(ev); // feed rows (tense follows status; server re-validates)
169
257
  switch (ev.t) {
170
258
  case "session":
171
259
  if (ev.sessionId) sessionId = ev.sessionId;
@@ -191,6 +279,7 @@ export function createProgressEmitter({
191
279
  /**
192
280
  * The current live progress payload (also what `send` receives).
193
281
  * @returns {{ state: string, phase?: string, step?: string, steps: string[],
282
+ * activity?: {kind: string, text: string, status: string, n?: number}[],
194
283
  * filesTouched: string[], lastLine?: string, elapsedMs: number, sessionId?: string }}
195
284
  */
196
285
  function snapshot() {
@@ -203,6 +292,8 @@ export function createProgressEmitter({
203
292
  };
204
293
  if (phase) snap.phase = sanitizeText(phase);
205
294
  if (step) snap.step = sanitizeText(step);
295
+ const rows = activity.list();
296
+ if (rows.length) snap.activity = rows;
206
297
  const ll = sanitizeText(lastLine);
207
298
  if (ll) snap.lastLine = ll;
208
299
  if (sessionId) snap.sessionId = sanitizeText(sessionId); // for 0282 (server drops it today)
@@ -289,6 +380,9 @@ export function createProgressEmitter({
289
380
  }
290
381
  for (const ev of tail) fold(ev);
291
382
  state = finalState === "error" ? "error" : "done";
383
+ // Settle the in-flight row instead of leaving "Editing…" forever; only
384
+ // an error finish marks it failed (mirrors lib/hosted-progress.ts).
385
+ activity.settle(state === "error" ? "failed" : "done");
292
386
  if (state === "error") {
293
387
  const r = sanitizeText(reason);
294
388
  if (r) errorMessage = r;
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,16 @@ 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
+ // Relaying a human's merge/close instruction (0701/0704). Both tools are
103
+ // required; on an older server the daemon simply never relays and behaves
104
+ // exactly as before. The authority is the server's either way — the daemon
105
+ // only passes along the id of the message that asked.
106
+ prActions: toolNames.includes("merge_pr") && toolNames.includes("close_pr"),
107
+ // Harness-enforced runtime permission bridge (0593). Both tools are
108
+ // required: a request without a durable reply path must fail closed.
109
+ runtimePermissions:
110
+ toolNames.includes("request_permission") &&
111
+ toolNames.includes("get_permission_decision"),
102
112
  };
103
113
 
104
114
  // Register local folders (0324/0325): a folder-mode daemon announces each
@@ -0,0 +1,305 @@
1
+ // The thread's pull request, daemon side (0704). 0701 resolves "which PR is this
2
+ // thread about?" once on the server and serves the answer to every substrate; the
3
+ // daemon reads it off `list_mentions` as `message.threadPr` and uses it for two
4
+ // decisions:
5
+ //
6
+ // 1. ADOPT vs FRESH — a mention in a PR thread means "continue THIS pull
7
+ // request": fetch its head branch, push onto it, never `git checkout -b`.
8
+ // 2. RELAY — "merge it" / "close it" is executed through the merge_pr /
9
+ // close_pr MCP tools rather than answered with "a human has to click".
10
+ //
11
+ // Pure + dependency-free apart from followup.mjs (the ONE truth table, shared
12
+ // with the server), so every rule below unit-tests without git, gh, or a network.
13
+ //
14
+ // Both decisions are deliberately conservative. The daemon is not the authority
15
+ // on either one: the server confirms the PR is really open before an iterate can
16
+ // land (and here `gh` does too), and `executePrAction` owns the merge/close gate
17
+ // — it re-classifies the cited instruction with a forced-tool model call, checks
18
+ // the author's workspace role, and refuses fail-closed. A daemon false NEGATIVE
19
+ // costs one extra mention; a daemon false POSITIVE is still caught server-side.
20
+
21
+ import { resolveFollowupMode } from "./followup.mjs";
22
+
23
+ /**
24
+ * @typedef {Object} ThreadPrAnchor
25
+ * @property {string} repoFullName
26
+ * @property {number} prNumber
27
+ * @property {string} prUrl
28
+ * @property {string|null} state 'open' | 'merged' | 'closed' | null (unknown)
29
+ * @property {string|null} branch head branch when the evidence recorded one
30
+ * @property {string|null} source 'run' | 'report' | 'message' | 'link'
31
+ */
32
+
33
+ /**
34
+ * Sanitize the `threadPr` block off a list_mentions payload. Everything here
35
+ * arrives over the wire, so nothing is trusted: a malformed anchor is simply no
36
+ * anchor, and the daemon behaves exactly as it did before 0704.
37
+ * @param {any} raw
38
+ * @returns {ThreadPrAnchor|null}
39
+ */
40
+ export function normalizeThreadPr(raw) {
41
+ if (!raw || typeof raw !== "object") return null;
42
+ const repoFullName = typeof raw.repoFullName === "string" ? raw.repoFullName.trim() : "";
43
+ const prUrl = typeof raw.prUrl === "string" ? raw.prUrl.trim() : "";
44
+ const prNumber = Number(raw.prNumber);
45
+ if (!repoFullName || !prUrl) return null;
46
+ if (!Number.isInteger(prNumber) || prNumber <= 0) return null;
47
+ const state = typeof raw.state === "string" && raw.state.trim() ? raw.state.trim().toLowerCase() : null;
48
+ const branch = typeof raw.branch === "string" && raw.branch.trim() ? raw.branch.trim() : null;
49
+ const source = typeof raw.source === "string" && raw.source.trim() ? raw.source.trim() : null;
50
+ return { repoFullName, prNumber, prUrl, state, branch, source };
51
+ }
52
+
53
+ /** Case-insensitive `owner/repo` equality — GitHub is case-insensitive on both. */
54
+ function sameRepo(a, b) {
55
+ return String(a || "").toLowerCase() === String(b || "").toLowerCase();
56
+ }
57
+
58
+ /**
59
+ * Should this mention adopt the thread's pull request instead of cutting a new
60
+ * branch? Mirrors the server's thread-anchor fallback in app/api/agent/route.ts
61
+ * signal for signal:
62
+ *
63
+ * - Only THREAD-LOCAL evidence may direct code onto a PR. A 'link' anchor is
64
+ * channel-wide — it would route an unrelated thread's work onto whatever PR
65
+ * the room happens to have linked.
66
+ * - The anchor must live in the repo this channel clones. A pasted cross-repo
67
+ * PR is context for chat and review, never an iterate target: the daemon
68
+ * would push a same-named branch into the wrong repository.
69
+ * - A known-terminal anchor (merged/closed) never adopts. Unknown state is
70
+ * only a hypothesis — `gh` proves it before anything is checked out
71
+ * (see confirmAnchorOpen).
72
+ * - The shared truth table decides intent: an explicit "in a separate PR"
73
+ * forks even inside a PR thread.
74
+ *
75
+ * @param {{ threadPr: any, repoFullName: string, signal: string|null|undefined }} o
76
+ * @returns {{ adopt: boolean, reason: string, anchor: ThreadPrAnchor|null }}
77
+ */
78
+ export function anchorIterateDecision({ threadPr, repoFullName, signal } = {}) {
79
+ const anchor = normalizeThreadPr(threadPr);
80
+ if (!anchor) return { adopt: false, reason: "no-anchor", anchor: null };
81
+ if (anchor.source === "link") return { adopt: false, reason: "link-anchor", anchor };
82
+ if (!sameRepo(anchor.repoFullName, repoFullName)) {
83
+ return { adopt: false, reason: "other-repo", anchor };
84
+ }
85
+ if (anchor.state === "merged" || anchor.state === "closed") {
86
+ return { adopt: false, reason: `anchor-${anchor.state}`, anchor };
87
+ }
88
+ // prOpen:true is the hypothesis this function is allowed to make (the terminal
89
+ // states are already out); hasActiveRun:true says "this thread owns work" —
90
+ // the anchor IS that work, whether or not a run row survived.
91
+ const mode = resolveFollowupMode({
92
+ hasActiveRun: true,
93
+ prOpen: true,
94
+ signal: signal || "ambiguous",
95
+ });
96
+ if (mode !== "iterate") return { adopt: false, reason: "new-scope", anchor };
97
+ return { adopt: true, reason: "adopt", anchor };
98
+ }
99
+
100
+ /**
101
+ * The staleness guard. `live` is what `gh pr view --json state,headRefName,…`
102
+ * reported for the anchor; this decides whether the daemon may check that branch
103
+ * out and push to it. Same shape as the server's confirmOpenPr: open, with a head
104
+ * branch, living in the base repo (a fork head is another repository's branch —
105
+ * pushing "to it" would touch an unrelated same-named branch here).
106
+ *
107
+ * No proof, no adoption: an unreachable `gh` returns null and the run starts a
108
+ * fresh branch rather than gambling on a possibly-merged PR.
109
+ *
110
+ * @param {{ live: any, anchor: ThreadPrAnchor }} o
111
+ * @returns {{ ok: boolean, branch: string|null, reason: string }}
112
+ */
113
+ export function confirmAnchorOpen({ live, anchor } = {}) {
114
+ if (!anchor) return { ok: false, branch: null, reason: "no-anchor" };
115
+ if (!live || typeof live !== "object") return { ok: false, branch: null, reason: "unverified" };
116
+ const state = typeof live.state === "string" ? live.state.trim().toLowerCase() : "";
117
+ if (state === "merged" || state === "closed") return { ok: false, branch: null, reason: state };
118
+ if (state !== "open") return { ok: false, branch: null, reason: "unverified" };
119
+ const headRepo = typeof live.headRepoFullName === "string" ? live.headRepoFullName : null;
120
+ if (headRepo && !sameRepo(headRepo, anchor.repoFullName)) {
121
+ return { ok: false, branch: null, reason: "fork-head" };
122
+ }
123
+ const branch = typeof live.headRefName === "string" && live.headRefName.trim() ? live.headRefName.trim() : null;
124
+ if (!branch) return { ok: false, branch: null, reason: "unverified" };
125
+ return { ok: true, branch, reason: "open" };
126
+ }
127
+
128
+ /**
129
+ * One line for the run's status card when an intended adoption did NOT happen —
130
+ * the person asked inside a PR thread, so silence would read as "it's pushing to
131
+ * my PR" while a fresh branch is being cut. Returns "" when there is nothing to
132
+ * confess (the reasons that are simply "this isn't an iterate").
133
+ * @param {{ reason: string, anchor: ThreadPrAnchor|null }} o
134
+ */
135
+ export function staleAnchorNote({ reason, anchor } = {}) {
136
+ if (!anchor) return "";
137
+ const pr = `PR #${anchor.prNumber}`;
138
+ if (reason === "merged" || reason === "anchor-merged") {
139
+ return `${pr} is already merged, so this goes on a new branch instead.`;
140
+ }
141
+ if (reason === "closed" || reason === "anchor-closed") {
142
+ return `${pr} is closed, so this goes on a new branch instead.`;
143
+ }
144
+ if (reason === "fork-head") {
145
+ return `${pr} is from a fork, so I can't push to its branch — this goes on a new branch instead.`;
146
+ }
147
+ if (reason === "unverified") {
148
+ return `I couldn't confirm ${pr} is still open, so this goes on a new branch instead.`;
149
+ }
150
+ if (reason === "checkout-failed") {
151
+ return `I couldn't check out ${pr}'s branch, so this goes on a new branch instead.`;
152
+ }
153
+ return "";
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Merge / close cue classification
158
+ // ---------------------------------------------------------------------------
159
+ //
160
+ // The server's own merge/close intent is an LLM call (decideAgentIntent with
161
+ // allowPrActions). The daemon cannot reach it: the MCP `classify_agent_intent`
162
+ // tool exposes ask/ship/deploy only — it never offers review/merge/close — so
163
+ // there is no shared classifier to import here. Per 0704 this is therefore a
164
+ // deliberately narrow phrase matcher over explicit instructions, and it is a
165
+ // TRIGGER, not an authority: every match is handed to merge_pr / close_pr, where
166
+ // the server re-classifies the cited human message with a forced-tool model call
167
+ // before it spends the App's write token.
168
+ //
169
+ // Bias: miss rather than guess. A missed cue costs one clearer sentence.
170
+
171
+ /** The pronouns/nouns an instruction can name the PR with. */
172
+ const OBJECT = String.raw`(?:it|this|that|them|the\s+pr|this\s+pr|that\s+pr|the\s+pull\s+request|this\s+pull\s+request|that\s+pull\s+request|it\s+in|this\s+one|that\s+one|el\s+pr|la\s+pr)`;
173
+ const PR_REF = String.raw`(?:#\d{1,6}|https?:\/\/\S*\/pull\/\d{1,6})`;
174
+
175
+ const MERGE_PATTERNS = [
176
+ new RegExp(String.raw`\bmerge\s+${OBJECT}\b`, "i"),
177
+ new RegExp(String.raw`\bmerge\s+${PR_REF}`, "i"),
178
+ // "land it" is unambiguous for a pull request. "ship it" is NOT — it is also
179
+ // the daemon's own word for "go do the work", so it stays out of this list.
180
+ new RegExp(String.raw`\bland\s+${OBJECT}\b`, "i"),
181
+ /\bmerge\s+away\b/i,
182
+ // Spanish / Portuguese imperatives, matching the multi-language spirit of the
183
+ // follow-up cue lists.
184
+ /\b(?:mergea(?:lo|la)?|merg[eé]alo|haz\s+merge|fusiona(?:lo|la)?|fusi[oó]nal[oa])\b/i,
185
+ ];
186
+
187
+ const CLOSE_PATTERNS = [
188
+ new RegExp(String.raw`\bclose\s+${OBJECT}\b`, "i"),
189
+ new RegExp(String.raw`\bclose\s+${PR_REF}`, "i"),
190
+ new RegExp(String.raw`\b(?:abandon|discard|scrap)\s+${OBJECT}\b`, "i"),
191
+ /\b(?:cierra(?:lo|la)?|ci[eé]rral[oa]|descarta(?:lo|la)?|desc[aá]rtal[oa])\b/i,
192
+ ];
193
+
194
+ /** A sentence that says the opposite, defers, or hypothesizes is not an
195
+ * instruction. Checked against the sentence the cue appeared in. */
196
+ const DISQUALIFIERS = [
197
+ // Negation and deferral.
198
+ /\b(?:don'?t|do\s+not|dont|never|no\s+need|hold\s+off|not\s+yet|wait|hang\s+on|instead\s+of)\b/i,
199
+ /\bno\s+(?:lo|la|le)\b/i,
200
+ /\b(?:todav[ií]a|a[uú]n)\s+no\b/i,
201
+ // Conditional / sequenced — "once CI passes, merge it" is a plan, not a go.
202
+ /\b(?:if|once|when|after|before|unless|until|assuming|in\s+case|whenever)\b/i,
203
+ /\b(?:si|cuando|despu[eé]s|antes)\b/i,
204
+ // Someone else is doing it.
205
+ /\b(?:i|we)(?:'|’)?(?:ll|\s+will|\s+am\s+going\s+to|\s+can|\s+should)\s+(?:merge|close)\b/i,
206
+ /\b(?:i|we)\s+(?:just\s+)?(?:merged|closed)\b/i,
207
+ // Vocabulary that contains the verb but means something else entirely.
208
+ /\bmerge\s+(?:conflict|commit|base|queue|strategy)/i,
209
+ /\bclose\s+(?:call|enough|to)\b/i,
210
+ /\b(?:already|just)\s+(?:merged|closed)\b/i,
211
+ ];
212
+
213
+ /** Politeness markers that keep a question-shaped sentence an instruction. */
214
+ const POLITE_REQUEST = /\b(?:can\s+you|could\s+you|would\s+you|will\s+you|please|puedes|podr[ií]as|por\s+favor)\b/i;
215
+
216
+ /** Strip `@handle` mentions so "@scout merge it" reads as "merge it". */
217
+ function stripMentions(text) {
218
+ return String(text || "").replace(/(^|\s)@[a-z0-9][a-z0-9._-]*/gi, " ");
219
+ }
220
+
221
+ /** Sentences, roughly. Newlines end a sentence too — chat rarely punctuates. */
222
+ function sentences(text) {
223
+ return text
224
+ .split(/(?<=[.!?…])\s+|\n+/)
225
+ .map((s) => s.trim())
226
+ .filter(Boolean);
227
+ }
228
+
229
+ function isInstruction(sentence) {
230
+ if (DISQUALIFIERS.some((re) => re.test(sentence))) return false;
231
+ // A question is a question ("should we merge it?") unless it's the polite
232
+ // shape of an order ("can you merge it?").
233
+ if (/\?\s*$/.test(sentence) && !POLITE_REQUEST.test(sentence)) return false;
234
+ return true;
235
+ }
236
+
237
+ /**
238
+ * Does this message explicitly instruct a merge or a close of the thread's PR?
239
+ * Returns 'merge', 'close', or null. A message that appears to ask for both gets
240
+ * null — an ambiguous instruction is not one.
241
+ * @param {string} text
242
+ * @returns {'merge'|'close'|null}
243
+ */
244
+ export function classifyPrActionCue(text) {
245
+ const body = stripMentions(text);
246
+ if (!body.trim()) return null;
247
+ let merge = false;
248
+ let close = false;
249
+ for (const sentence of sentences(body)) {
250
+ if (!isInstruction(sentence)) continue;
251
+ if (MERGE_PATTERNS.some((re) => re.test(sentence))) merge = true;
252
+ if (CLOSE_PATTERNS.some((re) => re.test(sentence))) close = true;
253
+ }
254
+ if (merge && close) return null;
255
+ if (merge) return "merge";
256
+ if (close) return "close";
257
+ return null;
258
+ }
259
+
260
+ /**
261
+ * Should this mention be relayed to merge_pr / close_pr instead of routed as
262
+ * chat or code? Every "no" is a fall-through to today's behavior — the relay
263
+ * only ever ADDS a path.
264
+ *
265
+ * Requires an anchor: without one the daemon has no idea which PR "it" is, and
266
+ * guessing is exactly the failure 0701 exists to prevent. It deliberately does
267
+ * NOT check whether the anchor is in this channel's repo, whether the asker is
268
+ * allowed, or whether the message names a different PR — those are the server's
269
+ * calls, and its refusals are better written than anything decided here.
270
+ *
271
+ * @param {{ body: string, threadPr: any, mode: string|null, canRelay: boolean }} o
272
+ * @returns {{ relay: boolean, action: 'merge'|'close'|null, reason: string, anchor: ThreadPrAnchor|null }}
273
+ */
274
+ export function prActionRelayDecision({ body, threadPr, mode, canRelay } = {}) {
275
+ const anchor = normalizeThreadPr(threadPr);
276
+ const action = classifyPrActionCue(body);
277
+ if (!action) return { relay: false, action: null, reason: "no-cue", anchor };
278
+ // Chat only is the human saying this agent does not act here (the server
279
+ // refuses PR actions the same way when mayExecute is false).
280
+ if (mode === "ask") return { relay: false, action, reason: "chat-only", anchor };
281
+ if (!canRelay) return { relay: false, action, reason: "no-tool", anchor };
282
+ if (!anchor) return { relay: false, action, reason: "no-anchor", anchor };
283
+ return { relay: true, action, reason: "relay", anchor };
284
+ }
285
+
286
+ /**
287
+ * What to say after a relay. The server posts the outcome of every executed
288
+ * action AND every refusal that reached executePrAction (its `message`), so
289
+ * repeating it would double-post in the thread. Only the tool-level refusals —
290
+ * which come back as `error` and are posted by nobody — need a message from the
291
+ * daemon, and they are surfaced verbatim: the server's wording is the authority.
292
+ * @param {any} result the parsed merge_pr / close_pr tool result
293
+ * @returns {{ post: string|null, ok: boolean, alreadyPosted: boolean }}
294
+ */
295
+ export function prActionReply(result) {
296
+ if (!result || typeof result !== "object") {
297
+ return { post: null, ok: false, alreadyPosted: false };
298
+ }
299
+ if (result.ok === false && typeof result.error === "string" && result.error.trim()) {
300
+ return { post: result.error, ok: false, alreadyPosted: false };
301
+ }
302
+ // ok:true, or an executePrAction refusal — either way it already spoke in the
303
+ // thread with its own words.
304
+ return { post: null, ok: result.ok === true, alreadyPosted: true };
305
+ }