hilos-agent 0.1.16 → 0.5.1

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,9 +26,9 @@ 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, minimalEnv } from "./cli.mjs";
28
30
  import { makeStreamParser } from "./agent-events.mjs";
29
- import { detectVendor, codeStreamArgs, createProgressEmitter } from "./progress-emitter.mjs";
31
+ import { detectVendor, codeStreamArgs, createProgressEmitter, fastChatCmd } from "./progress-emitter.mjs";
30
32
  import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
31
33
  import { buildResumeArgs, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
32
34
  import {
@@ -36,6 +38,30 @@ import {
36
38
  shouldReview,
37
39
  } from "./review.mjs";
38
40
  import { buildMemoryBlock } from "./memory.mjs";
41
+ import { deployFolder, resolveDeployTarget } from "./deploy.mjs";
42
+
43
+ /**
44
+ * The environment for a coding/chat CLI run. runCli always strips HILOS_* on top
45
+ * of whatever this returns; here we additionally honor `codingEnv: "minimal"`
46
+ * (strict isolation — only what the tool needs to reach its own model provider).
47
+ * Returns undefined for the default "inherit" mode so runCli falls back to the
48
+ * scrubbed process.env.
49
+ */
50
+ function codingChildEnv(cfg) {
51
+ return cfg?.codingEnv === "minimal"
52
+ ? minimalEnv(process.env, cfg.codingEnvAllow)
53
+ : undefined;
54
+ }
55
+
56
+ /**
57
+ * The fast chat command for this config: an explicit chatCmd wins, else the
58
+ * coding vendor's verified non-interactive print mode (fastChatCmd), else the
59
+ * coding command itself. Keeps chat/plan-ack/review on the USER'S tool — a
60
+ * Codex or Cursor daemon must never require Claude Code on PATH (0521).
61
+ */
62
+ function chatCmdFor(cfg) {
63
+ return cfg.chatCmd || fastChatCmd(detectVendor(cfg.codingCmd)) || cfg.codingCmd;
64
+ }
39
65
 
40
66
  /** PR number from a github pull-request URL, or null. */
41
67
  function prNumberFromUrl(url) {
@@ -80,10 +106,28 @@ function requesterTag(author) {
80
106
  return handle ? `@${handle}` : "";
81
107
  }
82
108
 
109
+ function compactRunMarker(status, branch) {
110
+ const b = branch ? ` \`${branch}\`` : "";
111
+ if (status === "pushed") return `Needs review:${b}`;
112
+ if (status === "discarded" || status === "cancelled") return `Stopped:${b}`;
113
+ if (status === "no-changes") return `Done:${b}`;
114
+ if (status === "changes" || status === "timeout") return `Needs follow-up:${b}`;
115
+ return `Failed:${b}`;
116
+ }
117
+
83
118
  function defaultDeps() {
84
119
  return {
85
120
  git: (cwd, args) =>
86
121
  spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 50 * 1024 * 1024 }),
122
+ // The async CLI runner, injectable so folder mode (and tests) can drive the
123
+ // coding run without spawning a real process. The repo flow still uses the
124
+ // imported runCli directly (unchanged); only folder mode goes through deps.
125
+ runCli: (opts) => runCli(opts),
126
+ // Does a path exist on disk? Injectable so folder mode's "missing folder"
127
+ // guard is unit-testable without touching the real filesystem.
128
+ pathExists: (p) => existsSync(p),
129
+ resolveDeployTarget: (args) => resolveDeployTarget(args),
130
+ deployFolder: (args) => deployFolder(args),
87
131
  openPR: (cwd, { title, body, branch, base }) => {
88
132
  const r = spawnSync(
89
133
  "gh",
@@ -149,7 +193,15 @@ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, pare
149
193
  report = m && m.report ? m.report : null;
150
194
  }
151
195
  const kind = report ? decisionKind(report) : null;
152
- if (kind) return { kind, note: report.decision?.note || null };
196
+ if (kind === "deploy") {
197
+ const provider = report.decision?.provider;
198
+ const prod = report.decision?.prod;
199
+ if ((provider === "vercel" || provider === "netlify") && typeof prod === "boolean") {
200
+ return { kind, provider, prod };
201
+ }
202
+ } else if (kind) {
203
+ return { kind, note: report.decision?.note || null };
204
+ }
153
205
  await deps.sleep(cfg.decisionPollMs);
154
206
  }
155
207
  return { kind: "timeout" };
@@ -166,9 +218,9 @@ async function linkPrFromUrl({ tool, channelId, url }) {
166
218
  async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, existingPrUrl, settleId, runId }) {
167
219
  const tag = requesterTag(requester);
168
220
  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.
221
+ // `parentId` here is the run's thread root. Terminal outcomes ask the server
222
+ // for channel visibility, but the workspace decides whether final agent
223
+ // results actually broadcast. A no-op when there's no thread root.
172
224
  if (decision.kind === "approved") {
173
225
  // Guard: nothing staged → don't push an empty branch and falsely report "shipped".
174
226
  if (deps.git(repoPath, ["diff", "--cached", "--quiet"]).status === 0) {
@@ -208,10 +260,9 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
208
260
  ? { ok: true, url: existingPrUrl }
209
261
  : deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
210
262
  // 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.
263
+ // run, SETTLE the report onto it in place; the workspace setting decides
264
+ // whether that final report is also channel-visible. Without a status id
265
+ // (older server / status post failed) fall back to posting a fresh report.
215
266
  const reportArgs = {
216
267
  channelId,
217
268
  title: `Shipped: ${title}`,
@@ -296,8 +347,8 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
296
347
  }
297
348
  const { title } = prTitleBody(task, cur);
298
349
  // 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.
350
+ // streaming this run; the workspace setting decides channel visibility.
351
+ // Otherwise post a fresh final report.
301
352
  const reportArgs = {
302
353
  channelId,
303
354
  title: `Shipped: ${title}`,
@@ -352,8 +403,9 @@ const CODE_SIGNAL = "__CODE__";
352
403
  * `error` is set when the model produced nothing (so the caller can be honest
353
404
  * about a timeout vs a missing binary instead of inventing a reply).
354
405
  */
355
- async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false }) {
356
- const cmd = cfg.chatCmd || cfg.codingCmd;
406
+ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false, runCliFn }) {
407
+ const doRun = runCliFn || runCli; // folder mode injects deps.runCli; repo flow uses the import
408
+ const cmd = chatCmdFor(cfg);
357
409
  const parts = cmd.split(" ").filter(Boolean);
358
410
  // When this thread already owns an OPEN pull request (a follow-up reply), the
359
411
  // router ALSO reads whether the latest message continues that PR, wants a
@@ -381,12 +433,13 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
381
433
  `that lists who reacted with each emoji.\n\n` +
382
434
  `Otherwise — a question, a greeting, general discussion, or they explicitly don't want code ` +
383
435
  `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.` +
436
+ `headings). When unsure, stay conversational; implementation and PR flow are for clear ` +
437
+ `action language.` +
385
438
  `${followupBlock}\n\n` +
386
439
  `You are only routing here — output ONLY your text response. Do NOT use any tools, do NOT ` +
387
440
  `edit files, do NOT run commands; a separate step does the actual coding.\n\n` +
388
441
  `${memoryPreamble(workspaceMemory)}Conversation so far:\n${transcript}`;
389
- const run = await runCli({
442
+ const run = await doRun({
390
443
  cmd: parts[0],
391
444
  args: [...parts.slice(1), prompt],
392
445
  timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
@@ -450,6 +503,34 @@ export function codeTaskPrompt(o) {
450
503
  return p;
451
504
  }
452
505
 
506
+ /**
507
+ * The prompt handed to the coding CLI in FOLDER mode (0322): the agent works
508
+ * directly in the user's own folder — no branch, no PR, no commit step. Same
509
+ * imperative "edit files now" framing as codeTaskPrompt, but it tells the agent
510
+ * its edits land straight in the folder and bans git/gh (there's nothing for the
511
+ * daemon to commit; the changes ARE the deliverable).
512
+ * @param {{ message?: { body?: string } | null, context?: { transcript?: string } | null, brief?: string, folderPath?: string }} [o]
513
+ */
514
+ export function folderTaskPrompt(o) {
515
+ const { message, context, brief, folderPath } = o || {};
516
+ const task = (brief && brief.trim()) || String(message?.body || "").trim();
517
+ const where = folderPath ? ` in the local folder ${folderPath}` : "";
518
+ let p =
519
+ `You are a coding agent working directly${where}. Implement the following by EDITING ` +
520
+ `FILES now — make the changes directly in this folder, do not just describe them, do not ` +
521
+ `ask questions:\n\n${task}`;
522
+ p +=
523
+ `\n\nIMPORTANT: your edits apply DIRECTLY to the user's folder — there is no branch, no ` +
524
+ `commit, and no pull request. Do NOT run git; do NOT stage, commit, push, create branches, ` +
525
+ `or open pull requests; do NOT use the \`gh\` CLI. Just leave your edits in the folder for ` +
526
+ `the person to review.`;
527
+ const transcript = context?.transcript?.trim();
528
+ if (transcript) {
529
+ p += `\n\nBackground from the team's discussion (context only — the task above is what to do):\n${transcript}`;
530
+ }
531
+ return p;
532
+ }
533
+
453
534
  /** owner/name from a GitHub remote URL (https or ssh), or null. */
454
535
  export function normalizeRemote(url) {
455
536
  const m = String(url || "")
@@ -511,7 +592,7 @@ export function planAckPrompt(o) {
511
592
  * empty/timeout/error so the run keeps the instant template and never stalls.
512
593
  */
513
594
  async function proposePlanAck({ task, transcript, repoFullName, cfg, signal }) {
514
- const cmd = cfg.chatCmd;
595
+ const cmd = chatCmdFor(cfg);
515
596
  if (!cmd) return null;
516
597
  const parts = cmd.split(" ").filter(Boolean);
517
598
  const prompt = planAckPrompt({ task, transcript, repoFullName });
@@ -521,6 +602,7 @@ async function proposePlanAck({ task, transcript, repoFullName, cfg, signal }) {
521
602
  timeoutMs: Math.min(cfg.chatTimeoutMs || 90000, 45000),
522
603
  label: "thinking",
523
604
  signal,
605
+ env: codingChildEnv(cfg),
524
606
  });
525
607
  if (run.aborted || signal?.aborted) return null;
526
608
  const text = (run.stdout || "").trim();
@@ -534,9 +616,15 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
534
616
  const { transcript } = context || (await fetchContext({ channelId, tool, parentId }));
535
617
  const name = me?.agentName || "an assistant";
536
618
  // Tell the agent what the room is connected to so it doesn't ask "which repo?".
619
+ // repoLink is already folder-excluded (handleTask picks kind!=='folder'), so the
620
+ // "repository" line never claims a folder path is a repo. When there's no repo
621
+ // but this channel maps to a local folder on THIS machine, say that instead.
622
+ const folderPath = !repoLink ? resolveFolderPath(cfg, channelId) : null;
537
623
  const repoLine = repoLink
538
624
  ? `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.`;
625
+ : folderPath
626
+ ? `This channel is connected to the local folder ${folderPath} on this machine; assume that folder for any file work — don't ask which one.`
627
+ : `If asked to change code, note that a repo isn't linked to this channel yet.`;
540
628
  const prompt =
541
629
  `You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
542
630
  `concisely and directly as a single chat message — no preamble, no headings. ` +
@@ -544,10 +632,10 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
544
632
  `${memoryPreamble(workspaceMemory)}` +
545
633
  `Conversation so far:\n${transcript}`;
546
634
 
547
- // Chat uses the FAST one-shot command (fall back to codingCmd if unset) bounded
548
- // by chatTimeoutMs, so a casual reply lands in seconds and a stalled model can't
549
- // dead-air the channel for the full coding timeout.
550
- const cmd = cfg.chatCmd || cfg.codingCmd;
635
+ // Chat uses the FAST one-shot command (the coding vendor's own print mode when
636
+ // chatCmd is unset) bounded by chatTimeoutMs, so a casual reply lands in seconds
637
+ // and a stalled model can't dead-air the channel for the full coding timeout.
638
+ const cmd = chatCmdFor(cfg);
551
639
  const parts = cmd.split(" ").filter(Boolean);
552
640
  console.log(` chat → running \`${cmd}\` (output appears when it finishes)…`);
553
641
 
@@ -583,6 +671,7 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
583
671
  timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
584
672
  label: "thinking",
585
673
  signal,
674
+ env: codingChildEnv(cfg),
586
675
  });
587
676
  } finally {
588
677
  beatStopped = true;
@@ -629,6 +718,11 @@ export function isReviewRequest(message) {
629
718
  );
630
719
  }
631
720
 
721
+ function agentMode(message) {
722
+ const mode = message?.agentMode || message?.metadata?.agentMode;
723
+ return mode === "ask" || mode === "ship" ? mode : null;
724
+ }
725
+
632
726
  /** The PR url a review-request/review points at, from the mention's reviewOf or the
633
727
  * channel's linked PR — whichever is present. */
634
728
  function reviewTargetPrUrl(message) {
@@ -707,6 +801,10 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
707
801
  body: `Reviewing ${label} now — I'll post my read shortly.`,
708
802
  }).catch(() => null);
709
803
  const threadRoot = parentId ?? ack?.messageId ?? null;
804
+ const updateReviewMarker = async (body) => {
805
+ if (!ack?.messageId || parentId) return;
806
+ await tool("edit_message", { messageId: ack.messageId, body }).catch(() => {});
807
+ };
710
808
 
711
809
  // The diff — the whole review is fed off this (no clone, no gh creds).
712
810
  // Capture the failure reason on BOTH error shapes: a soft failure returns
@@ -722,6 +820,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
722
820
  broadcast: Boolean(threadRoot),
723
821
  body: `I couldn't read ${label}'s diff to review it${why}. It may be private, merged, or not linked here.`,
724
822
  }).catch(() => {});
823
+ await updateReviewMarker(`Review failed: ${label}`);
725
824
  return { status: "review-no-diff" };
726
825
  }
727
826
 
@@ -744,7 +843,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
744
843
  // Read-only run: a throwaway cwd contains any stray write; the daemon reads ONLY
745
844
  // stdout and never stages/commits anything. Review uses the fast chat command
746
845
  // when set (no acceptEdits → can't write), falling back to the coding command.
747
- const cmd = cfg.reviewCmd || cfg.chatCmd || cfg.codingCmd;
846
+ const cmd = cfg.reviewCmd || chatCmdFor(cfg);
748
847
  const parts = String(cmd).split(" ").filter(Boolean);
749
848
  let sandboxDir = null;
750
849
  try {
@@ -760,6 +859,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
760
859
  timeoutMs: cfg.chatTimeoutMs ? Math.max(cfg.chatTimeoutMs, 120000) : cfg.runTimeoutMs,
761
860
  label: "reviewing",
762
861
  signal,
862
+ env: codingChildEnv(cfg),
763
863
  });
764
864
  if (sandboxDir) {
765
865
  try {
@@ -771,6 +871,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
771
871
 
772
872
  if (run.aborted || signal?.aborted) {
773
873
  await tool("post_message", { channelId, parentId: threadRoot, body: `Stopped the review of ${label}.` }).catch(() => {});
874
+ await updateReviewMarker(`Review stopped: ${label}`);
774
875
  return { status: "review-cancelled" };
775
876
  }
776
877
  const text = (run.stdout || "").trim();
@@ -784,6 +885,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
784
885
  ? `I couldn't start \`${cmd}\` to review ${label} — is it installed and on PATH?`
785
886
  : `I couldn't get a read on ${label} that time — mention me again to retry the review.`,
786
887
  }).catch(() => {});
888
+ await updateReviewMarker(`Review failed: ${label}`);
787
889
  return { status: "review-no-output" };
788
890
  }
789
891
 
@@ -807,24 +909,653 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
807
909
  parentId: threadRoot,
808
910
  body: `I looked at ${label} but couldn't post the review${posted?.error || posted?.message ? `: ${posted.error || posted.message}` : ""}.`,
809
911
  }).catch(() => {});
912
+ await updateReviewMarker(`Review failed: ${label}`);
810
913
  return { status: "review-post-failed", error: posted?.error || posted?.message };
811
914
  }
812
915
  reviewRounds.set(key, rounds + 1);
813
916
  console.log(` review → posted ${verdict} on ${label} (${findings.length} finding(s))`);
917
+ await updateReviewMarker(`Review ready: ${label}`);
814
918
  return { status: "reviewed", verdict, findings: findings.length, prUrl };
815
919
  }
816
920
 
921
+ /** Render a changed-file list for the folder report card. */
922
+ function renderChangedList({ created, modified, deleted }) {
923
+ const lines = [];
924
+ for (const f of created) lines.push(`- added \`${f}\``);
925
+ for (const f of modified) lines.push(`- changed \`${f}\``);
926
+ for (const f of deleted) lines.push(`- removed \`${f}\``);
927
+ return lines.join("\n");
928
+ }
929
+
930
+ function deployLabel(target) {
931
+ const provider = target.provider === "vercel" ? "Vercel" : "Netlify";
932
+ return `${provider} (${target.prod ? "production" : "preview"})`;
933
+ }
934
+
935
+ function folderDeployState(folderPath, git) {
936
+ const inside = git(folderPath, ["rev-parse", "--is-inside-work-tree"]);
937
+ const isGit = inside.status === 0 && String(inside.stdout || "").trim() === "true";
938
+ if (!isGit) return { commit: null, stat: "", changed: null };
939
+ const commitResult = git(folderPath, ["rev-parse", "--short", "HEAD"]);
940
+ const statResult = git(folderPath, ["diff", "--stat", "HEAD"]);
941
+ const statusResult = git(folderPath, ["status", "--porcelain"]);
942
+ const changed = diffStatusSets("", String(statusResult.stdout || ""));
943
+ return {
944
+ commit: commitResult.status === 0 ? String(commitResult.stdout || "").trim() : null,
945
+ stat: truncateDiff(String(statResult.stdout || "")).text.trim(),
946
+ changed,
947
+ };
948
+ }
949
+
950
+ // A report-card Deploy click is stored durably on the report as a pending
951
+ // decision, and list_mentions re-projects it on EVERY poll until a post_report
952
+ // settle clears it. So every terminal exit of handleFolderDeploy — including
953
+ // "stopped" and "deploy is off" — must settle the source report, or the
954
+ // decision re-triggers the deploy on the next poll / daemon restart (an
955
+ // explicit human cancel of a production deploy would not stick) and the card
956
+ // stays wedged on "deployment requested" with its action row hidden. Settling
957
+ // re-posts the prior report content untouched plus one honest caveat; the
958
+ // server replaces metadata.report wholesale, which clears the decision by
959
+ // construction and brings the Deploy button back for a retry.
960
+ async function settleDeploySource({ tool, channelId, sourceReportId, caveat }) {
961
+ if (!sourceReportId) return;
962
+ const prior = await tool("get_report", { messageId: sourceReportId }).catch(() => null);
963
+ const p = prior?.found && prior.report ? prior.report : null;
964
+ const caveats = [
965
+ ...(Array.isArray(p?.caveats) ? p.caveats.filter((v) => typeof v === "string") : []),
966
+ caveat,
967
+ ];
968
+ await tool("post_report", {
969
+ channelId,
970
+ messageId: sourceReportId,
971
+ broadcast: true,
972
+ ...(typeof p?.title === "string" ? { title: p.title } : {}),
973
+ summary: typeof p?.summary === "string" && p.summary ? p.summary : caveat,
974
+ caveats: [...new Set(caveats)],
975
+ todos: Array.isArray(p?.todos) ? p.todos.filter((v) => typeof v === "string") : [],
976
+ ...(Array.isArray(p?.verified) ? { verified: p.verified.filter((v) => typeof v === "string") } : {}),
977
+ ...(p?.deployment ? { deployment: p.deployment } : {}),
978
+ ...(typeof p?.previewUrl === "string" ? { previewUrl: p.previewUrl } : {}),
979
+ ...(typeof p?.pr?.url === "string" ? { prUrl: p.pr.url } : {}),
980
+ ...(typeof p?.pr?.number === "number" ? { prNumber: p.pr.number } : {}),
981
+ }).catch(() => {});
982
+ }
983
+
984
+ async function handleFolderDeploy({
985
+ message,
986
+ channelId,
987
+ tool,
988
+ cfg,
989
+ deps,
990
+ signal,
991
+ parentId,
992
+ folderPath,
993
+ sourceReportId,
994
+ }) {
995
+ if (!deps.pathExists(folderPath)) {
996
+ await settleDeploySource({
997
+ tool,
998
+ channelId,
999
+ sourceReportId,
1000
+ caveat: "A requested deployment didn't start: the folder wasn't found on this machine.",
1001
+ });
1002
+ await tool("post_message", {
1003
+ channelId,
1004
+ parentId,
1005
+ body: `I couldn't find the folder \`${folderPath}\` on this machine, so nothing was deployed.`,
1006
+ });
1007
+ return { status: "no-path" };
1008
+ }
1009
+ const target = deps.resolveDeployTarget({
1010
+ cfg,
1011
+ channelId,
1012
+ folderPath,
1013
+ pathExists: deps.pathExists,
1014
+ });
1015
+ if (!target.enabled) {
1016
+ await settleDeploySource({
1017
+ tool,
1018
+ channelId,
1019
+ sourceReportId,
1020
+ caveat: "A requested deployment didn't start: deployment is off for this folder.",
1021
+ });
1022
+ await tool("post_message", {
1023
+ channelId,
1024
+ parentId,
1025
+ body:
1026
+ "Deployment is off for this folder. Add a `deploy` entry for this channel in `hilos-agent.json`, " +
1027
+ "or link the folder once with Vercel/Netlify so its local project marker can be detected.",
1028
+ });
1029
+ return { status: "deploy-off" };
1030
+ }
1031
+
1032
+ const requested = message?.deployRequest;
1033
+ if (
1034
+ requested &&
1035
+ (requested.provider !== target.provider || requested.prod !== target.prod)
1036
+ ) {
1037
+ await settleDeploySource({
1038
+ tool,
1039
+ channelId,
1040
+ sourceReportId,
1041
+ caveat: "A requested deployment didn't start: the folder's deploy target changed after this report.",
1042
+ });
1043
+ await tool("post_message", {
1044
+ channelId,
1045
+ parentId,
1046
+ body: "The folder's deploy target changed after that report. Open the latest report and try again.",
1047
+ });
1048
+ return { status: "deploy-target-changed" };
1049
+ }
1050
+
1051
+ const prior = sourceReportId
1052
+ ? await tool("get_report", { messageId: sourceReportId }).catch(() => null)
1053
+ : null;
1054
+ const priorReport = prior?.found && prior.report ? prior.report : null;
1055
+ const state = folderDeployState(folderPath, deps.git);
1056
+ await tool("post_message", {
1057
+ channelId,
1058
+ parentId,
1059
+ body:
1060
+ `Deploying ${target.prod ? "to production" : "a preview"} on ` +
1061
+ `${target.provider === "vercel" ? "Vercel" : "Netlify"} from \`${folderPath}\`. ` +
1062
+ `${sourceReportId ? "I'll update the report when it finishes." : "I'll post the live link when it finishes."}`,
1063
+ });
1064
+ const result = await deps.deployFolder({ folderPath, target, signal });
1065
+ if (result.aborted || signal?.aborted) {
1066
+ // A stop must stick: settle the report so the pending decision can't
1067
+ // re-trigger this deploy on the next poll or daemon restart.
1068
+ await settleDeploySource({
1069
+ tool,
1070
+ channelId,
1071
+ sourceReportId,
1072
+ caveat: "A deployment was stopped before it finished. Use Deploy on this report to try again.",
1073
+ });
1074
+ await tool("post_message", { channelId, parentId, body: "Deployment was stopped." });
1075
+ return { status: "cancelled" };
1076
+ }
1077
+
1078
+ const folderName = folderPath.split("/").filter(Boolean).pop() || folderPath;
1079
+ const stateLines = [];
1080
+ if (state.commit) stateLines.push(`Deployed from commit \`${state.commit}\`.`);
1081
+ if (state.changed && renderChangedList(state.changed)) {
1082
+ stateLines.push(`Local changes included:\n${renderChangedList(state.changed)}`);
1083
+ }
1084
+ if (state.stat) stateLines.push("```\n" + state.stat + "\n```");
1085
+ const priorSummary = typeof priorReport?.summary === "string" ? priorReport.summary : "";
1086
+ const summary = result.ok
1087
+ ? [
1088
+ priorSummary,
1089
+ `Deployed to ${deployLabel(target)}: ${result.url}`,
1090
+ ...stateLines,
1091
+ ].filter(Boolean).join("\n\n")
1092
+ : [priorSummary, `Deployment to ${deployLabel(target)} did not finish.`, ...stateLines]
1093
+ .filter(Boolean)
1094
+ .join("\n\n");
1095
+ const priorCaveats = Array.isArray(priorReport?.caveats)
1096
+ ? priorReport.caveats.filter((value) => typeof value === "string")
1097
+ : [];
1098
+ const caveats = [...priorCaveats];
1099
+ if (result.ok && !target.prod) {
1100
+ caveats.push("Preview deployment — this is not your production domain.");
1101
+ } else if (!result.ok && result.caveat) {
1102
+ caveats.push(result.caveat);
1103
+ }
1104
+ const priorPreviewUrl =
1105
+ typeof priorReport?.previewUrl === "string" ? priorReport.previewUrl : undefined;
1106
+ if (!result.ok && priorPreviewUrl) {
1107
+ caveats.push("The live link is from the previous successful deployment; this new deploy did not replace it.");
1108
+ }
1109
+ const report = {
1110
+ channelId,
1111
+ ...(sourceReportId ? { messageId: sourceReportId } : { parentId }),
1112
+ broadcast: true,
1113
+ title: result.ok ? `Deployed: ${folderName}` : `Deployment failed: ${folderName}`,
1114
+ summary,
1115
+ caveats: [...new Set(caveats)],
1116
+ todos: Array.isArray(priorReport?.todos) ? priorReport.todos : [],
1117
+ ...(target.available
1118
+ ? { deployment: { provider: target.provider, prod: target.prod } }
1119
+ : {}),
1120
+ ...((result.ok && result.url) || priorPreviewUrl
1121
+ ? { previewUrl: result.ok ? result.url : priorPreviewUrl }
1122
+ : {}),
1123
+ };
1124
+ await tool("post_report", report);
1125
+ return {
1126
+ status: result.ok ? "folder-deployed" : "deploy-failed",
1127
+ previewUrl: result.ok ? result.url : null,
1128
+ };
1129
+ }
1130
+
1131
+ /**
1132
+ * FOLDER MODE (0322). A channel with NO linked GitHub repo but a `folders`
1133
+ * mapping runs the coding CLI directly in that folder and applies changes in
1134
+ * place — it's the user's own machine, same trust as running the CLI themselves.
1135
+ * No branch, no push, no PR, no `gh` — ever. When the folder is a git repo we
1136
+ * snapshot `git status` before/after to report exactly what changed and to offer
1137
+ * a precise revert on reject; a non-git folder gets an honest "can't track / can't
1138
+ * auto-undo" report. The report card drives a decision: approve = keep (already
1139
+ * live), request-changes = re-run in place (bounded), reject = revert the run's
1140
+ * own changes (git repos only).
1141
+ */
1142
+ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps, signal, parentId, folderPath, brief, workspaceMemory, context }) {
1143
+ void me;
1144
+ const git = deps.git;
1145
+ const tag = requesterTag(message.author);
1146
+ const lead = tag ? `${tag} — ` : "";
1147
+
1148
+ // 1. The mapped folder must actually exist on disk — be honest and actionable.
1149
+ if (!deps.pathExists(folderPath)) {
1150
+ await tool("post_message", {
1151
+ channelId,
1152
+ parentId,
1153
+ body:
1154
+ `I couldn't find the folder \`${folderPath}\` on this machine — it may have moved, been ` +
1155
+ `renamed, or live on a drive that isn't mounted. Fix the path in my \`folders\` config ` +
1156
+ `(or restore the folder), then mention me again.`,
1157
+ });
1158
+ return { status: "no-path" };
1159
+ }
1160
+ const deployTarget = deps.resolveDeployTarget({
1161
+ cfg,
1162
+ channelId,
1163
+ folderPath,
1164
+ pathExists: deps.pathExists,
1165
+ });
1166
+
1167
+ // 2. Is it a git repo? If so, snapshot the pre-run status so we can (a) report
1168
+ // exactly what the run changed and (b) revert precisely on reject. We NEVER touch
1169
+ // the index (no `git add`), never branch, never push. A non-git folder still runs
1170
+ // — we just can't offer diff/undo.
1171
+ const insideWorkTree = git(folderPath, ["rev-parse", "--is-inside-work-tree"]);
1172
+ const isGit = insideWorkTree.status === 0 && String(insideWorkTree.stdout || "").trim() === "true";
1173
+ const beforeStatus = isGit ? String(git(folderPath, ["status", "--porcelain"]).stdout || "") : "";
1174
+ const dirtyBefore = isGit && beforeStatus.trim() !== "";
1175
+
1176
+ // 3. Instant ack / thread anchor (mirrors the repo flow). When the mention was in
1177
+ // a thread we stay in it; a top-level mention makes the ack the thread anchor so
1178
+ // the run's progress + report card live under it.
1179
+ const ack = await tool("post_message", {
1180
+ channelId,
1181
+ parentId,
1182
+ body: `On it — working directly in \`${folderPath}\`. Changes apply straight to your folder (no branch, no PR); I'll report what changed.`,
1183
+ });
1184
+ const ackId = ack?.messageId ?? null;
1185
+ const threadRoot = parentId ?? ackId;
1186
+
1187
+ // Live progress: reuse the streaming card (post_progress) when the server
1188
+ // supports it, else the edit-in-place heartbeat, else nothing. Kept across
1189
+ // request-changes re-runs on the same status message.
1190
+ let progressId = null;
1191
+ const folderWorking = (elapsedMs, lastLine) => {
1192
+ const base = `Working in \`${folderPath}\` — ${fmtElapsed(elapsedMs)} elapsed. I'll report what changed when it's done.`;
1193
+ const tail = oneLine(lastLine);
1194
+ return tail ? `${base}\n\nLatest: ${tail}` : base;
1195
+ };
1196
+
1197
+ // Run the coding CLI in the folder, streaming progress + honoring timeouts and
1198
+ // the abort signal (reuses runCli via deps + the progress-emitter machinery).
1199
+ const runFolderCli = async (promptText) => {
1200
+ const parts = cfg.codingCmd.split(" ").filter(Boolean);
1201
+ const vendor = detectVendor(cfg.codingCmd);
1202
+ const streamOn = Boolean(caps.postProgress);
1203
+ const streamArgs = streamOn ? codeStreamArgs(vendor) : [];
1204
+ let emitter = null;
1205
+ let progressInflight = Promise.resolve();
1206
+ let stopHeartbeat = () => {};
1207
+ let lastLine = "";
1208
+ // With stream args the CLI's stdout is NDJSON events, not prose — parse it and
1209
+ // keep the final `result` event's summary so the report card gets clean text
1210
+ // (raw stdout would dump JSON into the report). Without stream args, stdout IS
1211
+ // the prose and resultText stays empty.
1212
+ let resultText = "";
1213
+ const resultParser = streamArgs.length ? makeStreamParser(vendor) : null;
1214
+ const foldResultEvents = (events) => {
1215
+ for (const ev of events || []) {
1216
+ if (ev && ev.t === "result" && ev.summary) resultText = ev.summary;
1217
+ }
1218
+ };
1219
+ if (streamOn) {
1220
+ if (!progressId) {
1221
+ try {
1222
+ const r = await tool("post_message", { channelId, parentId: threadRoot, body: folderWorking(0, "") });
1223
+ progressId = r?.messageId ?? null;
1224
+ } catch {
1225
+ progressId = null;
1226
+ }
1227
+ }
1228
+ const statusId = progressId;
1229
+ if (statusId) {
1230
+ emitter = createProgressEmitter({
1231
+ parser: makeStreamParser(vendor),
1232
+ now: deps.now,
1233
+ throttleMs: cfg.progressMs,
1234
+ send: (p) => {
1235
+ try {
1236
+ const r = tool("post_progress", { messageId: statusId, progress: p });
1237
+ if (r && typeof r.then === "function") {
1238
+ const done = r.then(() => {}, () => {});
1239
+ progressInflight = Promise.all([progressInflight, done]).then(() => {}, () => {});
1240
+ }
1241
+ } catch {
1242
+ /* a progress send must never break the run */
1243
+ }
1244
+ },
1245
+ });
1246
+ }
1247
+ } else if (caps.editMessage && cfg.heartbeatMs > 0) {
1248
+ const start = deps.now();
1249
+ let stopped = false;
1250
+ let busy = false;
1251
+ const beat = setInterval(async () => {
1252
+ if (stopped || busy) return;
1253
+ busy = true;
1254
+ const body = folderWorking(deps.now() - start, lastLine);
1255
+ try {
1256
+ if (!progressId) {
1257
+ const r = await tool("post_message", { channelId, parentId: threadRoot, body });
1258
+ progressId = r?.messageId ?? null;
1259
+ } else {
1260
+ await tool("edit_message", { messageId: progressId, body });
1261
+ }
1262
+ } catch {
1263
+ /* a heartbeat must never break the run */
1264
+ } finally {
1265
+ busy = false;
1266
+ }
1267
+ }, cfg.heartbeatMs);
1268
+ if (beat.unref) beat.unref();
1269
+ stopHeartbeat = () => {
1270
+ stopped = true;
1271
+ clearInterval(beat);
1272
+ };
1273
+ }
1274
+ let run;
1275
+ try {
1276
+ run = await deps.runCli({
1277
+ cmd: parts[0],
1278
+ args: [...parts.slice(1), ...streamArgs, memoryPreamble(workspaceMemory) + promptText],
1279
+ cwd: folderPath,
1280
+ timeoutMs: cfg.runTimeoutMs,
1281
+ label: "coding",
1282
+ signal,
1283
+ env: codingChildEnv(cfg),
1284
+ onData: (c) => {
1285
+ const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
1286
+ if (lines.length) lastLine = lines[lines.length - 1];
1287
+ if (resultParser) {
1288
+ try {
1289
+ foldResultEvents(resultParser.push(String(c)));
1290
+ } catch {
1291
+ /* result extraction must never break the run */
1292
+ }
1293
+ }
1294
+ if (emitter) {
1295
+ try {
1296
+ emitter.feed(c);
1297
+ } catch {
1298
+ /* a progress fold must never break the run */
1299
+ }
1300
+ }
1301
+ },
1302
+ });
1303
+ } finally {
1304
+ stopHeartbeat();
1305
+ if (emitter) {
1306
+ const errored = Boolean(run && (run.aborted || run.error || run.status !== 0));
1307
+ let reason = "";
1308
+ if (errored && run && !run.aborted) {
1309
+ const stderrTail = oneLine((run.stderr || "").trim().split("\n").slice(-3).join(" "), 200);
1310
+ if (run.error?.code === "ENOENT") reason = `Couldn't start ${parts[0]} — is it installed and on PATH?`;
1311
+ else if (run.error?.message) reason = run.error.message;
1312
+ else if (run.status != null) reason = `The CLI exited ${run.status}`;
1313
+ if (stderrTail) reason = reason ? `${reason}: ${stderrTail}` : stderrTail;
1314
+ }
1315
+ try {
1316
+ emitter.done(errored ? "error" : "done", reason);
1317
+ } catch {
1318
+ /* ignore */
1319
+ }
1320
+ try {
1321
+ await progressInflight;
1322
+ } catch {
1323
+ /* a drain failure must never break the run */
1324
+ }
1325
+ }
1326
+ }
1327
+ if (resultParser) {
1328
+ try {
1329
+ foldResultEvents(resultParser.flush());
1330
+ } catch {
1331
+ /* result extraction must never break the run */
1332
+ }
1333
+ }
1334
+ if (run.aborted || signal?.aborted) return { aborted: true };
1335
+ const failed = Boolean(run.error) || run.status !== 0;
1336
+ return {
1337
+ aborted: false,
1338
+ stdout: run.stdout || "",
1339
+ // Clean prose for the report: the stream `result` summary when streaming,
1340
+ // else empty (raw stdout is prose only in the non-streamed case).
1341
+ resultText,
1342
+ streamed: streamArgs.length > 0,
1343
+ failed,
1344
+ errCode: run.error?.code || null,
1345
+ errMessage: run.error?.message || null,
1346
+ status: run.status,
1347
+ stderrTail: oneLine((run.stderr || "").trim().split("\n").slice(-3).join(" "), 300),
1348
+ };
1349
+ };
1350
+
1351
+ // Compute what the run changed (git repos only), vs the pre-run snapshot.
1352
+ const computeChanged = () => {
1353
+ if (!isGit) return { modified: [], created: [], deleted: [] };
1354
+ const afterStatus = String(git(folderPath, ["status", "--porcelain"]).stdout || "");
1355
+ return diffStatusSets(beforeStatus, afterStatus);
1356
+ };
1357
+ const hasChanges = (c) => c.created.length + c.modified.length + c.deleted.length > 0;
1358
+
1359
+ const buildReport = (runResult, changed) => {
1360
+ const changedList = renderChangedList(changed);
1361
+ const stat = isGit ? truncateDiff(String(git(folderPath, ["diff", "--stat"]).stdout || "")).text.trim() : "";
1362
+ // Streamed runs put NDJSON on stdout — use the parsed result summary there;
1363
+ // non-streamed stdout is the agent's prose and is safe to quote.
1364
+ const rawOut = (runResult.resultText || (runResult.streamed ? "" : runResult.stdout) || "").trim();
1365
+ const parts = [`${lead}worked directly in \`${folderPath}\`.`];
1366
+ if (rawOut) parts.push(rawOut.slice(0, 2000));
1367
+ if (isGit) parts.push(changedList ? `Changed files:\n${changedList}` : "No tracked file changes were detected.");
1368
+ if (stat) parts.push("```\n" + stat + "\n```");
1369
+ const caveats = [`Changes were applied directly to ${folderPath} — there is no branch or PR to review.`];
1370
+ 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.");
1371
+ if (dirtyBefore) caveats.push("The folder already had uncommitted local changes before this run — those are mixed in with mine.");
1372
+ if (runResult.failed) {
1373
+ const why = runResult.errMessage ? `: ${runResult.errMessage}` : runResult.status != null ? ` (exit ${runResult.status})` : "";
1374
+ caveats.push(`The coding agent didn't exit cleanly${why} — review the changes carefully.`);
1375
+ }
1376
+ const folderName = folderPath.split("/").filter(Boolean).pop() || folderPath;
1377
+ return {
1378
+ title: `Folder run: ${folderName}`,
1379
+ summary: parts.join("\n\n"),
1380
+ caveats,
1381
+ todos: [],
1382
+ ...(deployTarget.enabled && deployTarget.available
1383
+ ? { deployment: { provider: deployTarget.provider, prod: deployTarget.prod } }
1384
+ : {}),
1385
+ };
1386
+ };
1387
+
1388
+ // --- Run ---
1389
+ let runResult = await runFolderCli(folderTaskPrompt({ message, context, brief, folderPath }));
1390
+ if (runResult.aborted) {
1391
+ await tool("post_message", {
1392
+ channelId,
1393
+ parentId: threadRoot,
1394
+ broadcast: Boolean(threadRoot),
1395
+ body: `Stopped — I left \`${folderPath}\` as it was.`,
1396
+ });
1397
+ return { status: "cancelled" };
1398
+ }
1399
+
1400
+ let changed = computeChanged();
1401
+
1402
+ // Honest early exits (no report/decision): a failed run that changed nothing, or
1403
+ // a clean success that changed nothing.
1404
+ if (runResult.failed && (!isGit || !hasChanges(changed))) {
1405
+ let body;
1406
+ if (runResult.errCode === "ENOENT") {
1407
+ body = `I couldn't start \`${cfg.codingCmd}\` — is it installed and on PATH?`;
1408
+ } else {
1409
+ const why = runResult.errMessage
1410
+ ? ` (${runResult.errMessage})`
1411
+ : runResult.status != null
1412
+ ? ` (the CLI exited ${runResult.status})`
1413
+ : "";
1414
+ const tail = runResult.stderrTail ? `: ${runResult.stderrTail}` : "";
1415
+ const left = isGit ? `\`${folderPath}\` is unchanged` : `check \`${folderPath}\` for any partial edits`;
1416
+ body = `The run didn't finish${why}${tail}. ${left} — mention me to retry.`;
1417
+ }
1418
+ await tool("post_message", { channelId, parentId: threadRoot, broadcast: Boolean(threadRoot), body });
1419
+ return { status: "run-failed" };
1420
+ }
1421
+ if (!runResult.failed && isGit && !hasChanges(changed)) {
1422
+ await tool("post_message", {
1423
+ channelId,
1424
+ parentId: threadRoot,
1425
+ broadcast: Boolean(threadRoot),
1426
+ body: `The run finished but nothing changed in \`${folderPath}\`. Mention me to try a different approach.`,
1427
+ });
1428
+ return { status: "no-changes" };
1429
+ }
1430
+
1431
+ // --- Report card + decision loop ---
1432
+ const postFolderReport = async (rr, ch) => {
1433
+ const report = buildReport(rr, ch);
1434
+ const res = await tool("post_report", { channelId, parentId: threadRoot, broadcast: true, ...report });
1435
+ return res?.messageId ?? null;
1436
+ };
1437
+ let reportMessageId = await postFolderReport(runResult, changed);
1438
+ let decision = await awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId: threadRoot, signal });
1439
+
1440
+ const maxRounds = cfg.maxRounds || 3;
1441
+ let round = 1;
1442
+ while (decision.kind === "changes" && round < maxRounds) {
1443
+ await tool("post_message", {
1444
+ channelId,
1445
+ parentId: threadRoot,
1446
+ body: `Revising in \`${folderPath}\` with your feedback${decision.note ? `: ${decision.note}` : ""} (round ${round + 1}/${maxRounds}).`,
1447
+ });
1448
+ runResult = await runFolderCli(
1449
+ folderTaskPrompt({ message, context, brief, folderPath }) +
1450
+ `\n\nReviewer feedback to address: ${decision.note || "(see the channel)"}`,
1451
+ );
1452
+ if (runResult.aborted) {
1453
+ await tool("post_message", { channelId, parentId: threadRoot, broadcast: Boolean(threadRoot), body: `Stopped — I left \`${folderPath}\` as it was.` });
1454
+ return { status: "cancelled" };
1455
+ }
1456
+ changed = computeChanged();
1457
+ reportMessageId = await postFolderReport(runResult, changed);
1458
+ decision = await awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId: threadRoot, signal });
1459
+ round += 1;
1460
+ }
1461
+
1462
+ if (decision.kind === "deploy") {
1463
+ return await handleFolderDeploy({
1464
+ message: {
1465
+ ...message,
1466
+ deployRequest: {
1467
+ reportMessageId,
1468
+ provider: decision.provider,
1469
+ prod: decision.prod,
1470
+ },
1471
+ },
1472
+ channelId,
1473
+ tool,
1474
+ cfg,
1475
+ deps,
1476
+ signal,
1477
+ parentId: threadRoot,
1478
+ folderPath,
1479
+ sourceReportId: reportMessageId,
1480
+ });
1481
+ }
1482
+
1483
+ // --- Terminal ---
1484
+ if (decision.kind === "approved") {
1485
+ await tool("post_message", {
1486
+ channelId,
1487
+ parentId: threadRoot,
1488
+ broadcast: Boolean(threadRoot),
1489
+ body: `${lead}approved — the changes are already live in \`${folderPath}\`.`,
1490
+ });
1491
+ return { status: "folder-done", decision: "approved" };
1492
+ }
1493
+
1494
+ if (decision.kind === "rejected") {
1495
+ if (!isGit) {
1496
+ const all = [...changed.created, ...changed.modified, ...changed.deleted];
1497
+ await tool("post_message", {
1498
+ channelId,
1499
+ parentId: threadRoot,
1500
+ broadcast: Boolean(threadRoot),
1501
+ body:
1502
+ `Rejected — but \`${folderPath}\` isn't a git repository, so I can't automatically undo the changes. ` +
1503
+ (all.length ? `I touched:\n${renderChangedList(changed)}\n\nRevert these by hand.` : `Please review the folder and revert by hand.`),
1504
+ });
1505
+ return { status: "folder-done", decision: "rejected", reverted: false };
1506
+ }
1507
+ // Revert ONLY what the run itself changed: checkout tracked modifications +
1508
+ // deletions, remove untracked files the run created. Never a blanket reset —
1509
+ // pre-existing local changes stay untouched.
1510
+ const trackedToRestore = [...changed.modified, ...changed.deleted];
1511
+ if (trackedToRestore.length) git(folderPath, ["checkout", "--", ...trackedToRestore]);
1512
+ if (changed.created.length) git(folderPath, ["clean", "-fd", "--", ...changed.created]);
1513
+ const revertedList = renderChangedList(changed);
1514
+ await tool("post_message", {
1515
+ channelId,
1516
+ parentId: threadRoot,
1517
+ broadcast: Boolean(threadRoot),
1518
+ body:
1519
+ `Rejected — reverted my changes in \`${folderPath}\`.` +
1520
+ (revertedList ? `\n\nRestored:\n${revertedList}` : "") +
1521
+ (dirtyBefore ? `\n\nYour pre-existing local changes were left untouched.` : ""),
1522
+ });
1523
+ return { status: "folder-done", decision: "rejected", reverted: true };
1524
+ }
1525
+
1526
+ if (decision.kind === "cancelled") {
1527
+ await tool("post_message", { channelId, parentId: threadRoot, broadcast: Boolean(threadRoot), body: `Stopped — the changes so far are still in \`${folderPath}\`.` });
1528
+ return { status: "cancelled" };
1529
+ }
1530
+
1531
+ // Timeout (or a leftover 'changes' past the round cap): note + release, same as
1532
+ // the repo flow.
1533
+ await tool("post_message", {
1534
+ channelId,
1535
+ parentId: threadRoot,
1536
+ broadcast: Boolean(threadRoot),
1537
+ body: `Still awaiting your review of the changes in \`${folderPath}\`. They're already applied — mention me to revise or revert once you've decided.`,
1538
+ });
1539
+ return { status: "folder-done", decision: "timeout" };
1540
+ }
1541
+
817
1542
  /** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
818
1543
  * cancels an in-flight run — the queue fires it when a human says "stop". */
819
1544
  export async function handleTask({ message, channelId, tool, me, caps = {} }, cfg, depsOverride, opts = {}) {
820
- const deps = depsOverride || defaultDeps();
1545
+ if (message.dispatch?.briefMarkdown) {
1546
+ message = { ...message, body: `${message.body}\n\n${message.dispatch.briefMarkdown}` };
1547
+ }
1548
+ const deps = depsOverride ? { ...defaultDeps(), ...depsOverride } : defaultDeps();
821
1549
  const git = deps.git;
822
1550
  const signal = opts.signal;
823
1551
  // When the mention was a thread reply, keep the whole exchange in that thread.
824
1552
  const parentId = message.parentId ?? null;
825
1553
 
826
1554
  const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
827
- const repoLink = links.find((l) => l.repo_full_name);
1555
+ // A folder link (0324) carries the folder PATH in repo_full_name for display —
1556
+ // it is NOT a GitHub repo, so it must never be picked as the channel's repo.
1557
+ // Exclude kind='folder'; pr/branch/repo rows with repo_full_name still count.
1558
+ const repoLink = links.find((l) => l.kind !== "folder" && l.repo_full_name);
828
1559
  // Workspace memory ("soul") — shared project context the agent should know.
829
1560
  const { memory: workspaceMemory = null } = await tool("get_workspace_memory", {}).catch(() => ({
830
1561
  memory: null,
@@ -834,6 +1565,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
834
1565
  // crucial: a reply like "yeah, do it" only means something against what was
835
1566
  // just said.
836
1567
  const context = await fetchContext({ channelId, tool, parentId });
1568
+ const mode = agentMode(message);
837
1569
 
838
1570
  // REVIEW EXECUTION (0288): a review-request routes to the read-only reviewer
839
1571
  // BEFORE the chat/code flow — it reads the PR's diff and posts an advisory review
@@ -863,13 +1595,155 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
863
1595
  }
864
1596
  }
865
1597
 
866
- // No repo linked → nothing to build; just reply.
1598
+ // No repo linked → either FOLDER mode (0322: a channel mapped to a plain local
1599
+ // folder can still get coding work done) or, failing that, just reply. A linked
1600
+ // repo always wins — the folder map is only consulted when there's no repo link.
867
1601
  if (!repoLink) {
1602
+ const folderPath = resolveFolderPath(cfg, channelId);
1603
+ if (folderPath) {
1604
+ // Durable report-card fallback: list_mentions projects a still-pending
1605
+ // in-place decision into the queue. Deploy only while that decision
1606
+ // remains pending; a settled report is a silent no-op, which prevents a
1607
+ // race with the active folder run from deploying twice.
1608
+ if (message.deployRequest && typeof message.deployRequest.reportMessageId === "string") {
1609
+ if (message.authorRole === "guest") {
1610
+ await tool("post_message", {
1611
+ channelId,
1612
+ parentId,
1613
+ body: "A workspace member needs to request that deployment.",
1614
+ });
1615
+ return { status: "chat" };
1616
+ }
1617
+ const sourceReportId = message.deployRequest.reportMessageId;
1618
+ const current = await tool("get_report", { messageId: sourceReportId }).catch(() => null);
1619
+ if (current?.found && current.report?.decision?.kind !== "deploy") {
1620
+ return { status: "deploy-already-handled" };
1621
+ }
1622
+ return await handleFolderDeploy({
1623
+ message,
1624
+ channelId,
1625
+ tool,
1626
+ cfg,
1627
+ deps,
1628
+ signal,
1629
+ parentId,
1630
+ folderPath,
1631
+ sourceReportId,
1632
+ });
1633
+ }
1634
+
1635
+ // Same chat-vs-code decision the repo path uses: mode 'ask'/'ship' short-
1636
+ // circuit exactly as in the repo flow, otherwise the LLM router judges intent
1637
+ // (its CLI call goes through deps.runCli so folder mode is unit-testable).
1638
+ let routed;
1639
+ if (mode === "ask") {
1640
+ routed = { code: false, reply: null };
1641
+ } else if (mode === "ship") {
1642
+ routed = { code: true, task: message.body };
1643
+ } else if (caps.agentIntent) {
1644
+ const classifierDeployTarget = deps.resolveDeployTarget({
1645
+ cfg,
1646
+ channelId,
1647
+ folderPath,
1648
+ pathExists: deps.pathExists,
1649
+ });
1650
+ const semantic = await tool("classify_agent_intent", {
1651
+ latestMessage: message.body,
1652
+ transcript: context.transcript,
1653
+ project: folderPath,
1654
+ channelId,
1655
+ allowDeploy: classifierDeployTarget.enabled === true,
1656
+ }).catch(() => null);
1657
+ if (semantic?.reason === "execution-disabled") {
1658
+ await tool("post_message", {
1659
+ channelId,
1660
+ parentId,
1661
+ body: "I can chat here, but agent execution is disabled in this channel.",
1662
+ });
1663
+ return { status: "chat" };
1664
+ }
1665
+ if (
1666
+ semantic?.ok &&
1667
+ message.authorRole === "guest" &&
1668
+ (semantic.mode === "ship" || semantic.mode === "deploy")
1669
+ ) {
1670
+ await tool("post_message", {
1671
+ channelId,
1672
+ parentId,
1673
+ body: "A workspace member needs to confirm that request before I can act on it.",
1674
+ });
1675
+ return { status: "chat" };
1676
+ }
1677
+ if (semantic?.ok && semantic.mode === "deploy") {
1678
+ return await handleFolderDeploy({
1679
+ message,
1680
+ channelId,
1681
+ tool,
1682
+ cfg,
1683
+ deps,
1684
+ signal,
1685
+ parentId,
1686
+ folderPath,
1687
+ sourceReportId: null,
1688
+ });
1689
+ }
1690
+ if (semantic?.ok && semantic.mode === "ship") {
1691
+ routed = { code: true, task: semantic.brief || message.body };
1692
+ } else if (semantic?.ok && semantic.mode === "ask") {
1693
+ routed = { code: false, reply: null };
1694
+ }
1695
+ }
1696
+ // Capability or classifier failure: preserve the previous semantic local
1697
+ // CLI router. There is still no keyword/regex intent list.
1698
+ if (!routed) {
1699
+ routed = await routeIntent({
1700
+ name: me?.agentName || "an assistant",
1701
+ repoFullName: folderPath,
1702
+ transcript: context.transcript,
1703
+ workspaceMemory,
1704
+ cfg,
1705
+ signal,
1706
+ runCliFn: deps.runCli,
1707
+ });
1708
+ }
1709
+ if (routed.aborted || signal?.aborted) {
1710
+ await tool("post_message", { channelId, parentId, body: "Stopped." });
1711
+ return { status: "chat" };
1712
+ }
1713
+ if (routed.code) {
1714
+ return await handleFolderTask({
1715
+ message,
1716
+ channelId,
1717
+ tool,
1718
+ me,
1719
+ caps,
1720
+ cfg,
1721
+ deps,
1722
+ signal,
1723
+ parentId,
1724
+ folderPath,
1725
+ brief: routed.task,
1726
+ workspaceMemory,
1727
+ context,
1728
+ });
1729
+ }
1730
+ // Not a coding task → post the router's reply if it produced one, else fall
1731
+ // through to a normal conversational reply.
1732
+ if (routed.reply) {
1733
+ await tool("post_message", { channelId, parentId, body: routed.reply });
1734
+ return { status: "chat" };
1735
+ }
1736
+ }
868
1737
  await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
869
1738
  return { status: "chat" };
870
1739
  }
871
1740
  const repoFullName = repoLink.repo_full_name;
872
1741
 
1742
+ if (mode === "ask") {
1743
+ await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
1744
+ return { status: "chat" };
1745
+ }
1746
+
873
1747
  // Same-PR follow-up (0281): if this mention is a reply in a thread that already
874
1748
  // owns a run (branch + PR), we may continue THAT run instead of forking a
875
1749
  // duplicate. Look it up by the thread root — the parentId the reply carried.
@@ -896,15 +1770,18 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
896
1770
  // signal is only ever acted on when the PR is open (resolveFollowupMode → 'new'
897
1771
  // otherwise), so telling the router the thread "owns an open PR" when the run is
898
1772
  // 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
- });
1773
+ const routed =
1774
+ mode === "ship"
1775
+ ? { code: true, task: message.body, followupSignal: null }
1776
+ : await routeIntent({
1777
+ name: me?.agentName || "an assistant",
1778
+ repoFullName,
1779
+ transcript: context.transcript,
1780
+ workspaceMemory,
1781
+ cfg,
1782
+ signal,
1783
+ hasActiveRun: activeRunOpen,
1784
+ });
908
1785
  if (routed.aborted || signal?.aborted) {
909
1786
  await tool("post_message", { channelId, parentId, body: "Stopped." });
910
1787
  return { status: "chat" };
@@ -914,7 +1791,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
914
1791
  if (!body) {
915
1792
  body =
916
1793
  routed.error?.code === "ENOENT"
917
- ? `(my chat command \`${cfg.chatCmd || cfg.codingCmd}\` isn't installed or on PATH.)`
1794
+ ? `(my chat command \`${chatCmdFor(cfg)}\` isn't installed or on PATH.)`
918
1795
  : `Still thinking on this — it's taking longer than usual. I'll follow up shortly.`;
919
1796
  }
920
1797
  await tool("post_message", { channelId, parentId, body });
@@ -1108,6 +1985,10 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1108
1985
  // The ack itself stays top-level (the channel-visible "on it"); terminal
1109
1986
  // outcomes + report cards broadcast back to the channel from the thread.
1110
1987
  const threadRoot = parentId ?? ackId;
1988
+ const updateChannelMarker = async (body) => {
1989
+ if (!ackId || parentId || !body) return;
1990
+ await tool("edit_message", { messageId: ackId, body }).catch(() => {});
1991
+ };
1111
1992
  // Bind this run to the thread root (0279/0280), now follow-up-aware (0281):
1112
1993
  // iterate → REUSE the thread's existing run (don't record a second one); the
1113
1994
  // same PR updates in place and its status stays awaiting_review.
@@ -1116,8 +1997,28 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1116
1997
  // new → record a fresh run, exactly as before.
1117
1998
  // Best-effort throughout — a bookkeeping failure (or an older server without the
1118
1999
  // runs tools) must NEVER break the run, so it proceeds without a runId.
1119
- let runId = effectiveMode === "iterate" ? activeRun?.runId ?? null : null;
1120
- if (caps.runs && threadRoot && effectiveMode !== "iterate") {
2000
+ let runId = message.dispatch?.runId ?? (effectiveMode === "iterate" ? activeRun?.runId ?? null : null);
2001
+ if (message.dispatch?.runId && caps.runs) {
2002
+ // Adopt the dispatch's canonical run. If the server already SETTLED it (the
2003
+ // reaper/outbox closed a dispatch we were slow to pick up), leave it alone
2004
+ // and skip the coding run — resurrecting it would ship work no one is
2005
+ // waiting on. Any OTHER failure (network, an older server without the guard)
2006
+ // keeps today's best-effort behavior: proceed with the run.
2007
+ try {
2008
+ await tool("update_run", { runId: message.dispatch.runId, status: "running" });
2009
+ } catch (e) {
2010
+ if (String(e?.message || "").includes("already settled")) {
2011
+ await tool("post_message", {
2012
+ channelId,
2013
+ parentId,
2014
+ body: "This dispatch was already closed on the server, so I left it alone. Approve it again if it's still wanted.",
2015
+ }).catch(() => {});
2016
+ return { status: "dispatch-settled" };
2017
+ }
2018
+ /* older server or a transient error — proceed best-effort */
2019
+ }
2020
+ }
2021
+ if (caps.runs && threadRoot && effectiveMode !== "iterate" && !message.dispatch?.runId) {
1121
2022
  try {
1122
2023
  if (effectiveMode === "redirect" && activeRun?.runId) {
1123
2024
  await tool("update_run", { runId: activeRun.runId, status: "superseded" }).catch(() => {});
@@ -1128,7 +2029,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1128
2029
  taskText: message.body,
1129
2030
  branch,
1130
2031
  // Map an unrecognized command to null rather than the off-vocabulary
1131
- // "unknown" — provider is documented as claude_code|codex|cursor|hilos.
2032
+ // "unknown" — provider is documented as
2033
+ // claude_code|codex|cursor|antigravity|hermes|hilos.
1132
2034
  provider: (() => {
1133
2035
  const v = detectVendor(cfg.codingCmd);
1134
2036
  return v === "unknown" ? null : v;
@@ -1141,7 +2043,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1141
2043
  }
1142
2044
  // Keep the inspectable continuation/redirect statement (don't overwrite it with
1143
2045
  // an LLM plan) so a human can correct the routing before code lands.
1144
- if (ackId && caps.editMessage && cfg.chatCmd && !continuingPrUrl && effectiveMode !== "redirect") {
2046
+ if (ackId && caps.editMessage && chatCmdFor(cfg) && !continuingPrUrl && effectiveMode !== "redirect") {
1145
2047
  // Feed the ack the router's distilled brief AND the conversation — not the raw
1146
2048
  // mention — so it states a real plan instead of "what's the task?".
1147
2049
  const plan = await proposePlanAck({
@@ -1319,6 +2221,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1319
2221
  timeoutMs: cfg.runTimeoutMs,
1320
2222
  label: "coding",
1321
2223
  signal,
2224
+ env: codingChildEnv(cfg),
1322
2225
  onData: (c) => {
1323
2226
  // Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
1324
2227
  const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
@@ -1491,14 +2394,16 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1491
2394
  // Post the right cancel message: honest about whether cleanup actually worked.
1492
2395
  const postStopped = async () => {
1493
2396
  const clean = discardBranch();
2397
+ const body = clean
2398
+ ? `Stopped — discarded \`${branch}\`.`
2399
+ : `Stopped, but couldn't fully clean \`${branch}\` — run \`git reset --hard && git switch ${startRef.ref || cfg.defaultBranch}\` in ${repoPath}.`;
1494
2400
  await tool("post_message", {
1495
2401
  channelId,
1496
2402
  parentId: threadRoot,
1497
2403
  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}.`,
2404
+ body,
1501
2405
  });
2406
+ await updateChannelMarker(compactRunMarker("cancelled", branch));
1502
2407
  return { status: "cancelled", branch };
1503
2408
  };
1504
2409
 
@@ -1511,6 +2416,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1511
2416
  let teamMemoryBlock = "";
1512
2417
  if (caps.recall) {
1513
2418
  try {
2419
+ // Pass the task's channelId so the server scopes recall to this project AND
2420
+ // can apply the guest gate (0298): the daemon's token is long-lived with no
2421
+ // bound channel, so the server relies on this per-call channelId to decide
2422
+ // whether a guest is present. Residual: a recall that omits channelId can't
2423
+ // be guest-gated server-side (no channel to check) — the daemon always
2424
+ // passes it here, so that path is covered.
1514
2425
  const recalled = await tool("recall", { channelId, limit: 20 }).catch(() => null);
1515
2426
  const mems = Array.isArray(recalled?.memories) ? recalled.memories : [];
1516
2427
  teamMemoryBlock = buildMemoryBlock(mems);
@@ -1559,6 +2470,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1559
2470
  `re-mention me to ship or discard.`,
1560
2471
  });
1561
2472
  await finalizeProgress(`\`${branch}\` has the agent's own commits — needs review.`);
2473
+ await updateChannelMarker(compactRunMarker("changes", branch));
1562
2474
  return { status: "self-committed-gated", branch };
1563
2475
  }
1564
2476
  // When a live status card was streaming, settle the report onto it (0289) —
@@ -1580,6 +2492,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1580
2492
  runId,
1581
2493
  });
1582
2494
  await recordSession(selfResult.prUrl);
2495
+ await updateChannelMarker(compactRunMarker(selfResult.status, selfResult.branch));
1583
2496
  return selfResult;
1584
2497
  }
1585
2498
  if (staged.empty) {
@@ -1613,6 +2526,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1613
2526
  git(repoPath, ["switch", "-"]);
1614
2527
  git(repoPath, ["branch", "-D", branch]);
1615
2528
  }
2529
+ await updateChannelMarker(
2530
+ compactRunMarker(staged.failed ? "run-failed" : "no-changes", branch),
2531
+ );
1616
2532
  return { status: staged.failed ? "run-failed" : "no-changes" };
1617
2533
  }
1618
2534
 
@@ -1627,7 +2543,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1627
2543
  // message. Only the DEFAULT gate:false report settles in place; gate:true's
1628
2544
  // proposal → decision → report lifecycle stays on separate messages (a
1629
2545
  // separate ticket). No status card (older server / status post failed) →
1630
- // settleId is null and applyDecision posts a fresh broadcast report as before.
2546
+ // settleId is null and applyDecision posts a fresh final report instead.
1631
2547
  const settleId = streamOn && progressId ? progressId : null;
1632
2548
  const result = await applyDecision({
1633
2549
  decision: { kind: "approved" },
@@ -1653,6 +2569,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1653
2569
  // Re-record with the now-known PR url so the local session record + the run row
1654
2570
  // carry the PR the session belongs to (best-effort; session id unchanged).
1655
2571
  await recordSession(result.prUrl);
2572
+ await updateChannelMarker(compactRunMarker(result.status, result.branch));
1656
2573
  return { ...result, stat: staged.stat, sessionId: runSessionId };
1657
2574
  }
1658
2575
 
@@ -1707,6 +2624,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1707
2624
  });
1708
2625
  await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
1709
2626
  await recordSession(result.prUrl);
2627
+ await updateChannelMarker(compactRunMarker(result.status, result.branch));
1710
2628
  return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round, sessionId: runSessionId };
1711
2629
  } finally {
1712
2630
  // Whatever happened, give the user their uncommitted work back.