hilos-agent 0.7.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/config.mjs CHANGED
@@ -57,6 +57,12 @@ const DEFAULTS = {
57
57
  // it still won't run arbitrary commands. Override in hilos-agent.json if you
58
58
  // want a stricter (or `--dangerously-skip-permissions`) command.
59
59
  codingCmd: "claude -p --permission-mode acceptEdits",
60
+ // ACP transport opt-in (0759): drive the coding CLI over the Agent Client
61
+ // Protocol (JSON-RPC on stdio) instead of argv + stdout scraping. Covers
62
+ // opencode (`opencode acp`) and cursor (`cursor-agent acp`), and only for
63
+ // runs the workspace already gates with runtime permissions; every other
64
+ // run keeps the existing paths. Also enabled by HILOS_ACP=1.
65
+ acpTransport: false,
60
66
  // Environment handed to the coding/chat CLI. hilos's own token (HILOS_*) is
61
67
  // ALWAYS stripped either way. "inherit" (default) passes the rest of your
62
68
  // shell env so the coding tool behaves exactly as if you ran it yourself.
@@ -124,6 +130,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
124
130
  chatTimeoutMs: process.env.HILOS_CHAT_TIMEOUT_MS ? Number(process.env.HILOS_CHAT_TIMEOUT_MS) : undefined,
125
131
  backfill: process.env.HILOS_BACKFILL === "1" ? true : undefined,
126
132
  once: process.env.HILOS_ONCE === "1" ? true : undefined,
133
+ acpTransport: process.env.HILOS_ACP === "1" ? true : undefined,
127
134
  };
128
135
  const merged = { ...DEFAULTS, ...file };
129
136
  for (const [k, v] of Object.entries(env)) if (v !== undefined && v !== "") merged[k] = v;
@@ -155,6 +162,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
155
162
  // edited file can NEVER drop the daemon's connection.
156
163
  const LIVE_FIELDS = [
157
164
  "codingCmd",
165
+ "acpTransport",
158
166
  "codingModel",
159
167
  "chatCmd",
160
168
  "codingEnv",
package/src/daemon.mjs CHANGED
@@ -16,6 +16,29 @@ export function branchSlug(text, suffix) {
16
16
  return `hilos/${base}-${suffix}`;
17
17
  }
18
18
 
19
+ /**
20
+ * How to push work when the coding child committed instead of leaving a diff.
21
+ *
22
+ * The daemon owns `intendedBranch`. A child that changes branches must not also
23
+ * change the PR head: if it switches to the configured base branch, using the
24
+ * child's current branch would ask GitHub to open `main` against `main`. Push
25
+ * the current HEAD commit under the daemon-owned branch name instead. Omitting
26
+ * `-u` on recovery also avoids rewriting the accidental branch's upstream.
27
+ */
28
+ export function selfDrivenShipPlan(currentBranch, intendedBranch) {
29
+ const current = String(currentBranch || "").trim();
30
+ const intended = String(intendedBranch || "").trim();
31
+ if (!intended) throw new Error("An intended task branch is required");
32
+ const recovered = !current || current !== intended;
33
+ return {
34
+ headBranch: intended,
35
+ pushArgs: recovered
36
+ ? ["push", "origin", `HEAD:refs/heads/${intended}`]
37
+ : ["push", "-u", "origin", intended],
38
+ recoveredFrom: recovered ? current || "detached HEAD" : null,
39
+ };
40
+ }
41
+
19
42
  /** Truncate a diff to a byte budget at a line boundary. */
20
43
  export function truncateDiff(diff, maxBytes = 12000) {
21
44
  if (diff.length <= maxBytes) return { text: diff, truncated: false, omittedLines: 0 };
package/src/handler.mjs CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  prTitleBody,
26
26
  mentionHandle,
27
27
  detectPrContinuation,
28
+ selfDrivenShipPlan,
28
29
  } from "./daemon.mjs";
29
30
  import {
30
31
  runCli,
@@ -47,6 +48,13 @@ import {
47
48
  fastChatCmd,
48
49
  } from "./progress-emitter.mjs";
49
50
  import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
51
+ import {
52
+ anchorIterateDecision,
53
+ confirmAnchorOpen,
54
+ prActionRelayDecision,
55
+ prActionReply,
56
+ staleAnchorNote,
57
+ } from "./thread-pr.mjs";
50
58
  import { buildResumeArgs, resumeDecision, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
51
59
  import { createModelArgsResolver } from "./model-resolve.mjs";
52
60
  import {
@@ -58,6 +66,7 @@ import {
58
66
  import { buildMemoryBlock } from "./memory.mjs";
59
67
  import { deployFolder, resolveDeployTarget } from "./deploy.mjs";
60
68
  import { runOpenCodeHttpSession } from "./opencode-session.mjs";
69
+ import { runAcpSession } from "./acp-session.mjs";
61
70
 
62
71
  /**
63
72
  * The environment for a coding/chat CLI run. runCli always strips HILOS_* on top
@@ -152,6 +161,7 @@ function defaultDeps() {
152
161
  // selected trust boundary without spawning a real model process.
153
162
  runCli: (opts) => runCli(opts),
154
163
  runOpenCodeHttpSession: (opts) => runOpenCodeHttpSession(opts),
164
+ runAcpSession: (opts) => runAcpSession(opts),
155
165
  // Does a path exist on disk? Injectable so folder mode's "missing folder"
156
166
  // guard is unit-testable without touching the real filesystem.
157
167
  pathExists: (p) => existsSync(p),
@@ -164,7 +174,7 @@ function defaultDeps() {
164
174
  { cwd, env: cwdEnv(cwd), encoding: "utf8" },
165
175
  );
166
176
  const url = (r.stdout || "").trim().split("\n").filter(Boolean).pop() || null;
167
- return { ok: r.status === 0, url, stderr: r.stderr || "" };
177
+ return { ok: r.status === 0, url, stdout: r.stdout || "", stderr: r.stderr || "" };
168
178
  },
169
179
  // The open PR for a branch, if one already exists — so when the CLI opened a
170
180
  // PR itself we report THAT instead of opening a duplicate.
@@ -189,6 +199,37 @@ function defaultDeps() {
189
199
  const out = (r.stdout || "").trim();
190
200
  return r.status === 0 && out ? out : null;
191
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
+ },
192
233
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
193
234
  now: () => Date.now(),
194
235
  };
@@ -199,7 +240,13 @@ function defaultDeps() {
199
240
  * Both repo and direct-folder runs use this exact callback contract so neither
200
241
  * path can accidentally become the ungated exception.
201
242
  */
202
- function openCodePermissionCallbacks({ tool, channelId, threadRoot, runId = null }) {
243
+ function openCodePermissionCallbacks({
244
+ tool,
245
+ channelId,
246
+ threadRoot,
247
+ runId = null,
248
+ provider = "opencode",
249
+ }) {
203
250
  return {
204
251
  requestPermission: async (request) => {
205
252
  const detail =
@@ -216,7 +263,7 @@ function openCodePermissionCallbacks({ tool, channelId, threadRoot, runId = null
216
263
  channelId,
217
264
  threadRootId: threadRoot,
218
265
  ...(runId ? { runId } : {}),
219
- provider: "opencode",
266
+ provider,
220
267
  vendorSessionId: request.sessionId,
221
268
  vendorRequestId: request.vendorRequestId,
222
269
  action: request.action,
@@ -241,7 +288,7 @@ function openCodePermissionCallbacks({ tool, channelId, threadRoot, runId = null
241
288
  }
242
289
  return tool("get_permission_decision", {
243
290
  requestId,
244
- provider: "opencode",
291
+ provider,
245
292
  vendorSessionId: request.sessionId,
246
293
  vendorRequestId: request.vendorRequestId,
247
294
  ...(failClosed ? { failClosed: true } : {}),
@@ -268,6 +315,39 @@ export function shouldUseRuntimePermissionBridge({
268
315
  );
269
316
  }
270
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
+
271
351
  async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId, signal }) {
272
352
  if (!reportMessageId) return { kind: "timeout" };
273
353
  const deadline = deps.now() + cfg.decisionTimeoutMs;
@@ -431,37 +511,63 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
431
511
  * The coding CLI committed the work itself (autonomous run with skip-permissions
432
512
  * in a repo whose docs prescribe a commit+PR flow), so the working tree is clean
433
513
  * and the daemon's diff is empty. Don't claim "no changes": adopt what it did —
434
- * push the branch it's on (no-op if already pushed), reuse the PR it opened or
435
- * 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).
436
518
  */
437
519
  async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, settleId, runId }) {
438
520
  const tag = requesterTag(requester);
439
521
  const lead = tag ? `${tag} — ` : "";
440
- // The agent may have committed on the daemon's branch or switched to one of its
441
- // own — push whatever HEAD is on now.
442
- const cur =
443
- (deps.git(repoPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]).stdout || "").trim() || branch;
444
- const push = deps.git(repoPath, ["push", "-u", "origin", cur]);
445
- 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;
446
528
  if (!prUrl && push.status === 0) {
447
- const { title, body } = prTitleBody(task, cur);
448
- const pr = deps.openPR(repoPath, { title, body, branch: cur, base: cfg.defaultBranch });
449
- 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;
450
537
  }
451
- 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
+ : "";
452
549
  // Seamless single card (0289): settle onto the live status message when one was
453
550
  // streaming this run; the workspace setting decides channel visibility.
454
551
  // Otherwise post a fresh final report.
455
552
  const reportArgs = {
456
553
  channelId,
457
- title: `Shipped: ${title}`,
554
+ title: prUrl
555
+ ? `Shipped: ${title}`
556
+ : push.status === 0
557
+ ? `PR failed: ${title}`
558
+ : `Push failed: ${title}`,
458
559
  summary: prUrl
459
- ? `${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}`
460
561
  : push.status === 0
461
- ? `${lead}the coding agent committed and pushed \`${cur}\`. Open a PR manually — \`gh\` didn't return one.`
462
- : `${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}`,
463
564
  prUrl: prUrl || undefined,
464
- 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
+ : [],
465
571
  };
466
572
  await tool(
467
573
  "post_report",
@@ -475,11 +581,25 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
475
581
  await tool("update_run", {
476
582
  runId,
477
583
  ...(prUrl ? { prUrl } : {}),
478
- 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
+ : {}),
479
593
  ...(settleId ? { reportMsgId: settleId } : {}),
480
594
  }).catch(() => {});
481
595
  }
482
- 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
+ };
483
603
  }
484
604
 
485
605
  // Sentinel the router model emits when the latest message is a request to change
@@ -1406,13 +1526,39 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
1406
1526
  }
1407
1527
  };
1408
1528
  try {
1409
- const useRuntimePermissionBridge = shouldUseRuntimePermissionBridge({
1529
+ const useAcpTransport = shouldUseAcpTransport({
1410
1530
  vendor,
1531
+ acpTransport: cfg.acpTransport,
1411
1532
  runtimePermissions: caps.runtimePermissions,
1412
1533
  codeArgs,
1413
1534
  codingCmd: cfg.codingCmd,
1414
1535
  });
1415
- if (useRuntimePermissionBridge) {
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) {
1416
1562
  run = await deps.runOpenCodeHttpSession({
1417
1563
  cmd: parts[0],
1418
1564
  args: codeArgs,
@@ -1738,6 +1884,43 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1738
1884
  }
1739
1885
  }
1740
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
+
1741
1924
  // No repo linked → either FOLDER mode (0322: a channel mapped to a plain local
1742
1925
  // folder can still get coding work done) or, failing that, just reply. A linked
1743
1926
  // repo always wins — the folder map is only consulted when there's no repo link.
@@ -2034,22 +2217,65 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2034
2217
  git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
2035
2218
 
2036
2219
  // Same-branch iteration: continue an existing PR on its own branch and push
2037
- // 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
2038
2221
  // in, and an explicit token WINS when both apply (they agree — same PR):
2039
2222
  // 1. detectPrContinuation — an explicit "PR <url>" in the text (the server's
2040
2223
  // "request changes" rework ping, or a person naming a PR). The fallback.
2041
2224
  // 2. the run resolver's 'iterate' (0281) — a plain follow-up reply in a thread
2042
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.
2043
2231
  // Otherwise, a fresh branch off the default (today's behavior).
2044
2232
  const cont = detectPrContinuation(message.body, repoFullName);
2045
- 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;
2046
2270
  let branch = null;
2047
2271
  let continuingPrUrl = null;
2048
- if (iterateUrl && deps.prHeadRef) {
2272
+ if (iterateUrl && (deps.prHeadRef || anchorAdopt)) {
2049
2273
  // Resolve the PR's head branch (source of truth); for a run-based iterate the
2050
- // 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.
2051
2276
  const headRef =
2052
- deps.prHeadRef(repoPath, iterateUrl) ||
2277
+ (anchorAdopt?.prUrl === iterateUrl ? anchorAdopt.branch : null) ||
2278
+ deps.prHeadRef?.(repoPath, iterateUrl) ||
2053
2279
  (followupMode === "iterate" ? activeRun?.branch || null : null);
2054
2280
  if (headRef) {
2055
2281
  // Fetch the PR branch, then point a local branch at its tip via FETCH_HEAD
@@ -2067,6 +2293,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2067
2293
  console.log(
2068
2294
  ` ! couldn't check out PR branch \`${headRef}\` (falling back to a new branch): ${(sw.stderr || "").trim().slice(0, 200)}`,
2069
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
+ }
2070
2302
  }
2071
2303
  }
2072
2304
  }
@@ -2118,6 +2350,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2118
2350
  } else if (effectiveMode === "redirect") {
2119
2351
  const n = activeRun?.prNumber || prNumberFromUrl(activeRun?.prUrl);
2120
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}`;
2121
2359
  }
2122
2360
  const ack = await tool("post_message", { channelId, parentId, body: ackBody });
2123
2361
  const ackId = ack?.messageId ?? null;
@@ -2186,7 +2424,14 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2186
2424
  }
2187
2425
  // Keep the inspectable continuation/redirect statement (don't overwrite it with
2188
2426
  // an LLM plan) so a human can correct the routing before code lands.
2189
- 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
+ ) {
2190
2435
  // Feed the ack the router's distilled brief AND the conversation — not the raw
2191
2436
  // mention — so it states a real plan instead of "what's the task?".
2192
2437
  const plan = await proposePlanAck({
@@ -2392,13 +2637,41 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2392
2637
  // unless --auto is present. For the gated tiers, bypass that responder
2393
2638
  // and own the authenticated HTTP session + SSE stream directly so hilos
2394
2639
  // is the sole authority answering the paused tool call (0593).
2395
- const useRuntimePermissionBridge = shouldUseRuntimePermissionBridge({
2640
+ const useAcpTransport = shouldUseAcpTransport({
2396
2641
  vendor,
2642
+ acpTransport: cfg.acpTransport,
2397
2643
  runtimePermissions: caps.runtimePermissions,
2398
2644
  codeArgs,
2399
2645
  codingCmd: cfg.codingCmd,
2646
+ resumeSessionId: resume ? resumeSessionId : null,
2400
2647
  });
2401
- if (useRuntimePermissionBridge) {
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) {
2402
2675
  run = await deps.runOpenCodeHttpSession({
2403
2676
  cmd: parts[0],
2404
2677
  args: codeArgs,
@@ -16,7 +16,7 @@
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.
@@ -225,6 +225,9 @@ export function createProgressEmitter({
225
225
  clearTimer = defaultClearTimer,
226
226
  } = {}) {
227
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();
228
231
  const files = [];
229
232
  const fileSet = new Set();
230
233
  const startedAt = now();
@@ -250,6 +253,7 @@ export function createProgressEmitter({
250
253
  function fold(ev) {
251
254
  if (!ev || typeof ev !== "object") return;
252
255
  ring.push(ev); // steps[] (coalesces same-file edits + dup labels)
256
+ activity.push(ev); // feed rows (tense follows status; server re-validates)
253
257
  switch (ev.t) {
254
258
  case "session":
255
259
  if (ev.sessionId) sessionId = ev.sessionId;
@@ -275,6 +279,7 @@ export function createProgressEmitter({
275
279
  /**
276
280
  * The current live progress payload (also what `send` receives).
277
281
  * @returns {{ state: string, phase?: string, step?: string, steps: string[],
282
+ * activity?: {kind: string, text: string, status: string, n?: number}[],
278
283
  * filesTouched: string[], lastLine?: string, elapsedMs: number, sessionId?: string }}
279
284
  */
280
285
  function snapshot() {
@@ -287,6 +292,8 @@ export function createProgressEmitter({
287
292
  };
288
293
  if (phase) snap.phase = sanitizeText(phase);
289
294
  if (step) snap.step = sanitizeText(step);
295
+ const rows = activity.list();
296
+ if (rows.length) snap.activity = rows;
290
297
  const ll = sanitizeText(lastLine);
291
298
  if (ll) snap.lastLine = ll;
292
299
  if (sessionId) snap.sessionId = sanitizeText(sessionId); // for 0282 (server drops it today)
@@ -373,6 +380,9 @@ export function createProgressEmitter({
373
380
  }
374
381
  for (const ev of tail) fold(ev);
375
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");
376
386
  if (state === "error") {
377
387
  const r = sanitizeText(reason);
378
388
  if (r) errorMessage = r;
package/src/run.mjs CHANGED
@@ -99,6 +99,11 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
99
99
  // Semantic local-folder intent (0515): the server guarantees a forced-tool
100
100
  // ask/ship/deploy decision. Older servers fall back to the local router.
101
101
  agentIntent: toolNames.includes("classify_agent_intent"),
102
+ // 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"),
102
107
  // Harness-enforced runtime permission bridge (0593). Both tools are
103
108
  // required: a request without a durable reply path must fail closed.
104
109
  runtimePermissions: