hilos-agent 0.4.0 → 0.6.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
@@ -26,11 +26,12 @@ import {
26
26
  mentionHandle,
27
27
  detectPrContinuation,
28
28
  } from "./daemon.mjs";
29
- import { runCli, buildHeartbeat, ackText, oneLine, fmtElapsed } from "./cli.mjs";
29
+ import { runCli, buildHeartbeat, ackText, oneLine, fmtElapsed, minimalEnv } from "./cli.mjs";
30
30
  import { makeStreamParser } from "./agent-events.mjs";
31
- import { detectVendor, codeStreamArgs, createProgressEmitter } from "./progress-emitter.mjs";
31
+ import { detectVendor, codeStreamArgs, createProgressEmitter, fastChatCmd } from "./progress-emitter.mjs";
32
32
  import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
33
33
  import { buildResumeArgs, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
34
+ import { createModelArgsResolver } from "./model-resolve.mjs";
34
35
  import {
35
36
  buildReviewPrompt,
36
37
  parseReviewOutput,
@@ -38,6 +39,30 @@ import {
38
39
  shouldReview,
39
40
  } from "./review.mjs";
40
41
  import { buildMemoryBlock } from "./memory.mjs";
42
+ import { deployFolder, resolveDeployTarget } from "./deploy.mjs";
43
+
44
+ /**
45
+ * The environment for a coding/chat CLI run. runCli always strips HILOS_* on top
46
+ * of whatever this returns; here we additionally honor `codingEnv: "minimal"`
47
+ * (strict isolation — only what the tool needs to reach its own model provider).
48
+ * Returns undefined for the default "inherit" mode so runCli falls back to the
49
+ * scrubbed process.env.
50
+ */
51
+ function codingChildEnv(cfg) {
52
+ return cfg?.codingEnv === "minimal"
53
+ ? minimalEnv(process.env, cfg.codingEnvAllow)
54
+ : undefined;
55
+ }
56
+
57
+ /**
58
+ * The fast chat command for this config: an explicit chatCmd wins, else the
59
+ * coding vendor's verified non-interactive print mode (fastChatCmd), else the
60
+ * coding command itself. Keeps chat/plan-ack/review on the USER'S tool — a
61
+ * Codex or Cursor daemon must never require Claude Code on PATH (0521).
62
+ */
63
+ function chatCmdFor(cfg) {
64
+ return cfg.chatCmd || fastChatCmd(detectVendor(cfg.codingCmd)) || cfg.codingCmd;
65
+ }
41
66
 
42
67
  /** PR number from a github pull-request URL, or null. */
43
68
  function prNumberFromUrl(url) {
@@ -51,6 +76,10 @@ function prNumberFromUrl(url) {
51
76
  // human re-triggering the review is the escape hatch past the cap.
52
77
  const reviewRounds = new Map();
53
78
 
79
+ // Tier → account-verified `--model` args for the code run (0504). Memoized per
80
+ // (binary, tier) for the process; a failed lookup emits [] and retries later.
81
+ const modelArgsFor = createModelArgsResolver({ run: (opts) => runCli(opts) });
82
+
54
83
 
55
84
  // Sentinel the router model emits (only when a thread already owns a run) to
56
85
  // classify a follow-up: change | new-scope | ambiguous. Parsed out of the router
@@ -102,6 +131,8 @@ function defaultDeps() {
102
131
  // Does a path exist on disk? Injectable so folder mode's "missing folder"
103
132
  // guard is unit-testable without touching the real filesystem.
104
133
  pathExists: (p) => existsSync(p),
134
+ resolveDeployTarget: (args) => resolveDeployTarget(args),
135
+ deployFolder: (args) => deployFolder(args),
105
136
  openPR: (cwd, { title, body, branch, base }) => {
106
137
  const r = spawnSync(
107
138
  "gh",
@@ -167,7 +198,15 @@ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, pare
167
198
  report = m && m.report ? m.report : null;
168
199
  }
169
200
  const kind = report ? decisionKind(report) : null;
170
- if (kind) return { kind, note: report.decision?.note || null };
201
+ if (kind === "deploy") {
202
+ const provider = report.decision?.provider;
203
+ const prod = report.decision?.prod;
204
+ if ((provider === "vercel" || provider === "netlify") && typeof prod === "boolean") {
205
+ return { kind, provider, prod };
206
+ }
207
+ } else if (kind) {
208
+ return { kind, note: report.decision?.note || null };
209
+ }
171
210
  await deps.sleep(cfg.decisionPollMs);
172
211
  }
173
212
  return { kind: "timeout" };
@@ -371,7 +410,7 @@ const CODE_SIGNAL = "__CODE__";
371
410
  */
372
411
  async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false, runCliFn }) {
373
412
  const doRun = runCliFn || runCli; // folder mode injects deps.runCli; repo flow uses the import
374
- const cmd = cfg.chatCmd || cfg.codingCmd;
413
+ const cmd = chatCmdFor(cfg);
375
414
  const parts = cmd.split(" ").filter(Boolean);
376
415
  // When this thread already owns an OPEN pull request (a follow-up reply), the
377
416
  // router ALSO reads whether the latest message continues that PR, wants a
@@ -558,7 +597,7 @@ export function planAckPrompt(o) {
558
597
  * empty/timeout/error so the run keeps the instant template and never stalls.
559
598
  */
560
599
  async function proposePlanAck({ task, transcript, repoFullName, cfg, signal }) {
561
- const cmd = cfg.chatCmd;
600
+ const cmd = chatCmdFor(cfg);
562
601
  if (!cmd) return null;
563
602
  const parts = cmd.split(" ").filter(Boolean);
564
603
  const prompt = planAckPrompt({ task, transcript, repoFullName });
@@ -568,6 +607,7 @@ async function proposePlanAck({ task, transcript, repoFullName, cfg, signal }) {
568
607
  timeoutMs: Math.min(cfg.chatTimeoutMs || 90000, 45000),
569
608
  label: "thinking",
570
609
  signal,
610
+ env: codingChildEnv(cfg),
571
611
  });
572
612
  if (run.aborted || signal?.aborted) return null;
573
613
  const text = (run.stdout || "").trim();
@@ -597,10 +637,10 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
597
637
  `${memoryPreamble(workspaceMemory)}` +
598
638
  `Conversation so far:\n${transcript}`;
599
639
 
600
- // Chat uses the FAST one-shot command (fall back to codingCmd if unset) bounded
601
- // by chatTimeoutMs, so a casual reply lands in seconds and a stalled model can't
602
- // dead-air the channel for the full coding timeout.
603
- const cmd = cfg.chatCmd || cfg.codingCmd;
640
+ // Chat uses the FAST one-shot command (the coding vendor's own print mode when
641
+ // chatCmd is unset) bounded by chatTimeoutMs, so a casual reply lands in seconds
642
+ // and a stalled model can't dead-air the channel for the full coding timeout.
643
+ const cmd = chatCmdFor(cfg);
604
644
  const parts = cmd.split(" ").filter(Boolean);
605
645
  console.log(` chat → running \`${cmd}\` (output appears when it finishes)…`);
606
646
 
@@ -636,6 +676,7 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
636
676
  timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
637
677
  label: "thinking",
638
678
  signal,
679
+ env: codingChildEnv(cfg),
639
680
  });
640
681
  } finally {
641
682
  beatStopped = true;
@@ -807,7 +848,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
807
848
  // Read-only run: a throwaway cwd contains any stray write; the daemon reads ONLY
808
849
  // stdout and never stages/commits anything. Review uses the fast chat command
809
850
  // when set (no acceptEdits → can't write), falling back to the coding command.
810
- const cmd = cfg.reviewCmd || cfg.chatCmd || cfg.codingCmd;
851
+ const cmd = cfg.reviewCmd || chatCmdFor(cfg);
811
852
  const parts = String(cmd).split(" ").filter(Boolean);
812
853
  let sandboxDir = null;
813
854
  try {
@@ -823,6 +864,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
823
864
  timeoutMs: cfg.chatTimeoutMs ? Math.max(cfg.chatTimeoutMs, 120000) : cfg.runTimeoutMs,
824
865
  label: "reviewing",
825
866
  signal,
867
+ env: codingChildEnv(cfg),
826
868
  });
827
869
  if (sandboxDir) {
828
870
  try {
@@ -890,6 +932,207 @@ function renderChangedList({ created, modified, deleted }) {
890
932
  return lines.join("\n");
891
933
  }
892
934
 
935
+ function deployLabel(target) {
936
+ const provider = target.provider === "vercel" ? "Vercel" : "Netlify";
937
+ return `${provider} (${target.prod ? "production" : "preview"})`;
938
+ }
939
+
940
+ function folderDeployState(folderPath, git) {
941
+ const inside = git(folderPath, ["rev-parse", "--is-inside-work-tree"]);
942
+ const isGit = inside.status === 0 && String(inside.stdout || "").trim() === "true";
943
+ if (!isGit) return { commit: null, stat: "", changed: null };
944
+ const commitResult = git(folderPath, ["rev-parse", "--short", "HEAD"]);
945
+ const statResult = git(folderPath, ["diff", "--stat", "HEAD"]);
946
+ const statusResult = git(folderPath, ["status", "--porcelain"]);
947
+ const changed = diffStatusSets("", String(statusResult.stdout || ""));
948
+ return {
949
+ commit: commitResult.status === 0 ? String(commitResult.stdout || "").trim() : null,
950
+ stat: truncateDiff(String(statResult.stdout || "")).text.trim(),
951
+ changed,
952
+ };
953
+ }
954
+
955
+ // A report-card Deploy click is stored durably on the report as a pending
956
+ // decision, and list_mentions re-projects it on EVERY poll until a post_report
957
+ // settle clears it. So every terminal exit of handleFolderDeploy — including
958
+ // "stopped" and "deploy is off" — must settle the source report, or the
959
+ // decision re-triggers the deploy on the next poll / daemon restart (an
960
+ // explicit human cancel of a production deploy would not stick) and the card
961
+ // stays wedged on "deployment requested" with its action row hidden. Settling
962
+ // re-posts the prior report content untouched plus one honest caveat; the
963
+ // server replaces metadata.report wholesale, which clears the decision by
964
+ // construction and brings the Deploy button back for a retry.
965
+ async function settleDeploySource({ tool, channelId, sourceReportId, caveat }) {
966
+ if (!sourceReportId) return;
967
+ const prior = await tool("get_report", { messageId: sourceReportId }).catch(() => null);
968
+ const p = prior?.found && prior.report ? prior.report : null;
969
+ const caveats = [
970
+ ...(Array.isArray(p?.caveats) ? p.caveats.filter((v) => typeof v === "string") : []),
971
+ caveat,
972
+ ];
973
+ await tool("post_report", {
974
+ channelId,
975
+ messageId: sourceReportId,
976
+ broadcast: true,
977
+ ...(typeof p?.title === "string" ? { title: p.title } : {}),
978
+ summary: typeof p?.summary === "string" && p.summary ? p.summary : caveat,
979
+ caveats: [...new Set(caveats)],
980
+ todos: Array.isArray(p?.todos) ? p.todos.filter((v) => typeof v === "string") : [],
981
+ ...(Array.isArray(p?.verified) ? { verified: p.verified.filter((v) => typeof v === "string") } : {}),
982
+ ...(p?.deployment ? { deployment: p.deployment } : {}),
983
+ ...(typeof p?.previewUrl === "string" ? { previewUrl: p.previewUrl } : {}),
984
+ ...(typeof p?.pr?.url === "string" ? { prUrl: p.pr.url } : {}),
985
+ ...(typeof p?.pr?.number === "number" ? { prNumber: p.pr.number } : {}),
986
+ }).catch(() => {});
987
+ }
988
+
989
+ async function handleFolderDeploy({
990
+ message,
991
+ channelId,
992
+ tool,
993
+ cfg,
994
+ deps,
995
+ signal,
996
+ parentId,
997
+ folderPath,
998
+ sourceReportId,
999
+ }) {
1000
+ if (!deps.pathExists(folderPath)) {
1001
+ await settleDeploySource({
1002
+ tool,
1003
+ channelId,
1004
+ sourceReportId,
1005
+ caveat: "A requested deployment didn't start: the folder wasn't found on this machine.",
1006
+ });
1007
+ await tool("post_message", {
1008
+ channelId,
1009
+ parentId,
1010
+ body: `I couldn't find the folder \`${folderPath}\` on this machine, so nothing was deployed.`,
1011
+ });
1012
+ return { status: "no-path" };
1013
+ }
1014
+ const target = deps.resolveDeployTarget({
1015
+ cfg,
1016
+ channelId,
1017
+ folderPath,
1018
+ pathExists: deps.pathExists,
1019
+ });
1020
+ if (!target.enabled) {
1021
+ await settleDeploySource({
1022
+ tool,
1023
+ channelId,
1024
+ sourceReportId,
1025
+ caveat: "A requested deployment didn't start: deployment is off for this folder.",
1026
+ });
1027
+ await tool("post_message", {
1028
+ channelId,
1029
+ parentId,
1030
+ body:
1031
+ "Deployment is off for this folder. Add a `deploy` entry for this channel in `hilos-agent.json`, " +
1032
+ "or link the folder once with Vercel/Netlify so its local project marker can be detected.",
1033
+ });
1034
+ return { status: "deploy-off" };
1035
+ }
1036
+
1037
+ const requested = message?.deployRequest;
1038
+ if (
1039
+ requested &&
1040
+ (requested.provider !== target.provider || requested.prod !== target.prod)
1041
+ ) {
1042
+ await settleDeploySource({
1043
+ tool,
1044
+ channelId,
1045
+ sourceReportId,
1046
+ caveat: "A requested deployment didn't start: the folder's deploy target changed after this report.",
1047
+ });
1048
+ await tool("post_message", {
1049
+ channelId,
1050
+ parentId,
1051
+ body: "The folder's deploy target changed after that report. Open the latest report and try again.",
1052
+ });
1053
+ return { status: "deploy-target-changed" };
1054
+ }
1055
+
1056
+ const prior = sourceReportId
1057
+ ? await tool("get_report", { messageId: sourceReportId }).catch(() => null)
1058
+ : null;
1059
+ const priorReport = prior?.found && prior.report ? prior.report : null;
1060
+ const state = folderDeployState(folderPath, deps.git);
1061
+ await tool("post_message", {
1062
+ channelId,
1063
+ parentId,
1064
+ body:
1065
+ `Deploying ${target.prod ? "to production" : "a preview"} on ` +
1066
+ `${target.provider === "vercel" ? "Vercel" : "Netlify"} from \`${folderPath}\`. ` +
1067
+ `${sourceReportId ? "I'll update the report when it finishes." : "I'll post the live link when it finishes."}`,
1068
+ });
1069
+ const result = await deps.deployFolder({ folderPath, target, signal });
1070
+ if (result.aborted || signal?.aborted) {
1071
+ // A stop must stick: settle the report so the pending decision can't
1072
+ // re-trigger this deploy on the next poll or daemon restart.
1073
+ await settleDeploySource({
1074
+ tool,
1075
+ channelId,
1076
+ sourceReportId,
1077
+ caveat: "A deployment was stopped before it finished. Use Deploy on this report to try again.",
1078
+ });
1079
+ await tool("post_message", { channelId, parentId, body: "Deployment was stopped." });
1080
+ return { status: "cancelled" };
1081
+ }
1082
+
1083
+ const folderName = folderPath.split("/").filter(Boolean).pop() || folderPath;
1084
+ const stateLines = [];
1085
+ if (state.commit) stateLines.push(`Deployed from commit \`${state.commit}\`.`);
1086
+ if (state.changed && renderChangedList(state.changed)) {
1087
+ stateLines.push(`Local changes included:\n${renderChangedList(state.changed)}`);
1088
+ }
1089
+ if (state.stat) stateLines.push("```\n" + state.stat + "\n```");
1090
+ const priorSummary = typeof priorReport?.summary === "string" ? priorReport.summary : "";
1091
+ const summary = result.ok
1092
+ ? [
1093
+ priorSummary,
1094
+ `Deployed to ${deployLabel(target)}: ${result.url}`,
1095
+ ...stateLines,
1096
+ ].filter(Boolean).join("\n\n")
1097
+ : [priorSummary, `Deployment to ${deployLabel(target)} did not finish.`, ...stateLines]
1098
+ .filter(Boolean)
1099
+ .join("\n\n");
1100
+ const priorCaveats = Array.isArray(priorReport?.caveats)
1101
+ ? priorReport.caveats.filter((value) => typeof value === "string")
1102
+ : [];
1103
+ const caveats = [...priorCaveats];
1104
+ if (result.ok && !target.prod) {
1105
+ caveats.push("Preview deployment — this is not your production domain.");
1106
+ } else if (!result.ok && result.caveat) {
1107
+ caveats.push(result.caveat);
1108
+ }
1109
+ const priorPreviewUrl =
1110
+ typeof priorReport?.previewUrl === "string" ? priorReport.previewUrl : undefined;
1111
+ if (!result.ok && priorPreviewUrl) {
1112
+ caveats.push("The live link is from the previous successful deployment; this new deploy did not replace it.");
1113
+ }
1114
+ const report = {
1115
+ channelId,
1116
+ ...(sourceReportId ? { messageId: sourceReportId } : { parentId }),
1117
+ broadcast: true,
1118
+ title: result.ok ? `Deployed: ${folderName}` : `Deployment failed: ${folderName}`,
1119
+ summary,
1120
+ caveats: [...new Set(caveats)],
1121
+ todos: Array.isArray(priorReport?.todos) ? priorReport.todos : [],
1122
+ ...(target.available
1123
+ ? { deployment: { provider: target.provider, prod: target.prod } }
1124
+ : {}),
1125
+ ...((result.ok && result.url) || priorPreviewUrl
1126
+ ? { previewUrl: result.ok ? result.url : priorPreviewUrl }
1127
+ : {}),
1128
+ };
1129
+ await tool("post_report", report);
1130
+ return {
1131
+ status: result.ok ? "folder-deployed" : "deploy-failed",
1132
+ previewUrl: result.ok ? result.url : null,
1133
+ };
1134
+ }
1135
+
893
1136
  /**
894
1137
  * FOLDER MODE (0322). A channel with NO linked GitHub repo but a `folders`
895
1138
  * mapping runs the coding CLI directly in that folder and applies changes in
@@ -919,6 +1162,12 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
919
1162
  });
920
1163
  return { status: "no-path" };
921
1164
  }
1165
+ const deployTarget = deps.resolveDeployTarget({
1166
+ cfg,
1167
+ channelId,
1168
+ folderPath,
1169
+ pathExists: deps.pathExists,
1170
+ });
922
1171
 
923
1172
  // 2. Is it a git repo? If so, snapshot the pre-run status so we can (a) report
924
1173
  // exactly what the run changed and (b) revert precisely on reject. We NEVER touch
@@ -1028,14 +1277,17 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
1028
1277
  };
1029
1278
  }
1030
1279
  let run;
1280
+ // Model preset (0504): same run-time resolution as the repo path.
1281
+ const modelArgs = await modelArgsFor(cfg, vendor);
1031
1282
  try {
1032
1283
  run = await deps.runCli({
1033
1284
  cmd: parts[0],
1034
- args: [...parts.slice(1), ...streamArgs, memoryPreamble(workspaceMemory) + promptText],
1285
+ args: [...parts.slice(1), ...modelArgs, ...streamArgs, memoryPreamble(workspaceMemory) + promptText],
1035
1286
  cwd: folderPath,
1036
1287
  timeoutMs: cfg.runTimeoutMs,
1037
1288
  label: "coding",
1038
1289
  signal,
1290
+ env: codingChildEnv(cfg),
1039
1291
  onData: (c) => {
1040
1292
  const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
1041
1293
  if (lines.length) lastLine = lines[lines.length - 1];
@@ -1129,7 +1381,15 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
1129
1381
  caveats.push(`The coding agent didn't exit cleanly${why} — review the changes carefully.`);
1130
1382
  }
1131
1383
  const folderName = folderPath.split("/").filter(Boolean).pop() || folderPath;
1132
- return { title: `Folder run: ${folderName}`, summary: parts.join("\n\n"), caveats, todos: [] };
1384
+ return {
1385
+ title: `Folder run: ${folderName}`,
1386
+ summary: parts.join("\n\n"),
1387
+ caveats,
1388
+ todos: [],
1389
+ ...(deployTarget.enabled && deployTarget.available
1390
+ ? { deployment: { provider: deployTarget.provider, prod: deployTarget.prod } }
1391
+ : {}),
1392
+ };
1133
1393
  };
1134
1394
 
1135
1395
  // --- Run ---
@@ -1206,6 +1466,27 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
1206
1466
  round += 1;
1207
1467
  }
1208
1468
 
1469
+ if (decision.kind === "deploy") {
1470
+ return await handleFolderDeploy({
1471
+ message: {
1472
+ ...message,
1473
+ deployRequest: {
1474
+ reportMessageId,
1475
+ provider: decision.provider,
1476
+ prod: decision.prod,
1477
+ },
1478
+ },
1479
+ channelId,
1480
+ tool,
1481
+ cfg,
1482
+ deps,
1483
+ signal,
1484
+ parentId: threadRoot,
1485
+ folderPath,
1486
+ sourceReportId: reportMessageId,
1487
+ });
1488
+ }
1489
+
1209
1490
  // --- Terminal ---
1210
1491
  if (decision.kind === "approved") {
1211
1492
  await tool("post_message", {
@@ -1268,7 +1549,10 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
1268
1549
  /** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
1269
1550
  * cancels an in-flight run — the queue fires it when a human says "stop". */
1270
1551
  export async function handleTask({ message, channelId, tool, me, caps = {} }, cfg, depsOverride, opts = {}) {
1271
- const deps = depsOverride || defaultDeps();
1552
+ if (message.dispatch?.briefMarkdown) {
1553
+ message = { ...message, body: `${message.body}\n\n${message.dispatch.briefMarkdown}` };
1554
+ }
1555
+ const deps = depsOverride ? { ...defaultDeps(), ...depsOverride } : defaultDeps();
1272
1556
  const git = deps.git;
1273
1557
  const signal = opts.signal;
1274
1558
  // When the mention was a thread reply, keep the whole exchange in that thread.
@@ -1324,15 +1608,102 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1324
1608
  if (!repoLink) {
1325
1609
  const folderPath = resolveFolderPath(cfg, channelId);
1326
1610
  if (folderPath) {
1611
+ // Durable report-card fallback: list_mentions projects a still-pending
1612
+ // in-place decision into the queue. Deploy only while that decision
1613
+ // remains pending; a settled report is a silent no-op, which prevents a
1614
+ // race with the active folder run from deploying twice.
1615
+ if (message.deployRequest && typeof message.deployRequest.reportMessageId === "string") {
1616
+ if (message.authorRole === "guest") {
1617
+ await tool("post_message", {
1618
+ channelId,
1619
+ parentId,
1620
+ body: "A workspace member needs to request that deployment.",
1621
+ });
1622
+ return { status: "chat" };
1623
+ }
1624
+ const sourceReportId = message.deployRequest.reportMessageId;
1625
+ const current = await tool("get_report", { messageId: sourceReportId }).catch(() => null);
1626
+ if (current?.found && current.report?.decision?.kind !== "deploy") {
1627
+ return { status: "deploy-already-handled" };
1628
+ }
1629
+ return await handleFolderDeploy({
1630
+ message,
1631
+ channelId,
1632
+ tool,
1633
+ cfg,
1634
+ deps,
1635
+ signal,
1636
+ parentId,
1637
+ folderPath,
1638
+ sourceReportId,
1639
+ });
1640
+ }
1641
+
1327
1642
  // Same chat-vs-code decision the repo path uses: mode 'ask'/'ship' short-
1328
1643
  // circuit exactly as in the repo flow, otherwise the LLM router judges intent
1329
1644
  // (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({
1645
+ let routed;
1646
+ if (mode === "ask") {
1647
+ routed = { code: false, reply: null };
1648
+ } else if (mode === "ship") {
1649
+ routed = { code: true, task: message.body };
1650
+ } else if (caps.agentIntent) {
1651
+ const classifierDeployTarget = deps.resolveDeployTarget({
1652
+ cfg,
1653
+ channelId,
1654
+ folderPath,
1655
+ pathExists: deps.pathExists,
1656
+ });
1657
+ const semantic = await tool("classify_agent_intent", {
1658
+ latestMessage: message.body,
1659
+ transcript: context.transcript,
1660
+ project: folderPath,
1661
+ channelId,
1662
+ allowDeploy: classifierDeployTarget.enabled === true,
1663
+ }).catch(() => null);
1664
+ if (semantic?.reason === "execution-disabled") {
1665
+ await tool("post_message", {
1666
+ channelId,
1667
+ parentId,
1668
+ body: "I can chat here, but agent execution is disabled in this channel.",
1669
+ });
1670
+ return { status: "chat" };
1671
+ }
1672
+ if (
1673
+ semantic?.ok &&
1674
+ message.authorRole === "guest" &&
1675
+ (semantic.mode === "ship" || semantic.mode === "deploy")
1676
+ ) {
1677
+ await tool("post_message", {
1678
+ channelId,
1679
+ parentId,
1680
+ body: "A workspace member needs to confirm that request before I can act on it.",
1681
+ });
1682
+ return { status: "chat" };
1683
+ }
1684
+ if (semantic?.ok && semantic.mode === "deploy") {
1685
+ return await handleFolderDeploy({
1686
+ message,
1687
+ channelId,
1688
+ tool,
1689
+ cfg,
1690
+ deps,
1691
+ signal,
1692
+ parentId,
1693
+ folderPath,
1694
+ sourceReportId: null,
1695
+ });
1696
+ }
1697
+ if (semantic?.ok && semantic.mode === "ship") {
1698
+ routed = { code: true, task: semantic.brief || message.body };
1699
+ } else if (semantic?.ok && semantic.mode === "ask") {
1700
+ routed = { code: false, reply: null };
1701
+ }
1702
+ }
1703
+ // Capability or classifier failure: preserve the previous semantic local
1704
+ // CLI router. There is still no keyword/regex intent list.
1705
+ if (!routed) {
1706
+ routed = await routeIntent({
1336
1707
  name: me?.agentName || "an assistant",
1337
1708
  repoFullName: folderPath,
1338
1709
  transcript: context.transcript,
@@ -1341,6 +1712,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1341
1712
  signal,
1342
1713
  runCliFn: deps.runCli,
1343
1714
  });
1715
+ }
1344
1716
  if (routed.aborted || signal?.aborted) {
1345
1717
  await tool("post_message", { channelId, parentId, body: "Stopped." });
1346
1718
  return { status: "chat" };
@@ -1426,7 +1798,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1426
1798
  if (!body) {
1427
1799
  body =
1428
1800
  routed.error?.code === "ENOENT"
1429
- ? `(my chat command \`${cfg.chatCmd || cfg.codingCmd}\` isn't installed or on PATH.)`
1801
+ ? `(my chat command \`${chatCmdFor(cfg)}\` isn't installed or on PATH.)`
1430
1802
  : `Still thinking on this — it's taking longer than usual. I'll follow up shortly.`;
1431
1803
  }
1432
1804
  await tool("post_message", { channelId, parentId, body });
@@ -1632,8 +2004,28 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1632
2004
  // new → record a fresh run, exactly as before.
1633
2005
  // Best-effort throughout — a bookkeeping failure (or an older server without the
1634
2006
  // runs tools) must NEVER break the run, so it proceeds without a runId.
1635
- let runId = effectiveMode === "iterate" ? activeRun?.runId ?? null : null;
1636
- if (caps.runs && threadRoot && effectiveMode !== "iterate") {
2007
+ let runId = message.dispatch?.runId ?? (effectiveMode === "iterate" ? activeRun?.runId ?? null : null);
2008
+ if (message.dispatch?.runId && caps.runs) {
2009
+ // Adopt the dispatch's canonical run. If the server already SETTLED it (the
2010
+ // reaper/outbox closed a dispatch we were slow to pick up), leave it alone
2011
+ // and skip the coding run — resurrecting it would ship work no one is
2012
+ // waiting on. Any OTHER failure (network, an older server without the guard)
2013
+ // keeps today's best-effort behavior: proceed with the run.
2014
+ try {
2015
+ await tool("update_run", { runId: message.dispatch.runId, status: "running" });
2016
+ } catch (e) {
2017
+ if (String(e?.message || "").includes("already settled")) {
2018
+ await tool("post_message", {
2019
+ channelId,
2020
+ parentId,
2021
+ body: "This dispatch was already closed on the server, so I left it alone. Approve it again if it's still wanted.",
2022
+ }).catch(() => {});
2023
+ return { status: "dispatch-settled" };
2024
+ }
2025
+ /* older server or a transient error — proceed best-effort */
2026
+ }
2027
+ }
2028
+ if (caps.runs && threadRoot && effectiveMode !== "iterate" && !message.dispatch?.runId) {
1637
2029
  try {
1638
2030
  if (effectiveMode === "redirect" && activeRun?.runId) {
1639
2031
  await tool("update_run", { runId: activeRun.runId, status: "superseded" }).catch(() => {});
@@ -1644,7 +2036,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1644
2036
  taskText: message.body,
1645
2037
  branch,
1646
2038
  // Map an unrecognized command to null rather than the off-vocabulary
1647
- // "unknown" — provider is documented as claude_code|codex|cursor|hilos.
2039
+ // "unknown" — provider is documented as
2040
+ // claude_code|codex|cursor|antigravity|hermes|hilos.
1648
2041
  provider: (() => {
1649
2042
  const v = detectVendor(cfg.codingCmd);
1650
2043
  return v === "unknown" ? null : v;
@@ -1657,7 +2050,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1657
2050
  }
1658
2051
  // Keep the inspectable continuation/redirect statement (don't overwrite it with
1659
2052
  // an LLM plan) so a human can correct the routing before code lands.
1660
- if (ackId && caps.editMessage && cfg.chatCmd && !continuingPrUrl && effectiveMode !== "redirect") {
2053
+ if (ackId && caps.editMessage && chatCmdFor(cfg) && !continuingPrUrl && effectiveMode !== "redirect") {
1661
2054
  // Feed the ack the router's distilled brief AND the conversation — not the raw
1662
2055
  // mention — so it states a real plan instead of "what's the task?".
1663
2056
  const plan = await proposePlanAck({
@@ -1692,9 +2085,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1692
2085
  // (get_active_run) with no local match means the session likely lives on another
1693
2086
  // machine/instance — a bad `--resume` id makes claude error → empty diff → a failed
1694
2087
  // run, so we DON'T resume and degrade to today's branch+feedback (never worse).
1695
- // Only claude_code has a proven resume flag; codex/cursor/unknown → [] anyway.
2088
+ // claude_code + cursor have proven resume flags (0282/0573); codex/unknown → [].
1696
2089
  let resumeSessionId = null;
1697
- if (effectiveMode === "iterate" && vendor === "claude_code") {
2090
+ if (effectiveMode === "iterate" && (vendor === "claude_code" || vendor === "cursor")) {
1698
2091
  const local = (() => {
1699
2092
  try {
1700
2093
  return readStateEntry(HILOS_DIR, threadRoot);
@@ -1823,9 +2216,13 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1823
2216
  // fallback) suppresses it. buildResumeArgs is [] unless vendor+session make
1824
2217
  // resume safe, so a non-resume run is byte-identical to the pre-0282 ARGV.
1825
2218
  const resumeArgs = resume ? buildResumeArgs(vendor, resumeSessionId) : [];
2219
+ // Model preset (0504): resolved at run time against the CLI's own model
2220
+ // list (cursor only today) — [] when unset/unresolvable, so the tool's
2221
+ // default stands. Inserted before resume/stream flags, after the base.
2222
+ const modelArgs = await modelArgsFor(cfg, vendor);
1826
2223
  const codeArgs = streamOn
1827
- ? [...parts.slice(1), ...resumeArgs, ...streamArgs]
1828
- : [...parts.slice(1), ...resumeArgs];
2224
+ ? [...parts.slice(1), ...modelArgs, ...resumeArgs, ...streamArgs]
2225
+ : [...parts.slice(1), ...modelArgs, ...resumeArgs];
1829
2226
  let run;
1830
2227
  try {
1831
2228
  run = await runCli({
@@ -1835,6 +2232,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
1835
2232
  timeoutMs: cfg.runTimeoutMs,
1836
2233
  label: "coding",
1837
2234
  signal,
2235
+ env: codingChildEnv(cfg),
1838
2236
  onData: (c) => {
1839
2237
  // Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
1840
2238
  const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);