hilos-agent 0.1.15 → 0.1.16

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
@@ -9,7 +9,8 @@
9
9
 
10
10
  import { spawnSync } from "node:child_process";
11
11
  import { randomBytes } from "node:crypto";
12
- import { rmSync } from "node:fs";
12
+ import { rmSync, mkdtempSync } from "node:fs";
13
+ import { hostname, tmpdir } from "node:os";
13
14
  import { join } from "node:path";
14
15
  import {
15
16
  branchSlug,
@@ -21,8 +22,57 @@ import {
21
22
  commitMessage,
22
23
  prTitleBody,
23
24
  mentionHandle,
25
+ detectPrContinuation,
24
26
  } from "./daemon.mjs";
25
27
  import { runCli, buildHeartbeat, ackText, oneLine } from "./cli.mjs";
28
+ import { makeStreamParser } from "./agent-events.mjs";
29
+ import { detectVendor, codeStreamArgs, createProgressEmitter } from "./progress-emitter.mjs";
30
+ import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
31
+ import { buildResumeArgs, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
32
+ import {
33
+ buildReviewPrompt,
34
+ parseReviewOutput,
35
+ reviewDedupeKey,
36
+ shouldReview,
37
+ } from "./review.mjs";
38
+ import { buildMemoryBlock } from "./memory.mjs";
39
+
40
+ /** PR number from a github pull-request URL, or null. */
41
+ function prNumberFromUrl(url) {
42
+ const m = /\/pull\/(\d+)/.exec(String(url || ""));
43
+ return m ? Number(m[1]) : null;
44
+ }
45
+
46
+ // How many times ONE reviewer will review the SAME PR before it stops — the
47
+ // ping-pong bound. Keyed by reviewDedupeKey(prUrl, reviewerId), counted in-process
48
+ // on the long-lived daemon so an author<->reviewer pair can't loop forever. A
49
+ // human re-triggering the review is the escape hatch past the cap.
50
+ const reviewRounds = new Map();
51
+
52
+
53
+ // Sentinel the router model emits (only when a thread already owns a run) to
54
+ // classify a follow-up: change | new-scope | ambiguous. Parsed out of the router
55
+ // output and stripped from the coding brief.
56
+ const FOLLOWUP_SIGNAL = "__FOLLOWUP__";
57
+
58
+ /** Pull the follow-up classification out of the router's output and strip the
59
+ * token from the text, so the signal never leaks into the coding brief / a chat
60
+ * reply. Returns { followupSignal, stripped }.
61
+ *
62
+ * Strip ONLY the `__FOLLOWUP__ <value>` token (plus any surrounding whitespace),
63
+ * NOT the whole line: if the model ever emits `__CODE__` and `__FOLLOWUP__` on the
64
+ * SAME line, removing the line would drop `__CODE__` too and misroute a real code
65
+ * run into a chat reply. Removing just the token preserves `__CODE__` while still
66
+ * keeping the signal out of the brief. */
67
+ export function extractFollowup(text) {
68
+ const m = /__FOLLOWUP__[:\s]+([a-z][a-z-]*)/i.exec(text);
69
+ const followupSignal = m ? normalizeSignal(m[1]) : null;
70
+ const stripped = text
71
+ .replace(/\s*__FOLLOWUP__(?:[:\s]+[a-z][a-z-]*)?/gi, "")
72
+ .replace(/\n{3,}/g, "\n\n")
73
+ .trim();
74
+ return { followupSignal, stripped };
75
+ }
26
76
 
27
77
  /** `@handle` for the person who asked, so the report tags them. */
28
78
  function requesterTag(author) {
@@ -54,6 +104,18 @@ function defaultDeps() {
54
104
  const url = (r.stdout || "").trim();
55
105
  return r.status === 0 && url ? url : null;
56
106
  },
107
+ // The head branch of an existing PR (by URL or number), so a "request
108
+ // changes" rework can check it out and update the SAME PR rather than
109
+ // opening a duplicate.
110
+ prHeadRef: (cwd, ref) => {
111
+ const r = spawnSync(
112
+ "gh",
113
+ ["pr", "view", String(ref), "--json", "headRefName", "--jq", ".headRefName // empty"],
114
+ { cwd, encoding: "utf8" },
115
+ );
116
+ const out = (r.stdout || "").trim();
117
+ return r.status === 0 && out ? out : null;
118
+ },
57
119
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
58
120
  now: () => Date.now(),
59
121
  };
@@ -101,7 +163,7 @@ async function linkPrFromUrl({ tool, channelId, url }) {
101
163
  await tool("link_pr", { channelId, repoFullName: m[1], prNumber: Number(m[2]) }).catch(() => {});
102
164
  }
103
165
 
104
- async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId }) {
166
+ async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, existingPrUrl, settleId, runId }) {
105
167
  const tag = requesterTag(requester);
106
168
  const lead = tag ? `${tag} — ` : "";
107
169
  // `parentId` here is the run's thread root: every outcome below is terminal, so
@@ -139,21 +201,47 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
139
201
  return { status: "push-failed", branch };
140
202
  }
141
203
  const { title, body } = prTitleBody(task, branch);
142
- const pr = deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
143
- await tool("post_report", {
204
+ // Continuing an existing PR (request-changes rework): the push already
205
+ // updated it — reuse its URL instead of opening a duplicate. Otherwise open
206
+ // a fresh PR.
207
+ const pr = existingPrUrl
208
+ ? { ok: true, url: existingPrUrl }
209
+ : deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
210
+ // Seamless single card (0289): when a live status message was streaming this
211
+ // run, SETTLE the report onto it in place (broadcast keeps it channel-visible)
212
+ // so the one card cross-fades from live run → report — no separate progress
213
+ // message + separate report card. Without a status id (older server / status
214
+ // post failed) fall back to posting a fresh broadcast report as before.
215
+ const reportArgs = {
144
216
  channelId,
145
- parentId,
146
- broadcast: true,
147
217
  title: `Shipped: ${title}`,
148
218
  summary:
149
219
  pr.ok && pr.url
150
- ? `${lead}pushed \`${branch}\` and opened a pull request.`
220
+ ? existingPrUrl
221
+ ? `${lead}pushed an update to \`${branch}\` and updated the pull request.`
222
+ : `${lead}pushed \`${branch}\` and opened a pull request.`
151
223
  : `${lead}pushed \`${branch}\`. Open a PR manually — \`gh\` failed.`,
152
224
  prUrl: pr.ok && pr.url ? pr.url : undefined,
153
225
  caveats: pr.ok ? [] : [`gh pr create failed: ${(pr.stderr || "").trim().slice(0, 200)}`],
154
- });
226
+ };
227
+ await tool(
228
+ "post_report",
229
+ settleId
230
+ ? { ...reportArgs, messageId: settleId, broadcast: true }
231
+ : { ...reportArgs, parentId, broadcast: true },
232
+ );
155
233
  // Work produced a PR → attach it to the channel so its live pill shows up.
156
234
  await linkPrFromUrl({ tool, channelId, url: pr.ok ? pr.url : null });
235
+ // Bind the PR to the thread's run (0279/0280) so a follow-up continues it.
236
+ // Best-effort; only when a run was recorded (caps.runs on this server).
237
+ if (runId) {
238
+ await tool("update_run", {
239
+ runId,
240
+ ...(pr.ok && pr.url ? { prUrl: pr.url } : {}),
241
+ status: "awaiting_review",
242
+ ...(settleId ? { reportMsgId: settleId } : {}),
243
+ }).catch(() => {});
244
+ }
157
245
  return { status: "pushed", branch, prUrl: pr.ok ? pr.url : null };
158
246
  }
159
247
 
@@ -192,7 +280,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
192
280
  * push the branch it's on (no-op if already pushed), reuse the PR it opened or
193
281
  * open one, and report it. Used only when NOT gated (bias-to-action).
194
282
  */
195
- async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId }) {
283
+ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, settleId, runId }) {
196
284
  const tag = requesterTag(requester);
197
285
  const lead = tag ? `${tag} — ` : "";
198
286
  // The agent may have committed on the daemon's branch or switched to one of its
@@ -207,10 +295,11 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
207
295
  prUrl = pr.ok && pr.url ? pr.url : null;
208
296
  }
209
297
  const { title } = prTitleBody(task, cur);
210
- await tool("post_report", {
298
+ // Seamless single card (0289): settle onto the live status message when one was
299
+ // streaming this run (broadcast keeps it channel-visible); otherwise a fresh
300
+ // broadcast report, exactly as before.
301
+ const reportArgs = {
211
302
  channelId,
212
- parentId,
213
- broadcast: true,
214
303
  title: `Shipped: ${title}`,
215
304
  summary: prUrl
216
305
  ? `${lead}the coding agent committed on \`${cur}\` and opened a pull request.`
@@ -219,8 +308,23 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
219
308
  : `${lead}the coding agent committed on \`${cur}\` locally, but pushing it failed.`,
220
309
  prUrl: prUrl || undefined,
221
310
  caveats: push.status === 0 ? [] : [`git push failed: ${(push.stderr || "").trim().slice(0, 200)}`],
222
- });
311
+ };
312
+ await tool(
313
+ "post_report",
314
+ settleId
315
+ ? { ...reportArgs, messageId: settleId, broadcast: true }
316
+ : { ...reportArgs, parentId, broadcast: true },
317
+ );
223
318
  if (prUrl) await linkPrFromUrl({ tool, channelId, url: prUrl });
319
+ // Bind the PR to the thread's run (0279/0280). Best-effort; only when recorded.
320
+ if (runId) {
321
+ await tool("update_run", {
322
+ runId,
323
+ ...(prUrl ? { prUrl } : {}),
324
+ status: "awaiting_review",
325
+ ...(settleId ? { reportMsgId: settleId } : {}),
326
+ }).catch(() => {});
327
+ }
224
328
  return { status: push.status === 0 ? "pushed" : "commit-local", branch: cur, prUrl };
225
329
  }
226
330
 
@@ -248,9 +352,23 @@ const CODE_SIGNAL = "__CODE__";
248
352
  * `error` is set when the model produced nothing (so the caller can be honest
249
353
  * about a timeout vs a missing binary instead of inventing a reply).
250
354
  */
251
- async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal }) {
355
+ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false }) {
252
356
  const cmd = cfg.chatCmd || cfg.codingCmd;
253
357
  const parts = cmd.split(" ").filter(Boolean);
358
+ // When this thread already owns an OPEN pull request (a follow-up reply), the
359
+ // router ALSO reads whether the latest message continues that PR, wants a
360
+ // separate one, or is unclear — the primary signal for same-PR continuation
361
+ // (0281). resolveFollowupMode turns it into iterate/redirect/new; the cue list
362
+ // in followup.mjs is only the fallback when the model says nothing.
363
+ const followupBlock = hasActiveRun
364
+ ? `\n\nThis message is a reply in a thread that ALREADY owns an OPEN pull request. If (and ` +
365
+ `only if) you emitted ${CODE_SIGNAL} above, ALSO output on its OWN line the token ` +
366
+ `${FOLLOWUP_SIGNAL} followed by exactly one of: change | new-scope | ambiguous — ` +
367
+ `"change" if the latest message continues or adjusts THAT pull request's work ("also make ` +
368
+ `it blue", "actually revert that", "tweak the copy"), "new-scope" if it EXPLICITLY asks for ` +
369
+ `a separate or new pull request, "ambiguous" otherwise. Default a plain follow-up to ` +
370
+ `"change". This line is metadata — keep it OUT of the imperative spec.`
371
+ : "";
254
372
  const prompt =
255
373
  `You are ${name}, a teammate in a team chat (hilos) connected to the git repository ` +
256
374
  `${repoFullName}. Read the conversation and judge what the LATEST message wants from you.\n\n` +
@@ -263,7 +381,8 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
263
381
  `that lists who reacted with each emoji.\n\n` +
264
382
  `Otherwise — a question, a greeting, general discussion, or they explicitly don't want code ` +
265
383
  `yet — just reply to them concisely and directly as a single chat message (no preamble, no ` +
266
- `headings). When unsure, prefer to act (${CODE_SIGNAL}); this is a build channel.\n\n` +
384
+ `headings). When unsure, prefer to act (${CODE_SIGNAL}); this is a build channel.` +
385
+ `${followupBlock}\n\n` +
267
386
  `You are only routing here — output ONLY your text response. Do NOT use any tools, do NOT ` +
268
387
  `edit files, do NOT run commands; a separate step does the actual coding.\n\n` +
269
388
  `${memoryPreamble(workspaceMemory)}Conversation so far:\n${transcript}`;
@@ -275,8 +394,14 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
275
394
  signal,
276
395
  });
277
396
  if (run.aborted || signal?.aborted) return { aborted: true };
278
- const out = (run.stdout || "").trim();
279
- if (!out) return { code: false, reply: null, error: run.error || new Error("no output") };
397
+ const raw = (run.stdout || "").trim();
398
+ if (!raw) return { code: false, reply: null, error: run.error || new Error("no output") };
399
+ // Lift the follow-up classification out (and strip its line) before parsing the
400
+ // brief, so the signal never pollutes the coding spec or a chat reply. Only
401
+ // meaningful when a run is active; otherwise there's no token to find.
402
+ const { followupSignal, stripped: out } = hasActiveRun
403
+ ? extractFollowup(raw)
404
+ : { followupSignal: null, stripped: raw };
280
405
  // Accept the sentinel ANYWHERE, not just the first line — models sometimes add a
281
406
  // line of preamble before it ("Got it, …\n__CODE__\n<brief>"). If it appears,
282
407
  // it's a code run; the brief is whatever follows the sentinel's line and any
@@ -286,9 +411,9 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
286
411
  if (idx >= 0) {
287
412
  const after = out.slice(idx + CODE_SIGNAL.length);
288
413
  const nl = after.indexOf("\n");
289
- return { code: true, task: (nl >= 0 ? after.slice(nl + 1) : after).trim() };
414
+ return { code: true, task: (nl >= 0 ? after.slice(nl + 1) : after).trim(), followupSignal };
290
415
  }
291
- return { code: false, reply: out };
416
+ return { code: false, reply: out, followupSignal };
292
417
  }
293
418
 
294
419
  /**
@@ -495,6 +620,200 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
495
620
  await deliver(body);
496
621
  }
497
622
 
623
+ /** True when this mention is a request for THIS agent to review a PR (0286/0288). */
624
+ export function isReviewRequest(message) {
625
+ return (
626
+ message?.kind === "review-request" ||
627
+ Boolean(message?.metadata?.reviewRequest) ||
628
+ Boolean(message?.reviewRequest)
629
+ );
630
+ }
631
+
632
+ /** The PR url a review-request/review points at, from the mention's reviewOf or the
633
+ * channel's linked PR — whichever is present. */
634
+ function reviewTargetPrUrl(message) {
635
+ return message?.reviewOf?.prUrl || message?.pr?.url || null;
636
+ }
637
+
638
+ /** Flatten get_reviews output into a compact, task-ready findings list the author's
639
+ * coding agent can act on (the review MESSAGE body only carries the summary). Uses
640
+ * the MOST RECENT review so a re-review supersedes stale findings. Returns "" when
641
+ * there's nothing structured to add. */
642
+ export function formatReviewFindings(got) {
643
+ const reviews = Array.isArray(got?.reviews) ? got.reviews : [];
644
+ if (!reviews.length) return "";
645
+ const latest = reviews[reviews.length - 1]; // get_reviews returns oldest→newest
646
+ const lines = [];
647
+ if (latest.summary) lines.push(latest.summary.trim());
648
+ for (const f of Array.isArray(latest.findings) ? latest.findings : []) {
649
+ if (!f || !f.note) continue;
650
+ const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""} — ` : "";
651
+ lines.push(`- [${f.severity || "info"}] ${loc}${f.note}`);
652
+ }
653
+ return lines.join("\n").slice(0, 4000);
654
+ }
655
+
656
+ /**
657
+ * EXECUTE a cross-agent review (WS3, 0288). A review-request lands in this agent's
658
+ * mentions; here it actually reads the PR's diff and posts an ADVISORY review that
659
+ * @-tags the author. It is strictly READ-ONLY and never touches git:
660
+ * - the full diff comes from get_pr_diff (no checkout needed),
661
+ * - the CLI runs in a throwaway temp dir so any stray file write is contained and
662
+ * discarded (belt-and-suspenders with the prompt's edit/git ban), and
663
+ * - this function ONLY calls post_review — never post_report, a decision, a
664
+ * branch, a commit, a push, or a PR. The human stays the sole merge gate.
665
+ * Best-effort: any failure posts an honest note and returns; it never throws into
666
+ * the poll loop. Bounded by shouldReview()/reviewRounds so a reviewer can't
667
+ * re-review the same PR endlessly (the ping-pong bound).
668
+ */
669
+ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory, context, signal }) {
670
+ const parentId = message.parentId ?? null;
671
+ const prUrl = reviewTargetPrUrl(message);
672
+ const reviewOf =
673
+ message?.reviewOf && (message.reviewOf.prUrl || message.reviewOf.messageId)
674
+ ? message.reviewOf
675
+ : prUrl
676
+ ? { prUrl }
677
+ : null;
678
+ if (!prUrl || !reviewOf) {
679
+ await tool("post_message", {
680
+ channelId,
681
+ parentId,
682
+ body: `I was asked to review, but I couldn't find which PR — link the PR (or ask again with its URL) and I'll take a look.`,
683
+ }).catch(() => {});
684
+ return { status: "review-no-pr" };
685
+ }
686
+
687
+ // Ping-pong bound: don't re-review the same PR past the round cap.
688
+ const reviewerId = me?.agentId || me?.id || me?.agentName || "reviewer";
689
+ const key = reviewDedupeKey(prUrl, reviewerId);
690
+ const rounds = reviewRounds.get(key) || 0;
691
+ const maxRounds = cfg.maxRounds || 3;
692
+ if (!shouldReview({ rounds, maxRounds })) {
693
+ await tool("post_message", {
694
+ channelId,
695
+ parentId,
696
+ body: `I've already reviewed this PR ${rounds} time(s) — I'll leave it to a human from here so we don't loop.`,
697
+ }).catch(() => {});
698
+ return { status: "review-capped", rounds };
699
+ }
700
+
701
+ const prNum = prNumberFromUrl(prUrl);
702
+ const label = prNum ? `PR #${prNum}` : "the PR";
703
+ // Instant, channel-visible ack; anchors the thread for the review card.
704
+ const ack = await tool("post_message", {
705
+ channelId,
706
+ parentId,
707
+ body: `Reviewing ${label} now — I'll post my read shortly.`,
708
+ }).catch(() => null);
709
+ const threadRoot = parentId ?? ack?.messageId ?? null;
710
+
711
+ // The diff — the whole review is fed off this (no clone, no gh creds).
712
+ // Capture the failure reason on BOTH error shapes: a soft failure returns
713
+ // { ok:false, error } (auth/no-token/fetch), a hard failure throws and we map its
714
+ // message here — so the honest note below can say WHY, not just that it couldn't.
715
+ const diff = await tool("get_pr_diff", { prUrl, channelId }).catch((e) => ({ ok: false, error: e?.message }));
716
+ if (!diff || diff.ok === false || !Array.isArray(diff.files) || diff.files.length === 0) {
717
+ const reason = diff?.error || diff?.message;
718
+ const why = reason ? ` (${reason})` : "";
719
+ await tool("post_message", {
720
+ channelId,
721
+ parentId: threadRoot,
722
+ broadcast: Boolean(threadRoot),
723
+ body: `I couldn't read ${label}'s diff to review it${why}. It may be private, merged, or not linked here.`,
724
+ }).catch(() => {});
725
+ return { status: "review-no-diff" };
726
+ }
727
+
728
+ // The author's own summary (when the request pointed at their report message) —
729
+ // extra intent for the reviewer. Best-effort.
730
+ let reportSummary = null;
731
+ if (reviewOf.messageId) {
732
+ const gr = await tool("get_report", { messageId: reviewOf.messageId }).catch(() => null);
733
+ if (gr?.found && gr.report?.summary) reportSummary = gr.report.summary;
734
+ }
735
+
736
+ const prompt = buildReviewPrompt({
737
+ diffFiles: diff.files,
738
+ task: message.body,
739
+ reportSummary,
740
+ threadContext: context?.transcript,
741
+ workspaceMemory,
742
+ });
743
+
744
+ // Read-only run: a throwaway cwd contains any stray write; the daemon reads ONLY
745
+ // stdout and never stages/commits anything. Review uses the fast chat command
746
+ // when set (no acceptEdits → can't write), falling back to the coding command.
747
+ const cmd = cfg.reviewCmd || cfg.chatCmd || cfg.codingCmd;
748
+ const parts = String(cmd).split(" ").filter(Boolean);
749
+ let sandboxDir = null;
750
+ try {
751
+ sandboxDir = mkdtempSync(join(tmpdir(), "hilos-review-"));
752
+ } catch {
753
+ sandboxDir = undefined; // fall back to the daemon's cwd; the prompt still bans edits
754
+ }
755
+ console.log(` review → running \`${cmd}\` on ${label} (read-only)…`);
756
+ const run = await runCli({
757
+ cmd: parts[0],
758
+ args: [...parts.slice(1), prompt],
759
+ cwd: sandboxDir || undefined,
760
+ timeoutMs: cfg.chatTimeoutMs ? Math.max(cfg.chatTimeoutMs, 120000) : cfg.runTimeoutMs,
761
+ label: "reviewing",
762
+ signal,
763
+ });
764
+ if (sandboxDir) {
765
+ try {
766
+ rmSync(sandboxDir, { recursive: true, force: true });
767
+ } catch {
768
+ /* temp dir cleanup is best-effort */
769
+ }
770
+ }
771
+
772
+ if (run.aborted || signal?.aborted) {
773
+ await tool("post_message", { channelId, parentId: threadRoot, body: `Stopped the review of ${label}.` }).catch(() => {});
774
+ return { status: "review-cancelled" };
775
+ }
776
+ const text = (run.stdout || "").trim();
777
+ if (!text) {
778
+ const enoent = run.error?.code === "ENOENT";
779
+ await tool("post_message", {
780
+ channelId,
781
+ parentId: threadRoot,
782
+ broadcast: Boolean(threadRoot),
783
+ body: enoent
784
+ ? `I couldn't start \`${cmd}\` to review ${label} — is it installed and on PATH?`
785
+ : `I couldn't get a read on ${label} that time — mention me again to retry the review.`,
786
+ }).catch(() => {});
787
+ return { status: "review-no-output" };
788
+ }
789
+
790
+ const { verdict, summary, findings } = parseReviewOutput(text);
791
+ // Advisory ONLY — post_review records a verdict + findings and @-tags the author.
792
+ // It NEVER merges, blocks, or writes a decision. The server rejects a self-review,
793
+ // so an agent reviewing its own PR degrades to an honest note here.
794
+ const posted = await tool("post_review", {
795
+ channelId,
796
+ reviewOf,
797
+ verdict,
798
+ summary: summary || `Reviewed ${label}.`,
799
+ findings,
800
+ parentId: threadRoot || undefined,
801
+ broadcast: Boolean(threadRoot),
802
+ }).catch((e) => ({ ok: false, error: e?.message }));
803
+ if (posted?.ok === false) {
804
+ // Most common: "You can't review your own work." — surface honestly, no crash.
805
+ await tool("post_message", {
806
+ channelId,
807
+ parentId: threadRoot,
808
+ body: `I looked at ${label} but couldn't post the review${posted?.error || posted?.message ? `: ${posted.error || posted.message}` : ""}.`,
809
+ }).catch(() => {});
810
+ return { status: "review-post-failed", error: posted?.error || posted?.message };
811
+ }
812
+ reviewRounds.set(key, rounds + 1);
813
+ console.log(` review → posted ${verdict} on ${label} (${findings.length} finding(s))`);
814
+ return { status: "reviewed", verdict, findings: findings.length, prUrl };
815
+ }
816
+
498
817
  /** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
499
818
  * cancels an in-flight run — the queue fires it when a human says "stop". */
500
819
  export async function handleTask({ message, channelId, tool, me, caps = {} }, cfg, depsOverride, opts = {}) {
@@ -516,6 +835,34 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
516
835
  // just said.
517
836
  const context = await fetchContext({ channelId, tool, parentId });
518
837
 
838
+ // REVIEW EXECUTION (0288): a review-request routes to the read-only reviewer
839
+ // BEFORE the chat/code flow — it reads the PR's diff and posts an advisory review
840
+ // that @-tags the author. Gated on caps.review (post_review + get_pr_diff on this
841
+ // server); without it (older deploy) this is skipped and behavior is unchanged.
842
+ if (caps.review && isReviewRequest(message)) {
843
+ return await reviewTask({ message, channelId, tool, me, cfg, workspaceMemory, context, signal });
844
+ }
845
+
846
+ // AUTHOR SIDE (0288): a review that @-tags this agent about work it owns. The
847
+ // review body carries the verdict + summary; the full findings live in metadata,
848
+ // so pull them via get_reviews and fold them into the conversation the coding
849
+ // agent sees — then fall through to the normal follow-up machinery, which (0281)
850
+ // checks out the thread's PR branch and pushes onto the SAME human-gated PR. Best-
851
+ // effort; a failure just leaves the summary line to drive the iterate.
852
+ if (caps.review && message?.kind === "review") {
853
+ const target =
854
+ message?.reviewOf && (message.reviewOf.prUrl || message.reviewOf.messageId)
855
+ ? message.reviewOf
856
+ : reviewTargetPrUrl(message)
857
+ ? { prUrl: reviewTargetPrUrl(message) }
858
+ : null;
859
+ if (target) {
860
+ const got = await tool("get_reviews", { reviewOf: target }).catch(() => null);
861
+ const detail = formatReviewFindings(got);
862
+ if (detail) context.transcript = `${context.transcript}\n\nReviewer findings to address:\n${detail}`;
863
+ }
864
+ }
865
+
519
866
  // No repo linked → nothing to build; just reply.
520
867
  if (!repoLink) {
521
868
  await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
@@ -523,10 +870,32 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
523
870
  }
524
871
  const repoFullName = repoLink.repo_full_name;
525
872
 
873
+ // Same-PR follow-up (0281): if this mention is a reply in a thread that already
874
+ // owns a run (branch + PR), we may continue THAT run instead of forking a
875
+ // duplicate. Look it up by the thread root — the parentId the reply carried.
876
+ // Best-effort + gated on the server supporting the runs entity (caps.runs); any
877
+ // failure falls back to a fresh run (mode 'new'), never breaking the task.
878
+ let activeRun = null;
879
+ if (caps.runs && parentId) {
880
+ const r = await tool("get_active_run", { channelId, threadRootId: parentId }).catch(() => null);
881
+ if (r && r.found) activeRun = r;
882
+ }
883
+ // Only an OPEN PR is iterable: awaiting_review (the review gate) or
884
+ // changes_requested (a rework in flight). A merged/closed run is never
885
+ // continued (the resolver's recency guard).
886
+ const activeRunOpen = Boolean(
887
+ activeRun && (activeRun.status === "awaiting_review" || activeRun.status === "changes_requested"),
888
+ );
889
+
526
890
  // Chat vs code is the LLM's call, not a word list: it reads the whole
527
891
  // conversation and either returns a chat reply or signals a code run. Handles
528
892
  // any phrasing, any language, and "go for it" obviously means "do the thing we
529
- // just discussed". When it replies (chat), post that and we're done.
893
+ // just discussed". When it replies (chat), post that and we're done. When the
894
+ // thread owns an OPEN PR it ALSO classifies the follow-up (change / new-scope /
895
+ // ambiguous). Gate on activeRunOpen, not merely Boolean(activeRun): the follow-up
896
+ // signal is only ever acted on when the PR is open (resolveFollowupMode → 'new'
897
+ // otherwise), so telling the router the thread "owns an open PR" when the run is
898
+ // merged/closed would just mislead the model and waste a classification.
530
899
  const routed = await routeIntent({
531
900
  name: me?.agentName || "an assistant",
532
901
  repoFullName,
@@ -534,6 +903,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
534
903
  workspaceMemory,
535
904
  cfg,
536
905
  signal,
906
+ hasActiveRun: activeRunOpen,
537
907
  });
538
908
  if (routed.aborted || signal?.aborted) {
539
909
  await tool("post_message", { channelId, parentId, body: "Stopped." });
@@ -552,6 +922,20 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
552
922
  }
553
923
  // routed.code → fall through to the coding flow below.
554
924
 
925
+ // How this code follow-up routes against the thread's run. The LLM's read
926
+ // (routed.followupSignal) is primary; the cue list is the fallback when it said
927
+ // nothing. resolveFollowupMode enforces the safety table — 'iterate' ONLY onto
928
+ // an open PR, an explicit new-scope forks ('redirect'), everything else is a
929
+ // fresh run ('new'). A chat reply never reaches here, so a chat is never an
930
+ // iterate. (Recomputed as `effectiveMode` after branch-prep in case an intended
931
+ // iterate can't check out its branch.)
932
+ const followupSignal = routed.followupSignal || classifyFollowupCue(message.body);
933
+ const followupMode = resolveFollowupMode({
934
+ hasActiveRun: Boolean(activeRun),
935
+ prOpen: activeRunOpen,
936
+ signal: followupSignal,
937
+ });
938
+
555
939
  let repoPath = resolveRepoPath(cfg, repoFullName);
556
940
  if (!repoPath) {
557
941
  // Zero-config path: if the daemon is running INSIDE a checkout of this repo
@@ -627,23 +1011,95 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
627
1011
  };
628
1012
 
629
1013
  try {
630
- const branch = branchSlug(message.body, randomBytes(3).toString("hex"));
631
1014
  git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
632
- const co = git(repoPath, ["switch", "-c", branch]);
633
- if (co.status !== 0) {
634
- await tool("post_message", {
635
- channelId,
636
- parentId,
637
- body: `Couldn't create branch \`${branch}\`: ${co.stderr.trim()}`,
638
- });
639
- return { status: "branch-error" };
1015
+
1016
+ // Same-branch iteration: continue an existing PR on its own branch and push
1017
+ // onto it, so the PR updates in place instead of a duplicate opening. Two ways
1018
+ // in, and an explicit token WINS when both apply (they agree — same PR):
1019
+ // 1. detectPrContinuation — an explicit "PR <url>" in the text (the server's
1020
+ // "request changes" rework ping, or a person naming a PR). The fallback.
1021
+ // 2. the run resolver's 'iterate' (0281) — a plain follow-up reply in a thread
1022
+ // whose run already owns an OPEN PR. The PRIMARY path.
1023
+ // Otherwise, a fresh branch off the default (today's behavior).
1024
+ const cont = detectPrContinuation(message.body, repoFullName);
1025
+ const iterateUrl = cont?.url || (followupMode === "iterate" && activeRun?.prUrl) || null;
1026
+ let branch = null;
1027
+ let continuingPrUrl = null;
1028
+ if (iterateUrl && deps.prHeadRef) {
1029
+ // Resolve the PR's head branch (source of truth); for a run-based iterate the
1030
+ // run's recorded branch is a fallback if gh can't view the PR.
1031
+ const headRef =
1032
+ deps.prHeadRef(repoPath, iterateUrl) ||
1033
+ (followupMode === "iterate" ? activeRun?.branch || null : null);
1034
+ if (headRef) {
1035
+ // Fetch the PR branch, then point a local branch at its tip via FETCH_HEAD
1036
+ // (works on single-branch clones where `origin/<headRef>` isn't tracked).
1037
+ // `-C` repoints whether or not a stale local copy exists — the daemon is
1038
+ // stateless across runs, so origin is the source of truth.
1039
+ const fetched = git(repoPath, ["fetch", "origin", headRef]);
1040
+ const sw =
1041
+ fetched.status === 0 ? git(repoPath, ["switch", "-C", headRef, "FETCH_HEAD"]) : fetched;
1042
+ if (fetched.status === 0 && sw.status === 0) {
1043
+ branch = headRef;
1044
+ continuingPrUrl = iterateUrl;
1045
+ console.log(` code → continuing existing PR ${iterateUrl} on \`${headRef}\``);
1046
+ } else {
1047
+ console.log(
1048
+ ` ! couldn't check out PR branch \`${headRef}\` (falling back to a new branch): ${(sw.stderr || "").trim().slice(0, 200)}`,
1049
+ );
1050
+ }
1051
+ }
1052
+ }
1053
+ // An explicit PR token (detectPrContinuation) that names a DIFFERENT PR than the
1054
+ // thread's active run must NOT reuse/update that run for the terminal
1055
+ // update_run/report: we push to the TOKEN's PR (via existingPrUrl below), so
1056
+ // binding the unrelated active run would push to the token PR (#99) yet mark the
1057
+ // active run (#142) awaiting_review — a mismatch. Treat this as 'new' for
1058
+ // run-binding: the token still WINS for which PR gets the push; only the
1059
+ // bookkeeping refuses to touch the mismatched run (start_run records a fresh run
1060
+ // for the token PR instead). A token naming the SAME PR the run owns keeps the
1061
+ // iterate-reuse.
1062
+ const explicitTokenMismatch = Boolean(cont?.url && activeRun?.prUrl && cont.url !== activeRun.prUrl);
1063
+ // If we INTENDED to iterate but couldn't check out the PR's branch, we're on a
1064
+ // fresh branch now — so don't reuse the old run either; treat it as a new run.
1065
+ const effectiveMode =
1066
+ explicitTokenMismatch || (followupMode === "iterate" && !continuingPrUrl) ? "new" : followupMode;
1067
+ if (!branch) {
1068
+ branch = branchSlug(message.body, randomBytes(3).toString("hex"));
1069
+ const co = git(repoPath, ["switch", "-c", branch]);
1070
+ if (co.status !== 0) {
1071
+ await tool("post_message", {
1072
+ channelId,
1073
+ parentId,
1074
+ body: `Couldn't create branch \`${branch}\`: ${co.stderr.trim()}`,
1075
+ });
1076
+ return { status: "branch-error" };
1077
+ }
640
1078
  }
1079
+ // The branch tip BEFORE the agent runs — the base for detecting commits the
1080
+ // CLI makes itself. For a continuation this is the PR's existing tip (not the
1081
+ // default branch), so its prior commits aren't misread as "the agent
1082
+ // self-committed".
1083
+ const baseRef = (git(repoPath, ["rev-parse", "HEAD"]).stdout || "").trim() || (startRef.ref || cfg.defaultBranch);
641
1084
 
642
1085
  // Instant acknowledgement: post a template in <1s so the channel shows life
643
1086
  // before any model call. If the server supports edit_message and a fast chatCmd
644
1087
  // is set, a bounded plan-ack edits in a sentence of specifics ("I'll do X…").
645
1088
  // Best-effort — a slow or failed ack just leaves the template, never blocks.
646
- const ack = await tool("post_message", { channelId, parentId, body: ackText(repoFullName) });
1089
+ //
1090
+ // INSPECTABLE ACK (0281): when we're continuing or forking off an existing run,
1091
+ // SAY so plainly (with the PR number + branch) so a human can correct the LLM's
1092
+ // routing before any code lands. This is a key safety mitigation for a wrong
1093
+ // 'iterate'. A plain new run keeps the standard template.
1094
+ let ackBody = ackText(repoFullName);
1095
+ if (continuingPrUrl) {
1096
+ const n = prNumberFromUrl(continuingPrUrl);
1097
+ ackBody = `Continuing ${n ? `PR #${n}` : "the existing pull request"} on \`${branch}\` — I'll push this change there and update the report.`;
1098
+ } else if (effectiveMode === "redirect") {
1099
+ const n = activeRun?.prNumber || prNumberFromUrl(activeRun?.prUrl);
1100
+ ackBody = `Starting a new PR${n ? ` (separate from #${n})` : ""} on \`${branch}\`.`;
1101
+ }
1102
+ const ack = await tool("post_message", { channelId, parentId, body: ackBody });
647
1103
  const ackId = ack?.messageId ?? null;
648
1104
  // Where the run streams + reports. When the mention was already in a thread,
649
1105
  // stay in it. When it was top-level, the ack becomes the thread anchor so the
@@ -652,7 +1108,40 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
652
1108
  // The ack itself stays top-level (the channel-visible "on it"); terminal
653
1109
  // outcomes + report cards broadcast back to the channel from the thread.
654
1110
  const threadRoot = parentId ?? ackId;
655
- if (ackId && caps.editMessage && cfg.chatCmd) {
1111
+ // Bind this run to the thread root (0279/0280), now follow-up-aware (0281):
1112
+ // iterate → REUSE the thread's existing run (don't record a second one); the
1113
+ // same PR updates in place and its status stays awaiting_review.
1114
+ // redirect → supersede the old run (so a future follow-up no longer points at
1115
+ // it), then record a FRESH run for the new, separate PR.
1116
+ // new → record a fresh run, exactly as before.
1117
+ // Best-effort throughout — a bookkeeping failure (or an older server without the
1118
+ // runs tools) must NEVER break the run, so it proceeds without a runId.
1119
+ let runId = effectiveMode === "iterate" ? activeRun?.runId ?? null : null;
1120
+ if (caps.runs && threadRoot && effectiveMode !== "iterate") {
1121
+ try {
1122
+ if (effectiveMode === "redirect" && activeRun?.runId) {
1123
+ await tool("update_run", { runId: activeRun.runId, status: "superseded" }).catch(() => {});
1124
+ }
1125
+ const r = await tool("start_run", {
1126
+ channelId,
1127
+ threadRootId: threadRoot,
1128
+ taskText: message.body,
1129
+ branch,
1130
+ // Map an unrecognized command to null rather than the off-vocabulary
1131
+ // "unknown" — provider is documented as claude_code|codex|cursor|hilos.
1132
+ provider: (() => {
1133
+ const v = detectVendor(cfg.codingCmd);
1134
+ return v === "unknown" ? null : v;
1135
+ })(),
1136
+ });
1137
+ runId = r?.runId ?? null;
1138
+ } catch {
1139
+ /* never break the run on a run-record failure */
1140
+ }
1141
+ }
1142
+ // Keep the inspectable continuation/redirect statement (don't overwrite it with
1143
+ // an LLM plan) so a human can correct the routing before code lands.
1144
+ if (ackId && caps.editMessage && cfg.chatCmd && !continuingPrUrl && effectiveMode !== "redirect") {
656
1145
  // Feed the ack the router's distilled brief AND the conversation — not the raw
657
1146
  // mention — so it states a real plan instead of "what's the task?".
658
1147
  const plan = await proposePlanAck({
@@ -669,9 +1158,49 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
669
1158
  // then EDIT it on later beats — alive without thread spam. Needs edit_message;
670
1159
  // without it we skip rather than post a fresh message every beat. lastLine
671
1160
  // carries the CLI's latest output into the beat.
672
- const heartbeatOn = Boolean(caps.editMessage) && cfg.heartbeatMs > 0;
1161
+ // Live progress (0274): when the server supports post_progress, the code run
1162
+ // STREAMS a coalesced "what I'm doing right now" card onto one thread status
1163
+ // message (via the 0272 stream parser) instead of the 3-min edit heartbeat. On
1164
+ // older servers (no post_progress) we fall back to the exact legacy behavior.
1165
+ const streamOn = Boolean(caps.postProgress);
1166
+ const vendor = detectVendor(cfg.codingCmd);
1167
+ const streamArgs = codeStreamArgs(vendor);
1168
+ let runSessionId = null; // captured from the stream for 0282 (resume)
1169
+ const machine = hostname();
1170
+
1171
+ // Session resume (0282): on an ITERATE we can resume the coding agent's SESSION so
1172
+ // the follow-up continues with the first run's reasoning/plan — not just its code
1173
+ // (which the branch checkout already restores). GATE on LOCAL confidence: only
1174
+ // resume when THIS machine's ~/.hilos/state.json recorded that thread's session
1175
+ // AND it matches this hostname. A providerSessionId that came ONLY from the server
1176
+ // (get_active_run) with no local match means the session likely lives on another
1177
+ // machine/instance — a bad `--resume` id makes claude error → empty diff → a failed
1178
+ // run, so we DON'T resume and degrade to today's branch+feedback (never worse).
1179
+ // Only claude_code has a proven resume flag; codex/cursor/unknown → [] anyway.
1180
+ let resumeSessionId = null;
1181
+ if (effectiveMode === "iterate" && vendor === "claude_code") {
1182
+ const local = (() => {
1183
+ try {
1184
+ return readStateEntry(HILOS_DIR, threadRoot);
1185
+ } catch {
1186
+ return null;
1187
+ }
1188
+ })();
1189
+ const serverSid = activeRun?.providerSessionId || null;
1190
+ // Confidence = a local record, for THIS machine, with a session id. If the server
1191
+ // also has one it must AGREE (a mismatch means the run moved — don't resume a
1192
+ // stale/foreign id). A server-only id (no local record) is never resumed.
1193
+ if (local && local.machine === machine && local.sessionId && (!serverSid || serverSid === local.sessionId)) {
1194
+ resumeSessionId = local.sessionId;
1195
+ console.log(` code → resuming session ${local.sessionId} for this iterate (local + machine match)`);
1196
+ } else if (serverSid) {
1197
+ console.log(" code → not resuming (session recorded on another machine or unverified locally); using branch + feedback");
1198
+ }
1199
+ }
1200
+
1201
+ const heartbeatOn = Boolean(caps.editMessage) && cfg.heartbeatMs > 0 && !streamOn;
673
1202
  let lastLine = "";
674
- let progressId = null; // the thread progress reply, once a beat has posted it
1203
+ let progressId = null; // the thread progress/status reply, once one has posted
675
1204
  const startHeartbeat = () => {
676
1205
  if (!heartbeatOn) return () => {};
677
1206
  const start = deps.now();
@@ -714,27 +1243,139 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
714
1243
  // Run the CLI and stage everything it changed; return the diff stats (no post).
715
1244
  // Workspace memory (the project's soul) is prepended so the coding agent has
716
1245
  // the shared context before it starts.
717
- const runAndStage = async (promptText) => {
1246
+ const runAndStage = async (promptText, { resume = true } = {}) => {
718
1247
  console.log(
719
1248
  ` code → running \`${cfg.codingCmd}\` in ${repoPath} (this can take a few minutes; output appears when it finishes)…`,
720
1249
  );
721
- const stopHeartbeat = startHeartbeat();
1250
+ // Streaming path: post ONE thread status message up front, then stream a live
1251
+ // card into it via post_progress. Legacy path: the 3-min edit heartbeat. Only
1252
+ // one of them runs — never both.
1253
+ let stopHeartbeat = () => {};
1254
+ let emitter = null;
1255
+ // Every post_progress write we dispatch, chained so the finally can await ALL
1256
+ // of them before the run returns. This closes the read-modify-write race with
1257
+ // the in-place settle (0289): if a trailing progress write were still landing
1258
+ // when post_report settles the report, it would re-write metadata WITHOUT the
1259
+ // report (it read the pre-settle snapshot) and clobber it. Draining every send
1260
+ // here guarantees no progress write is in flight once we settle.
1261
+ let progressInflight = Promise.resolve();
1262
+ if (streamOn) {
1263
+ if (!progressId) {
1264
+ try {
1265
+ const r = await tool("post_message", {
1266
+ channelId,
1267
+ parentId: threadRoot,
1268
+ body: buildHeartbeat({ repoFullName, branch, elapsedMs: 0, lastLine: "" }),
1269
+ });
1270
+ progressId = r?.messageId ?? null;
1271
+ } catch {
1272
+ progressId = null; // no status card → the run still proceeds
1273
+ }
1274
+ }
1275
+ const statusId = progressId;
1276
+ if (statusId) {
1277
+ emitter = createProgressEmitter({
1278
+ parser: makeStreamParser(vendor),
1279
+ now: deps.now,
1280
+ throttleMs: cfg.progressMs,
1281
+ // The module never imports MCP — the send fn is injected here. It must
1282
+ // never throw into the run (createProgressEmitter also guards).
1283
+ send: (p) => {
1284
+ try {
1285
+ const r = tool("post_progress", { messageId: statusId, progress: p });
1286
+ if (r && typeof r.then === "function") {
1287
+ const done = r.then(() => {}, () => {});
1288
+ progressInflight = Promise.all([progressInflight, done]).then(
1289
+ () => {},
1290
+ () => {},
1291
+ );
1292
+ }
1293
+ } catch {
1294
+ /* a progress send must never break the run */
1295
+ }
1296
+ },
1297
+ });
1298
+ }
1299
+ } else {
1300
+ stopHeartbeat = startHeartbeat();
1301
+ }
1302
+ // The code run gets the vendor's stream flags appended to its ARGV (not the
1303
+ // display string), and ONLY the code run — chat/routing/plan-ack stay plain.
1304
+ // On an iterate we ALSO insert `--resume <id>` (0282) into the code ARGV
1305
+ // (after the base args, before the stream flags + the trailing prompt) so the
1306
+ // follow-up continues the first run's session; `resume:false` (the never-worse
1307
+ // fallback) suppresses it. buildResumeArgs is [] unless vendor+session make
1308
+ // resume safe, so a non-resume run is byte-identical to the pre-0282 ARGV.
1309
+ const resumeArgs = resume ? buildResumeArgs(vendor, resumeSessionId) : [];
1310
+ const codeArgs = streamOn
1311
+ ? [...parts.slice(1), ...resumeArgs, ...streamArgs]
1312
+ : [...parts.slice(1), ...resumeArgs];
722
1313
  let run;
723
1314
  try {
724
1315
  run = await runCli({
725
1316
  cmd: parts[0],
726
- args: [...parts.slice(1), memoryPreamble(workspaceMemory) + promptText],
1317
+ args: [...codeArgs, memoryPreamble(workspaceMemory) + promptText],
727
1318
  cwd: repoPath,
728
1319
  timeoutMs: cfg.runTimeoutMs,
729
1320
  label: "coding",
730
1321
  signal,
731
1322
  onData: (c) => {
1323
+ // Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
732
1324
  const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
733
1325
  if (lines.length) lastLine = lines[lines.length - 1];
1326
+ if (emitter) {
1327
+ try {
1328
+ emitter.feed(c);
1329
+ } catch {
1330
+ /* a progress fold must never break the run */
1331
+ }
1332
+ }
734
1333
  },
735
1334
  });
736
1335
  } finally {
737
1336
  stopHeartbeat();
1337
+ if (emitter) {
1338
+ // Terminal state: flip the status card off "working" (state 'done'/'error')
1339
+ // so it stops claiming the agent is alive. For a run that produced work,
1340
+ // the gate:false path then SETTLES the report onto this same card in place
1341
+ // (0289) — the card cross-fades run → report; for no-changes/failed/gate
1342
+ // outcomes this terminal 'done'/'error' is the card's final state.
1343
+ const errored = Boolean(run && (run.aborted || run.error || run.status !== 0));
1344
+ // On error, carry an honest reason onto the card (0294) — the same signal
1345
+ // the chat note uses: spawn error message, else exit status, plus a short
1346
+ // stderr tail. Sanitized again server-side; an old server just drops it.
1347
+ let reason = "";
1348
+ if (errored && run && !run.aborted) {
1349
+ const stderrTail = oneLine((run.stderr || "").trim().split("\n").slice(-3).join(" "), 200);
1350
+ if (run.error?.code === "ENOENT") {
1351
+ reason = `Couldn't start ${parts[0]} — is it installed and on PATH?`;
1352
+ } else if (run.error?.message) {
1353
+ reason = run.error.message;
1354
+ } else if (run.status != null) {
1355
+ reason = `The CLI exited ${run.status}`;
1356
+ }
1357
+ if (stderrTail) reason = reason ? `${reason}: ${stderrTail}` : stderrTail;
1358
+ }
1359
+ try {
1360
+ emitter.done(errored ? "error" : "done", reason);
1361
+ } catch {
1362
+ /* ignore */
1363
+ }
1364
+ // Wait for every progress write (including the terminal one above) to land
1365
+ // BEFORE runAndStage returns, so nothing is in flight when the caller
1366
+ // settles the report onto this card — no lost-update clobber (0289).
1367
+ try {
1368
+ await progressInflight;
1369
+ } catch {
1370
+ /* a drain failure must never break the run */
1371
+ }
1372
+ try {
1373
+ const snap = emitter.snapshot();
1374
+ if (snap && snap.sessionId) runSessionId = snap.sessionId;
1375
+ } catch {
1376
+ /* ignore */
1377
+ }
1378
+ }
738
1379
  }
739
1380
  if (run.aborted || signal?.aborted) {
740
1381
  console.log(" code → cancelled");
@@ -763,9 +1404,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
763
1404
  // (commit/push/PR) despite being told only to edit. Detect commits it made
764
1405
  // on this branch so we report + ship them instead of falsely claiming "no
765
1406
  // changes" and deleting a branch that has real work.
766
- const base = startRef.ref || cfg.defaultBranch;
767
1407
  const ahead =
768
- Number((git(repoPath, ["rev-list", "--count", `${base}..HEAD`]).stdout || "0").trim()) || 0;
1408
+ Number((git(repoPath, ["rev-list", "--count", `${baseRef}..HEAD`]).stdout || "0").trim()) || 0;
769
1409
  if (ahead > 0) {
770
1410
  console.log(` code → agent self-committed (${ahead} commit(s) ahead); reconciling`);
771
1411
  return { empty: true, failed: false, ahead };
@@ -779,6 +1419,36 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
779
1419
  return { empty: false, diffText, truncated, omittedLines, stat, runFailed: run.status !== 0 };
780
1420
  };
781
1421
 
1422
+ // Persist the run's coding-agent session (0282) so a LATER iterate can resume it.
1423
+ // Two records, both best-effort — a failure here NEVER breaks the run:
1424
+ // - update_run({ providerSessionId }) → the server's durable record (survives a
1425
+ // daemon restart; also read by the hosted UI). Only when a runId was recorded.
1426
+ // - ~/.hilos/state.json for this thread root, stamped with THIS machine — the
1427
+ // LOCAL proof the session lives here, which the resume gate above requires.
1428
+ // Called after each run (a resumed run yields a possibly-new sessionId, so the
1429
+ // chain continues across multiple iterations) and again post-ship to fill prUrl.
1430
+ const recordSession = async (prUrl) => {
1431
+ const sid = runSessionId;
1432
+ if (!sid) return;
1433
+ if (runId) {
1434
+ await tool("update_run", { runId, providerSessionId: sid }).catch(() => {});
1435
+ }
1436
+ try {
1437
+ if (threadRoot) {
1438
+ writeState(HILOS_DIR, threadRoot, {
1439
+ runId: runId || null,
1440
+ branch,
1441
+ prUrl: prUrl || continuingPrUrl || null,
1442
+ sessionId: sid,
1443
+ machine,
1444
+ updatedAt: new Date().toISOString(),
1445
+ });
1446
+ }
1447
+ } catch {
1448
+ /* a state write must never break the run */
1449
+ }
1450
+ };
1451
+
782
1452
  // Post a proposal card (approve-before-push mode) from a staged change.
783
1453
  const postProposal = async (staged) => {
784
1454
  const report = buildProposalReport({
@@ -832,8 +1502,47 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
832
1502
  return { status: "cancelled", branch };
833
1503
  };
834
1504
 
835
- const staged = await runAndStage(codeTaskPrompt({ message, context, brief: routed.task, repoFullName }));
1505
+ // Team memory recall (0297): best-effort fetch accumulated learnings for this
1506
+ // channel before the coding run so the coding agent has the team's conventions
1507
+ // and gotchas from the start. Capability-gated: older servers without the
1508
+ // `recall` tool degrade silently (caps.recall=false → skip, no change in
1509
+ // behavior). A recall failure — network hiccup, server error — NEVER fails the
1510
+ // task; it is caught and the run proceeds without the block.
1511
+ let teamMemoryBlock = "";
1512
+ if (caps.recall) {
1513
+ try {
1514
+ const recalled = await tool("recall", { channelId, limit: 20 }).catch(() => null);
1515
+ const mems = Array.isArray(recalled?.memories) ? recalled.memories : [];
1516
+ teamMemoryBlock = buildMemoryBlock(mems);
1517
+ } catch {
1518
+ /* recall failure must never break the coding task */
1519
+ }
1520
+ }
1521
+
1522
+ const codePrompt =
1523
+ (teamMemoryBlock ? teamMemoryBlock + "\n\n" : "") +
1524
+ codeTaskPrompt({ message, context, brief: routed.task, repoFullName });
1525
+ let staged = await runAndStage(codePrompt);
836
1526
  if (staged.aborted) return await postStopped();
1527
+ // Never-worse-than-today (0282): if we RESUMED a session and that run FAILED (a
1528
+ // stale/foreign `--resume` id makes claude error out → empty diff → failed), retry
1529
+ // ONCE without --resume — the exact branch+feedback iterate we'd have run before
1530
+ // 0282. A dead session can therefore never dead-end an iteration. Gated on
1531
+ // staged.failed (the reliable resume-error signal) so a legitimate no-op run isn't
1532
+ // re-run. Adopt the retry regardless — it IS the pre-0282 baseline.
1533
+ if (resumeSessionId && staged.empty && staged.failed) {
1534
+ console.log(" code → resumed run failed; retrying once without --resume (branch + feedback)");
1535
+ const retry = await runAndStage(codePrompt, { resume: false });
1536
+ if (retry.aborted) return await postStopped();
1537
+ staged = retry;
1538
+ }
1539
+ // Persist this run's session (best-effort) so the NEXT iterate can resume it and
1540
+ // machine-match. prUrl is filled in again at ship time.
1541
+ await recordSession(continuingPrUrl);
1542
+ // Resume is a ONE-shot for the first run of this iterate: the gate:true feedback
1543
+ // rounds below are intra-run refinements (same working tree, same PR turn), not a
1544
+ // fresh cross-turn resume, so they must NOT replay the original `--resume` id.
1545
+ resumeSessionId = null;
837
1546
  // The CLI committed on its own (clean tree, but commits ahead of base). Don't
838
1547
  // report "no changes" or delete the branch — surface the real work.
839
1548
  if (staged.empty && !staged.failed && staged.ahead > 0) {
@@ -852,8 +1561,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
852
1561
  await finalizeProgress(`\`${branch}\` has the agent's own commits — needs review.`);
853
1562
  return { status: "self-committed-gated", branch };
854
1563
  }
855
- await finalizeProgress(`Agent shipped \`${branch}\` itself report below.`);
856
- return await shipSelfDriven({
1564
+ // When a live status card was streaming, settle the report onto it (0289)
1565
+ // the card cross-fades run → report. Skip finalizeProgress then (it edits the
1566
+ // card's body, which the settle overwrites with the report anyway).
1567
+ const selfSettleId = streamOn && progressId ? progressId : null;
1568
+ if (!selfSettleId) await finalizeProgress(`Agent shipped \`${branch}\` itself — report below.`);
1569
+ const selfResult = await shipSelfDriven({
857
1570
  repoPath,
858
1571
  branch,
859
1572
  task: routed.task || message.body,
@@ -863,12 +1576,22 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
863
1576
  channelId,
864
1577
  deps,
865
1578
  parentId: threadRoot,
1579
+ settleId: selfSettleId,
1580
+ runId,
866
1581
  });
1582
+ await recordSession(selfResult.prUrl);
1583
+ return selfResult;
867
1584
  }
868
1585
  if (staged.empty) {
1586
+ // When continuing an existing PR, never delete that branch — it's the open
1587
+ // PR — and don't say "cleaning up". Just report and leave the PR as it was.
1588
+ const cleanupNote = continuingPrUrl ? "" : ` Cleaning up \`${branch}\`.`;
1589
+ const retryTail = continuingPrUrl
1590
+ ? ` — \`${branch}\` is unchanged; mention me to retry.`
1591
+ : ` Cleaning up \`${branch}\` — mention me to retry.`;
869
1592
  let body;
870
1593
  if (staged.failed && staged.errCode === "ENOENT") {
871
- body = `I couldn't start \`${cfg.codingCmd}\` — is it installed and on PATH? Cleaning up \`${branch}\`.`;
1594
+ body = `I couldn't start \`${cfg.codingCmd}\` — is it installed and on PATH?${cleanupNote}`;
872
1595
  } else if (staged.failed) {
873
1596
  const why = staged.errMessage
874
1597
  ? ` (${staged.errMessage})`
@@ -876,16 +1599,20 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
876
1599
  ? ` (the CLI exited ${staged.status})`
877
1600
  : "";
878
1601
  const tail = staged.stderrTail ? `: ${staged.stderrTail}` : "";
879
- body = `The run didn't finish${why}${tail}. Cleaning up \`${branch}\` — mention me to retry.`;
1602
+ body = `The run didn't finish${why}${tail}.${retryTail}`;
880
1603
  } else {
881
- body = `No changes were produced. Cleaning up \`${branch}\`.`;
1604
+ body = continuingPrUrl
1605
+ ? `That feedback produced no further changes — \`${branch}\` is unchanged.`
1606
+ : `No changes were produced.${cleanupNote}`;
882
1607
  }
883
1608
  await tool("post_message", { channelId, parentId: threadRoot, broadcast: true, body });
884
1609
  await finalizeProgress(
885
1610
  staged.failed ? `Run ended early on \`${branch}\` — see the note below.` : `No changes needed on \`${branch}\`.`,
886
1611
  );
887
- git(repoPath, ["switch", "-"]);
888
- git(repoPath, ["branch", "-D", branch]);
1612
+ if (!continuingPrUrl) {
1613
+ git(repoPath, ["switch", "-"]);
1614
+ git(repoPath, ["branch", "-D", branch]);
1615
+ }
889
1616
  return { status: staged.failed ? "run-failed" : "no-changes" };
890
1617
  }
891
1618
 
@@ -895,20 +1622,38 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
895
1622
  if (!cfg.gate) {
896
1623
  // A "stop" that lands between the run finishing and the ship must still win.
897
1624
  if (signal?.aborted) return await postStopped();
1625
+ // Seamless single card (0289): when a live status card streamed this run,
1626
+ // settle the report onto it in place instead of posting a separate report
1627
+ // message. Only the DEFAULT gate:false report settles in place; gate:true's
1628
+ // proposal → decision → report lifecycle stays on separate messages (a
1629
+ // separate ticket). No status card (older server / status post failed) →
1630
+ // settleId is null and applyDecision posts a fresh broadcast report as before.
1631
+ const settleId = streamOn && progressId ? progressId : null;
898
1632
  const result = await applyDecision({
899
1633
  decision: { kind: "approved" },
900
1634
  repoPath,
901
1635
  branch,
902
- task: message.body,
1636
+ // For a continuation the raw mention is the rework ping ("please update the
1637
+ // PR …"); use the router's distilled brief for a clean commit/PR title.
1638
+ task: continuingPrUrl ? routed.task || message.body : message.body,
903
1639
  requester: message.author,
904
1640
  cfg,
905
1641
  tool,
906
1642
  channelId,
907
1643
  deps,
908
1644
  parentId: threadRoot,
1645
+ existingPrUrl: continuingPrUrl,
1646
+ settleId,
1647
+ runId,
909
1648
  });
910
- await finalizeProgress(`Done on \`${branch}\` see the report below.`);
911
- return { ...result, stat: staged.stat };
1649
+ // The settle already flipped the card to the report (body + metadata); editing
1650
+ // the body again would clobber the report summary, so only finalize when we
1651
+ // posted a separate report.
1652
+ if (!settleId) await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
1653
+ // Re-record with the now-known PR url so the local session record + the run row
1654
+ // carry the PR the session belongs to (best-effort; session id unchanged).
1655
+ await recordSession(result.prUrl);
1656
+ return { ...result, stat: staged.stat, sessionId: runSessionId };
912
1657
  }
913
1658
 
914
1659
  await finalizeProgress(`Coding done on \`${branch}\` — proposal below for review.`);
@@ -948,16 +1693,21 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
948
1693
  decision,
949
1694
  repoPath,
950
1695
  branch,
951
- task: message.body,
1696
+ // A gate:true approve on a continuation already has an open PR — reuse it so
1697
+ // we update in place instead of a failing `gh pr create`.
1698
+ task: continuingPrUrl ? routed.task || message.body : message.body,
952
1699
  requester: message.author,
953
1700
  cfg,
954
1701
  tool,
955
1702
  channelId,
956
1703
  deps,
957
1704
  parentId: threadRoot,
1705
+ existingPrUrl: continuingPrUrl,
1706
+ runId,
958
1707
  });
959
1708
  await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
960
- return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round };
1709
+ await recordSession(result.prUrl);
1710
+ return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round, sessionId: runSessionId };
961
1711
  } finally {
962
1712
  // Whatever happened, give the user their uncommitted work back.
963
1713
  await restoreStash();