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/README.md +129 -4
- package/bin/hilos-agent.mjs +31 -6
- package/package.json +1 -1
- package/src/cli.mjs +97 -1
- package/src/config.mjs +38 -3
- package/src/daemon.mjs +49 -0
- package/src/deploy.mjs +234 -0
- package/src/handler.mjs +963 -45
- package/src/hook.mjs +427 -0
- package/src/memory.mjs +26 -2
- package/src/progress-emitter.mjs +28 -3
- package/src/redact.mjs +54 -0
- package/src/resume.mjs +1 -1
- package/src/run.mjs +130 -14
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)
|
|
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
|
|
170
|
-
//
|
|
171
|
-
//
|
|
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
|
|
212
|
-
//
|
|
213
|
-
//
|
|
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
|
|
300
|
-
//
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
:
|
|
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 (
|
|
548
|
-
// by chatTimeoutMs, so a casual reply lands in seconds
|
|
549
|
-
// dead-air the channel for the full coding timeout.
|
|
550
|
-
const cmd = cfg
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 →
|
|
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 =
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|