hilos-agent 0.1.16 → 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,13 +9,15 @@
9
9
 
10
10
  import { spawnSync } from "node:child_process";
11
11
  import { randomBytes } from "node:crypto";
12
- import { rmSync, mkdtempSync } from "node:fs";
12
+ import { rmSync, mkdtempSync, existsSync } from "node:fs";
13
13
  import { hostname, tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import {
16
16
  branchSlug,
17
17
  truncateDiff,
18
18
  resolveRepoPath,
19
+ resolveFolderPath,
20
+ diffStatusSets,
19
21
  parseShortstat,
20
22
  buildProposalReport,
21
23
  decisionKind,
@@ -24,7 +26,7 @@ import {
24
26
  mentionHandle,
25
27
  detectPrContinuation,
26
28
  } from "./daemon.mjs";
27
- import { runCli, buildHeartbeat, ackText, oneLine } from "./cli.mjs";
29
+ import { runCli, buildHeartbeat, ackText, oneLine, fmtElapsed } from "./cli.mjs";
28
30
  import { makeStreamParser } from "./agent-events.mjs";
29
31
  import { detectVendor, codeStreamArgs, createProgressEmitter } from "./progress-emitter.mjs";
30
32
  import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
@@ -80,10 +82,26 @@ function requesterTag(author) {
80
82
  return handle ? `@${handle}` : "";
81
83
  }
82
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
+
83
94
  function defaultDeps() {
84
95
  return {
85
96
  git: (cwd, args) =>
86
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),
87
105
  openPR: (cwd, { title, body, branch, base }) => {
88
106
  const r = spawnSync(
89
107
  "gh",
@@ -166,9 +184,9 @@ async function linkPrFromUrl({ tool, channelId, url }) {
166
184
  async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, existingPrUrl, settleId, runId }) {
167
185
  const tag = requesterTag(requester);
168
186
  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.
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.
172
190
  if (decision.kind === "approved") {
173
191
  // Guard: nothing staged → don't push an empty branch and falsely report "shipped".
174
192
  if (deps.git(repoPath, ["diff", "--cached", "--quiet"]).status === 0) {
@@ -208,10 +226,9 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
208
226
  ? { ok: true, url: existingPrUrl }
209
227
  : deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
210
228
  // 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.
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.
215
232
  const reportArgs = {
216
233
  channelId,
217
234
  title: `Shipped: ${title}`,
@@ -296,8 +313,8 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
296
313
  }
297
314
  const { title } = prTitleBody(task, cur);
298
315
  // 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.
316
+ // streaming this run; the workspace setting decides channel visibility.
317
+ // Otherwise post a fresh final report.
301
318
  const reportArgs = {
302
319
  channelId,
303
320
  title: `Shipped: ${title}`,
@@ -352,7 +369,8 @@ const CODE_SIGNAL = "__CODE__";
352
369
  * `error` is set when the model produced nothing (so the caller can be honest
353
370
  * about a timeout vs a missing binary instead of inventing a reply).
354
371
  */
355
- async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false }) {
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
356
374
  const cmd = cfg.chatCmd || cfg.codingCmd;
357
375
  const parts = cmd.split(" ").filter(Boolean);
358
376
  // When this thread already owns an OPEN pull request (a follow-up reply), the
@@ -381,12 +399,13 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
381
399
  `that lists who reacted with each emoji.\n\n` +
382
400
  `Otherwise — a question, a greeting, general discussion, or they explicitly don't want code ` +
383
401
  `yet — just reply to them concisely and directly as a single chat message (no preamble, no ` +
384
- `headings). When unsure, prefer to act (${CODE_SIGNAL}); this is a build channel.` +
402
+ `headings). When unsure, stay conversational; implementation and PR flow are for clear ` +
403
+ `action language.` +
385
404
  `${followupBlock}\n\n` +
386
405
  `You are only routing here — output ONLY your text response. Do NOT use any tools, do NOT ` +
387
406
  `edit files, do NOT run commands; a separate step does the actual coding.\n\n` +
388
407
  `${memoryPreamble(workspaceMemory)}Conversation so far:\n${transcript}`;
389
- const run = await runCli({
408
+ const run = await doRun({
390
409
  cmd: parts[0],
391
410
  args: [...parts.slice(1), prompt],
392
411
  timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
@@ -450,6 +469,34 @@ export function codeTaskPrompt(o) {
450
469
  return p;
451
470
  }
452
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
+
453
500
  /** owner/name from a GitHub remote URL (https or ssh), or null. */
454
501
  export function normalizeRemote(url) {
455
502
  const m = String(url || "")
@@ -534,9 +581,15 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
534
581
  const { transcript } = context || (await fetchContext({ channelId, tool, parentId }));
535
582
  const name = me?.agentName || "an assistant";
536
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;
537
588
  const repoLine = repoLink
538
589
  ? `This channel is connected to the repository ${repoLink.repo_full_name}; assume that repo for any code work — don't ask which one.`
539
- : `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.`;
540
593
  const prompt =
541
594
  `You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
542
595
  `concisely and directly as a single chat message — no preamble, no headings. ` +
@@ -629,6 +682,11 @@ export function isReviewRequest(message) {
629
682
  );
630
683
  }
631
684
 
685
+ function agentMode(message) {
686
+ const mode = message?.agentMode || message?.metadata?.agentMode;
687
+ return mode === "ask" || mode === "ship" ? mode : null;
688
+ }
689
+
632
690
  /** The PR url a review-request/review points at, from the mention's reviewOf or the
633
691
  * channel's linked PR — whichever is present. */
634
692
  function reviewTargetPrUrl(message) {
@@ -707,6 +765,10 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
707
765
  body: `Reviewing ${label} now — I'll post my read shortly.`,
708
766
  }).catch(() => null);
709
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
+ };
710
772
 
711
773
  // The diff — the whole review is fed off this (no clone, no gh creds).
712
774
  // Capture the failure reason on BOTH error shapes: a soft failure returns
@@ -722,6 +784,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
722
784
  broadcast: Boolean(threadRoot),
723
785
  body: `I couldn't read ${label}'s diff to review it${why}. It may be private, merged, or not linked here.`,
724
786
  }).catch(() => {});
787
+ await updateReviewMarker(`Review failed: ${label}`);
725
788
  return { status: "review-no-diff" };
726
789
  }
727
790
 
@@ -771,6 +834,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
771
834
 
772
835
  if (run.aborted || signal?.aborted) {
773
836
  await tool("post_message", { channelId, parentId: threadRoot, body: `Stopped the review of ${label}.` }).catch(() => {});
837
+ await updateReviewMarker(`Review stopped: ${label}`);
774
838
  return { status: "review-cancelled" };
775
839
  }
776
840
  const text = (run.stdout || "").trim();
@@ -784,6 +848,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
784
848
  ? `I couldn't start \`${cmd}\` to review ${label} — is it installed and on PATH?`
785
849
  : `I couldn't get a read on ${label} that time — mention me again to retry the review.`,
786
850
  }).catch(() => {});
851
+ await updateReviewMarker(`Review failed: ${label}`);
787
852
  return { status: "review-no-output" };
788
853
  }
789
854
 
@@ -807,13 +872,399 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
807
872
  parentId: threadRoot,
808
873
  body: `I looked at ${label} but couldn't post the review${posted?.error || posted?.message ? `: ${posted.error || posted.message}` : ""}.`,
809
874
  }).catch(() => {});
875
+ await updateReviewMarker(`Review failed: ${label}`);
810
876
  return { status: "review-post-failed", error: posted?.error || posted?.message };
811
877
  }
812
878
  reviewRounds.set(key, rounds + 1);
813
879
  console.log(` review → posted ${verdict} on ${label} (${findings.length} finding(s))`);
880
+ await updateReviewMarker(`Review ready: ${label}`);
814
881
  return { status: "reviewed", verdict, findings: findings.length, prUrl };
815
882
  }
816
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
+
817
1268
  /** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
818
1269
  * cancels an in-flight run — the queue fires it when a human says "stop". */
819
1270
  export async function handleTask({ message, channelId, tool, me, caps = {} }, cfg, depsOverride, opts = {}) {
@@ -824,7 +1275,10 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
824
1275
  const parentId = message.parentId ?? null;
825
1276
 
826
1277
  const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
827
- 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);
828
1282
  // Workspace memory ("soul") — shared project context the agent should know.
829
1283
  const { memory: workspaceMemory = null } = await tool("get_workspace_memory", {}).catch(() => ({
830
1284
  memory: null,
@@ -834,6 +1288,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
834
1288
  // crucial: a reply like "yeah, do it" only means something against what was
835
1289
  // just said.
836
1290
  const context = await fetchContext({ channelId, tool, parentId });
1291
+ const mode = agentMode(message);
837
1292
 
838
1293
  // REVIEW EXECUTION (0288): a review-request routes to the read-only reviewer
839
1294
  // BEFORE the chat/code flow — it reads the PR's diff and posts an advisory review
@@ -863,13 +1318,67 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
863
1318
  }
864
1319
  }
865
1320
 
866
- // 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.
867
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
+ }
868
1372
  await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
869
1373
  return { status: "chat" };
870
1374
  }
871
1375
  const repoFullName = repoLink.repo_full_name;
872
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
+
873
1382
  // Same-PR follow-up (0281): if this mention is a reply in a thread that already
874
1383
  // owns a run (branch + PR), we may continue THAT run instead of forking a
875
1384
  // duplicate. Look it up by the thread root — the parentId the reply carried.
@@ -896,15 +1405,18 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
896
1405
  // signal is only ever acted on when the PR is open (resolveFollowupMode → 'new'
897
1406
  // otherwise), so telling the router the thread "owns an open PR" when the run is
898
1407
  // merged/closed would just mislead the model and waste a classification.
899
- const routed = await routeIntent({
900
- name: me?.agentName || "an assistant",
901
- repoFullName,
902
- transcript: context.transcript,
903
- workspaceMemory,
904
- cfg,
905
- signal,
906
- hasActiveRun: activeRunOpen,
907
- });
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
+ });
908
1420
  if (routed.aborted || signal?.aborted) {
909
1421
  await tool("post_message", { channelId, parentId, body: "Stopped." });
910
1422
  return { status: "chat" };
@@ -1108,6 +1620,10 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1108
1620
  // The ack itself stays top-level (the channel-visible "on it"); terminal
1109
1621
  // outcomes + report cards broadcast back to the channel from the thread.
1110
1622
  const threadRoot = parentId ?? ackId;
1623
+ const updateChannelMarker = async (body) => {
1624
+ if (!ackId || parentId || !body) return;
1625
+ await tool("edit_message", { messageId: ackId, body }).catch(() => {});
1626
+ };
1111
1627
  // Bind this run to the thread root (0279/0280), now follow-up-aware (0281):
1112
1628
  // iterate → REUSE the thread's existing run (don't record a second one); the
1113
1629
  // same PR updates in place and its status stays awaiting_review.
@@ -1491,14 +2007,16 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1491
2007
  // Post the right cancel message: honest about whether cleanup actually worked.
1492
2008
  const postStopped = async () => {
1493
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}.`;
1494
2013
  await tool("post_message", {
1495
2014
  channelId,
1496
2015
  parentId: threadRoot,
1497
2016
  broadcast: true,
1498
- body: clean
1499
- ? `Stopped — discarded \`${branch}\`.`
1500
- : `Stopped, but couldn't fully clean \`${branch}\` — run \`git reset --hard && git switch ${startRef.ref || cfg.defaultBranch}\` in ${repoPath}.`,
2017
+ body,
1501
2018
  });
2019
+ await updateChannelMarker(compactRunMarker("cancelled", branch));
1502
2020
  return { status: "cancelled", branch };
1503
2021
  };
1504
2022
 
@@ -1511,6 +2029,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1511
2029
  let teamMemoryBlock = "";
1512
2030
  if (caps.recall) {
1513
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.
1514
2038
  const recalled = await tool("recall", { channelId, limit: 20 }).catch(() => null);
1515
2039
  const mems = Array.isArray(recalled?.memories) ? recalled.memories : [];
1516
2040
  teamMemoryBlock = buildMemoryBlock(mems);
@@ -1559,6 +2083,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1559
2083
  `re-mention me to ship or discard.`,
1560
2084
  });
1561
2085
  await finalizeProgress(`\`${branch}\` has the agent's own commits — needs review.`);
2086
+ await updateChannelMarker(compactRunMarker("changes", branch));
1562
2087
  return { status: "self-committed-gated", branch };
1563
2088
  }
1564
2089
  // When a live status card was streaming, settle the report onto it (0289) —
@@ -1580,6 +2105,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1580
2105
  runId,
1581
2106
  });
1582
2107
  await recordSession(selfResult.prUrl);
2108
+ await updateChannelMarker(compactRunMarker(selfResult.status, selfResult.branch));
1583
2109
  return selfResult;
1584
2110
  }
1585
2111
  if (staged.empty) {
@@ -1613,6 +2139,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1613
2139
  git(repoPath, ["switch", "-"]);
1614
2140
  git(repoPath, ["branch", "-D", branch]);
1615
2141
  }
2142
+ await updateChannelMarker(
2143
+ compactRunMarker(staged.failed ? "run-failed" : "no-changes", branch),
2144
+ );
1616
2145
  return { status: staged.failed ? "run-failed" : "no-changes" };
1617
2146
  }
1618
2147
 
@@ -1627,7 +2156,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1627
2156
  // message. Only the DEFAULT gate:false report settles in place; gate:true's
1628
2157
  // proposal → decision → report lifecycle stays on separate messages (a
1629
2158
  // separate ticket). No status card (older server / status post failed) →
1630
- // settleId is null and applyDecision posts a fresh broadcast report as before.
2159
+ // settleId is null and applyDecision posts a fresh final report instead.
1631
2160
  const settleId = streamOn && progressId ? progressId : null;
1632
2161
  const result = await applyDecision({
1633
2162
  decision: { kind: "approved" },
@@ -1653,6 +2182,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1653
2182
  // Re-record with the now-known PR url so the local session record + the run row
1654
2183
  // carry the PR the session belongs to (best-effort; session id unchanged).
1655
2184
  await recordSession(result.prUrl);
2185
+ await updateChannelMarker(compactRunMarker(result.status, result.branch));
1656
2186
  return { ...result, stat: staged.stat, sessionId: runSessionId };
1657
2187
  }
1658
2188
 
@@ -1707,6 +2237,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1707
2237
  });
1708
2238
  await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
1709
2239
  await recordSession(result.prUrl);
2240
+ await updateChannelMarker(compactRunMarker(result.status, result.branch));
1710
2241
  return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round, sessionId: runSessionId };
1711
2242
  } finally {
1712
2243
  // Whatever happened, give the user their uncommitted work back.