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.
- package/README.md +29 -3
- package/bin/hilos-agent.mjs +16 -1
- package/package.json +1 -1
- package/src/acp-session.mjs +538 -0
- package/src/agent-events.mjs +232 -2
- package/src/cli.mjs +33 -4
- package/src/config.mjs +8 -0
- package/src/daemon.mjs +23 -0
- package/src/handler.mjs +558 -95
- package/src/opencode-permissions.mjs +654 -0
- package/src/opencode-session.mjs +770 -0
- package/src/progress-emitter.mjs +103 -9
- package/src/resume.mjs +85 -12
- package/src/run.mjs +10 -0
- package/src/thread-pr.mjs +305 -0
package/src/progress-emitter.mjs
CHANGED
|
@@ -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, `
|
|
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.
|
|
@@ -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
|
|
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,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
|
+
}
|