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/src/handler.mjs CHANGED
@@ -25,12 +25,37 @@ import {
25
25
  prTitleBody,
26
26
  mentionHandle,
27
27
  detectPrContinuation,
28
+ selfDrivenShipPlan,
28
29
  } from "./daemon.mjs";
29
- import { runCli, buildHeartbeat, ackText, oneLine, fmtElapsed, minimalEnv } from "./cli.mjs";
30
+ import {
31
+ runCli,
32
+ buildHeartbeat,
33
+ ackText,
34
+ oneLine,
35
+ fmtElapsed,
36
+ minimalEnv,
37
+ scrubHilosEnv,
38
+ envForCwd,
39
+ } from "./cli.mjs";
30
40
  import { makeStreamParser } from "./agent-events.mjs";
31
- import { detectVendor, codeStreamArgs, createProgressEmitter, fastChatCmd } from "./progress-emitter.mjs";
41
+ import {
42
+ detectVendor,
43
+ codeStreamArgs,
44
+ codeDirArgs,
45
+ codeProjectKey,
46
+ attachTarget,
47
+ createProgressEmitter,
48
+ fastChatCmd,
49
+ } from "./progress-emitter.mjs";
32
50
  import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
33
- import { buildResumeArgs, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
51
+ import {
52
+ anchorIterateDecision,
53
+ confirmAnchorOpen,
54
+ prActionRelayDecision,
55
+ prActionReply,
56
+ staleAnchorNote,
57
+ } from "./thread-pr.mjs";
58
+ import { buildResumeArgs, resumeDecision, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
34
59
  import { createModelArgsResolver } from "./model-resolve.mjs";
35
60
  import {
36
61
  buildReviewPrompt,
@@ -40,6 +65,8 @@ import {
40
65
  } from "./review.mjs";
41
66
  import { buildMemoryBlock } from "./memory.mjs";
42
67
  import { deployFolder, resolveDeployTarget } from "./deploy.mjs";
68
+ import { runOpenCodeHttpSession } from "./opencode-session.mjs";
69
+ import { runAcpSession } from "./acp-session.mjs";
43
70
 
44
71
  /**
45
72
  * The environment for a coding/chat CLI run. runCli always strips HILOS_* on top
@@ -120,14 +147,21 @@ function compactRunMarker(status, branch) {
120
147
  return `Failed:${b}`;
121
148
  }
122
149
 
150
+ // Every helper below runs a tool in a directory we choose, so each one hands
151
+ // the child a PWD that matches that directory instead of the daemon's launch
152
+ // dir (0615). git and gh both use the real cwd, so this is hygiene rather than
153
+ // a fix — but a child whose env contradicts its cwd is wrong on any tool.
154
+ const cwdEnv = (cwd) => envForCwd(process.env, cwd);
155
+
123
156
  function defaultDeps() {
124
157
  return {
125
158
  git: (cwd, args) =>
126
- spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 50 * 1024 * 1024 }),
127
- // The async CLI runner, injectable so folder mode (and tests) can drive the
128
- // coding run without spawning a real process. The repo flow still uses the
129
- // imported runCli directly (unchanged); only folder mode goes through deps.
159
+ spawnSync("git", args, { cwd, env: cwdEnv(cwd), encoding: "utf8", maxBuffer: 50 * 1024 * 1024 }),
160
+ // Async coding runners are injectable so handler-level tests can prove the
161
+ // selected trust boundary without spawning a real model process.
130
162
  runCli: (opts) => runCli(opts),
163
+ runOpenCodeHttpSession: (opts) => runOpenCodeHttpSession(opts),
164
+ runAcpSession: (opts) => runAcpSession(opts),
131
165
  // Does a path exist on disk? Injectable so folder mode's "missing folder"
132
166
  // guard is unit-testable without touching the real filesystem.
133
167
  pathExists: (p) => existsSync(p),
@@ -137,10 +171,10 @@ function defaultDeps() {
137
171
  const r = spawnSync(
138
172
  "gh",
139
173
  ["pr", "create", "--title", title, "--body", body, "--head", branch, "--base", base],
140
- { cwd, encoding: "utf8" },
174
+ { cwd, env: cwdEnv(cwd), encoding: "utf8" },
141
175
  );
142
176
  const url = (r.stdout || "").trim().split("\n").filter(Boolean).pop() || null;
143
- return { ok: r.status === 0, url, stderr: r.stderr || "" };
177
+ return { ok: r.status === 0, url, stdout: r.stdout || "", stderr: r.stderr || "" };
144
178
  },
145
179
  // The open PR for a branch, if one already exists — so when the CLI opened a
146
180
  // PR itself we report THAT instead of opening a duplicate.
@@ -148,7 +182,7 @@ function defaultDeps() {
148
182
  const r = spawnSync(
149
183
  "gh",
150
184
  ["pr", "list", "--head", branch, "--state", "open", "--json", "url", "--jq", ".[0].url // empty"],
151
- { cwd, encoding: "utf8" },
185
+ { cwd, env: cwdEnv(cwd), encoding: "utf8" },
152
186
  );
153
187
  const url = (r.stdout || "").trim();
154
188
  return r.status === 0 && url ? url : null;
@@ -160,16 +194,160 @@ function defaultDeps() {
160
194
  const r = spawnSync(
161
195
  "gh",
162
196
  ["pr", "view", String(ref), "--json", "headRefName", "--jq", ".headRefName // empty"],
163
- { cwd, encoding: "utf8" },
197
+ { cwd, env: cwdEnv(cwd), encoding: "utf8" },
164
198
  );
165
199
  const out = (r.stdout || "").trim();
166
200
  return r.status === 0 && out ? out : null;
167
201
  },
202
+ // The live state of a PR (0704): is it still OPEN, what is its head branch,
203
+ // and does that head live in the base repo? The staleness guard for adopting
204
+ // the thread's pull request — a merged/closed anchor must never take a push,
205
+ // and a fork head is another repository's branch. Returns null when `gh`
206
+ // can't answer, which the caller reads as "no proof, no adoption".
207
+ prState: (cwd, ref) => {
208
+ const r = spawnSync(
209
+ "gh",
210
+ [
211
+ "pr",
212
+ "view",
213
+ String(ref),
214
+ "--json",
215
+ "state,headRefName,headRepository,headRepositoryOwner",
216
+ ],
217
+ { cwd, env: cwdEnv(cwd), encoding: "utf8" },
218
+ );
219
+ if (r.status !== 0) return null;
220
+ try {
221
+ const j = JSON.parse(r.stdout || "{}");
222
+ const owner = j.headRepositoryOwner?.login || j.headRepositoryOwner?.name || null;
223
+ const repo = j.headRepository?.name || null;
224
+ return {
225
+ state: typeof j.state === "string" ? j.state : "",
226
+ headRefName: typeof j.headRefName === "string" ? j.headRefName : null,
227
+ headRepoFullName: owner && repo ? `${owner}/${repo}` : null,
228
+ };
229
+ } catch {
230
+ return null;
231
+ }
232
+ },
168
233
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
169
234
  now: () => Date.now(),
170
235
  };
171
236
  }
172
237
 
238
+ /**
239
+ * Bind one OpenCode HTTP session to hilos's vendor-neutral permission tools.
240
+ * Both repo and direct-folder runs use this exact callback contract so neither
241
+ * path can accidentally become the ungated exception.
242
+ */
243
+ function openCodePermissionCallbacks({
244
+ tool,
245
+ channelId,
246
+ threadRoot,
247
+ runId = null,
248
+ provider = "opencode",
249
+ }) {
250
+ return {
251
+ requestPermission: async (request) => {
252
+ const detail =
253
+ request.metadata && typeof request.metadata === "object"
254
+ ? request.metadata
255
+ : {};
256
+ const title = [
257
+ detail.title,
258
+ detail.description,
259
+ detail.command,
260
+ request.resources?.[0],
261
+ ].find((value) => typeof value === "string" && value.trim());
262
+ return tool("request_permission", {
263
+ channelId,
264
+ threadRootId: threadRoot,
265
+ ...(runId ? { runId } : {}),
266
+ provider,
267
+ vendorSessionId: request.sessionId,
268
+ vendorRequestId: request.vendorRequestId,
269
+ action: request.action,
270
+ title: title || `${request.action} permission`,
271
+ resources: request.resources,
272
+ suggestedSave: request.suggestedSave,
273
+ metadata: detail,
274
+ ...(request.source ? { source: request.source } : {}),
275
+ });
276
+ },
277
+ getPermissionDecision: async (handle, { request, failClosed = false }) => {
278
+ const requestId =
279
+ handle &&
280
+ typeof handle === "object" &&
281
+ typeof handle.requestId === "string"
282
+ ? handle.requestId
283
+ : null;
284
+ if (!requestId) {
285
+ throw new Error(
286
+ "hilos returned a pending permission without a request id",
287
+ );
288
+ }
289
+ return tool("get_permission_decision", {
290
+ requestId,
291
+ provider,
292
+ vendorSessionId: request.sessionId,
293
+ vendorRequestId: request.vendorRequestId,
294
+ ...(failClosed ? { failClosed: true } : {}),
295
+ });
296
+ },
297
+ };
298
+ }
299
+
300
+ /** The local HTTP bridge can own only a local OpenCode server. An explicit
301
+ * `--attach` remains on OpenCode's CLI responder, which rejects unanswered asks
302
+ * fail closed; taking over a remote server requires a separate authenticated
303
+ * transport contract. Shared by repo and direct-folder runs. */
304
+ export function shouldUseRuntimePermissionBridge({
305
+ vendor,
306
+ runtimePermissions,
307
+ codeArgs,
308
+ codingCmd,
309
+ }) {
310
+ return (
311
+ vendor === "opencode" &&
312
+ runtimePermissions === true &&
313
+ !codeArgs.includes("--auto") &&
314
+ attachTarget(codingCmd) === null
315
+ );
316
+ }
317
+
318
+ /** ACP transport opt-in (0759; slice 1 opencode, slice 2 cursor). Only runs
319
+ * the workspace already gates with runtime permissions qualify, PLUS the
320
+ * operator's explicit acpTransport flag, and only when the vendor's own
321
+ * auto-allow escape hatches are absent — over ACP, hilos must be the one
322
+ * answering asks, so a run configured to never ask has nothing to gate here.
323
+ * Resumed runs stay on the existing paths: their session continuity contract
324
+ * is argv/HTTP-shaped and the ACP path does not carry it yet. */
325
+ export function shouldUseAcpTransport({
326
+ vendor,
327
+ acpTransport,
328
+ runtimePermissions,
329
+ codeArgs,
330
+ codingCmd,
331
+ resumeSessionId = /** @type {string | null} */ (null),
332
+ }) {
333
+ if (acpTransport !== true || resumeSessionId != null) return false;
334
+ if (runtimePermissions !== true) return false;
335
+ if (vendor === "opencode") {
336
+ return shouldUseRuntimePermissionBridge({
337
+ vendor,
338
+ runtimePermissions,
339
+ codeArgs,
340
+ codingCmd,
341
+ });
342
+ }
343
+ if (vendor === "cursor") {
344
+ // -f/--force is cursor's "allow everything" switch — the exact analog of
345
+ // opencode's --auto.
346
+ return !codeArgs.includes("-f") && !codeArgs.includes("--force");
347
+ }
348
+ return false;
349
+ }
350
+
173
351
  async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId, signal }) {
174
352
  if (!reportMessageId) return { kind: "timeout" };
175
353
  const deadline = deps.now() + cfg.decisionTimeoutMs;
@@ -333,37 +511,63 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
333
511
  * The coding CLI committed the work itself (autonomous run with skip-permissions
334
512
  * in a repo whose docs prescribe a commit+PR flow), so the working tree is clean
335
513
  * and the daemon's diff is empty. Don't claim "no changes": adopt what it did —
336
- * push the branch it's on (no-op if already pushed), reuse the PR it opened or
337
- * open one, and report it. Used only when NOT gated (bias-to-action).
514
+ * push its HEAD under the daemon-owned task branch, reuse the PR it opened or
515
+ * open one, and report it. Keeping the intended head matters: a child that
516
+ * switched to `main` would otherwise make `gh` try to open main → main. Used
517
+ * only when NOT gated (bias-to-action).
338
518
  */
339
519
  async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, settleId, runId }) {
340
520
  const tag = requesterTag(requester);
341
521
  const lead = tag ? `${tag} — ` : "";
342
- // The agent may have committed on the daemon's branch or switched to one of its
343
- // own — push whatever HEAD is on now.
344
- const cur =
345
- (deps.git(repoPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]).stdout || "").trim() || branch;
346
- const push = deps.git(repoPath, ["push", "-u", "origin", cur]);
347
- let prUrl = deps.findPR ? deps.findPR(repoPath, cur) : null;
522
+ const currentBranch =
523
+ (deps.git(repoPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]).stdout || "").trim();
524
+ const ship = selfDrivenShipPlan(currentBranch, branch);
525
+ const push = deps.git(repoPath, ship.pushArgs);
526
+ let prUrl = deps.findPR ? deps.findPR(repoPath, ship.headBranch) : null;
527
+ let prAttempt = null;
348
528
  if (!prUrl && push.status === 0) {
349
- const { title, body } = prTitleBody(task, cur);
350
- const pr = deps.openPR(repoPath, { title, body, branch: cur, base: cfg.defaultBranch });
351
- prUrl = pr.ok && pr.url ? pr.url : null;
529
+ const { title, body } = prTitleBody(task, ship.headBranch);
530
+ prAttempt = deps.openPR(repoPath, {
531
+ title,
532
+ body,
533
+ branch: ship.headBranch,
534
+ base: cfg.defaultBranch,
535
+ });
536
+ prUrl = prAttempt.ok && prAttempt.url ? prAttempt.url : null;
352
537
  }
353
- const { title } = prTitleBody(task, cur);
538
+ const { title } = prTitleBody(task, ship.headBranch);
539
+ const recoveredNote = ship.recoveredFrom
540
+ ? ` The child had switched to \`${ship.recoveredFrom}\`; hilos recovered its HEAD onto \`${ship.headBranch}\`.`
541
+ : "";
542
+ const prFailure =
543
+ prAttempt && !prUrl
544
+ ? oneLine(
545
+ (prAttempt.stderr || prAttempt.stdout || "gh did not return a pull request URL").trim(),
546
+ 200,
547
+ )
548
+ : "";
354
549
  // Seamless single card (0289): settle onto the live status message when one was
355
550
  // streaming this run; the workspace setting decides channel visibility.
356
551
  // Otherwise post a fresh final report.
357
552
  const reportArgs = {
358
553
  channelId,
359
- title: `Shipped: ${title}`,
554
+ title: prUrl
555
+ ? `Shipped: ${title}`
556
+ : push.status === 0
557
+ ? `PR failed: ${title}`
558
+ : `Push failed: ${title}`,
360
559
  summary: prUrl
361
- ? `${lead}the coding agent committed on \`${cur}\` and opened a pull request.`
560
+ ? `${lead}the coding agent committed the work itself and opened a pull request from \`${ship.headBranch}\`.${recoveredNote}`
362
561
  : push.status === 0
363
- ? `${lead}the coding agent committed and pushed \`${cur}\`. Open a PR manually — \`gh\` didn't return one.`
364
- : `${lead}the coding agent committed on \`${cur}\` locally, but pushing it failed.`,
562
+ ? `${lead}the coding agent committed and pushed \`${ship.headBranch}\`, but GitHub rejected PR creation.${recoveredNote}`
563
+ : `${lead}the coding agent committed locally, but pushing \`${ship.headBranch}\` failed.${recoveredNote}`,
365
564
  prUrl: prUrl || undefined,
366
- caveats: push.status === 0 ? [] : [`git push failed: ${(push.stderr || "").trim().slice(0, 200)}`],
565
+ caveats:
566
+ push.status !== 0
567
+ ? [`git push failed: ${(push.stderr || "").trim().slice(0, 200)}`]
568
+ : prFailure
569
+ ? [`gh pr create failed: ${prFailure}`]
570
+ : [],
367
571
  };
368
572
  await tool(
369
573
  "post_report",
@@ -377,11 +581,25 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
377
581
  await tool("update_run", {
378
582
  runId,
379
583
  ...(prUrl ? { prUrl } : {}),
380
- status: "awaiting_review",
584
+ status: prUrl ? "awaiting_review" : "failed",
585
+ ...(!prUrl
586
+ ? {
587
+ reason:
588
+ push.status !== 0
589
+ ? oneLine((push.stderr || "git push failed").trim(), 300)
590
+ : prFailure || "GitHub did not return a pull request URL",
591
+ }
592
+ : {}),
381
593
  ...(settleId ? { reportMsgId: settleId } : {}),
382
594
  }).catch(() => {});
383
595
  }
384
- return { status: push.status === 0 ? "pushed" : "commit-local", branch: cur, prUrl };
596
+ const status =
597
+ push.status !== 0 ? "commit-local" : prUrl ? "pushed" : "pr-failed";
598
+ return {
599
+ status,
600
+ branch: ship.headBranch,
601
+ prUrl,
602
+ };
385
603
  }
386
604
 
387
605
  // Sentinel the router model emits when the latest message is a request to change
@@ -1279,34 +1497,98 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
1279
1497
  let run;
1280
1498
  // Model preset (0504): same run-time resolution as the repo path.
1281
1499
  const modelArgs = await modelArgsFor(cfg, vendor);
1500
+ // Project pin (0608): opencode reads its project from PWD, so without this
1501
+ // a folder run could edit the daemon's launch directory instead of the
1502
+ // folder the channel is linked to. [] for every other vendor.
1503
+ const dirArgs = codeDirArgs(vendor, folderPath, cfg.codingCmd);
1504
+ const codeArgs = [
1505
+ ...parts.slice(1),
1506
+ ...modelArgs,
1507
+ ...dirArgs,
1508
+ ...streamArgs,
1509
+ ];
1510
+ const handleCliData = (c) => {
1511
+ const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
1512
+ if (lines.length) lastLine = lines[lines.length - 1];
1513
+ if (resultParser) {
1514
+ try {
1515
+ foldResultEvents(resultParser.push(String(c)));
1516
+ } catch {
1517
+ /* result extraction must never break the run */
1518
+ }
1519
+ }
1520
+ if (emitter) {
1521
+ try {
1522
+ emitter.feed(c);
1523
+ } catch {
1524
+ /* a progress fold must never break the run */
1525
+ }
1526
+ }
1527
+ };
1282
1528
  try {
1283
- run = await deps.runCli({
1284
- cmd: parts[0],
1285
- args: [...parts.slice(1), ...modelArgs, ...streamArgs, memoryPreamble(workspaceMemory) + promptText],
1286
- cwd: folderPath,
1287
- timeoutMs: cfg.runTimeoutMs,
1288
- label: "coding",
1289
- signal,
1290
- env: codingChildEnv(cfg),
1291
- onData: (c) => {
1292
- const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
1293
- if (lines.length) lastLine = lines[lines.length - 1];
1294
- if (resultParser) {
1295
- try {
1296
- foldResultEvents(resultParser.push(String(c)));
1297
- } catch {
1298
- /* result extraction must never break the run */
1299
- }
1300
- }
1301
- if (emitter) {
1302
- try {
1303
- emitter.feed(c);
1304
- } catch {
1305
- /* a progress fold must never break the run */
1306
- }
1307
- }
1308
- },
1529
+ const useAcpTransport = shouldUseAcpTransport({
1530
+ vendor,
1531
+ acpTransport: cfg.acpTransport,
1532
+ runtimePermissions: caps.runtimePermissions,
1533
+ codeArgs,
1534
+ codingCmd: cfg.codingCmd,
1309
1535
  });
1536
+ const useRuntimePermissionBridge =
1537
+ !useAcpTransport &&
1538
+ shouldUseRuntimePermissionBridge({
1539
+ vendor,
1540
+ runtimePermissions: caps.runtimePermissions,
1541
+ codeArgs,
1542
+ codingCmd: cfg.codingCmd,
1543
+ });
1544
+ if (useAcpTransport) {
1545
+ run = await deps.runAcpSession({
1546
+ cmd: parts[0],
1547
+ vendor,
1548
+ cwd: folderPath,
1549
+ prompt: memoryPreamble(workspaceMemory) + promptText,
1550
+ timeoutMs: cfg.runTimeoutMs,
1551
+ signal,
1552
+ env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
1553
+ onData: handleCliData,
1554
+ ...openCodePermissionCallbacks({
1555
+ tool,
1556
+ channelId,
1557
+ threadRoot,
1558
+ provider: vendor,
1559
+ }),
1560
+ });
1561
+ } else if (useRuntimePermissionBridge) {
1562
+ run = await deps.runOpenCodeHttpSession({
1563
+ cmd: parts[0],
1564
+ args: codeArgs,
1565
+ cwd: folderPath,
1566
+ prompt: memoryPreamble(workspaceMemory) + promptText,
1567
+ timeoutMs: cfg.runTimeoutMs,
1568
+ signal,
1569
+ env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
1570
+ onData: handleCliData,
1571
+ ...openCodePermissionCallbacks({
1572
+ tool,
1573
+ channelId,
1574
+ threadRoot,
1575
+ }),
1576
+ });
1577
+ } else {
1578
+ run = await deps.runCli({
1579
+ cmd: parts[0],
1580
+ args: [
1581
+ ...codeArgs,
1582
+ memoryPreamble(workspaceMemory) + promptText,
1583
+ ],
1584
+ cwd: folderPath,
1585
+ timeoutMs: cfg.runTimeoutMs,
1586
+ label: "coding",
1587
+ signal,
1588
+ env: codingChildEnv(cfg),
1589
+ onData: handleCliData,
1590
+ });
1591
+ }
1310
1592
  } finally {
1311
1593
  stopHeartbeat();
1312
1594
  if (emitter) {
@@ -1602,6 +1884,43 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1602
1884
  }
1603
1885
  }
1604
1886
 
1887
+ // MERGE / CLOSE RELAY (0704): "merge it" in a PR thread used to get an answer
1888
+ // describing the button a human should press. Now it is executed — through the
1889
+ // server, which is the authority: merge_pr / close_pr take the id of the human
1890
+ // message that asked, re-classify that text with a forced-tool model call, and
1891
+ // check the author's workspace role before spending the App's write token. The
1892
+ // daemon carries no new trust; it only stops swallowing the request.
1893
+ //
1894
+ // Runs BEFORE the chat/code split for the same reason the server runs it before
1895
+ // the coding gates: a merge instruction must never be read as a request to
1896
+ // start a new run. Every "no" here falls through to today's behavior.
1897
+ {
1898
+ const relay = prActionRelayDecision({
1899
+ body: message.body,
1900
+ threadPr: message.threadPr,
1901
+ mode,
1902
+ // A manager-routed dispatch is a brief to implement, not an instruction to
1903
+ // land something — its body is assembled text, never a person's sentence.
1904
+ canRelay: Boolean(caps.prActions) && !message.dispatch,
1905
+ });
1906
+ if (relay.relay) {
1907
+ const verb = relay.action;
1908
+ console.log(` ${verb} → relaying PR #${relay.anchor.prNumber} to the server (it decides)`);
1909
+ const result = await tool(verb === "merge" ? "merge_pr" : "close_pr", {
1910
+ channelId,
1911
+ // The mention itself is the instruction on record. The server verifies
1912
+ // it is a human's message, in this channel, from an owner or admin, and
1913
+ // that it really asks for THIS action — citing anything else fails.
1914
+ instructionMessageId: message.id,
1915
+ }).catch((e) => ({ ok: false, error: `I couldn't reach hilos to ${verb} that pull request: ${e?.message || e}` }));
1916
+ const reply = prActionReply(result);
1917
+ if (reply.post) {
1918
+ await tool("post_message", { channelId, parentId, body: reply.post }).catch(() => {});
1919
+ }
1920
+ return { status: reply.ok ? `pr-${verb}d` : "pr-action-refused", action: verb };
1921
+ }
1922
+ }
1923
+
1605
1924
  // No repo linked → either FOLDER mode (0322: a channel mapped to a plain local
1606
1925
  // folder can still get coding work done) or, failing that, just reply. A linked
1607
1926
  // repo always wins — the folder map is only consulted when there's no repo link.
@@ -1898,22 +2217,65 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1898
2217
  git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
1899
2218
 
1900
2219
  // Same-branch iteration: continue an existing PR on its own branch and push
1901
- // onto it, so the PR updates in place instead of a duplicate opening. Two ways
2220
+ // onto it, so the PR updates in place instead of a duplicate opening. Three ways
1902
2221
  // in, and an explicit token WINS when both apply (they agree — same PR):
1903
2222
  // 1. detectPrContinuation — an explicit "PR <url>" in the text (the server's
1904
2223
  // "request changes" rework ping, or a person naming a PR). The fallback.
1905
2224
  // 2. the run resolver's 'iterate' (0281) — a plain follow-up reply in a thread
1906
2225
  // whose run already owns an OPEN PR. The PRIMARY path.
2226
+ // 3. the THREAD ANCHOR (0704) — no active run owns this thread, but the server
2227
+ // says (on list_mentions, as `threadPr`) that the thread is about a pull
2228
+ // request: a second agent asked here, or a reply long after the first run
2229
+ // settled, still amends THAT PR. Same shape as the server's own anchor
2230
+ // fallback, and it never overrides 1 or 2.
1907
2231
  // Otherwise, a fresh branch off the default (today's behavior).
1908
2232
  const cont = detectPrContinuation(message.body, repoFullName);
1909
- const iterateUrl = cont?.url || (followupMode === "iterate" && activeRun?.prUrl) || null;
2233
+ const runIterateUrl = (followupMode === "iterate" && activeRun?.prUrl) || null;
2234
+ // The anchor is a HYPOTHESIS (its recorded state may be stale or unknown);
2235
+ // `gh` is the proof. No proof, no adoption — the run cuts a fresh branch and
2236
+ // says so on the status card rather than pushing onto a merged PR.
2237
+ let anchorAdopt = null;
2238
+ let anchorNote = "";
2239
+ if (!cont?.url && !runIterateUrl) {
2240
+ const decision = anchorIterateDecision({
2241
+ threadPr: message.threadPr,
2242
+ repoFullName,
2243
+ signal: followupSignal,
2244
+ });
2245
+ if (decision.adopt && deps.prState) {
2246
+ const live = deps.prState(repoPath, decision.anchor.prUrl);
2247
+ const confirmed = confirmAnchorOpen({ live, anchor: decision.anchor });
2248
+ if (confirmed.ok) {
2249
+ anchorAdopt = {
2250
+ prUrl: decision.anchor.prUrl,
2251
+ prNumber: decision.anchor.prNumber,
2252
+ branch: confirmed.branch,
2253
+ };
2254
+ console.log(
2255
+ ` code → thread anchor: PR #${decision.anchor.prNumber} is open on \`${confirmed.branch}\` — adopting it`,
2256
+ );
2257
+ } else {
2258
+ anchorNote = staleAnchorNote({ reason: confirmed.reason, anchor: decision.anchor });
2259
+ console.log(
2260
+ ` ! thread anchor PR #${decision.anchor.prNumber} not adopted (${confirmed.reason}) — new branch`,
2261
+ );
2262
+ }
2263
+ } else if (decision.adopt) {
2264
+ anchorNote = staleAnchorNote({ reason: "unverified", anchor: decision.anchor });
2265
+ } else if (decision.reason === "anchor-merged" || decision.reason === "anchor-closed") {
2266
+ anchorNote = staleAnchorNote({ reason: decision.reason, anchor: decision.anchor });
2267
+ }
2268
+ }
2269
+ const iterateUrl = cont?.url || runIterateUrl || anchorAdopt?.prUrl || null;
1910
2270
  let branch = null;
1911
2271
  let continuingPrUrl = null;
1912
- if (iterateUrl && deps.prHeadRef) {
2272
+ if (iterateUrl && (deps.prHeadRef || anchorAdopt)) {
1913
2273
  // Resolve the PR's head branch (source of truth); for a run-based iterate the
1914
- // run's recorded branch is a fallback if gh can't view the PR.
2274
+ // run's recorded branch is a fallback if gh can't view the PR. An adopted
2275
+ // anchor already has its head branch straight from the live PR.
1915
2276
  const headRef =
1916
- deps.prHeadRef(repoPath, iterateUrl) ||
2277
+ (anchorAdopt?.prUrl === iterateUrl ? anchorAdopt.branch : null) ||
2278
+ deps.prHeadRef?.(repoPath, iterateUrl) ||
1917
2279
  (followupMode === "iterate" ? activeRun?.branch || null : null);
1918
2280
  if (headRef) {
1919
2281
  // Fetch the PR branch, then point a local branch at its tip via FETCH_HEAD
@@ -1931,6 +2293,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1931
2293
  console.log(
1932
2294
  ` ! couldn't check out PR branch \`${headRef}\` (falling back to a new branch): ${(sw.stderr || "").trim().slice(0, 200)}`,
1933
2295
  );
2296
+ // An adopted anchor that can't be checked out is the same broken promise
2297
+ // as a stale one — the thread asked about a PR and gets a new branch, so
2298
+ // the status card has to say it.
2299
+ if (anchorAdopt?.prUrl === iterateUrl) {
2300
+ anchorNote = staleAnchorNote({ reason: "checkout-failed", anchor: anchorAdopt });
2301
+ }
1934
2302
  }
1935
2303
  }
1936
2304
  }
@@ -1982,6 +2350,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1982
2350
  } else if (effectiveMode === "redirect") {
1983
2351
  const n = activeRun?.prNumber || prNumberFromUrl(activeRun?.prUrl);
1984
2352
  ackBody = `Starting a new PR${n ? ` (separate from #${n})` : ""} on \`${branch}\`.`;
2353
+ } else if (anchorNote) {
2354
+ // The thread is about a pull request this run could NOT adopt (0704). Say it
2355
+ // on the status card itself — silence here would read as "pushing to my PR"
2356
+ // while a fresh branch is being cut. Still one card, per the run-output
2357
+ // contract: no extra chat message.
2358
+ ackBody = `${anchorNote} ${ackBody}`;
1985
2359
  }
1986
2360
  const ack = await tool("post_message", { channelId, parentId, body: ackBody });
1987
2361
  const ackId = ack?.messageId ?? null;
@@ -2037,7 +2411,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2037
2411
  branch,
2038
2412
  // Map an unrecognized command to null rather than the off-vocabulary
2039
2413
  // "unknown" — provider is documented as
2040
- // claude_code|codex|cursor|antigravity|hermes|hilos.
2414
+ // claude_code|codex|cursor|opencode|antigravity|hermes|hilos.
2041
2415
  provider: (() => {
2042
2416
  const v = detectVendor(cfg.codingCmd);
2043
2417
  return v === "unknown" ? null : v;
@@ -2050,7 +2424,14 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2050
2424
  }
2051
2425
  // Keep the inspectable continuation/redirect statement (don't overwrite it with
2052
2426
  // an LLM plan) so a human can correct the routing before code lands.
2053
- if (ackId && caps.editMessage && chatCmdFor(cfg) && !continuingPrUrl && effectiveMode !== "redirect") {
2427
+ if (
2428
+ ackId &&
2429
+ caps.editMessage &&
2430
+ chatCmdFor(cfg) &&
2431
+ !continuingPrUrl &&
2432
+ !anchorNote &&
2433
+ effectiveMode !== "redirect"
2434
+ ) {
2054
2435
  // Feed the ack the router's distilled brief AND the conversation — not the raw
2055
2436
  // mention — so it states a real plan instead of "what's the task?".
2056
2437
  const plan = await proposePlanAck({
@@ -2085,9 +2466,13 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2085
2466
  // (get_active_run) with no local match means the session likely lives on another
2086
2467
  // machine/instance — a bad `--resume` id makes claude error → empty diff → a failed
2087
2468
  // run, so we DON'T resume and degrade to today's branch+feedback (never worse).
2088
- // claude_code + cursor have proven resume flags (0282/0573); codex/unknown → [].
2469
+ // claude_code + cursor + opencode have proven resume flags (0282/0573/0608);
2470
+ // codex/unknown → []. The gate itself is pure (resumeDecision in resume.mjs):
2471
+ // vendor, machine, project and server agreement all have to line up, and any
2472
+ // "no" degrades to the branch+feedback iterate rather than risking a bad id.
2473
+ const projectKey = codeProjectKey(vendor, repoPath, cfg.codingCmd);
2089
2474
  let resumeSessionId = null;
2090
- if (effectiveMode === "iterate" && (vendor === "claude_code" || vendor === "cursor")) {
2475
+ if (effectiveMode === "iterate") {
2091
2476
  const local = (() => {
2092
2477
  try {
2093
2478
  return readStateEntry(HILOS_DIR, threadRoot);
@@ -2096,14 +2481,18 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2096
2481
  }
2097
2482
  })();
2098
2483
  const serverSid = activeRun?.providerSessionId || null;
2099
- // Confidence = a local record, for THIS machine, with a session id. If the server
2100
- // also has one it must AGREE (a mismatch means the run moved — don't resume a
2101
- // stale/foreign id). A server-only id (no local record) is never resumed.
2102
- if (local && local.machine === machine && local.sessionId && (!serverSid || serverSid === local.sessionId)) {
2103
- resumeSessionId = local.sessionId;
2104
- console.log(` code → resuming session ${local.sessionId} for this iterate (local + machine match)`);
2105
- } else if (serverSid) {
2106
- console.log(" code → not resuming (session recorded on another machine or unverified locally); using branch + feedback");
2484
+ const decision = resumeDecision({
2485
+ vendor,
2486
+ entry: local,
2487
+ serverSessionId: serverSid,
2488
+ machine,
2489
+ projectKey,
2490
+ });
2491
+ if (decision.sessionId) {
2492
+ resumeSessionId = decision.sessionId;
2493
+ console.log(` code → resuming session ${decision.sessionId} for this iterate (local + machine match)`);
2494
+ } else if (local?.sessionId || serverSid) {
2495
+ console.log(` code → not resuming (${decision.reason}); using branch + feedback`);
2107
2496
  }
2108
2497
  }
2109
2498
 
@@ -2220,34 +2609,103 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2220
2609
  // list (cursor only today) — [] when unset/unresolvable, so the tool's
2221
2610
  // default stands. Inserted before resume/stream flags, after the base.
2222
2611
  const modelArgs = await modelArgsFor(cfg, vendor);
2612
+ // Project pin (0608, opencode only): the CLI resolves its project from PWD,
2613
+ // not the spawn cwd, and its sessions are per project — `--dir` makes both
2614
+ // deterministic. [] for every other vendor (and for an `--attach`ed run,
2615
+ // where the project lives on the remote server), so their ARGV is unchanged.
2616
+ // The spawned PWD now matches the cwd too (0615); `--dir` stays as the
2617
+ // CLI's own explicit contract, and to keep attach runs off our local path.
2618
+ const dirArgs = codeDirArgs(vendor, repoPath, cfg.codingCmd);
2223
2619
  const codeArgs = streamOn
2224
- ? [...parts.slice(1), ...modelArgs, ...resumeArgs, ...streamArgs]
2225
- : [...parts.slice(1), ...modelArgs, ...resumeArgs];
2620
+ ? [...parts.slice(1), ...modelArgs, ...dirArgs, ...resumeArgs, ...streamArgs]
2621
+ : [...parts.slice(1), ...modelArgs, ...dirArgs, ...resumeArgs];
2622
+ const handleCliData = (c) => {
2623
+ // Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
2624
+ const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
2625
+ if (lines.length) lastLine = lines[lines.length - 1];
2626
+ if (emitter) {
2627
+ try {
2628
+ emitter.feed(c);
2629
+ } catch {
2630
+ /* a progress fold must never break the run */
2631
+ }
2632
+ }
2633
+ };
2226
2634
  let run;
2227
2635
  try {
2228
- run = await runCli({
2229
- cmd: parts[0],
2230
- args: [...codeArgs, memoryPreamble(workspaceMemory) + promptText],
2231
- cwd: repoPath,
2232
- timeoutMs: cfg.runTimeoutMs,
2233
- label: "coding",
2234
- signal,
2235
- env: codingChildEnv(cfg),
2236
- onData: (c) => {
2237
- // Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
2238
- const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
2239
- if (lines.length) lastLine = lines[lines.length - 1];
2240
- if (emitter) {
2241
- try {
2242
- emitter.feed(c);
2243
- } catch {
2244
- /* a progress fold must never break the run */
2245
- }
2246
- }
2247
- },
2636
+ // OpenCode's own non-interactive CLI auto-rejects every permission ask
2637
+ // unless --auto is present. For the gated tiers, bypass that responder
2638
+ // and own the authenticated HTTP session + SSE stream directly so hilos
2639
+ // is the sole authority answering the paused tool call (0593).
2640
+ const useAcpTransport = shouldUseAcpTransport({
2641
+ vendor,
2642
+ acpTransport: cfg.acpTransport,
2643
+ runtimePermissions: caps.runtimePermissions,
2644
+ codeArgs,
2645
+ codingCmd: cfg.codingCmd,
2646
+ resumeSessionId: resume ? resumeSessionId : null,
2248
2647
  });
2648
+ const useRuntimePermissionBridge =
2649
+ !useAcpTransport &&
2650
+ shouldUseRuntimePermissionBridge({
2651
+ vendor,
2652
+ runtimePermissions: caps.runtimePermissions,
2653
+ codeArgs,
2654
+ codingCmd: cfg.codingCmd,
2655
+ });
2656
+ if (useAcpTransport) {
2657
+ run = await deps.runAcpSession({
2658
+ cmd: parts[0],
2659
+ vendor,
2660
+ cwd: repoPath,
2661
+ prompt: memoryPreamble(workspaceMemory) + promptText,
2662
+ timeoutMs: cfg.runTimeoutMs,
2663
+ signal,
2664
+ env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
2665
+ onData: handleCliData,
2666
+ ...openCodePermissionCallbacks({
2667
+ tool,
2668
+ channelId,
2669
+ threadRoot,
2670
+ runId,
2671
+ provider: vendor,
2672
+ }),
2673
+ });
2674
+ } else if (useRuntimePermissionBridge) {
2675
+ run = await deps.runOpenCodeHttpSession({
2676
+ cmd: parts[0],
2677
+ args: codeArgs,
2678
+ cwd: repoPath,
2679
+ prompt: memoryPreamble(workspaceMemory) + promptText,
2680
+ timeoutMs: cfg.runTimeoutMs,
2681
+ signal,
2682
+ // A model running inside the server must never inherit the daemon's
2683
+ // hilos bearer token. The bridge's random loopback password is added
2684
+ // after this scrub and dies with the process group.
2685
+ env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
2686
+ onData: handleCliData,
2687
+ ...openCodePermissionCallbacks({
2688
+ tool,
2689
+ channelId,
2690
+ threadRoot,
2691
+ runId,
2692
+ }),
2693
+ });
2694
+ } else {
2695
+ run = await runCli({
2696
+ cmd: parts[0],
2697
+ args: [...codeArgs, memoryPreamble(workspaceMemory) + promptText],
2698
+ cwd: repoPath,
2699
+ timeoutMs: cfg.runTimeoutMs,
2700
+ label: "coding",
2701
+ signal,
2702
+ env: codingChildEnv(cfg),
2703
+ onData: handleCliData,
2704
+ });
2705
+ }
2249
2706
  } finally {
2250
2707
  stopHeartbeat();
2708
+ if (run?.sessionId) runSessionId = run.sessionId;
2251
2709
  if (emitter) {
2252
2710
  // Terminal state: flip the status card off "working" (state 'done'/'error')
2253
2711
  // so it stops claiming the agent is alive. For a run that produced work,
@@ -2355,6 +2813,11 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2355
2813
  prUrl: prUrl || continuingPrUrl || null,
2356
2814
  sessionId: sid,
2357
2815
  machine,
2816
+ // The CLI that created the session (a `ses_…` is meaningless to
2817
+ // claude's `--resume`) and the project it belongs to — opencode
2818
+ // scopes sessions per project. The gate above requires both (0608).
2819
+ vendor,
2820
+ cwd: projectKey,
2358
2821
  updatedAt: new Date().toISOString(),
2359
2822
  });
2360
2823
  }