hilos-agent 0.1.15 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/handler.mjs CHANGED
@@ -9,20 +9,72 @@
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, existsSync } from "node:fs";
13
+ import { hostname, tmpdir } from "node:os";
13
14
  import { join } from "node:path";
14
15
  import {
15
16
  branchSlug,
16
17
  truncateDiff,
17
18
  resolveRepoPath,
19
+ resolveFolderPath,
20
+ diffStatusSets,
18
21
  parseShortstat,
19
22
  buildProposalReport,
20
23
  decisionKind,
21
24
  commitMessage,
22
25
  prTitleBody,
23
26
  mentionHandle,
27
+ detectPrContinuation,
24
28
  } from "./daemon.mjs";
25
- import { runCli, buildHeartbeat, ackText, oneLine } from "./cli.mjs";
29
+ import { runCli, buildHeartbeat, ackText, oneLine, fmtElapsed } from "./cli.mjs";
30
+ import { makeStreamParser } from "./agent-events.mjs";
31
+ import { detectVendor, codeStreamArgs, createProgressEmitter } from "./progress-emitter.mjs";
32
+ import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
33
+ import { buildResumeArgs, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
34
+ import {
35
+ buildReviewPrompt,
36
+ parseReviewOutput,
37
+ reviewDedupeKey,
38
+ shouldReview,
39
+ } from "./review.mjs";
40
+ import { buildMemoryBlock } from "./memory.mjs";
41
+
42
+ /** PR number from a github pull-request URL, or null. */
43
+ function prNumberFromUrl(url) {
44
+ const m = /\/pull\/(\d+)/.exec(String(url || ""));
45
+ return m ? Number(m[1]) : null;
46
+ }
47
+
48
+ // How many times ONE reviewer will review the SAME PR before it stops — the
49
+ // ping-pong bound. Keyed by reviewDedupeKey(prUrl, reviewerId), counted in-process
50
+ // on the long-lived daemon so an author<->reviewer pair can't loop forever. A
51
+ // human re-triggering the review is the escape hatch past the cap.
52
+ const reviewRounds = new Map();
53
+
54
+
55
+ // Sentinel the router model emits (only when a thread already owns a run) to
56
+ // classify a follow-up: change | new-scope | ambiguous. Parsed out of the router
57
+ // output and stripped from the coding brief.
58
+ const FOLLOWUP_SIGNAL = "__FOLLOWUP__";
59
+
60
+ /** Pull the follow-up classification out of the router's output and strip the
61
+ * token from the text, so the signal never leaks into the coding brief / a chat
62
+ * reply. Returns { followupSignal, stripped }.
63
+ *
64
+ * Strip ONLY the `__FOLLOWUP__ <value>` token (plus any surrounding whitespace),
65
+ * NOT the whole line: if the model ever emits `__CODE__` and `__FOLLOWUP__` on the
66
+ * SAME line, removing the line would drop `__CODE__` too and misroute a real code
67
+ * run into a chat reply. Removing just the token preserves `__CODE__` while still
68
+ * keeping the signal out of the brief. */
69
+ export function extractFollowup(text) {
70
+ const m = /__FOLLOWUP__[:\s]+([a-z][a-z-]*)/i.exec(text);
71
+ const followupSignal = m ? normalizeSignal(m[1]) : null;
72
+ const stripped = text
73
+ .replace(/\s*__FOLLOWUP__(?:[:\s]+[a-z][a-z-]*)?/gi, "")
74
+ .replace(/\n{3,}/g, "\n\n")
75
+ .trim();
76
+ return { followupSignal, stripped };
77
+ }
26
78
 
27
79
  /** `@handle` for the person who asked, so the report tags them. */
28
80
  function requesterTag(author) {
@@ -30,10 +82,26 @@ function requesterTag(author) {
30
82
  return handle ? `@${handle}` : "";
31
83
  }
32
84
 
85
+ function compactRunMarker(status, branch) {
86
+ const b = branch ? ` \`${branch}\`` : "";
87
+ if (status === "pushed") return `Needs review:${b}`;
88
+ if (status === "discarded" || status === "cancelled") return `Stopped:${b}`;
89
+ if (status === "no-changes") return `Done:${b}`;
90
+ if (status === "changes" || status === "timeout") return `Needs follow-up:${b}`;
91
+ return `Failed:${b}`;
92
+ }
93
+
33
94
  function defaultDeps() {
34
95
  return {
35
96
  git: (cwd, args) =>
36
97
  spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 50 * 1024 * 1024 }),
98
+ // The async CLI runner, injectable so folder mode (and tests) can drive the
99
+ // coding run without spawning a real process. The repo flow still uses the
100
+ // imported runCli directly (unchanged); only folder mode goes through deps.
101
+ runCli: (opts) => runCli(opts),
102
+ // Does a path exist on disk? Injectable so folder mode's "missing folder"
103
+ // guard is unit-testable without touching the real filesystem.
104
+ pathExists: (p) => existsSync(p),
37
105
  openPR: (cwd, { title, body, branch, base }) => {
38
106
  const r = spawnSync(
39
107
  "gh",
@@ -54,6 +122,18 @@ function defaultDeps() {
54
122
  const url = (r.stdout || "").trim();
55
123
  return r.status === 0 && url ? url : null;
56
124
  },
125
+ // The head branch of an existing PR (by URL or number), so a "request
126
+ // changes" rework can check it out and update the SAME PR rather than
127
+ // opening a duplicate.
128
+ prHeadRef: (cwd, ref) => {
129
+ const r = spawnSync(
130
+ "gh",
131
+ ["pr", "view", String(ref), "--json", "headRefName", "--jq", ".headRefName // empty"],
132
+ { cwd, encoding: "utf8" },
133
+ );
134
+ const out = (r.stdout || "").trim();
135
+ return r.status === 0 && out ? out : null;
136
+ },
57
137
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
58
138
  now: () => Date.now(),
59
139
  };
@@ -101,12 +181,12 @@ async function linkPrFromUrl({ tool, channelId, url }) {
101
181
  await tool("link_pr", { channelId, repoFullName: m[1], prNumber: Number(m[2]) }).catch(() => {});
102
182
  }
103
183
 
104
- async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId }) {
184
+ async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, existingPrUrl, settleId, runId }) {
105
185
  const tag = requesterTag(requester);
106
186
  const lead = tag ? `${tag} — ` : "";
107
- // `parentId` here is the run's thread root: every outcome below is terminal, so
108
- // it broadcasts back to the channel (visible in both thread and channel) — the
109
- // hosted runner's reply_broadcast. A no-op when there's no thread root.
187
+ // `parentId` here is the run's thread root. Terminal outcomes ask the server
188
+ // for channel visibility, but the workspace decides whether final agent
189
+ // results actually broadcast. A no-op when there's no thread root.
110
190
  if (decision.kind === "approved") {
111
191
  // Guard: nothing staged → don't push an empty branch and falsely report "shipped".
112
192
  if (deps.git(repoPath, ["diff", "--cached", "--quiet"]).status === 0) {
@@ -139,21 +219,46 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
139
219
  return { status: "push-failed", branch };
140
220
  }
141
221
  const { title, body } = prTitleBody(task, branch);
142
- const pr = deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
143
- await tool("post_report", {
222
+ // Continuing an existing PR (request-changes rework): the push already
223
+ // updated it — reuse its URL instead of opening a duplicate. Otherwise open
224
+ // a fresh PR.
225
+ const pr = existingPrUrl
226
+ ? { ok: true, url: existingPrUrl }
227
+ : deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
228
+ // Seamless single card (0289): when a live status message was streaming this
229
+ // run, SETTLE the report onto it in place; the workspace setting decides
230
+ // whether that final report is also channel-visible. Without a status id
231
+ // (older server / status post failed) fall back to posting a fresh report.
232
+ const reportArgs = {
144
233
  channelId,
145
- parentId,
146
- broadcast: true,
147
234
  title: `Shipped: ${title}`,
148
235
  summary:
149
236
  pr.ok && pr.url
150
- ? `${lead}pushed \`${branch}\` and opened a pull request.`
237
+ ? existingPrUrl
238
+ ? `${lead}pushed an update to \`${branch}\` and updated the pull request.`
239
+ : `${lead}pushed \`${branch}\` and opened a pull request.`
151
240
  : `${lead}pushed \`${branch}\`. Open a PR manually — \`gh\` failed.`,
152
241
  prUrl: pr.ok && pr.url ? pr.url : undefined,
153
242
  caveats: pr.ok ? [] : [`gh pr create failed: ${(pr.stderr || "").trim().slice(0, 200)}`],
154
- });
243
+ };
244
+ await tool(
245
+ "post_report",
246
+ settleId
247
+ ? { ...reportArgs, messageId: settleId, broadcast: true }
248
+ : { ...reportArgs, parentId, broadcast: true },
249
+ );
155
250
  // Work produced a PR → attach it to the channel so its live pill shows up.
156
251
  await linkPrFromUrl({ tool, channelId, url: pr.ok ? pr.url : null });
252
+ // Bind the PR to the thread's run (0279/0280) so a follow-up continues it.
253
+ // Best-effort; only when a run was recorded (caps.runs on this server).
254
+ if (runId) {
255
+ await tool("update_run", {
256
+ runId,
257
+ ...(pr.ok && pr.url ? { prUrl: pr.url } : {}),
258
+ status: "awaiting_review",
259
+ ...(settleId ? { reportMsgId: settleId } : {}),
260
+ }).catch(() => {});
261
+ }
157
262
  return { status: "pushed", branch, prUrl: pr.ok ? pr.url : null };
158
263
  }
159
264
 
@@ -192,7 +297,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
192
297
  * push the branch it's on (no-op if already pushed), reuse the PR it opened or
193
298
  * open one, and report it. Used only when NOT gated (bias-to-action).
194
299
  */
195
- async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId }) {
300
+ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, settleId, runId }) {
196
301
  const tag = requesterTag(requester);
197
302
  const lead = tag ? `${tag} — ` : "";
198
303
  // The agent may have committed on the daemon's branch or switched to one of its
@@ -207,10 +312,11 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
207
312
  prUrl = pr.ok && pr.url ? pr.url : null;
208
313
  }
209
314
  const { title } = prTitleBody(task, cur);
210
- await tool("post_report", {
315
+ // Seamless single card (0289): settle onto the live status message when one was
316
+ // streaming this run; the workspace setting decides channel visibility.
317
+ // Otherwise post a fresh final report.
318
+ const reportArgs = {
211
319
  channelId,
212
- parentId,
213
- broadcast: true,
214
320
  title: `Shipped: ${title}`,
215
321
  summary: prUrl
216
322
  ? `${lead}the coding agent committed on \`${cur}\` and opened a pull request.`
@@ -219,8 +325,23 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
219
325
  : `${lead}the coding agent committed on \`${cur}\` locally, but pushing it failed.`,
220
326
  prUrl: prUrl || undefined,
221
327
  caveats: push.status === 0 ? [] : [`git push failed: ${(push.stderr || "").trim().slice(0, 200)}`],
222
- });
328
+ };
329
+ await tool(
330
+ "post_report",
331
+ settleId
332
+ ? { ...reportArgs, messageId: settleId, broadcast: true }
333
+ : { ...reportArgs, parentId, broadcast: true },
334
+ );
223
335
  if (prUrl) await linkPrFromUrl({ tool, channelId, url: prUrl });
336
+ // Bind the PR to the thread's run (0279/0280). Best-effort; only when recorded.
337
+ if (runId) {
338
+ await tool("update_run", {
339
+ runId,
340
+ ...(prUrl ? { prUrl } : {}),
341
+ status: "awaiting_review",
342
+ ...(settleId ? { reportMsgId: settleId } : {}),
343
+ }).catch(() => {});
344
+ }
224
345
  return { status: push.status === 0 ? "pushed" : "commit-local", branch: cur, prUrl };
225
346
  }
226
347
 
@@ -248,9 +369,24 @@ const CODE_SIGNAL = "__CODE__";
248
369
  * `error` is set when the model produced nothing (so the caller can be honest
249
370
  * about a timeout vs a missing binary instead of inventing a reply).
250
371
  */
251
- async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal }) {
372
+ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false, runCliFn }) {
373
+ const doRun = runCliFn || runCli; // folder mode injects deps.runCli; repo flow uses the import
252
374
  const cmd = cfg.chatCmd || cfg.codingCmd;
253
375
  const parts = cmd.split(" ").filter(Boolean);
376
+ // When this thread already owns an OPEN pull request (a follow-up reply), the
377
+ // router ALSO reads whether the latest message continues that PR, wants a
378
+ // separate one, or is unclear — the primary signal for same-PR continuation
379
+ // (0281). resolveFollowupMode turns it into iterate/redirect/new; the cue list
380
+ // in followup.mjs is only the fallback when the model says nothing.
381
+ const followupBlock = hasActiveRun
382
+ ? `\n\nThis message is a reply in a thread that ALREADY owns an OPEN pull request. If (and ` +
383
+ `only if) you emitted ${CODE_SIGNAL} above, ALSO output on its OWN line the token ` +
384
+ `${FOLLOWUP_SIGNAL} followed by exactly one of: change | new-scope | ambiguous — ` +
385
+ `"change" if the latest message continues or adjusts THAT pull request's work ("also make ` +
386
+ `it blue", "actually revert that", "tweak the copy"), "new-scope" if it EXPLICITLY asks for ` +
387
+ `a separate or new pull request, "ambiguous" otherwise. Default a plain follow-up to ` +
388
+ `"change". This line is metadata — keep it OUT of the imperative spec.`
389
+ : "";
254
390
  const prompt =
255
391
  `You are ${name}, a teammate in a team chat (hilos) connected to the git repository ` +
256
392
  `${repoFullName}. Read the conversation and judge what the LATEST message wants from you.\n\n` +
@@ -263,11 +399,13 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
263
399
  `that lists who reacted with each emoji.\n\n` +
264
400
  `Otherwise — a question, a greeting, general discussion, or they explicitly don't want code ` +
265
401
  `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` +
402
+ `headings). When unsure, stay conversational; implementation and PR flow are for clear ` +
403
+ `action language.` +
404
+ `${followupBlock}\n\n` +
267
405
  `You are only routing here — output ONLY your text response. Do NOT use any tools, do NOT ` +
268
406
  `edit files, do NOT run commands; a separate step does the actual coding.\n\n` +
269
407
  `${memoryPreamble(workspaceMemory)}Conversation so far:\n${transcript}`;
270
- const run = await runCli({
408
+ const run = await doRun({
271
409
  cmd: parts[0],
272
410
  args: [...parts.slice(1), prompt],
273
411
  timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
@@ -275,8 +413,14 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
275
413
  signal,
276
414
  });
277
415
  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") };
416
+ const raw = (run.stdout || "").trim();
417
+ if (!raw) return { code: false, reply: null, error: run.error || new Error("no output") };
418
+ // Lift the follow-up classification out (and strip its line) before parsing the
419
+ // brief, so the signal never pollutes the coding spec or a chat reply. Only
420
+ // meaningful when a run is active; otherwise there's no token to find.
421
+ const { followupSignal, stripped: out } = hasActiveRun
422
+ ? extractFollowup(raw)
423
+ : { followupSignal: null, stripped: raw };
280
424
  // Accept the sentinel ANYWHERE, not just the first line — models sometimes add a
281
425
  // line of preamble before it ("Got it, …\n__CODE__\n<brief>"). If it appears,
282
426
  // it's a code run; the brief is whatever follows the sentinel's line and any
@@ -286,9 +430,9 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
286
430
  if (idx >= 0) {
287
431
  const after = out.slice(idx + CODE_SIGNAL.length);
288
432
  const nl = after.indexOf("\n");
289
- return { code: true, task: (nl >= 0 ? after.slice(nl + 1) : after).trim() };
433
+ return { code: true, task: (nl >= 0 ? after.slice(nl + 1) : after).trim(), followupSignal };
290
434
  }
291
- return { code: false, reply: out };
435
+ return { code: false, reply: out, followupSignal };
292
436
  }
293
437
 
294
438
  /**
@@ -325,6 +469,34 @@ export function codeTaskPrompt(o) {
325
469
  return p;
326
470
  }
327
471
 
472
+ /**
473
+ * The prompt handed to the coding CLI in FOLDER mode (0322): the agent works
474
+ * directly in the user's own folder — no branch, no PR, no commit step. Same
475
+ * imperative "edit files now" framing as codeTaskPrompt, but it tells the agent
476
+ * its edits land straight in the folder and bans git/gh (there's nothing for the
477
+ * daemon to commit; the changes ARE the deliverable).
478
+ * @param {{ message?: { body?: string } | null, context?: { transcript?: string } | null, brief?: string, folderPath?: string }} [o]
479
+ */
480
+ export function folderTaskPrompt(o) {
481
+ const { message, context, brief, folderPath } = o || {};
482
+ const task = (brief && brief.trim()) || String(message?.body || "").trim();
483
+ const where = folderPath ? ` in the local folder ${folderPath}` : "";
484
+ let p =
485
+ `You are a coding agent working directly${where}. Implement the following by EDITING ` +
486
+ `FILES now — make the changes directly in this folder, do not just describe them, do not ` +
487
+ `ask questions:\n\n${task}`;
488
+ p +=
489
+ `\n\nIMPORTANT: your edits apply DIRECTLY to the user's folder — there is no branch, no ` +
490
+ `commit, and no pull request. Do NOT run git; do NOT stage, commit, push, create branches, ` +
491
+ `or open pull requests; do NOT use the \`gh\` CLI. Just leave your edits in the folder for ` +
492
+ `the person to review.`;
493
+ const transcript = context?.transcript?.trim();
494
+ if (transcript) {
495
+ p += `\n\nBackground from the team's discussion (context only — the task above is what to do):\n${transcript}`;
496
+ }
497
+ return p;
498
+ }
499
+
328
500
  /** owner/name from a GitHub remote URL (https or ssh), or null. */
329
501
  export function normalizeRemote(url) {
330
502
  const m = String(url || "")
@@ -409,9 +581,15 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
409
581
  const { transcript } = context || (await fetchContext({ channelId, tool, parentId }));
410
582
  const name = me?.agentName || "an assistant";
411
583
  // Tell the agent what the room is connected to so it doesn't ask "which repo?".
584
+ // repoLink is already folder-excluded (handleTask picks kind!=='folder'), so the
585
+ // "repository" line never claims a folder path is a repo. When there's no repo
586
+ // but this channel maps to a local folder on THIS machine, say that instead.
587
+ const folderPath = !repoLink ? resolveFolderPath(cfg, channelId) : null;
412
588
  const repoLine = repoLink
413
589
  ? `This channel is connected to the repository ${repoLink.repo_full_name}; assume that repo for any code work — don't ask which one.`
414
- : `If asked to change code, note that a repo isn't linked to this channel yet.`;
590
+ : folderPath
591
+ ? `This channel is connected to the local folder ${folderPath} on this machine; assume that folder for any file work — don't ask which one.`
592
+ : `If asked to change code, note that a repo isn't linked to this channel yet.`;
415
593
  const prompt =
416
594
  `You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
417
595
  `concisely and directly as a single chat message — no preamble, no headings. ` +
@@ -495,6 +673,598 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
495
673
  await deliver(body);
496
674
  }
497
675
 
676
+ /** True when this mention is a request for THIS agent to review a PR (0286/0288). */
677
+ export function isReviewRequest(message) {
678
+ return (
679
+ message?.kind === "review-request" ||
680
+ Boolean(message?.metadata?.reviewRequest) ||
681
+ Boolean(message?.reviewRequest)
682
+ );
683
+ }
684
+
685
+ function agentMode(message) {
686
+ const mode = message?.agentMode || message?.metadata?.agentMode;
687
+ return mode === "ask" || mode === "ship" ? mode : null;
688
+ }
689
+
690
+ /** The PR url a review-request/review points at, from the mention's reviewOf or the
691
+ * channel's linked PR — whichever is present. */
692
+ function reviewTargetPrUrl(message) {
693
+ return message?.reviewOf?.prUrl || message?.pr?.url || null;
694
+ }
695
+
696
+ /** Flatten get_reviews output into a compact, task-ready findings list the author's
697
+ * coding agent can act on (the review MESSAGE body only carries the summary). Uses
698
+ * the MOST RECENT review so a re-review supersedes stale findings. Returns "" when
699
+ * there's nothing structured to add. */
700
+ export function formatReviewFindings(got) {
701
+ const reviews = Array.isArray(got?.reviews) ? got.reviews : [];
702
+ if (!reviews.length) return "";
703
+ const latest = reviews[reviews.length - 1]; // get_reviews returns oldest→newest
704
+ const lines = [];
705
+ if (latest.summary) lines.push(latest.summary.trim());
706
+ for (const f of Array.isArray(latest.findings) ? latest.findings : []) {
707
+ if (!f || !f.note) continue;
708
+ const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""} — ` : "";
709
+ lines.push(`- [${f.severity || "info"}] ${loc}${f.note}`);
710
+ }
711
+ return lines.join("\n").slice(0, 4000);
712
+ }
713
+
714
+ /**
715
+ * EXECUTE a cross-agent review (WS3, 0288). A review-request lands in this agent's
716
+ * mentions; here it actually reads the PR's diff and posts an ADVISORY review that
717
+ * @-tags the author. It is strictly READ-ONLY and never touches git:
718
+ * - the full diff comes from get_pr_diff (no checkout needed),
719
+ * - the CLI runs in a throwaway temp dir so any stray file write is contained and
720
+ * discarded (belt-and-suspenders with the prompt's edit/git ban), and
721
+ * - this function ONLY calls post_review — never post_report, a decision, a
722
+ * branch, a commit, a push, or a PR. The human stays the sole merge gate.
723
+ * Best-effort: any failure posts an honest note and returns; it never throws into
724
+ * the poll loop. Bounded by shouldReview()/reviewRounds so a reviewer can't
725
+ * re-review the same PR endlessly (the ping-pong bound).
726
+ */
727
+ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory, context, signal }) {
728
+ const parentId = message.parentId ?? null;
729
+ const prUrl = reviewTargetPrUrl(message);
730
+ const reviewOf =
731
+ message?.reviewOf && (message.reviewOf.prUrl || message.reviewOf.messageId)
732
+ ? message.reviewOf
733
+ : prUrl
734
+ ? { prUrl }
735
+ : null;
736
+ if (!prUrl || !reviewOf) {
737
+ await tool("post_message", {
738
+ channelId,
739
+ parentId,
740
+ 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.`,
741
+ }).catch(() => {});
742
+ return { status: "review-no-pr" };
743
+ }
744
+
745
+ // Ping-pong bound: don't re-review the same PR past the round cap.
746
+ const reviewerId = me?.agentId || me?.id || me?.agentName || "reviewer";
747
+ const key = reviewDedupeKey(prUrl, reviewerId);
748
+ const rounds = reviewRounds.get(key) || 0;
749
+ const maxRounds = cfg.maxRounds || 3;
750
+ if (!shouldReview({ rounds, maxRounds })) {
751
+ await tool("post_message", {
752
+ channelId,
753
+ parentId,
754
+ body: `I've already reviewed this PR ${rounds} time(s) — I'll leave it to a human from here so we don't loop.`,
755
+ }).catch(() => {});
756
+ return { status: "review-capped", rounds };
757
+ }
758
+
759
+ const prNum = prNumberFromUrl(prUrl);
760
+ const label = prNum ? `PR #${prNum}` : "the PR";
761
+ // Instant, channel-visible ack; anchors the thread for the review card.
762
+ const ack = await tool("post_message", {
763
+ channelId,
764
+ parentId,
765
+ body: `Reviewing ${label} now — I'll post my read shortly.`,
766
+ }).catch(() => null);
767
+ const threadRoot = parentId ?? ack?.messageId ?? null;
768
+ const updateReviewMarker = async (body) => {
769
+ if (!ack?.messageId || parentId) return;
770
+ await tool("edit_message", { messageId: ack.messageId, body }).catch(() => {});
771
+ };
772
+
773
+ // The diff — the whole review is fed off this (no clone, no gh creds).
774
+ // Capture the failure reason on BOTH error shapes: a soft failure returns
775
+ // { ok:false, error } (auth/no-token/fetch), a hard failure throws and we map its
776
+ // message here — so the honest note below can say WHY, not just that it couldn't.
777
+ const diff = await tool("get_pr_diff", { prUrl, channelId }).catch((e) => ({ ok: false, error: e?.message }));
778
+ if (!diff || diff.ok === false || !Array.isArray(diff.files) || diff.files.length === 0) {
779
+ const reason = diff?.error || diff?.message;
780
+ const why = reason ? ` (${reason})` : "";
781
+ await tool("post_message", {
782
+ channelId,
783
+ parentId: threadRoot,
784
+ broadcast: Boolean(threadRoot),
785
+ body: `I couldn't read ${label}'s diff to review it${why}. It may be private, merged, or not linked here.`,
786
+ }).catch(() => {});
787
+ await updateReviewMarker(`Review failed: ${label}`);
788
+ return { status: "review-no-diff" };
789
+ }
790
+
791
+ // The author's own summary (when the request pointed at their report message) —
792
+ // extra intent for the reviewer. Best-effort.
793
+ let reportSummary = null;
794
+ if (reviewOf.messageId) {
795
+ const gr = await tool("get_report", { messageId: reviewOf.messageId }).catch(() => null);
796
+ if (gr?.found && gr.report?.summary) reportSummary = gr.report.summary;
797
+ }
798
+
799
+ const prompt = buildReviewPrompt({
800
+ diffFiles: diff.files,
801
+ task: message.body,
802
+ reportSummary,
803
+ threadContext: context?.transcript,
804
+ workspaceMemory,
805
+ });
806
+
807
+ // Read-only run: a throwaway cwd contains any stray write; the daemon reads ONLY
808
+ // stdout and never stages/commits anything. Review uses the fast chat command
809
+ // when set (no acceptEdits → can't write), falling back to the coding command.
810
+ const cmd = cfg.reviewCmd || cfg.chatCmd || cfg.codingCmd;
811
+ const parts = String(cmd).split(" ").filter(Boolean);
812
+ let sandboxDir = null;
813
+ try {
814
+ sandboxDir = mkdtempSync(join(tmpdir(), "hilos-review-"));
815
+ } catch {
816
+ sandboxDir = undefined; // fall back to the daemon's cwd; the prompt still bans edits
817
+ }
818
+ console.log(` review → running \`${cmd}\` on ${label} (read-only)…`);
819
+ const run = await runCli({
820
+ cmd: parts[0],
821
+ args: [...parts.slice(1), prompt],
822
+ cwd: sandboxDir || undefined,
823
+ timeoutMs: cfg.chatTimeoutMs ? Math.max(cfg.chatTimeoutMs, 120000) : cfg.runTimeoutMs,
824
+ label: "reviewing",
825
+ signal,
826
+ });
827
+ if (sandboxDir) {
828
+ try {
829
+ rmSync(sandboxDir, { recursive: true, force: true });
830
+ } catch {
831
+ /* temp dir cleanup is best-effort */
832
+ }
833
+ }
834
+
835
+ if (run.aborted || signal?.aborted) {
836
+ await tool("post_message", { channelId, parentId: threadRoot, body: `Stopped the review of ${label}.` }).catch(() => {});
837
+ await updateReviewMarker(`Review stopped: ${label}`);
838
+ return { status: "review-cancelled" };
839
+ }
840
+ const text = (run.stdout || "").trim();
841
+ if (!text) {
842
+ const enoent = run.error?.code === "ENOENT";
843
+ await tool("post_message", {
844
+ channelId,
845
+ parentId: threadRoot,
846
+ broadcast: Boolean(threadRoot),
847
+ body: enoent
848
+ ? `I couldn't start \`${cmd}\` to review ${label} — is it installed and on PATH?`
849
+ : `I couldn't get a read on ${label} that time — mention me again to retry the review.`,
850
+ }).catch(() => {});
851
+ await updateReviewMarker(`Review failed: ${label}`);
852
+ return { status: "review-no-output" };
853
+ }
854
+
855
+ const { verdict, summary, findings } = parseReviewOutput(text);
856
+ // Advisory ONLY — post_review records a verdict + findings and @-tags the author.
857
+ // It NEVER merges, blocks, or writes a decision. The server rejects a self-review,
858
+ // so an agent reviewing its own PR degrades to an honest note here.
859
+ const posted = await tool("post_review", {
860
+ channelId,
861
+ reviewOf,
862
+ verdict,
863
+ summary: summary || `Reviewed ${label}.`,
864
+ findings,
865
+ parentId: threadRoot || undefined,
866
+ broadcast: Boolean(threadRoot),
867
+ }).catch((e) => ({ ok: false, error: e?.message }));
868
+ if (posted?.ok === false) {
869
+ // Most common: "You can't review your own work." — surface honestly, no crash.
870
+ await tool("post_message", {
871
+ channelId,
872
+ parentId: threadRoot,
873
+ body: `I looked at ${label} but couldn't post the review${posted?.error || posted?.message ? `: ${posted.error || posted.message}` : ""}.`,
874
+ }).catch(() => {});
875
+ await updateReviewMarker(`Review failed: ${label}`);
876
+ return { status: "review-post-failed", error: posted?.error || posted?.message };
877
+ }
878
+ reviewRounds.set(key, rounds + 1);
879
+ console.log(` review → posted ${verdict} on ${label} (${findings.length} finding(s))`);
880
+ await updateReviewMarker(`Review ready: ${label}`);
881
+ return { status: "reviewed", verdict, findings: findings.length, prUrl };
882
+ }
883
+
884
+ /** Render a changed-file list for the folder report card. */
885
+ function renderChangedList({ created, modified, deleted }) {
886
+ const lines = [];
887
+ for (const f of created) lines.push(`- added \`${f}\``);
888
+ for (const f of modified) lines.push(`- changed \`${f}\``);
889
+ for (const f of deleted) lines.push(`- removed \`${f}\``);
890
+ return lines.join("\n");
891
+ }
892
+
893
+ /**
894
+ * FOLDER MODE (0322). A channel with NO linked GitHub repo but a `folders`
895
+ * mapping runs the coding CLI directly in that folder and applies changes in
896
+ * place — it's the user's own machine, same trust as running the CLI themselves.
897
+ * No branch, no push, no PR, no `gh` — ever. When the folder is a git repo we
898
+ * snapshot `git status` before/after to report exactly what changed and to offer
899
+ * a precise revert on reject; a non-git folder gets an honest "can't track / can't
900
+ * auto-undo" report. The report card drives a decision: approve = keep (already
901
+ * live), request-changes = re-run in place (bounded), reject = revert the run's
902
+ * own changes (git repos only).
903
+ */
904
+ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps, signal, parentId, folderPath, brief, workspaceMemory, context }) {
905
+ void me;
906
+ const git = deps.git;
907
+ const tag = requesterTag(message.author);
908
+ const lead = tag ? `${tag} — ` : "";
909
+
910
+ // 1. The mapped folder must actually exist on disk — be honest and actionable.
911
+ if (!deps.pathExists(folderPath)) {
912
+ await tool("post_message", {
913
+ channelId,
914
+ parentId,
915
+ body:
916
+ `I couldn't find the folder \`${folderPath}\` on this machine — it may have moved, been ` +
917
+ `renamed, or live on a drive that isn't mounted. Fix the path in my \`folders\` config ` +
918
+ `(or restore the folder), then mention me again.`,
919
+ });
920
+ return { status: "no-path" };
921
+ }
922
+
923
+ // 2. Is it a git repo? If so, snapshot the pre-run status so we can (a) report
924
+ // exactly what the run changed and (b) revert precisely on reject. We NEVER touch
925
+ // the index (no `git add`), never branch, never push. A non-git folder still runs
926
+ // — we just can't offer diff/undo.
927
+ const insideWorkTree = git(folderPath, ["rev-parse", "--is-inside-work-tree"]);
928
+ const isGit = insideWorkTree.status === 0 && String(insideWorkTree.stdout || "").trim() === "true";
929
+ const beforeStatus = isGit ? String(git(folderPath, ["status", "--porcelain"]).stdout || "") : "";
930
+ const dirtyBefore = isGit && beforeStatus.trim() !== "";
931
+
932
+ // 3. Instant ack / thread anchor (mirrors the repo flow). When the mention was in
933
+ // a thread we stay in it; a top-level mention makes the ack the thread anchor so
934
+ // the run's progress + report card live under it.
935
+ const ack = await tool("post_message", {
936
+ channelId,
937
+ parentId,
938
+ body: `On it — working directly in \`${folderPath}\`. Changes apply straight to your folder (no branch, no PR); I'll report what changed.`,
939
+ });
940
+ const ackId = ack?.messageId ?? null;
941
+ const threadRoot = parentId ?? ackId;
942
+
943
+ // Live progress: reuse the streaming card (post_progress) when the server
944
+ // supports it, else the edit-in-place heartbeat, else nothing. Kept across
945
+ // request-changes re-runs on the same status message.
946
+ let progressId = null;
947
+ const folderWorking = (elapsedMs, lastLine) => {
948
+ const base = `Working in \`${folderPath}\` — ${fmtElapsed(elapsedMs)} elapsed. I'll report what changed when it's done.`;
949
+ const tail = oneLine(lastLine);
950
+ return tail ? `${base}\n\nLatest: ${tail}` : base;
951
+ };
952
+
953
+ // Run the coding CLI in the folder, streaming progress + honoring timeouts and
954
+ // the abort signal (reuses runCli via deps + the progress-emitter machinery).
955
+ const runFolderCli = async (promptText) => {
956
+ const parts = cfg.codingCmd.split(" ").filter(Boolean);
957
+ const vendor = detectVendor(cfg.codingCmd);
958
+ const streamOn = Boolean(caps.postProgress);
959
+ const streamArgs = streamOn ? codeStreamArgs(vendor) : [];
960
+ let emitter = null;
961
+ let progressInflight = Promise.resolve();
962
+ let stopHeartbeat = () => {};
963
+ let lastLine = "";
964
+ // With stream args the CLI's stdout is NDJSON events, not prose — parse it and
965
+ // keep the final `result` event's summary so the report card gets clean text
966
+ // (raw stdout would dump JSON into the report). Without stream args, stdout IS
967
+ // the prose and resultText stays empty.
968
+ let resultText = "";
969
+ const resultParser = streamArgs.length ? makeStreamParser(vendor) : null;
970
+ const foldResultEvents = (events) => {
971
+ for (const ev of events || []) {
972
+ if (ev && ev.t === "result" && ev.summary) resultText = ev.summary;
973
+ }
974
+ };
975
+ if (streamOn) {
976
+ if (!progressId) {
977
+ try {
978
+ const r = await tool("post_message", { channelId, parentId: threadRoot, body: folderWorking(0, "") });
979
+ progressId = r?.messageId ?? null;
980
+ } catch {
981
+ progressId = null;
982
+ }
983
+ }
984
+ const statusId = progressId;
985
+ if (statusId) {
986
+ emitter = createProgressEmitter({
987
+ parser: makeStreamParser(vendor),
988
+ now: deps.now,
989
+ throttleMs: cfg.progressMs,
990
+ send: (p) => {
991
+ try {
992
+ const r = tool("post_progress", { messageId: statusId, progress: p });
993
+ if (r && typeof r.then === "function") {
994
+ const done = r.then(() => {}, () => {});
995
+ progressInflight = Promise.all([progressInflight, done]).then(() => {}, () => {});
996
+ }
997
+ } catch {
998
+ /* a progress send must never break the run */
999
+ }
1000
+ },
1001
+ });
1002
+ }
1003
+ } else if (caps.editMessage && cfg.heartbeatMs > 0) {
1004
+ const start = deps.now();
1005
+ let stopped = false;
1006
+ let busy = false;
1007
+ const beat = setInterval(async () => {
1008
+ if (stopped || busy) return;
1009
+ busy = true;
1010
+ const body = folderWorking(deps.now() - start, lastLine);
1011
+ try {
1012
+ if (!progressId) {
1013
+ const r = await tool("post_message", { channelId, parentId: threadRoot, body });
1014
+ progressId = r?.messageId ?? null;
1015
+ } else {
1016
+ await tool("edit_message", { messageId: progressId, body });
1017
+ }
1018
+ } catch {
1019
+ /* a heartbeat must never break the run */
1020
+ } finally {
1021
+ busy = false;
1022
+ }
1023
+ }, cfg.heartbeatMs);
1024
+ if (beat.unref) beat.unref();
1025
+ stopHeartbeat = () => {
1026
+ stopped = true;
1027
+ clearInterval(beat);
1028
+ };
1029
+ }
1030
+ let run;
1031
+ try {
1032
+ run = await deps.runCli({
1033
+ cmd: parts[0],
1034
+ args: [...parts.slice(1), ...streamArgs, memoryPreamble(workspaceMemory) + promptText],
1035
+ cwd: folderPath,
1036
+ timeoutMs: cfg.runTimeoutMs,
1037
+ label: "coding",
1038
+ signal,
1039
+ onData: (c) => {
1040
+ const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
1041
+ if (lines.length) lastLine = lines[lines.length - 1];
1042
+ if (resultParser) {
1043
+ try {
1044
+ foldResultEvents(resultParser.push(String(c)));
1045
+ } catch {
1046
+ /* result extraction must never break the run */
1047
+ }
1048
+ }
1049
+ if (emitter) {
1050
+ try {
1051
+ emitter.feed(c);
1052
+ } catch {
1053
+ /* a progress fold must never break the run */
1054
+ }
1055
+ }
1056
+ },
1057
+ });
1058
+ } finally {
1059
+ stopHeartbeat();
1060
+ if (emitter) {
1061
+ const errored = Boolean(run && (run.aborted || run.error || run.status !== 0));
1062
+ let reason = "";
1063
+ if (errored && run && !run.aborted) {
1064
+ const stderrTail = oneLine((run.stderr || "").trim().split("\n").slice(-3).join(" "), 200);
1065
+ if (run.error?.code === "ENOENT") reason = `Couldn't start ${parts[0]} — is it installed and on PATH?`;
1066
+ else if (run.error?.message) reason = run.error.message;
1067
+ else if (run.status != null) reason = `The CLI exited ${run.status}`;
1068
+ if (stderrTail) reason = reason ? `${reason}: ${stderrTail}` : stderrTail;
1069
+ }
1070
+ try {
1071
+ emitter.done(errored ? "error" : "done", reason);
1072
+ } catch {
1073
+ /* ignore */
1074
+ }
1075
+ try {
1076
+ await progressInflight;
1077
+ } catch {
1078
+ /* a drain failure must never break the run */
1079
+ }
1080
+ }
1081
+ }
1082
+ if (resultParser) {
1083
+ try {
1084
+ foldResultEvents(resultParser.flush());
1085
+ } catch {
1086
+ /* result extraction must never break the run */
1087
+ }
1088
+ }
1089
+ if (run.aborted || signal?.aborted) return { aborted: true };
1090
+ const failed = Boolean(run.error) || run.status !== 0;
1091
+ return {
1092
+ aborted: false,
1093
+ stdout: run.stdout || "",
1094
+ // Clean prose for the report: the stream `result` summary when streaming,
1095
+ // else empty (raw stdout is prose only in the non-streamed case).
1096
+ resultText,
1097
+ streamed: streamArgs.length > 0,
1098
+ failed,
1099
+ errCode: run.error?.code || null,
1100
+ errMessage: run.error?.message || null,
1101
+ status: run.status,
1102
+ stderrTail: oneLine((run.stderr || "").trim().split("\n").slice(-3).join(" "), 300),
1103
+ };
1104
+ };
1105
+
1106
+ // Compute what the run changed (git repos only), vs the pre-run snapshot.
1107
+ const computeChanged = () => {
1108
+ if (!isGit) return { modified: [], created: [], deleted: [] };
1109
+ const afterStatus = String(git(folderPath, ["status", "--porcelain"]).stdout || "");
1110
+ return diffStatusSets(beforeStatus, afterStatus);
1111
+ };
1112
+ const hasChanges = (c) => c.created.length + c.modified.length + c.deleted.length > 0;
1113
+
1114
+ const buildReport = (runResult, changed) => {
1115
+ const changedList = renderChangedList(changed);
1116
+ const stat = isGit ? truncateDiff(String(git(folderPath, ["diff", "--stat"]).stdout || "")).text.trim() : "";
1117
+ // Streamed runs put NDJSON on stdout — use the parsed result summary there;
1118
+ // non-streamed stdout is the agent's prose and is safe to quote.
1119
+ const rawOut = (runResult.resultText || (runResult.streamed ? "" : runResult.stdout) || "").trim();
1120
+ const parts = [`${lead}worked directly in \`${folderPath}\`.`];
1121
+ if (rawOut) parts.push(rawOut.slice(0, 2000));
1122
+ if (isGit) parts.push(changedList ? `Changed files:\n${changedList}` : "No tracked file changes were detected.");
1123
+ if (stat) parts.push("```\n" + stat + "\n```");
1124
+ const caveats = [`Changes were applied directly to ${folderPath} — there is no branch or PR to review.`];
1125
+ if (!isGit) caveats.push("This folder isn't a git repository, so I can't show a file-level diff or automatically undo the changes.");
1126
+ if (dirtyBefore) caveats.push("The folder already had uncommitted local changes before this run — those are mixed in with mine.");
1127
+ if (runResult.failed) {
1128
+ const why = runResult.errMessage ? `: ${runResult.errMessage}` : runResult.status != null ? ` (exit ${runResult.status})` : "";
1129
+ caveats.push(`The coding agent didn't exit cleanly${why} — review the changes carefully.`);
1130
+ }
1131
+ const folderName = folderPath.split("/").filter(Boolean).pop() || folderPath;
1132
+ return { title: `Folder run: ${folderName}`, summary: parts.join("\n\n"), caveats, todos: [] };
1133
+ };
1134
+
1135
+ // --- Run ---
1136
+ let runResult = await runFolderCli(folderTaskPrompt({ message, context, brief, folderPath }));
1137
+ if (runResult.aborted) {
1138
+ await tool("post_message", {
1139
+ channelId,
1140
+ parentId: threadRoot,
1141
+ broadcast: Boolean(threadRoot),
1142
+ body: `Stopped — I left \`${folderPath}\` as it was.`,
1143
+ });
1144
+ return { status: "cancelled" };
1145
+ }
1146
+
1147
+ let changed = computeChanged();
1148
+
1149
+ // Honest early exits (no report/decision): a failed run that changed nothing, or
1150
+ // a clean success that changed nothing.
1151
+ if (runResult.failed && (!isGit || !hasChanges(changed))) {
1152
+ let body;
1153
+ if (runResult.errCode === "ENOENT") {
1154
+ body = `I couldn't start \`${cfg.codingCmd}\` — is it installed and on PATH?`;
1155
+ } else {
1156
+ const why = runResult.errMessage
1157
+ ? ` (${runResult.errMessage})`
1158
+ : runResult.status != null
1159
+ ? ` (the CLI exited ${runResult.status})`
1160
+ : "";
1161
+ const tail = runResult.stderrTail ? `: ${runResult.stderrTail}` : "";
1162
+ const left = isGit ? `\`${folderPath}\` is unchanged` : `check \`${folderPath}\` for any partial edits`;
1163
+ body = `The run didn't finish${why}${tail}. ${left} — mention me to retry.`;
1164
+ }
1165
+ await tool("post_message", { channelId, parentId: threadRoot, broadcast: Boolean(threadRoot), body });
1166
+ return { status: "run-failed" };
1167
+ }
1168
+ if (!runResult.failed && isGit && !hasChanges(changed)) {
1169
+ await tool("post_message", {
1170
+ channelId,
1171
+ parentId: threadRoot,
1172
+ broadcast: Boolean(threadRoot),
1173
+ body: `The run finished but nothing changed in \`${folderPath}\`. Mention me to try a different approach.`,
1174
+ });
1175
+ return { status: "no-changes" };
1176
+ }
1177
+
1178
+ // --- Report card + decision loop ---
1179
+ const postFolderReport = async (rr, ch) => {
1180
+ const report = buildReport(rr, ch);
1181
+ const res = await tool("post_report", { channelId, parentId: threadRoot, broadcast: true, ...report });
1182
+ return res?.messageId ?? null;
1183
+ };
1184
+ let reportMessageId = await postFolderReport(runResult, changed);
1185
+ let decision = await awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId: threadRoot, signal });
1186
+
1187
+ const maxRounds = cfg.maxRounds || 3;
1188
+ let round = 1;
1189
+ while (decision.kind === "changes" && round < maxRounds) {
1190
+ await tool("post_message", {
1191
+ channelId,
1192
+ parentId: threadRoot,
1193
+ body: `Revising in \`${folderPath}\` with your feedback${decision.note ? `: ${decision.note}` : ""} (round ${round + 1}/${maxRounds}).`,
1194
+ });
1195
+ runResult = await runFolderCli(
1196
+ folderTaskPrompt({ message, context, brief, folderPath }) +
1197
+ `\n\nReviewer feedback to address: ${decision.note || "(see the channel)"}`,
1198
+ );
1199
+ if (runResult.aborted) {
1200
+ await tool("post_message", { channelId, parentId: threadRoot, broadcast: Boolean(threadRoot), body: `Stopped — I left \`${folderPath}\` as it was.` });
1201
+ return { status: "cancelled" };
1202
+ }
1203
+ changed = computeChanged();
1204
+ reportMessageId = await postFolderReport(runResult, changed);
1205
+ decision = await awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId: threadRoot, signal });
1206
+ round += 1;
1207
+ }
1208
+
1209
+ // --- Terminal ---
1210
+ if (decision.kind === "approved") {
1211
+ await tool("post_message", {
1212
+ channelId,
1213
+ parentId: threadRoot,
1214
+ broadcast: Boolean(threadRoot),
1215
+ body: `${lead}approved — the changes are already live in \`${folderPath}\`.`,
1216
+ });
1217
+ return { status: "folder-done", decision: "approved" };
1218
+ }
1219
+
1220
+ if (decision.kind === "rejected") {
1221
+ if (!isGit) {
1222
+ const all = [...changed.created, ...changed.modified, ...changed.deleted];
1223
+ await tool("post_message", {
1224
+ channelId,
1225
+ parentId: threadRoot,
1226
+ broadcast: Boolean(threadRoot),
1227
+ body:
1228
+ `Rejected — but \`${folderPath}\` isn't a git repository, so I can't automatically undo the changes. ` +
1229
+ (all.length ? `I touched:\n${renderChangedList(changed)}\n\nRevert these by hand.` : `Please review the folder and revert by hand.`),
1230
+ });
1231
+ return { status: "folder-done", decision: "rejected", reverted: false };
1232
+ }
1233
+ // Revert ONLY what the run itself changed: checkout tracked modifications +
1234
+ // deletions, remove untracked files the run created. Never a blanket reset —
1235
+ // pre-existing local changes stay untouched.
1236
+ const trackedToRestore = [...changed.modified, ...changed.deleted];
1237
+ if (trackedToRestore.length) git(folderPath, ["checkout", "--", ...trackedToRestore]);
1238
+ if (changed.created.length) git(folderPath, ["clean", "-fd", "--", ...changed.created]);
1239
+ const revertedList = renderChangedList(changed);
1240
+ await tool("post_message", {
1241
+ channelId,
1242
+ parentId: threadRoot,
1243
+ broadcast: Boolean(threadRoot),
1244
+ body:
1245
+ `Rejected — reverted my changes in \`${folderPath}\`.` +
1246
+ (revertedList ? `\n\nRestored:\n${revertedList}` : "") +
1247
+ (dirtyBefore ? `\n\nYour pre-existing local changes were left untouched.` : ""),
1248
+ });
1249
+ return { status: "folder-done", decision: "rejected", reverted: true };
1250
+ }
1251
+
1252
+ if (decision.kind === "cancelled") {
1253
+ await tool("post_message", { channelId, parentId: threadRoot, broadcast: Boolean(threadRoot), body: `Stopped — the changes so far are still in \`${folderPath}\`.` });
1254
+ return { status: "cancelled" };
1255
+ }
1256
+
1257
+ // Timeout (or a leftover 'changes' past the round cap): note + release, same as
1258
+ // the repo flow.
1259
+ await tool("post_message", {
1260
+ channelId,
1261
+ parentId: threadRoot,
1262
+ broadcast: Boolean(threadRoot),
1263
+ body: `Still awaiting your review of the changes in \`${folderPath}\`. They're already applied — mention me to revise or revert once you've decided.`,
1264
+ });
1265
+ return { status: "folder-done", decision: "timeout" };
1266
+ }
1267
+
498
1268
  /** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
499
1269
  * cancels an in-flight run — the queue fires it when a human says "stop". */
500
1270
  export async function handleTask({ message, channelId, tool, me, caps = {} }, cfg, depsOverride, opts = {}) {
@@ -505,7 +1275,10 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
505
1275
  const parentId = message.parentId ?? null;
506
1276
 
507
1277
  const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
508
- const repoLink = links.find((l) => l.repo_full_name);
1278
+ // A folder link (0324) carries the folder PATH in repo_full_name for display —
1279
+ // it is NOT a GitHub repo, so it must never be picked as the channel's repo.
1280
+ // Exclude kind='folder'; pr/branch/repo rows with repo_full_name still count.
1281
+ const repoLink = links.find((l) => l.kind !== "folder" && l.repo_full_name);
509
1282
  // Workspace memory ("soul") — shared project context the agent should know.
510
1283
  const { memory: workspaceMemory = null } = await tool("get_workspace_memory", {}).catch(() => ({
511
1284
  memory: null,
@@ -515,26 +1288,135 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
515
1288
  // crucial: a reply like "yeah, do it" only means something against what was
516
1289
  // just said.
517
1290
  const context = await fetchContext({ channelId, tool, parentId });
1291
+ const mode = agentMode(message);
1292
+
1293
+ // REVIEW EXECUTION (0288): a review-request routes to the read-only reviewer
1294
+ // BEFORE the chat/code flow — it reads the PR's diff and posts an advisory review
1295
+ // that @-tags the author. Gated on caps.review (post_review + get_pr_diff on this
1296
+ // server); without it (older deploy) this is skipped and behavior is unchanged.
1297
+ if (caps.review && isReviewRequest(message)) {
1298
+ return await reviewTask({ message, channelId, tool, me, cfg, workspaceMemory, context, signal });
1299
+ }
1300
+
1301
+ // AUTHOR SIDE (0288): a review that @-tags this agent about work it owns. The
1302
+ // review body carries the verdict + summary; the full findings live in metadata,
1303
+ // so pull them via get_reviews and fold them into the conversation the coding
1304
+ // agent sees — then fall through to the normal follow-up machinery, which (0281)
1305
+ // checks out the thread's PR branch and pushes onto the SAME human-gated PR. Best-
1306
+ // effort; a failure just leaves the summary line to drive the iterate.
1307
+ if (caps.review && message?.kind === "review") {
1308
+ const target =
1309
+ message?.reviewOf && (message.reviewOf.prUrl || message.reviewOf.messageId)
1310
+ ? message.reviewOf
1311
+ : reviewTargetPrUrl(message)
1312
+ ? { prUrl: reviewTargetPrUrl(message) }
1313
+ : null;
1314
+ if (target) {
1315
+ const got = await tool("get_reviews", { reviewOf: target }).catch(() => null);
1316
+ const detail = formatReviewFindings(got);
1317
+ if (detail) context.transcript = `${context.transcript}\n\nReviewer findings to address:\n${detail}`;
1318
+ }
1319
+ }
518
1320
 
519
- // No repo linked → nothing to build; just reply.
1321
+ // No repo linked → either FOLDER mode (0322: a channel mapped to a plain local
1322
+ // folder can still get coding work done) or, failing that, just reply. A linked
1323
+ // repo always wins — the folder map is only consulted when there's no repo link.
520
1324
  if (!repoLink) {
1325
+ const folderPath = resolveFolderPath(cfg, channelId);
1326
+ if (folderPath) {
1327
+ // Same chat-vs-code decision the repo path uses: mode 'ask'/'ship' short-
1328
+ // circuit exactly as in the repo flow, otherwise the LLM router judges intent
1329
+ // (its CLI call goes through deps.runCli so folder mode is unit-testable).
1330
+ const routed =
1331
+ mode === "ask"
1332
+ ? { code: false, reply: null }
1333
+ : mode === "ship"
1334
+ ? { code: true, task: message.body }
1335
+ : await routeIntent({
1336
+ name: me?.agentName || "an assistant",
1337
+ repoFullName: folderPath,
1338
+ transcript: context.transcript,
1339
+ workspaceMemory,
1340
+ cfg,
1341
+ signal,
1342
+ runCliFn: deps.runCli,
1343
+ });
1344
+ if (routed.aborted || signal?.aborted) {
1345
+ await tool("post_message", { channelId, parentId, body: "Stopped." });
1346
+ return { status: "chat" };
1347
+ }
1348
+ if (routed.code) {
1349
+ return await handleFolderTask({
1350
+ message,
1351
+ channelId,
1352
+ tool,
1353
+ me,
1354
+ caps,
1355
+ cfg,
1356
+ deps,
1357
+ signal,
1358
+ parentId,
1359
+ folderPath,
1360
+ brief: routed.task,
1361
+ workspaceMemory,
1362
+ context,
1363
+ });
1364
+ }
1365
+ // Not a coding task → post the router's reply if it produced one, else fall
1366
+ // through to a normal conversational reply.
1367
+ if (routed.reply) {
1368
+ await tool("post_message", { channelId, parentId, body: routed.reply });
1369
+ return { status: "chat" };
1370
+ }
1371
+ }
521
1372
  await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
522
1373
  return { status: "chat" };
523
1374
  }
524
1375
  const repoFullName = repoLink.repo_full_name;
525
1376
 
1377
+ if (mode === "ask") {
1378
+ await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
1379
+ return { status: "chat" };
1380
+ }
1381
+
1382
+ // Same-PR follow-up (0281): if this mention is a reply in a thread that already
1383
+ // owns a run (branch + PR), we may continue THAT run instead of forking a
1384
+ // duplicate. Look it up by the thread root — the parentId the reply carried.
1385
+ // Best-effort + gated on the server supporting the runs entity (caps.runs); any
1386
+ // failure falls back to a fresh run (mode 'new'), never breaking the task.
1387
+ let activeRun = null;
1388
+ if (caps.runs && parentId) {
1389
+ const r = await tool("get_active_run", { channelId, threadRootId: parentId }).catch(() => null);
1390
+ if (r && r.found) activeRun = r;
1391
+ }
1392
+ // Only an OPEN PR is iterable: awaiting_review (the review gate) or
1393
+ // changes_requested (a rework in flight). A merged/closed run is never
1394
+ // continued (the resolver's recency guard).
1395
+ const activeRunOpen = Boolean(
1396
+ activeRun && (activeRun.status === "awaiting_review" || activeRun.status === "changes_requested"),
1397
+ );
1398
+
526
1399
  // Chat vs code is the LLM's call, not a word list: it reads the whole
527
1400
  // conversation and either returns a chat reply or signals a code run. Handles
528
1401
  // 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.
530
- const routed = await routeIntent({
531
- name: me?.agentName || "an assistant",
532
- repoFullName,
533
- transcript: context.transcript,
534
- workspaceMemory,
535
- cfg,
536
- signal,
537
- });
1402
+ // just discussed". When it replies (chat), post that and we're done. When the
1403
+ // thread owns an OPEN PR it ALSO classifies the follow-up (change / new-scope /
1404
+ // ambiguous). Gate on activeRunOpen, not merely Boolean(activeRun): the follow-up
1405
+ // signal is only ever acted on when the PR is open (resolveFollowupMode → 'new'
1406
+ // otherwise), so telling the router the thread "owns an open PR" when the run is
1407
+ // merged/closed would just mislead the model and waste a classification.
1408
+ const routed =
1409
+ mode === "ship"
1410
+ ? { code: true, task: message.body, followupSignal: null }
1411
+ : await routeIntent({
1412
+ name: me?.agentName || "an assistant",
1413
+ repoFullName,
1414
+ transcript: context.transcript,
1415
+ workspaceMemory,
1416
+ cfg,
1417
+ signal,
1418
+ hasActiveRun: activeRunOpen,
1419
+ });
538
1420
  if (routed.aborted || signal?.aborted) {
539
1421
  await tool("post_message", { channelId, parentId, body: "Stopped." });
540
1422
  return { status: "chat" };
@@ -552,6 +1434,20 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
552
1434
  }
553
1435
  // routed.code → fall through to the coding flow below.
554
1436
 
1437
+ // How this code follow-up routes against the thread's run. The LLM's read
1438
+ // (routed.followupSignal) is primary; the cue list is the fallback when it said
1439
+ // nothing. resolveFollowupMode enforces the safety table — 'iterate' ONLY onto
1440
+ // an open PR, an explicit new-scope forks ('redirect'), everything else is a
1441
+ // fresh run ('new'). A chat reply never reaches here, so a chat is never an
1442
+ // iterate. (Recomputed as `effectiveMode` after branch-prep in case an intended
1443
+ // iterate can't check out its branch.)
1444
+ const followupSignal = routed.followupSignal || classifyFollowupCue(message.body);
1445
+ const followupMode = resolveFollowupMode({
1446
+ hasActiveRun: Boolean(activeRun),
1447
+ prOpen: activeRunOpen,
1448
+ signal: followupSignal,
1449
+ });
1450
+
555
1451
  let repoPath = resolveRepoPath(cfg, repoFullName);
556
1452
  if (!repoPath) {
557
1453
  // Zero-config path: if the daemon is running INSIDE a checkout of this repo
@@ -627,23 +1523,95 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
627
1523
  };
628
1524
 
629
1525
  try {
630
- const branch = branchSlug(message.body, randomBytes(3).toString("hex"));
631
1526
  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" };
1527
+
1528
+ // Same-branch iteration: continue an existing PR on its own branch and push
1529
+ // onto it, so the PR updates in place instead of a duplicate opening. Two ways
1530
+ // in, and an explicit token WINS when both apply (they agree — same PR):
1531
+ // 1. detectPrContinuation — an explicit "PR <url>" in the text (the server's
1532
+ // "request changes" rework ping, or a person naming a PR). The fallback.
1533
+ // 2. the run resolver's 'iterate' (0281) — a plain follow-up reply in a thread
1534
+ // whose run already owns an OPEN PR. The PRIMARY path.
1535
+ // Otherwise, a fresh branch off the default (today's behavior).
1536
+ const cont = detectPrContinuation(message.body, repoFullName);
1537
+ const iterateUrl = cont?.url || (followupMode === "iterate" && activeRun?.prUrl) || null;
1538
+ let branch = null;
1539
+ let continuingPrUrl = null;
1540
+ if (iterateUrl && deps.prHeadRef) {
1541
+ // Resolve the PR's head branch (source of truth); for a run-based iterate the
1542
+ // run's recorded branch is a fallback if gh can't view the PR.
1543
+ const headRef =
1544
+ deps.prHeadRef(repoPath, iterateUrl) ||
1545
+ (followupMode === "iterate" ? activeRun?.branch || null : null);
1546
+ if (headRef) {
1547
+ // Fetch the PR branch, then point a local branch at its tip via FETCH_HEAD
1548
+ // (works on single-branch clones where `origin/<headRef>` isn't tracked).
1549
+ // `-C` repoints whether or not a stale local copy exists — the daemon is
1550
+ // stateless across runs, so origin is the source of truth.
1551
+ const fetched = git(repoPath, ["fetch", "origin", headRef]);
1552
+ const sw =
1553
+ fetched.status === 0 ? git(repoPath, ["switch", "-C", headRef, "FETCH_HEAD"]) : fetched;
1554
+ if (fetched.status === 0 && sw.status === 0) {
1555
+ branch = headRef;
1556
+ continuingPrUrl = iterateUrl;
1557
+ console.log(` code → continuing existing PR ${iterateUrl} on \`${headRef}\``);
1558
+ } else {
1559
+ console.log(
1560
+ ` ! couldn't check out PR branch \`${headRef}\` (falling back to a new branch): ${(sw.stderr || "").trim().slice(0, 200)}`,
1561
+ );
1562
+ }
1563
+ }
1564
+ }
1565
+ // An explicit PR token (detectPrContinuation) that names a DIFFERENT PR than the
1566
+ // thread's active run must NOT reuse/update that run for the terminal
1567
+ // update_run/report: we push to the TOKEN's PR (via existingPrUrl below), so
1568
+ // binding the unrelated active run would push to the token PR (#99) yet mark the
1569
+ // active run (#142) awaiting_review — a mismatch. Treat this as 'new' for
1570
+ // run-binding: the token still WINS for which PR gets the push; only the
1571
+ // bookkeeping refuses to touch the mismatched run (start_run records a fresh run
1572
+ // for the token PR instead). A token naming the SAME PR the run owns keeps the
1573
+ // iterate-reuse.
1574
+ const explicitTokenMismatch = Boolean(cont?.url && activeRun?.prUrl && cont.url !== activeRun.prUrl);
1575
+ // If we INTENDED to iterate but couldn't check out the PR's branch, we're on a
1576
+ // fresh branch now — so don't reuse the old run either; treat it as a new run.
1577
+ const effectiveMode =
1578
+ explicitTokenMismatch || (followupMode === "iterate" && !continuingPrUrl) ? "new" : followupMode;
1579
+ if (!branch) {
1580
+ branch = branchSlug(message.body, randomBytes(3).toString("hex"));
1581
+ const co = git(repoPath, ["switch", "-c", branch]);
1582
+ if (co.status !== 0) {
1583
+ await tool("post_message", {
1584
+ channelId,
1585
+ parentId,
1586
+ body: `Couldn't create branch \`${branch}\`: ${co.stderr.trim()}`,
1587
+ });
1588
+ return { status: "branch-error" };
1589
+ }
640
1590
  }
1591
+ // The branch tip BEFORE the agent runs — the base for detecting commits the
1592
+ // CLI makes itself. For a continuation this is the PR's existing tip (not the
1593
+ // default branch), so its prior commits aren't misread as "the agent
1594
+ // self-committed".
1595
+ const baseRef = (git(repoPath, ["rev-parse", "HEAD"]).stdout || "").trim() || (startRef.ref || cfg.defaultBranch);
641
1596
 
642
1597
  // Instant acknowledgement: post a template in <1s so the channel shows life
643
1598
  // before any model call. If the server supports edit_message and a fast chatCmd
644
1599
  // is set, a bounded plan-ack edits in a sentence of specifics ("I'll do X…").
645
1600
  // 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) });
1601
+ //
1602
+ // INSPECTABLE ACK (0281): when we're continuing or forking off an existing run,
1603
+ // SAY so plainly (with the PR number + branch) so a human can correct the LLM's
1604
+ // routing before any code lands. This is a key safety mitigation for a wrong
1605
+ // 'iterate'. A plain new run keeps the standard template.
1606
+ let ackBody = ackText(repoFullName);
1607
+ if (continuingPrUrl) {
1608
+ const n = prNumberFromUrl(continuingPrUrl);
1609
+ ackBody = `Continuing ${n ? `PR #${n}` : "the existing pull request"} on \`${branch}\` — I'll push this change there and update the report.`;
1610
+ } else if (effectiveMode === "redirect") {
1611
+ const n = activeRun?.prNumber || prNumberFromUrl(activeRun?.prUrl);
1612
+ ackBody = `Starting a new PR${n ? ` (separate from #${n})` : ""} on \`${branch}\`.`;
1613
+ }
1614
+ const ack = await tool("post_message", { channelId, parentId, body: ackBody });
647
1615
  const ackId = ack?.messageId ?? null;
648
1616
  // Where the run streams + reports. When the mention was already in a thread,
649
1617
  // stay in it. When it was top-level, the ack becomes the thread anchor so the
@@ -652,7 +1620,44 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
652
1620
  // The ack itself stays top-level (the channel-visible "on it"); terminal
653
1621
  // outcomes + report cards broadcast back to the channel from the thread.
654
1622
  const threadRoot = parentId ?? ackId;
655
- if (ackId && caps.editMessage && cfg.chatCmd) {
1623
+ const updateChannelMarker = async (body) => {
1624
+ if (!ackId || parentId || !body) return;
1625
+ await tool("edit_message", { messageId: ackId, body }).catch(() => {});
1626
+ };
1627
+ // Bind this run to the thread root (0279/0280), now follow-up-aware (0281):
1628
+ // iterate → REUSE the thread's existing run (don't record a second one); the
1629
+ // same PR updates in place and its status stays awaiting_review.
1630
+ // redirect → supersede the old run (so a future follow-up no longer points at
1631
+ // it), then record a FRESH run for the new, separate PR.
1632
+ // new → record a fresh run, exactly as before.
1633
+ // Best-effort throughout — a bookkeeping failure (or an older server without the
1634
+ // runs tools) must NEVER break the run, so it proceeds without a runId.
1635
+ let runId = effectiveMode === "iterate" ? activeRun?.runId ?? null : null;
1636
+ if (caps.runs && threadRoot && effectiveMode !== "iterate") {
1637
+ try {
1638
+ if (effectiveMode === "redirect" && activeRun?.runId) {
1639
+ await tool("update_run", { runId: activeRun.runId, status: "superseded" }).catch(() => {});
1640
+ }
1641
+ const r = await tool("start_run", {
1642
+ channelId,
1643
+ threadRootId: threadRoot,
1644
+ taskText: message.body,
1645
+ branch,
1646
+ // Map an unrecognized command to null rather than the off-vocabulary
1647
+ // "unknown" — provider is documented as claude_code|codex|cursor|hilos.
1648
+ provider: (() => {
1649
+ const v = detectVendor(cfg.codingCmd);
1650
+ return v === "unknown" ? null : v;
1651
+ })(),
1652
+ });
1653
+ runId = r?.runId ?? null;
1654
+ } catch {
1655
+ /* never break the run on a run-record failure */
1656
+ }
1657
+ }
1658
+ // Keep the inspectable continuation/redirect statement (don't overwrite it with
1659
+ // an LLM plan) so a human can correct the routing before code lands.
1660
+ if (ackId && caps.editMessage && cfg.chatCmd && !continuingPrUrl && effectiveMode !== "redirect") {
656
1661
  // Feed the ack the router's distilled brief AND the conversation — not the raw
657
1662
  // mention — so it states a real plan instead of "what's the task?".
658
1663
  const plan = await proposePlanAck({
@@ -669,9 +1674,49 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
669
1674
  // then EDIT it on later beats — alive without thread spam. Needs edit_message;
670
1675
  // without it we skip rather than post a fresh message every beat. lastLine
671
1676
  // carries the CLI's latest output into the beat.
672
- const heartbeatOn = Boolean(caps.editMessage) && cfg.heartbeatMs > 0;
1677
+ // Live progress (0274): when the server supports post_progress, the code run
1678
+ // STREAMS a coalesced "what I'm doing right now" card onto one thread status
1679
+ // message (via the 0272 stream parser) instead of the 3-min edit heartbeat. On
1680
+ // older servers (no post_progress) we fall back to the exact legacy behavior.
1681
+ const streamOn = Boolean(caps.postProgress);
1682
+ const vendor = detectVendor(cfg.codingCmd);
1683
+ const streamArgs = codeStreamArgs(vendor);
1684
+ let runSessionId = null; // captured from the stream for 0282 (resume)
1685
+ const machine = hostname();
1686
+
1687
+ // Session resume (0282): on an ITERATE we can resume the coding agent's SESSION so
1688
+ // the follow-up continues with the first run's reasoning/plan — not just its code
1689
+ // (which the branch checkout already restores). GATE on LOCAL confidence: only
1690
+ // resume when THIS machine's ~/.hilos/state.json recorded that thread's session
1691
+ // AND it matches this hostname. A providerSessionId that came ONLY from the server
1692
+ // (get_active_run) with no local match means the session likely lives on another
1693
+ // machine/instance — a bad `--resume` id makes claude error → empty diff → a failed
1694
+ // run, so we DON'T resume and degrade to today's branch+feedback (never worse).
1695
+ // Only claude_code has a proven resume flag; codex/cursor/unknown → [] anyway.
1696
+ let resumeSessionId = null;
1697
+ if (effectiveMode === "iterate" && vendor === "claude_code") {
1698
+ const local = (() => {
1699
+ try {
1700
+ return readStateEntry(HILOS_DIR, threadRoot);
1701
+ } catch {
1702
+ return null;
1703
+ }
1704
+ })();
1705
+ const serverSid = activeRun?.providerSessionId || null;
1706
+ // Confidence = a local record, for THIS machine, with a session id. If the server
1707
+ // also has one it must AGREE (a mismatch means the run moved — don't resume a
1708
+ // stale/foreign id). A server-only id (no local record) is never resumed.
1709
+ if (local && local.machine === machine && local.sessionId && (!serverSid || serverSid === local.sessionId)) {
1710
+ resumeSessionId = local.sessionId;
1711
+ console.log(` code → resuming session ${local.sessionId} for this iterate (local + machine match)`);
1712
+ } else if (serverSid) {
1713
+ console.log(" code → not resuming (session recorded on another machine or unverified locally); using branch + feedback");
1714
+ }
1715
+ }
1716
+
1717
+ const heartbeatOn = Boolean(caps.editMessage) && cfg.heartbeatMs > 0 && !streamOn;
673
1718
  let lastLine = "";
674
- let progressId = null; // the thread progress reply, once a beat has posted it
1719
+ let progressId = null; // the thread progress/status reply, once one has posted
675
1720
  const startHeartbeat = () => {
676
1721
  if (!heartbeatOn) return () => {};
677
1722
  const start = deps.now();
@@ -714,27 +1759,139 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
714
1759
  // Run the CLI and stage everything it changed; return the diff stats (no post).
715
1760
  // Workspace memory (the project's soul) is prepended so the coding agent has
716
1761
  // the shared context before it starts.
717
- const runAndStage = async (promptText) => {
1762
+ const runAndStage = async (promptText, { resume = true } = {}) => {
718
1763
  console.log(
719
1764
  ` code → running \`${cfg.codingCmd}\` in ${repoPath} (this can take a few minutes; output appears when it finishes)…`,
720
1765
  );
721
- const stopHeartbeat = startHeartbeat();
1766
+ // Streaming path: post ONE thread status message up front, then stream a live
1767
+ // card into it via post_progress. Legacy path: the 3-min edit heartbeat. Only
1768
+ // one of them runs — never both.
1769
+ let stopHeartbeat = () => {};
1770
+ let emitter = null;
1771
+ // Every post_progress write we dispatch, chained so the finally can await ALL
1772
+ // of them before the run returns. This closes the read-modify-write race with
1773
+ // the in-place settle (0289): if a trailing progress write were still landing
1774
+ // when post_report settles the report, it would re-write metadata WITHOUT the
1775
+ // report (it read the pre-settle snapshot) and clobber it. Draining every send
1776
+ // here guarantees no progress write is in flight once we settle.
1777
+ let progressInflight = Promise.resolve();
1778
+ if (streamOn) {
1779
+ if (!progressId) {
1780
+ try {
1781
+ const r = await tool("post_message", {
1782
+ channelId,
1783
+ parentId: threadRoot,
1784
+ body: buildHeartbeat({ repoFullName, branch, elapsedMs: 0, lastLine: "" }),
1785
+ });
1786
+ progressId = r?.messageId ?? null;
1787
+ } catch {
1788
+ progressId = null; // no status card → the run still proceeds
1789
+ }
1790
+ }
1791
+ const statusId = progressId;
1792
+ if (statusId) {
1793
+ emitter = createProgressEmitter({
1794
+ parser: makeStreamParser(vendor),
1795
+ now: deps.now,
1796
+ throttleMs: cfg.progressMs,
1797
+ // The module never imports MCP — the send fn is injected here. It must
1798
+ // never throw into the run (createProgressEmitter also guards).
1799
+ send: (p) => {
1800
+ try {
1801
+ const r = tool("post_progress", { messageId: statusId, progress: p });
1802
+ if (r && typeof r.then === "function") {
1803
+ const done = r.then(() => {}, () => {});
1804
+ progressInflight = Promise.all([progressInflight, done]).then(
1805
+ () => {},
1806
+ () => {},
1807
+ );
1808
+ }
1809
+ } catch {
1810
+ /* a progress send must never break the run */
1811
+ }
1812
+ },
1813
+ });
1814
+ }
1815
+ } else {
1816
+ stopHeartbeat = startHeartbeat();
1817
+ }
1818
+ // The code run gets the vendor's stream flags appended to its ARGV (not the
1819
+ // display string), and ONLY the code run — chat/routing/plan-ack stay plain.
1820
+ // On an iterate we ALSO insert `--resume <id>` (0282) into the code ARGV
1821
+ // (after the base args, before the stream flags + the trailing prompt) so the
1822
+ // follow-up continues the first run's session; `resume:false` (the never-worse
1823
+ // fallback) suppresses it. buildResumeArgs is [] unless vendor+session make
1824
+ // resume safe, so a non-resume run is byte-identical to the pre-0282 ARGV.
1825
+ const resumeArgs = resume ? buildResumeArgs(vendor, resumeSessionId) : [];
1826
+ const codeArgs = streamOn
1827
+ ? [...parts.slice(1), ...resumeArgs, ...streamArgs]
1828
+ : [...parts.slice(1), ...resumeArgs];
722
1829
  let run;
723
1830
  try {
724
1831
  run = await runCli({
725
1832
  cmd: parts[0],
726
- args: [...parts.slice(1), memoryPreamble(workspaceMemory) + promptText],
1833
+ args: [...codeArgs, memoryPreamble(workspaceMemory) + promptText],
727
1834
  cwd: repoPath,
728
1835
  timeoutMs: cfg.runTimeoutMs,
729
1836
  label: "coding",
730
1837
  signal,
731
1838
  onData: (c) => {
1839
+ // Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
732
1840
  const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
733
1841
  if (lines.length) lastLine = lines[lines.length - 1];
1842
+ if (emitter) {
1843
+ try {
1844
+ emitter.feed(c);
1845
+ } catch {
1846
+ /* a progress fold must never break the run */
1847
+ }
1848
+ }
734
1849
  },
735
1850
  });
736
1851
  } finally {
737
1852
  stopHeartbeat();
1853
+ if (emitter) {
1854
+ // Terminal state: flip the status card off "working" (state 'done'/'error')
1855
+ // so it stops claiming the agent is alive. For a run that produced work,
1856
+ // the gate:false path then SETTLES the report onto this same card in place
1857
+ // (0289) — the card cross-fades run → report; for no-changes/failed/gate
1858
+ // outcomes this terminal 'done'/'error' is the card's final state.
1859
+ const errored = Boolean(run && (run.aborted || run.error || run.status !== 0));
1860
+ // On error, carry an honest reason onto the card (0294) — the same signal
1861
+ // the chat note uses: spawn error message, else exit status, plus a short
1862
+ // stderr tail. Sanitized again server-side; an old server just drops it.
1863
+ let reason = "";
1864
+ if (errored && run && !run.aborted) {
1865
+ const stderrTail = oneLine((run.stderr || "").trim().split("\n").slice(-3).join(" "), 200);
1866
+ if (run.error?.code === "ENOENT") {
1867
+ reason = `Couldn't start ${parts[0]} — is it installed and on PATH?`;
1868
+ } else if (run.error?.message) {
1869
+ reason = run.error.message;
1870
+ } else if (run.status != null) {
1871
+ reason = `The CLI exited ${run.status}`;
1872
+ }
1873
+ if (stderrTail) reason = reason ? `${reason}: ${stderrTail}` : stderrTail;
1874
+ }
1875
+ try {
1876
+ emitter.done(errored ? "error" : "done", reason);
1877
+ } catch {
1878
+ /* ignore */
1879
+ }
1880
+ // Wait for every progress write (including the terminal one above) to land
1881
+ // BEFORE runAndStage returns, so nothing is in flight when the caller
1882
+ // settles the report onto this card — no lost-update clobber (0289).
1883
+ try {
1884
+ await progressInflight;
1885
+ } catch {
1886
+ /* a drain failure must never break the run */
1887
+ }
1888
+ try {
1889
+ const snap = emitter.snapshot();
1890
+ if (snap && snap.sessionId) runSessionId = snap.sessionId;
1891
+ } catch {
1892
+ /* ignore */
1893
+ }
1894
+ }
738
1895
  }
739
1896
  if (run.aborted || signal?.aborted) {
740
1897
  console.log(" code → cancelled");
@@ -763,9 +1920,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
763
1920
  // (commit/push/PR) despite being told only to edit. Detect commits it made
764
1921
  // on this branch so we report + ship them instead of falsely claiming "no
765
1922
  // changes" and deleting a branch that has real work.
766
- const base = startRef.ref || cfg.defaultBranch;
767
1923
  const ahead =
768
- Number((git(repoPath, ["rev-list", "--count", `${base}..HEAD`]).stdout || "0").trim()) || 0;
1924
+ Number((git(repoPath, ["rev-list", "--count", `${baseRef}..HEAD`]).stdout || "0").trim()) || 0;
769
1925
  if (ahead > 0) {
770
1926
  console.log(` code → agent self-committed (${ahead} commit(s) ahead); reconciling`);
771
1927
  return { empty: true, failed: false, ahead };
@@ -779,6 +1935,36 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
779
1935
  return { empty: false, diffText, truncated, omittedLines, stat, runFailed: run.status !== 0 };
780
1936
  };
781
1937
 
1938
+ // Persist the run's coding-agent session (0282) so a LATER iterate can resume it.
1939
+ // Two records, both best-effort — a failure here NEVER breaks the run:
1940
+ // - update_run({ providerSessionId }) → the server's durable record (survives a
1941
+ // daemon restart; also read by the hosted UI). Only when a runId was recorded.
1942
+ // - ~/.hilos/state.json for this thread root, stamped with THIS machine — the
1943
+ // LOCAL proof the session lives here, which the resume gate above requires.
1944
+ // Called after each run (a resumed run yields a possibly-new sessionId, so the
1945
+ // chain continues across multiple iterations) and again post-ship to fill prUrl.
1946
+ const recordSession = async (prUrl) => {
1947
+ const sid = runSessionId;
1948
+ if (!sid) return;
1949
+ if (runId) {
1950
+ await tool("update_run", { runId, providerSessionId: sid }).catch(() => {});
1951
+ }
1952
+ try {
1953
+ if (threadRoot) {
1954
+ writeState(HILOS_DIR, threadRoot, {
1955
+ runId: runId || null,
1956
+ branch,
1957
+ prUrl: prUrl || continuingPrUrl || null,
1958
+ sessionId: sid,
1959
+ machine,
1960
+ updatedAt: new Date().toISOString(),
1961
+ });
1962
+ }
1963
+ } catch {
1964
+ /* a state write must never break the run */
1965
+ }
1966
+ };
1967
+
782
1968
  // Post a proposal card (approve-before-push mode) from a staged change.
783
1969
  const postProposal = async (staged) => {
784
1970
  const report = buildProposalReport({
@@ -821,19 +2007,66 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
821
2007
  // Post the right cancel message: honest about whether cleanup actually worked.
822
2008
  const postStopped = async () => {
823
2009
  const clean = discardBranch();
2010
+ const body = clean
2011
+ ? `Stopped — discarded \`${branch}\`.`
2012
+ : `Stopped, but couldn't fully clean \`${branch}\` — run \`git reset --hard && git switch ${startRef.ref || cfg.defaultBranch}\` in ${repoPath}.`;
824
2013
  await tool("post_message", {
825
2014
  channelId,
826
2015
  parentId: threadRoot,
827
2016
  broadcast: true,
828
- body: clean
829
- ? `Stopped — discarded \`${branch}\`.`
830
- : `Stopped, but couldn't fully clean \`${branch}\` — run \`git reset --hard && git switch ${startRef.ref || cfg.defaultBranch}\` in ${repoPath}.`,
2017
+ body,
831
2018
  });
2019
+ await updateChannelMarker(compactRunMarker("cancelled", branch));
832
2020
  return { status: "cancelled", branch };
833
2021
  };
834
2022
 
835
- const staged = await runAndStage(codeTaskPrompt({ message, context, brief: routed.task, repoFullName }));
2023
+ // Team memory recall (0297): best-effort fetch accumulated learnings for this
2024
+ // channel before the coding run so the coding agent has the team's conventions
2025
+ // and gotchas from the start. Capability-gated: older servers without the
2026
+ // `recall` tool degrade silently (caps.recall=false → skip, no change in
2027
+ // behavior). A recall failure — network hiccup, server error — NEVER fails the
2028
+ // task; it is caught and the run proceeds without the block.
2029
+ let teamMemoryBlock = "";
2030
+ if (caps.recall) {
2031
+ try {
2032
+ // Pass the task's channelId so the server scopes recall to this project AND
2033
+ // can apply the guest gate (0298): the daemon's token is long-lived with no
2034
+ // bound channel, so the server relies on this per-call channelId to decide
2035
+ // whether a guest is present. Residual: a recall that omits channelId can't
2036
+ // be guest-gated server-side (no channel to check) — the daemon always
2037
+ // passes it here, so that path is covered.
2038
+ const recalled = await tool("recall", { channelId, limit: 20 }).catch(() => null);
2039
+ const mems = Array.isArray(recalled?.memories) ? recalled.memories : [];
2040
+ teamMemoryBlock = buildMemoryBlock(mems);
2041
+ } catch {
2042
+ /* recall failure must never break the coding task */
2043
+ }
2044
+ }
2045
+
2046
+ const codePrompt =
2047
+ (teamMemoryBlock ? teamMemoryBlock + "\n\n" : "") +
2048
+ codeTaskPrompt({ message, context, brief: routed.task, repoFullName });
2049
+ let staged = await runAndStage(codePrompt);
836
2050
  if (staged.aborted) return await postStopped();
2051
+ // Never-worse-than-today (0282): if we RESUMED a session and that run FAILED (a
2052
+ // stale/foreign `--resume` id makes claude error out → empty diff → failed), retry
2053
+ // ONCE without --resume — the exact branch+feedback iterate we'd have run before
2054
+ // 0282. A dead session can therefore never dead-end an iteration. Gated on
2055
+ // staged.failed (the reliable resume-error signal) so a legitimate no-op run isn't
2056
+ // re-run. Adopt the retry regardless — it IS the pre-0282 baseline.
2057
+ if (resumeSessionId && staged.empty && staged.failed) {
2058
+ console.log(" code → resumed run failed; retrying once without --resume (branch + feedback)");
2059
+ const retry = await runAndStage(codePrompt, { resume: false });
2060
+ if (retry.aborted) return await postStopped();
2061
+ staged = retry;
2062
+ }
2063
+ // Persist this run's session (best-effort) so the NEXT iterate can resume it and
2064
+ // machine-match. prUrl is filled in again at ship time.
2065
+ await recordSession(continuingPrUrl);
2066
+ // Resume is a ONE-shot for the first run of this iterate: the gate:true feedback
2067
+ // rounds below are intra-run refinements (same working tree, same PR turn), not a
2068
+ // fresh cross-turn resume, so they must NOT replay the original `--resume` id.
2069
+ resumeSessionId = null;
837
2070
  // The CLI committed on its own (clean tree, but commits ahead of base). Don't
838
2071
  // report "no changes" or delete the branch — surface the real work.
839
2072
  if (staged.empty && !staged.failed && staged.ahead > 0) {
@@ -850,10 +2083,15 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
850
2083
  `re-mention me to ship or discard.`,
851
2084
  });
852
2085
  await finalizeProgress(`\`${branch}\` has the agent's own commits — needs review.`);
2086
+ await updateChannelMarker(compactRunMarker("changes", branch));
853
2087
  return { status: "self-committed-gated", branch };
854
2088
  }
855
- await finalizeProgress(`Agent shipped \`${branch}\` itself report below.`);
856
- return await shipSelfDriven({
2089
+ // When a live status card was streaming, settle the report onto it (0289)
2090
+ // the card cross-fades run → report. Skip finalizeProgress then (it edits the
2091
+ // card's body, which the settle overwrites with the report anyway).
2092
+ const selfSettleId = streamOn && progressId ? progressId : null;
2093
+ if (!selfSettleId) await finalizeProgress(`Agent shipped \`${branch}\` itself — report below.`);
2094
+ const selfResult = await shipSelfDriven({
857
2095
  repoPath,
858
2096
  branch,
859
2097
  task: routed.task || message.body,
@@ -863,12 +2101,23 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
863
2101
  channelId,
864
2102
  deps,
865
2103
  parentId: threadRoot,
2104
+ settleId: selfSettleId,
2105
+ runId,
866
2106
  });
2107
+ await recordSession(selfResult.prUrl);
2108
+ await updateChannelMarker(compactRunMarker(selfResult.status, selfResult.branch));
2109
+ return selfResult;
867
2110
  }
868
2111
  if (staged.empty) {
2112
+ // When continuing an existing PR, never delete that branch — it's the open
2113
+ // PR — and don't say "cleaning up". Just report and leave the PR as it was.
2114
+ const cleanupNote = continuingPrUrl ? "" : ` Cleaning up \`${branch}\`.`;
2115
+ const retryTail = continuingPrUrl
2116
+ ? ` — \`${branch}\` is unchanged; mention me to retry.`
2117
+ : ` Cleaning up \`${branch}\` — mention me to retry.`;
869
2118
  let body;
870
2119
  if (staged.failed && staged.errCode === "ENOENT") {
871
- body = `I couldn't start \`${cfg.codingCmd}\` — is it installed and on PATH? Cleaning up \`${branch}\`.`;
2120
+ body = `I couldn't start \`${cfg.codingCmd}\` — is it installed and on PATH?${cleanupNote}`;
872
2121
  } else if (staged.failed) {
873
2122
  const why = staged.errMessage
874
2123
  ? ` (${staged.errMessage})`
@@ -876,16 +2125,23 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
876
2125
  ? ` (the CLI exited ${staged.status})`
877
2126
  : "";
878
2127
  const tail = staged.stderrTail ? `: ${staged.stderrTail}` : "";
879
- body = `The run didn't finish${why}${tail}. Cleaning up \`${branch}\` — mention me to retry.`;
2128
+ body = `The run didn't finish${why}${tail}.${retryTail}`;
880
2129
  } else {
881
- body = `No changes were produced. Cleaning up \`${branch}\`.`;
2130
+ body = continuingPrUrl
2131
+ ? `That feedback produced no further changes — \`${branch}\` is unchanged.`
2132
+ : `No changes were produced.${cleanupNote}`;
882
2133
  }
883
2134
  await tool("post_message", { channelId, parentId: threadRoot, broadcast: true, body });
884
2135
  await finalizeProgress(
885
2136
  staged.failed ? `Run ended early on \`${branch}\` — see the note below.` : `No changes needed on \`${branch}\`.`,
886
2137
  );
887
- git(repoPath, ["switch", "-"]);
888
- git(repoPath, ["branch", "-D", branch]);
2138
+ if (!continuingPrUrl) {
2139
+ git(repoPath, ["switch", "-"]);
2140
+ git(repoPath, ["branch", "-D", branch]);
2141
+ }
2142
+ await updateChannelMarker(
2143
+ compactRunMarker(staged.failed ? "run-failed" : "no-changes", branch),
2144
+ );
889
2145
  return { status: staged.failed ? "run-failed" : "no-changes" };
890
2146
  }
891
2147
 
@@ -895,20 +2151,39 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
895
2151
  if (!cfg.gate) {
896
2152
  // A "stop" that lands between the run finishing and the ship must still win.
897
2153
  if (signal?.aborted) return await postStopped();
2154
+ // Seamless single card (0289): when a live status card streamed this run,
2155
+ // settle the report onto it in place instead of posting a separate report
2156
+ // message. Only the DEFAULT gate:false report settles in place; gate:true's
2157
+ // proposal → decision → report lifecycle stays on separate messages (a
2158
+ // separate ticket). No status card (older server / status post failed) →
2159
+ // settleId is null and applyDecision posts a fresh final report instead.
2160
+ const settleId = streamOn && progressId ? progressId : null;
898
2161
  const result = await applyDecision({
899
2162
  decision: { kind: "approved" },
900
2163
  repoPath,
901
2164
  branch,
902
- task: message.body,
2165
+ // For a continuation the raw mention is the rework ping ("please update the
2166
+ // PR …"); use the router's distilled brief for a clean commit/PR title.
2167
+ task: continuingPrUrl ? routed.task || message.body : message.body,
903
2168
  requester: message.author,
904
2169
  cfg,
905
2170
  tool,
906
2171
  channelId,
907
2172
  deps,
908
2173
  parentId: threadRoot,
2174
+ existingPrUrl: continuingPrUrl,
2175
+ settleId,
2176
+ runId,
909
2177
  });
910
- await finalizeProgress(`Done on \`${branch}\` see the report below.`);
911
- return { ...result, stat: staged.stat };
2178
+ // The settle already flipped the card to the report (body + metadata); editing
2179
+ // the body again would clobber the report summary, so only finalize when we
2180
+ // posted a separate report.
2181
+ if (!settleId) await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
2182
+ // Re-record with the now-known PR url so the local session record + the run row
2183
+ // carry the PR the session belongs to (best-effort; session id unchanged).
2184
+ await recordSession(result.prUrl);
2185
+ await updateChannelMarker(compactRunMarker(result.status, result.branch));
2186
+ return { ...result, stat: staged.stat, sessionId: runSessionId };
912
2187
  }
913
2188
 
914
2189
  await finalizeProgress(`Coding done on \`${branch}\` — proposal below for review.`);
@@ -948,16 +2223,22 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
948
2223
  decision,
949
2224
  repoPath,
950
2225
  branch,
951
- task: message.body,
2226
+ // A gate:true approve on a continuation already has an open PR — reuse it so
2227
+ // we update in place instead of a failing `gh pr create`.
2228
+ task: continuingPrUrl ? routed.task || message.body : message.body,
952
2229
  requester: message.author,
953
2230
  cfg,
954
2231
  tool,
955
2232
  channelId,
956
2233
  deps,
957
2234
  parentId: threadRoot,
2235
+ existingPrUrl: continuingPrUrl,
2236
+ runId,
958
2237
  });
959
2238
  await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
960
- return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round };
2239
+ await recordSession(result.prUrl);
2240
+ await updateChannelMarker(compactRunMarker(result.status, result.branch));
2241
+ return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round, sessionId: runSessionId };
961
2242
  } finally {
962
2243
  // Whatever happened, give the user their uncommitted work back.
963
2244
  await restoreStash();