hilos-agent 0.1.14 → 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,15 +163,19 @@ 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} — ` : "";
169
+ // `parentId` here is the run's thread root: every outcome below is terminal, so
170
+ // it broadcasts back to the channel (visible in both thread and channel) — the
171
+ // hosted runner's reply_broadcast. A no-op when there's no thread root.
107
172
  if (decision.kind === "approved") {
108
173
  // Guard: nothing staged → don't push an empty branch and falsely report "shipped".
109
174
  if (deps.git(repoPath, ["diff", "--cached", "--quiet"]).status === 0) {
110
175
  await tool("post_message", {
111
176
  channelId,
112
177
  parentId,
178
+ broadcast: true,
113
179
  body: `Approved, but there are no staged changes to commit on \`${branch}\`.`,
114
180
  });
115
181
  return { status: "nothing-staged", branch };
@@ -119,6 +185,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
119
185
  await tool("post_message", {
120
186
  channelId,
121
187
  parentId,
188
+ broadcast: true,
122
189
  body: `Approved, but the commit failed: ${(commit.stderr || "").trim().slice(0, 300)}`,
123
190
  });
124
191
  return { status: "commit-failed", branch };
@@ -128,25 +195,53 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
128
195
  await tool("post_message", {
129
196
  channelId,
130
197
  parentId,
198
+ broadcast: true,
131
199
  body: `Approved, but the push failed: ${(push.stderr || "").trim().slice(0, 300)}`,
132
200
  });
133
201
  return { status: "push-failed", branch };
134
202
  }
135
203
  const { title, body } = prTitleBody(task, branch);
136
- const pr = deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
137
- 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 = {
138
216
  channelId,
139
- parentId,
140
217
  title: `Shipped: ${title}`,
141
218
  summary:
142
219
  pr.ok && pr.url
143
- ? `${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.`
144
223
  : `${lead}pushed \`${branch}\`. Open a PR manually — \`gh\` failed.`,
145
224
  prUrl: pr.ok && pr.url ? pr.url : undefined,
146
225
  caveats: pr.ok ? [] : [`gh pr create failed: ${(pr.stderr || "").trim().slice(0, 200)}`],
147
- });
226
+ };
227
+ await tool(
228
+ "post_report",
229
+ settleId
230
+ ? { ...reportArgs, messageId: settleId, broadcast: true }
231
+ : { ...reportArgs, parentId, broadcast: true },
232
+ );
148
233
  // Work produced a PR → attach it to the channel so its live pill shows up.
149
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
+ }
150
245
  return { status: "pushed", branch, prUrl: pr.ok ? pr.url : null };
151
246
  }
152
247
 
@@ -155,7 +250,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
155
250
  deps.git(repoPath, ["clean", "-fd"]);
156
251
  deps.git(repoPath, ["switch", "-"]);
157
252
  deps.git(repoPath, ["branch", "-D", branch]);
158
- await tool("post_message", { channelId, parentId, body: `Rejected — discarded \`${branch}\`.` });
253
+ await tool("post_message", { channelId, parentId, broadcast: true, body: `Rejected — discarded \`${branch}\`.` });
159
254
  return { status: "discarded", branch };
160
255
  }
161
256
 
@@ -163,6 +258,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
163
258
  await tool("post_message", {
164
259
  channelId,
165
260
  parentId,
261
+ broadcast: true,
166
262
  body: `Got it${decision.note ? `: ${decision.note}` : ""}. Leaving \`${branch}\` for a follow-up.`,
167
263
  });
168
264
  return { status: "changes", branch, note: decision.note };
@@ -171,6 +267,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
171
267
  await tool("post_message", {
172
268
  channelId,
173
269
  parentId,
270
+ broadcast: true,
174
271
  body: `Still awaiting review on \`${branch}\`. Re-mention me once you've decided.`,
175
272
  });
176
273
  return { status: "timeout", branch };
@@ -183,7 +280,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
183
280
  * push the branch it's on (no-op if already pushed), reuse the PR it opened or
184
281
  * open one, and report it. Used only when NOT gated (bias-to-action).
185
282
  */
186
- 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 }) {
187
284
  const tag = requesterTag(requester);
188
285
  const lead = tag ? `${tag} — ` : "";
189
286
  // The agent may have committed on the daemon's branch or switched to one of its
@@ -198,9 +295,11 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
198
295
  prUrl = pr.ok && pr.url ? pr.url : null;
199
296
  }
200
297
  const { title } = prTitleBody(task, cur);
201
- 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 = {
202
302
  channelId,
203
- parentId,
204
303
  title: `Shipped: ${title}`,
205
304
  summary: prUrl
206
305
  ? `${lead}the coding agent committed on \`${cur}\` and opened a pull request.`
@@ -209,8 +308,23 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
209
308
  : `${lead}the coding agent committed on \`${cur}\` locally, but pushing it failed.`,
210
309
  prUrl: prUrl || undefined,
211
310
  caveats: push.status === 0 ? [] : [`git push failed: ${(push.stderr || "").trim().slice(0, 200)}`],
212
- });
311
+ };
312
+ await tool(
313
+ "post_report",
314
+ settleId
315
+ ? { ...reportArgs, messageId: settleId, broadcast: true }
316
+ : { ...reportArgs, parentId, broadcast: true },
317
+ );
213
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
+ }
214
328
  return { status: push.status === 0 ? "pushed" : "commit-local", branch: cur, prUrl };
215
329
  }
216
330
 
@@ -238,9 +352,23 @@ const CODE_SIGNAL = "__CODE__";
238
352
  * `error` is set when the model produced nothing (so the caller can be honest
239
353
  * about a timeout vs a missing binary instead of inventing a reply).
240
354
  */
241
- async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal }) {
355
+ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false }) {
242
356
  const cmd = cfg.chatCmd || cfg.codingCmd;
243
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
+ : "";
244
372
  const prompt =
245
373
  `You are ${name}, a teammate in a team chat (hilos) connected to the git repository ` +
246
374
  `${repoFullName}. Read the conversation and judge what the LATEST message wants from you.\n\n` +
@@ -253,7 +381,8 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
253
381
  `that lists who reacted with each emoji.\n\n` +
254
382
  `Otherwise — a question, a greeting, general discussion, or they explicitly don't want code ` +
255
383
  `yet — just reply to them concisely and directly as a single chat message (no preamble, no ` +
256
- `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` +
257
386
  `You are only routing here — output ONLY your text response. Do NOT use any tools, do NOT ` +
258
387
  `edit files, do NOT run commands; a separate step does the actual coding.\n\n` +
259
388
  `${memoryPreamble(workspaceMemory)}Conversation so far:\n${transcript}`;
@@ -265,8 +394,14 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
265
394
  signal,
266
395
  });
267
396
  if (run.aborted || signal?.aborted) return { aborted: true };
268
- const out = (run.stdout || "").trim();
269
- 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 };
270
405
  // Accept the sentinel ANYWHERE, not just the first line — models sometimes add a
271
406
  // line of preamble before it ("Got it, …\n__CODE__\n<brief>"). If it appears,
272
407
  // it's a code run; the brief is whatever follows the sentinel's line and any
@@ -276,9 +411,9 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
276
411
  if (idx >= 0) {
277
412
  const after = out.slice(idx + CODE_SIGNAL.length);
278
413
  const nl = after.indexOf("\n");
279
- 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 };
280
415
  }
281
- return { code: false, reply: out };
416
+ return { code: false, reply: out, followupSignal };
282
417
  }
283
418
 
284
419
  /**
@@ -485,6 +620,200 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
485
620
  await deliver(body);
486
621
  }
487
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
+
488
817
  /** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
489
818
  * cancels an in-flight run — the queue fires it when a human says "stop". */
490
819
  export async function handleTask({ message, channelId, tool, me, caps = {} }, cfg, depsOverride, opts = {}) {
@@ -506,6 +835,34 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
506
835
  // just said.
507
836
  const context = await fetchContext({ channelId, tool, parentId });
508
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
+
509
866
  // No repo linked → nothing to build; just reply.
510
867
  if (!repoLink) {
511
868
  await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
@@ -513,10 +870,32 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
513
870
  }
514
871
  const repoFullName = repoLink.repo_full_name;
515
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
+
516
890
  // Chat vs code is the LLM's call, not a word list: it reads the whole
517
891
  // conversation and either returns a chat reply or signals a code run. Handles
518
892
  // any phrasing, any language, and "go for it" obviously means "do the thing we
519
- // 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.
520
899
  const routed = await routeIntent({
521
900
  name: me?.agentName || "an assistant",
522
901
  repoFullName,
@@ -524,6 +903,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
524
903
  workspaceMemory,
525
904
  cfg,
526
905
  signal,
906
+ hasActiveRun: activeRunOpen,
527
907
  });
528
908
  if (routed.aborted || signal?.aborted) {
529
909
  await tool("post_message", { channelId, parentId, body: "Stopped." });
@@ -542,6 +922,20 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
542
922
  }
543
923
  // routed.code → fall through to the coding flow below.
544
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
+
545
939
  let repoPath = resolveRepoPath(cfg, repoFullName);
546
940
  if (!repoPath) {
547
941
  // Zero-config path: if the daemon is running INSIDE a checkout of this repo
@@ -617,25 +1011,137 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
617
1011
  };
618
1012
 
619
1013
  try {
620
- const branch = branchSlug(message.body, randomBytes(3).toString("hex"));
621
1014
  git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
622
- const co = git(repoPath, ["switch", "-c", branch]);
623
- if (co.status !== 0) {
624
- await tool("post_message", {
625
- channelId,
626
- parentId,
627
- body: `Couldn't create branch \`${branch}\`: ${co.stderr.trim()}`,
628
- });
629
- 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
+ }
630
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
+ }
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);
631
1084
 
632
1085
  // Instant acknowledgement: post a template in <1s so the channel shows life
633
1086
  // before any model call. If the server supports edit_message and a fast chatCmd
634
1087
  // is set, a bounded plan-ack edits in a sentence of specifics ("I'll do X…").
635
1088
  // Best-effort — a slow or failed ack just leaves the template, never blocks.
636
- 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 });
637
1103
  const ackId = ack?.messageId ?? null;
638
- if (ackId && caps.editMessage && cfg.chatCmd) {
1104
+ // Where the run streams + reports. When the mention was already in a thread,
1105
+ // stay in it. When it was top-level, the ack becomes the thread anchor so the
1106
+ // progress worklog and the report card live UNDER it — one tidy thread instead
1107
+ // of three separate top-level posts — exactly like the hosted/native runner.
1108
+ // The ack itself stays top-level (the channel-visible "on it"); terminal
1109
+ // outcomes + report cards broadcast back to the channel from the thread.
1110
+ const threadRoot = parentId ?? ackId;
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") {
639
1145
  // Feed the ack the router's distilled brief AND the conversation — not the raw
640
1146
  // mention — so it states a real plan instead of "what's the task?".
641
1147
  const plan = await proposePlanAck({
@@ -652,9 +1158,49 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
652
1158
  // then EDIT it on later beats — alive without thread spam. Needs edit_message;
653
1159
  // without it we skip rather than post a fresh message every beat. lastLine
654
1160
  // carries the CLI's latest output into the beat.
655
- 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;
656
1202
  let lastLine = "";
657
- 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
658
1204
  const startHeartbeat = () => {
659
1205
  if (!heartbeatOn) return () => {};
660
1206
  const start = deps.now();
@@ -666,7 +1212,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
666
1212
  const body = buildHeartbeat({ repoFullName, branch, elapsedMs: deps.now() - start, lastLine });
667
1213
  try {
668
1214
  if (!progressId) {
669
- const r = await tool("post_message", { channelId, parentId, body });
1215
+ // Progress is the worklog it streams in the thread only (no broadcast).
1216
+ const r = await tool("post_message", { channelId, parentId: threadRoot, body });
670
1217
  progressId = r?.messageId ?? null;
671
1218
  } else {
672
1219
  await tool("edit_message", { messageId: progressId, body });
@@ -696,27 +1243,139 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
696
1243
  // Run the CLI and stage everything it changed; return the diff stats (no post).
697
1244
  // Workspace memory (the project's soul) is prepended so the coding agent has
698
1245
  // the shared context before it starts.
699
- const runAndStage = async (promptText) => {
1246
+ const runAndStage = async (promptText, { resume = true } = {}) => {
700
1247
  console.log(
701
1248
  ` code → running \`${cfg.codingCmd}\` in ${repoPath} (this can take a few minutes; output appears when it finishes)…`,
702
1249
  );
703
- 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];
704
1313
  let run;
705
1314
  try {
706
1315
  run = await runCli({
707
1316
  cmd: parts[0],
708
- args: [...parts.slice(1), memoryPreamble(workspaceMemory) + promptText],
1317
+ args: [...codeArgs, memoryPreamble(workspaceMemory) + promptText],
709
1318
  cwd: repoPath,
710
1319
  timeoutMs: cfg.runTimeoutMs,
711
1320
  label: "coding",
712
1321
  signal,
713
1322
  onData: (c) => {
1323
+ // Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
714
1324
  const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
715
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
+ }
716
1333
  },
717
1334
  });
718
1335
  } finally {
719
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
+ }
720
1379
  }
721
1380
  if (run.aborted || signal?.aborted) {
722
1381
  console.log(" code → cancelled");
@@ -745,9 +1404,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
745
1404
  // (commit/push/PR) despite being told only to edit. Detect commits it made
746
1405
  // on this branch so we report + ship them instead of falsely claiming "no
747
1406
  // changes" and deleting a branch that has real work.
748
- const base = startRef.ref || cfg.defaultBranch;
749
1407
  const ahead =
750
- 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;
751
1409
  if (ahead > 0) {
752
1410
  console.log(` code → agent self-committed (${ahead} commit(s) ahead); reconciling`);
753
1411
  return { empty: true, failed: false, ahead };
@@ -761,6 +1419,36 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
761
1419
  return { empty: false, diffText, truncated, omittedLines, stat, runFailed: run.status !== 0 };
762
1420
  };
763
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
+
764
1452
  // Post a proposal card (approve-before-push mode) from a staged change.
765
1453
  const postProposal = async (staged) => {
766
1454
  const report = buildProposalReport({
@@ -774,7 +1462,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
774
1462
  stat: staged.stat,
775
1463
  runFailed: staged.runFailed,
776
1464
  });
777
- const res = await tool("post_report", { channelId, parentId, ...report });
1465
+ const res = await tool("post_report", { channelId, parentId: threadRoot, broadcast: true, ...report });
778
1466
  return { reportMessageId: res?.messageId ?? null, stat: staged.stat };
779
1467
  };
780
1468
 
@@ -805,7 +1493,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
805
1493
  const clean = discardBranch();
806
1494
  await tool("post_message", {
807
1495
  channelId,
808
- parentId,
1496
+ parentId: threadRoot,
1497
+ broadcast: true,
809
1498
  body: clean
810
1499
  ? `Stopped — discarded \`${branch}\`.`
811
1500
  : `Stopped, but couldn't fully clean \`${branch}\` — run \`git reset --hard && git switch ${startRef.ref || cfg.defaultBranch}\` in ${repoPath}.`,
@@ -813,8 +1502,47 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
813
1502
  return { status: "cancelled", branch };
814
1503
  };
815
1504
 
816
- 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);
817
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;
818
1546
  // The CLI committed on its own (clean tree, but commits ahead of base). Don't
819
1547
  // report "no changes" or delete the branch — surface the real work.
820
1548
  if (staged.empty && !staged.failed && staged.ahead > 0) {
@@ -823,7 +1551,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
823
1551
  // honest and let the human review instead of auto-shipping. Leave the branch.
824
1552
  await tool("post_message", {
825
1553
  channelId,
826
- parentId,
1554
+ parentId: threadRoot,
1555
+ broadcast: true,
827
1556
  body:
828
1557
  `The coding agent committed on \`${branch}\` itself (I asked it to only edit files). ` +
829
1558
  `Under approve-before-push I won't auto-push it — review \`${branch}\` locally, then ` +
@@ -832,8 +1561,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
832
1561
  await finalizeProgress(`\`${branch}\` has the agent's own commits — needs review.`);
833
1562
  return { status: "self-committed-gated", branch };
834
1563
  }
835
- await finalizeProgress(`Agent shipped \`${branch}\` itself report below.`);
836
- 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({
837
1570
  repoPath,
838
1571
  branch,
839
1572
  task: routed.task || message.body,
@@ -842,13 +1575,23 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
842
1575
  tool,
843
1576
  channelId,
844
1577
  deps,
845
- parentId,
1578
+ parentId: threadRoot,
1579
+ settleId: selfSettleId,
1580
+ runId,
846
1581
  });
1582
+ await recordSession(selfResult.prUrl);
1583
+ return selfResult;
847
1584
  }
848
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.`;
849
1592
  let body;
850
1593
  if (staged.failed && staged.errCode === "ENOENT") {
851
- 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}`;
852
1595
  } else if (staged.failed) {
853
1596
  const why = staged.errMessage
854
1597
  ? ` (${staged.errMessage})`
@@ -856,16 +1599,20 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
856
1599
  ? ` (the CLI exited ${staged.status})`
857
1600
  : "";
858
1601
  const tail = staged.stderrTail ? `: ${staged.stderrTail}` : "";
859
- 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}`;
860
1603
  } else {
861
- 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}`;
862
1607
  }
863
- await tool("post_message", { channelId, parentId, body });
1608
+ await tool("post_message", { channelId, parentId: threadRoot, broadcast: true, body });
864
1609
  await finalizeProgress(
865
1610
  staged.failed ? `Run ended early on \`${branch}\` — see the note below.` : `No changes needed on \`${branch}\`.`,
866
1611
  );
867
- git(repoPath, ["switch", "-"]);
868
- git(repoPath, ["branch", "-D", branch]);
1612
+ if (!continuingPrUrl) {
1613
+ git(repoPath, ["switch", "-"]);
1614
+ git(repoPath, ["branch", "-D", branch]);
1615
+ }
869
1616
  return { status: staged.failed ? "run-failed" : "no-changes" };
870
1617
  }
871
1618
 
@@ -875,32 +1622,50 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
875
1622
  if (!cfg.gate) {
876
1623
  // A "stop" that lands between the run finishing and the ship must still win.
877
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;
878
1632
  const result = await applyDecision({
879
1633
  decision: { kind: "approved" },
880
1634
  repoPath,
881
1635
  branch,
882
- 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,
883
1639
  requester: message.author,
884
1640
  cfg,
885
1641
  tool,
886
1642
  channelId,
887
1643
  deps,
888
- parentId,
1644
+ parentId: threadRoot,
1645
+ existingPrUrl: continuingPrUrl,
1646
+ settleId,
1647
+ runId,
889
1648
  });
890
- await finalizeProgress(`Done on \`${branch}\` see the report below.`);
891
- 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 };
892
1657
  }
893
1658
 
894
1659
  await finalizeProgress(`Coding done on \`${branch}\` — proposal below for review.`);
895
1660
  let proposal = await postProposal(staged);
896
- let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId, signal });
1661
+ let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId: threadRoot, signal });
897
1662
  if (decision.kind === "cancelled") return await postStopped();
898
1663
  const maxRounds = cfg.maxRounds || 3;
899
1664
  let round = 1;
900
1665
  while (decision.kind === "changes" && round < maxRounds) {
901
1666
  await tool("post_message", {
902
1667
  channelId,
903
- parentId,
1668
+ parentId: threadRoot,
904
1669
  body: `Revising with your feedback${decision.note ? `: ${decision.note}` : ""} (round ${round + 1}/${maxRounds}).`,
905
1670
  });
906
1671
  const refined = await runAndStage(
@@ -910,13 +1675,14 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
910
1675
  if (refined.empty) {
911
1676
  await tool("post_message", {
912
1677
  channelId,
913
- parentId,
1678
+ parentId: threadRoot,
1679
+ broadcast: true,
914
1680
  body: `That feedback produced no further changes — leaving \`${branch}\` as proposed.`,
915
1681
  });
916
1682
  break;
917
1683
  }
918
1684
  proposal = await postProposal(refined);
919
- decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId, signal });
1685
+ decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId: threadRoot, signal });
920
1686
  if (decision.kind === "cancelled") return await postStopped();
921
1687
  round += 1;
922
1688
  }
@@ -927,16 +1693,21 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
927
1693
  decision,
928
1694
  repoPath,
929
1695
  branch,
930
- 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,
931
1699
  requester: message.author,
932
1700
  cfg,
933
1701
  tool,
934
1702
  channelId,
935
1703
  deps,
936
- parentId,
1704
+ parentId: threadRoot,
1705
+ existingPrUrl: continuingPrUrl,
1706
+ runId,
937
1707
  });
938
1708
  await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
939
- 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 };
940
1711
  } finally {
941
1712
  // Whatever happened, give the user their uncommitted work back.
942
1713
  await restoreStash();