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/README.md +26 -4
- package/bin/hilos-agent.mjs +16 -4
- package/package.json +1 -1
- package/src/agent-events.mjs +78 -10
- package/src/cli.mjs +122 -2
- package/src/config.mjs +47 -5
- package/src/deploy.mjs +234 -0
- package/src/handler.mjs +426 -28
- package/src/model-resolve.mjs +99 -0
- package/src/progress-emitter.mjs +40 -7
- package/src/redact.mjs +54 -0
- package/src/resume.mjs +10 -7
- package/src/run.mjs +13 -3
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)
|
|
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
|
|
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
|
|
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 (
|
|
601
|
-
// by chatTimeoutMs, so a casual reply lands in seconds
|
|
602
|
-
// dead-air the channel for the full coding timeout.
|
|
603
|
-
const cmd = cfg
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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);
|