hilos-agent 0.1.16 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -0
- package/bin/hilos-agent.mjs +20 -2
- package/package.json +1 -1
- package/src/config.mjs +14 -0
- package/src/daemon.mjs +49 -0
- package/src/handler.mjs +561 -30
- package/src/hook.mjs +427 -0
- package/src/memory.mjs +26 -2
- package/src/run.mjs +117 -11
package/src/handler.mjs
CHANGED
|
@@ -9,13 +9,15 @@
|
|
|
9
9
|
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
|
-
import { rmSync, mkdtempSync } from "node:fs";
|
|
12
|
+
import { rmSync, mkdtempSync, existsSync } from "node:fs";
|
|
13
13
|
import { hostname, tmpdir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import {
|
|
16
16
|
branchSlug,
|
|
17
17
|
truncateDiff,
|
|
18
18
|
resolveRepoPath,
|
|
19
|
+
resolveFolderPath,
|
|
20
|
+
diffStatusSets,
|
|
19
21
|
parseShortstat,
|
|
20
22
|
buildProposalReport,
|
|
21
23
|
decisionKind,
|
|
@@ -24,7 +26,7 @@ import {
|
|
|
24
26
|
mentionHandle,
|
|
25
27
|
detectPrContinuation,
|
|
26
28
|
} from "./daemon.mjs";
|
|
27
|
-
import { runCli, buildHeartbeat, ackText, oneLine } from "./cli.mjs";
|
|
29
|
+
import { runCli, buildHeartbeat, ackText, oneLine, fmtElapsed } from "./cli.mjs";
|
|
28
30
|
import { makeStreamParser } from "./agent-events.mjs";
|
|
29
31
|
import { detectVendor, codeStreamArgs, createProgressEmitter } from "./progress-emitter.mjs";
|
|
30
32
|
import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
|
|
@@ -80,10 +82,26 @@ function requesterTag(author) {
|
|
|
80
82
|
return handle ? `@${handle}` : "";
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
function compactRunMarker(status, branch) {
|
|
86
|
+
const b = branch ? ` \`${branch}\`` : "";
|
|
87
|
+
if (status === "pushed") return `Needs review:${b}`;
|
|
88
|
+
if (status === "discarded" || status === "cancelled") return `Stopped:${b}`;
|
|
89
|
+
if (status === "no-changes") return `Done:${b}`;
|
|
90
|
+
if (status === "changes" || status === "timeout") return `Needs follow-up:${b}`;
|
|
91
|
+
return `Failed:${b}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
83
94
|
function defaultDeps() {
|
|
84
95
|
return {
|
|
85
96
|
git: (cwd, args) =>
|
|
86
97
|
spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 50 * 1024 * 1024 }),
|
|
98
|
+
// The async CLI runner, injectable so folder mode (and tests) can drive the
|
|
99
|
+
// coding run without spawning a real process. The repo flow still uses the
|
|
100
|
+
// imported runCli directly (unchanged); only folder mode goes through deps.
|
|
101
|
+
runCli: (opts) => runCli(opts),
|
|
102
|
+
// Does a path exist on disk? Injectable so folder mode's "missing folder"
|
|
103
|
+
// guard is unit-testable without touching the real filesystem.
|
|
104
|
+
pathExists: (p) => existsSync(p),
|
|
87
105
|
openPR: (cwd, { title, body, branch, base }) => {
|
|
88
106
|
const r = spawnSync(
|
|
89
107
|
"gh",
|
|
@@ -166,9 +184,9 @@ async function linkPrFromUrl({ tool, channelId, url }) {
|
|
|
166
184
|
async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, existingPrUrl, settleId, runId }) {
|
|
167
185
|
const tag = requesterTag(requester);
|
|
168
186
|
const lead = tag ? `${tag} — ` : "";
|
|
169
|
-
// `parentId` here is the run's thread root
|
|
170
|
-
//
|
|
171
|
-
//
|
|
187
|
+
// `parentId` here is the run's thread root. Terminal outcomes ask the server
|
|
188
|
+
// for channel visibility, but the workspace decides whether final agent
|
|
189
|
+
// results actually broadcast. A no-op when there's no thread root.
|
|
172
190
|
if (decision.kind === "approved") {
|
|
173
191
|
// Guard: nothing staged → don't push an empty branch and falsely report "shipped".
|
|
174
192
|
if (deps.git(repoPath, ["diff", "--cached", "--quiet"]).status === 0) {
|
|
@@ -208,10 +226,9 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
208
226
|
? { ok: true, url: existingPrUrl }
|
|
209
227
|
: deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
|
|
210
228
|
// Seamless single card (0289): when a live status message was streaming this
|
|
211
|
-
// run, SETTLE the report onto it in place
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
// post failed) fall back to posting a fresh broadcast report as before.
|
|
229
|
+
// run, SETTLE the report onto it in place; the workspace setting decides
|
|
230
|
+
// whether that final report is also channel-visible. Without a status id
|
|
231
|
+
// (older server / status post failed) fall back to posting a fresh report.
|
|
215
232
|
const reportArgs = {
|
|
216
233
|
channelId,
|
|
217
234
|
title: `Shipped: ${title}`,
|
|
@@ -296,8 +313,8 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
296
313
|
}
|
|
297
314
|
const { title } = prTitleBody(task, cur);
|
|
298
315
|
// Seamless single card (0289): settle onto the live status message when one was
|
|
299
|
-
// streaming this run
|
|
300
|
-
//
|
|
316
|
+
// streaming this run; the workspace setting decides channel visibility.
|
|
317
|
+
// Otherwise post a fresh final report.
|
|
301
318
|
const reportArgs = {
|
|
302
319
|
channelId,
|
|
303
320
|
title: `Shipped: ${title}`,
|
|
@@ -352,7 +369,8 @@ const CODE_SIGNAL = "__CODE__";
|
|
|
352
369
|
* `error` is set when the model produced nothing (so the caller can be honest
|
|
353
370
|
* about a timeout vs a missing binary instead of inventing a reply).
|
|
354
371
|
*/
|
|
355
|
-
async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false }) {
|
|
372
|
+
async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false, runCliFn }) {
|
|
373
|
+
const doRun = runCliFn || runCli; // folder mode injects deps.runCli; repo flow uses the import
|
|
356
374
|
const cmd = cfg.chatCmd || cfg.codingCmd;
|
|
357
375
|
const parts = cmd.split(" ").filter(Boolean);
|
|
358
376
|
// When this thread already owns an OPEN pull request (a follow-up reply), the
|
|
@@ -381,12 +399,13 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
381
399
|
`that lists who reacted with each emoji.\n\n` +
|
|
382
400
|
`Otherwise — a question, a greeting, general discussion, or they explicitly don't want code ` +
|
|
383
401
|
`yet — just reply to them concisely and directly as a single chat message (no preamble, no ` +
|
|
384
|
-
`headings). When unsure,
|
|
402
|
+
`headings). When unsure, stay conversational; implementation and PR flow are for clear ` +
|
|
403
|
+
`action language.` +
|
|
385
404
|
`${followupBlock}\n\n` +
|
|
386
405
|
`You are only routing here — output ONLY your text response. Do NOT use any tools, do NOT ` +
|
|
387
406
|
`edit files, do NOT run commands; a separate step does the actual coding.\n\n` +
|
|
388
407
|
`${memoryPreamble(workspaceMemory)}Conversation so far:\n${transcript}`;
|
|
389
|
-
const run = await
|
|
408
|
+
const run = await doRun({
|
|
390
409
|
cmd: parts[0],
|
|
391
410
|
args: [...parts.slice(1), prompt],
|
|
392
411
|
timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
|
|
@@ -450,6 +469,34 @@ export function codeTaskPrompt(o) {
|
|
|
450
469
|
return p;
|
|
451
470
|
}
|
|
452
471
|
|
|
472
|
+
/**
|
|
473
|
+
* The prompt handed to the coding CLI in FOLDER mode (0322): the agent works
|
|
474
|
+
* directly in the user's own folder — no branch, no PR, no commit step. Same
|
|
475
|
+
* imperative "edit files now" framing as codeTaskPrompt, but it tells the agent
|
|
476
|
+
* its edits land straight in the folder and bans git/gh (there's nothing for the
|
|
477
|
+
* daemon to commit; the changes ARE the deliverable).
|
|
478
|
+
* @param {{ message?: { body?: string } | null, context?: { transcript?: string } | null, brief?: string, folderPath?: string }} [o]
|
|
479
|
+
*/
|
|
480
|
+
export function folderTaskPrompt(o) {
|
|
481
|
+
const { message, context, brief, folderPath } = o || {};
|
|
482
|
+
const task = (brief && brief.trim()) || String(message?.body || "").trim();
|
|
483
|
+
const where = folderPath ? ` in the local folder ${folderPath}` : "";
|
|
484
|
+
let p =
|
|
485
|
+
`You are a coding agent working directly${where}. Implement the following by EDITING ` +
|
|
486
|
+
`FILES now — make the changes directly in this folder, do not just describe them, do not ` +
|
|
487
|
+
`ask questions:\n\n${task}`;
|
|
488
|
+
p +=
|
|
489
|
+
`\n\nIMPORTANT: your edits apply DIRECTLY to the user's folder — there is no branch, no ` +
|
|
490
|
+
`commit, and no pull request. Do NOT run git; do NOT stage, commit, push, create branches, ` +
|
|
491
|
+
`or open pull requests; do NOT use the \`gh\` CLI. Just leave your edits in the folder for ` +
|
|
492
|
+
`the person to review.`;
|
|
493
|
+
const transcript = context?.transcript?.trim();
|
|
494
|
+
if (transcript) {
|
|
495
|
+
p += `\n\nBackground from the team's discussion (context only — the task above is what to do):\n${transcript}`;
|
|
496
|
+
}
|
|
497
|
+
return p;
|
|
498
|
+
}
|
|
499
|
+
|
|
453
500
|
/** owner/name from a GitHub remote URL (https or ssh), or null. */
|
|
454
501
|
export function normalizeRemote(url) {
|
|
455
502
|
const m = String(url || "")
|
|
@@ -534,9 +581,15 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
|
|
|
534
581
|
const { transcript } = context || (await fetchContext({ channelId, tool, parentId }));
|
|
535
582
|
const name = me?.agentName || "an assistant";
|
|
536
583
|
// Tell the agent what the room is connected to so it doesn't ask "which repo?".
|
|
584
|
+
// repoLink is already folder-excluded (handleTask picks kind!=='folder'), so the
|
|
585
|
+
// "repository" line never claims a folder path is a repo. When there's no repo
|
|
586
|
+
// but this channel maps to a local folder on THIS machine, say that instead.
|
|
587
|
+
const folderPath = !repoLink ? resolveFolderPath(cfg, channelId) : null;
|
|
537
588
|
const repoLine = repoLink
|
|
538
589
|
? `This channel is connected to the repository ${repoLink.repo_full_name}; assume that repo for any code work — don't ask which one.`
|
|
539
|
-
:
|
|
590
|
+
: folderPath
|
|
591
|
+
? `This channel is connected to the local folder ${folderPath} on this machine; assume that folder for any file work — don't ask which one.`
|
|
592
|
+
: `If asked to change code, note that a repo isn't linked to this channel yet.`;
|
|
540
593
|
const prompt =
|
|
541
594
|
`You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
|
|
542
595
|
`concisely and directly as a single chat message — no preamble, no headings. ` +
|
|
@@ -629,6 +682,11 @@ export function isReviewRequest(message) {
|
|
|
629
682
|
);
|
|
630
683
|
}
|
|
631
684
|
|
|
685
|
+
function agentMode(message) {
|
|
686
|
+
const mode = message?.agentMode || message?.metadata?.agentMode;
|
|
687
|
+
return mode === "ask" || mode === "ship" ? mode : null;
|
|
688
|
+
}
|
|
689
|
+
|
|
632
690
|
/** The PR url a review-request/review points at, from the mention's reviewOf or the
|
|
633
691
|
* channel's linked PR — whichever is present. */
|
|
634
692
|
function reviewTargetPrUrl(message) {
|
|
@@ -707,6 +765,10 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
|
|
|
707
765
|
body: `Reviewing ${label} now — I'll post my read shortly.`,
|
|
708
766
|
}).catch(() => null);
|
|
709
767
|
const threadRoot = parentId ?? ack?.messageId ?? null;
|
|
768
|
+
const updateReviewMarker = async (body) => {
|
|
769
|
+
if (!ack?.messageId || parentId) return;
|
|
770
|
+
await tool("edit_message", { messageId: ack.messageId, body }).catch(() => {});
|
|
771
|
+
};
|
|
710
772
|
|
|
711
773
|
// The diff — the whole review is fed off this (no clone, no gh creds).
|
|
712
774
|
// Capture the failure reason on BOTH error shapes: a soft failure returns
|
|
@@ -722,6 +784,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
|
|
|
722
784
|
broadcast: Boolean(threadRoot),
|
|
723
785
|
body: `I couldn't read ${label}'s diff to review it${why}. It may be private, merged, or not linked here.`,
|
|
724
786
|
}).catch(() => {});
|
|
787
|
+
await updateReviewMarker(`Review failed: ${label}`);
|
|
725
788
|
return { status: "review-no-diff" };
|
|
726
789
|
}
|
|
727
790
|
|
|
@@ -771,6 +834,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
|
|
|
771
834
|
|
|
772
835
|
if (run.aborted || signal?.aborted) {
|
|
773
836
|
await tool("post_message", { channelId, parentId: threadRoot, body: `Stopped the review of ${label}.` }).catch(() => {});
|
|
837
|
+
await updateReviewMarker(`Review stopped: ${label}`);
|
|
774
838
|
return { status: "review-cancelled" };
|
|
775
839
|
}
|
|
776
840
|
const text = (run.stdout || "").trim();
|
|
@@ -784,6 +848,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
|
|
|
784
848
|
? `I couldn't start \`${cmd}\` to review ${label} — is it installed and on PATH?`
|
|
785
849
|
: `I couldn't get a read on ${label} that time — mention me again to retry the review.`,
|
|
786
850
|
}).catch(() => {});
|
|
851
|
+
await updateReviewMarker(`Review failed: ${label}`);
|
|
787
852
|
return { status: "review-no-output" };
|
|
788
853
|
}
|
|
789
854
|
|
|
@@ -807,13 +872,399 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
|
|
|
807
872
|
parentId: threadRoot,
|
|
808
873
|
body: `I looked at ${label} but couldn't post the review${posted?.error || posted?.message ? `: ${posted.error || posted.message}` : ""}.`,
|
|
809
874
|
}).catch(() => {});
|
|
875
|
+
await updateReviewMarker(`Review failed: ${label}`);
|
|
810
876
|
return { status: "review-post-failed", error: posted?.error || posted?.message };
|
|
811
877
|
}
|
|
812
878
|
reviewRounds.set(key, rounds + 1);
|
|
813
879
|
console.log(` review → posted ${verdict} on ${label} (${findings.length} finding(s))`);
|
|
880
|
+
await updateReviewMarker(`Review ready: ${label}`);
|
|
814
881
|
return { status: "reviewed", verdict, findings: findings.length, prUrl };
|
|
815
882
|
}
|
|
816
883
|
|
|
884
|
+
/** Render a changed-file list for the folder report card. */
|
|
885
|
+
function renderChangedList({ created, modified, deleted }) {
|
|
886
|
+
const lines = [];
|
|
887
|
+
for (const f of created) lines.push(`- added \`${f}\``);
|
|
888
|
+
for (const f of modified) lines.push(`- changed \`${f}\``);
|
|
889
|
+
for (const f of deleted) lines.push(`- removed \`${f}\``);
|
|
890
|
+
return lines.join("\n");
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* FOLDER MODE (0322). A channel with NO linked GitHub repo but a `folders`
|
|
895
|
+
* mapping runs the coding CLI directly in that folder and applies changes in
|
|
896
|
+
* place — it's the user's own machine, same trust as running the CLI themselves.
|
|
897
|
+
* No branch, no push, no PR, no `gh` — ever. When the folder is a git repo we
|
|
898
|
+
* snapshot `git status` before/after to report exactly what changed and to offer
|
|
899
|
+
* a precise revert on reject; a non-git folder gets an honest "can't track / can't
|
|
900
|
+
* auto-undo" report. The report card drives a decision: approve = keep (already
|
|
901
|
+
* live), request-changes = re-run in place (bounded), reject = revert the run's
|
|
902
|
+
* own changes (git repos only).
|
|
903
|
+
*/
|
|
904
|
+
async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps, signal, parentId, folderPath, brief, workspaceMemory, context }) {
|
|
905
|
+
void me;
|
|
906
|
+
const git = deps.git;
|
|
907
|
+
const tag = requesterTag(message.author);
|
|
908
|
+
const lead = tag ? `${tag} — ` : "";
|
|
909
|
+
|
|
910
|
+
// 1. The mapped folder must actually exist on disk — be honest and actionable.
|
|
911
|
+
if (!deps.pathExists(folderPath)) {
|
|
912
|
+
await tool("post_message", {
|
|
913
|
+
channelId,
|
|
914
|
+
parentId,
|
|
915
|
+
body:
|
|
916
|
+
`I couldn't find the folder \`${folderPath}\` on this machine — it may have moved, been ` +
|
|
917
|
+
`renamed, or live on a drive that isn't mounted. Fix the path in my \`folders\` config ` +
|
|
918
|
+
`(or restore the folder), then mention me again.`,
|
|
919
|
+
});
|
|
920
|
+
return { status: "no-path" };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// 2. Is it a git repo? If so, snapshot the pre-run status so we can (a) report
|
|
924
|
+
// exactly what the run changed and (b) revert precisely on reject. We NEVER touch
|
|
925
|
+
// the index (no `git add`), never branch, never push. A non-git folder still runs
|
|
926
|
+
// — we just can't offer diff/undo.
|
|
927
|
+
const insideWorkTree = git(folderPath, ["rev-parse", "--is-inside-work-tree"]);
|
|
928
|
+
const isGit = insideWorkTree.status === 0 && String(insideWorkTree.stdout || "").trim() === "true";
|
|
929
|
+
const beforeStatus = isGit ? String(git(folderPath, ["status", "--porcelain"]).stdout || "") : "";
|
|
930
|
+
const dirtyBefore = isGit && beforeStatus.trim() !== "";
|
|
931
|
+
|
|
932
|
+
// 3. Instant ack / thread anchor (mirrors the repo flow). When the mention was in
|
|
933
|
+
// a thread we stay in it; a top-level mention makes the ack the thread anchor so
|
|
934
|
+
// the run's progress + report card live under it.
|
|
935
|
+
const ack = await tool("post_message", {
|
|
936
|
+
channelId,
|
|
937
|
+
parentId,
|
|
938
|
+
body: `On it — working directly in \`${folderPath}\`. Changes apply straight to your folder (no branch, no PR); I'll report what changed.`,
|
|
939
|
+
});
|
|
940
|
+
const ackId = ack?.messageId ?? null;
|
|
941
|
+
const threadRoot = parentId ?? ackId;
|
|
942
|
+
|
|
943
|
+
// Live progress: reuse the streaming card (post_progress) when the server
|
|
944
|
+
// supports it, else the edit-in-place heartbeat, else nothing. Kept across
|
|
945
|
+
// request-changes re-runs on the same status message.
|
|
946
|
+
let progressId = null;
|
|
947
|
+
const folderWorking = (elapsedMs, lastLine) => {
|
|
948
|
+
const base = `Working in \`${folderPath}\` — ${fmtElapsed(elapsedMs)} elapsed. I'll report what changed when it's done.`;
|
|
949
|
+
const tail = oneLine(lastLine);
|
|
950
|
+
return tail ? `${base}\n\nLatest: ${tail}` : base;
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
// Run the coding CLI in the folder, streaming progress + honoring timeouts and
|
|
954
|
+
// the abort signal (reuses runCli via deps + the progress-emitter machinery).
|
|
955
|
+
const runFolderCli = async (promptText) => {
|
|
956
|
+
const parts = cfg.codingCmd.split(" ").filter(Boolean);
|
|
957
|
+
const vendor = detectVendor(cfg.codingCmd);
|
|
958
|
+
const streamOn = Boolean(caps.postProgress);
|
|
959
|
+
const streamArgs = streamOn ? codeStreamArgs(vendor) : [];
|
|
960
|
+
let emitter = null;
|
|
961
|
+
let progressInflight = Promise.resolve();
|
|
962
|
+
let stopHeartbeat = () => {};
|
|
963
|
+
let lastLine = "";
|
|
964
|
+
// With stream args the CLI's stdout is NDJSON events, not prose — parse it and
|
|
965
|
+
// keep the final `result` event's summary so the report card gets clean text
|
|
966
|
+
// (raw stdout would dump JSON into the report). Without stream args, stdout IS
|
|
967
|
+
// the prose and resultText stays empty.
|
|
968
|
+
let resultText = "";
|
|
969
|
+
const resultParser = streamArgs.length ? makeStreamParser(vendor) : null;
|
|
970
|
+
const foldResultEvents = (events) => {
|
|
971
|
+
for (const ev of events || []) {
|
|
972
|
+
if (ev && ev.t === "result" && ev.summary) resultText = ev.summary;
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
if (streamOn) {
|
|
976
|
+
if (!progressId) {
|
|
977
|
+
try {
|
|
978
|
+
const r = await tool("post_message", { channelId, parentId: threadRoot, body: folderWorking(0, "") });
|
|
979
|
+
progressId = r?.messageId ?? null;
|
|
980
|
+
} catch {
|
|
981
|
+
progressId = null;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
const statusId = progressId;
|
|
985
|
+
if (statusId) {
|
|
986
|
+
emitter = createProgressEmitter({
|
|
987
|
+
parser: makeStreamParser(vendor),
|
|
988
|
+
now: deps.now,
|
|
989
|
+
throttleMs: cfg.progressMs,
|
|
990
|
+
send: (p) => {
|
|
991
|
+
try {
|
|
992
|
+
const r = tool("post_progress", { messageId: statusId, progress: p });
|
|
993
|
+
if (r && typeof r.then === "function") {
|
|
994
|
+
const done = r.then(() => {}, () => {});
|
|
995
|
+
progressInflight = Promise.all([progressInflight, done]).then(() => {}, () => {});
|
|
996
|
+
}
|
|
997
|
+
} catch {
|
|
998
|
+
/* a progress send must never break the run */
|
|
999
|
+
}
|
|
1000
|
+
},
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
} else if (caps.editMessage && cfg.heartbeatMs > 0) {
|
|
1004
|
+
const start = deps.now();
|
|
1005
|
+
let stopped = false;
|
|
1006
|
+
let busy = false;
|
|
1007
|
+
const beat = setInterval(async () => {
|
|
1008
|
+
if (stopped || busy) return;
|
|
1009
|
+
busy = true;
|
|
1010
|
+
const body = folderWorking(deps.now() - start, lastLine);
|
|
1011
|
+
try {
|
|
1012
|
+
if (!progressId) {
|
|
1013
|
+
const r = await tool("post_message", { channelId, parentId: threadRoot, body });
|
|
1014
|
+
progressId = r?.messageId ?? null;
|
|
1015
|
+
} else {
|
|
1016
|
+
await tool("edit_message", { messageId: progressId, body });
|
|
1017
|
+
}
|
|
1018
|
+
} catch {
|
|
1019
|
+
/* a heartbeat must never break the run */
|
|
1020
|
+
} finally {
|
|
1021
|
+
busy = false;
|
|
1022
|
+
}
|
|
1023
|
+
}, cfg.heartbeatMs);
|
|
1024
|
+
if (beat.unref) beat.unref();
|
|
1025
|
+
stopHeartbeat = () => {
|
|
1026
|
+
stopped = true;
|
|
1027
|
+
clearInterval(beat);
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
let run;
|
|
1031
|
+
try {
|
|
1032
|
+
run = await deps.runCli({
|
|
1033
|
+
cmd: parts[0],
|
|
1034
|
+
args: [...parts.slice(1), ...streamArgs, memoryPreamble(workspaceMemory) + promptText],
|
|
1035
|
+
cwd: folderPath,
|
|
1036
|
+
timeoutMs: cfg.runTimeoutMs,
|
|
1037
|
+
label: "coding",
|
|
1038
|
+
signal,
|
|
1039
|
+
onData: (c) => {
|
|
1040
|
+
const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
1041
|
+
if (lines.length) lastLine = lines[lines.length - 1];
|
|
1042
|
+
if (resultParser) {
|
|
1043
|
+
try {
|
|
1044
|
+
foldResultEvents(resultParser.push(String(c)));
|
|
1045
|
+
} catch {
|
|
1046
|
+
/* result extraction must never break the run */
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
if (emitter) {
|
|
1050
|
+
try {
|
|
1051
|
+
emitter.feed(c);
|
|
1052
|
+
} catch {
|
|
1053
|
+
/* a progress fold must never break the run */
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
},
|
|
1057
|
+
});
|
|
1058
|
+
} finally {
|
|
1059
|
+
stopHeartbeat();
|
|
1060
|
+
if (emitter) {
|
|
1061
|
+
const errored = Boolean(run && (run.aborted || run.error || run.status !== 0));
|
|
1062
|
+
let reason = "";
|
|
1063
|
+
if (errored && run && !run.aborted) {
|
|
1064
|
+
const stderrTail = oneLine((run.stderr || "").trim().split("\n").slice(-3).join(" "), 200);
|
|
1065
|
+
if (run.error?.code === "ENOENT") reason = `Couldn't start ${parts[0]} — is it installed and on PATH?`;
|
|
1066
|
+
else if (run.error?.message) reason = run.error.message;
|
|
1067
|
+
else if (run.status != null) reason = `The CLI exited ${run.status}`;
|
|
1068
|
+
if (stderrTail) reason = reason ? `${reason}: ${stderrTail}` : stderrTail;
|
|
1069
|
+
}
|
|
1070
|
+
try {
|
|
1071
|
+
emitter.done(errored ? "error" : "done", reason);
|
|
1072
|
+
} catch {
|
|
1073
|
+
/* ignore */
|
|
1074
|
+
}
|
|
1075
|
+
try {
|
|
1076
|
+
await progressInflight;
|
|
1077
|
+
} catch {
|
|
1078
|
+
/* a drain failure must never break the run */
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (resultParser) {
|
|
1083
|
+
try {
|
|
1084
|
+
foldResultEvents(resultParser.flush());
|
|
1085
|
+
} catch {
|
|
1086
|
+
/* result extraction must never break the run */
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (run.aborted || signal?.aborted) return { aborted: true };
|
|
1090
|
+
const failed = Boolean(run.error) || run.status !== 0;
|
|
1091
|
+
return {
|
|
1092
|
+
aborted: false,
|
|
1093
|
+
stdout: run.stdout || "",
|
|
1094
|
+
// Clean prose for the report: the stream `result` summary when streaming,
|
|
1095
|
+
// else empty (raw stdout is prose only in the non-streamed case).
|
|
1096
|
+
resultText,
|
|
1097
|
+
streamed: streamArgs.length > 0,
|
|
1098
|
+
failed,
|
|
1099
|
+
errCode: run.error?.code || null,
|
|
1100
|
+
errMessage: run.error?.message || null,
|
|
1101
|
+
status: run.status,
|
|
1102
|
+
stderrTail: oneLine((run.stderr || "").trim().split("\n").slice(-3).join(" "), 300),
|
|
1103
|
+
};
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
// Compute what the run changed (git repos only), vs the pre-run snapshot.
|
|
1107
|
+
const computeChanged = () => {
|
|
1108
|
+
if (!isGit) return { modified: [], created: [], deleted: [] };
|
|
1109
|
+
const afterStatus = String(git(folderPath, ["status", "--porcelain"]).stdout || "");
|
|
1110
|
+
return diffStatusSets(beforeStatus, afterStatus);
|
|
1111
|
+
};
|
|
1112
|
+
const hasChanges = (c) => c.created.length + c.modified.length + c.deleted.length > 0;
|
|
1113
|
+
|
|
1114
|
+
const buildReport = (runResult, changed) => {
|
|
1115
|
+
const changedList = renderChangedList(changed);
|
|
1116
|
+
const stat = isGit ? truncateDiff(String(git(folderPath, ["diff", "--stat"]).stdout || "")).text.trim() : "";
|
|
1117
|
+
// Streamed runs put NDJSON on stdout — use the parsed result summary there;
|
|
1118
|
+
// non-streamed stdout is the agent's prose and is safe to quote.
|
|
1119
|
+
const rawOut = (runResult.resultText || (runResult.streamed ? "" : runResult.stdout) || "").trim();
|
|
1120
|
+
const parts = [`${lead}worked directly in \`${folderPath}\`.`];
|
|
1121
|
+
if (rawOut) parts.push(rawOut.slice(0, 2000));
|
|
1122
|
+
if (isGit) parts.push(changedList ? `Changed files:\n${changedList}` : "No tracked file changes were detected.");
|
|
1123
|
+
if (stat) parts.push("```\n" + stat + "\n```");
|
|
1124
|
+
const caveats = [`Changes were applied directly to ${folderPath} — there is no branch or PR to review.`];
|
|
1125
|
+
if (!isGit) caveats.push("This folder isn't a git repository, so I can't show a file-level diff or automatically undo the changes.");
|
|
1126
|
+
if (dirtyBefore) caveats.push("The folder already had uncommitted local changes before this run — those are mixed in with mine.");
|
|
1127
|
+
if (runResult.failed) {
|
|
1128
|
+
const why = runResult.errMessage ? `: ${runResult.errMessage}` : runResult.status != null ? ` (exit ${runResult.status})` : "";
|
|
1129
|
+
caveats.push(`The coding agent didn't exit cleanly${why} — review the changes carefully.`);
|
|
1130
|
+
}
|
|
1131
|
+
const folderName = folderPath.split("/").filter(Boolean).pop() || folderPath;
|
|
1132
|
+
return { title: `Folder run: ${folderName}`, summary: parts.join("\n\n"), caveats, todos: [] };
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
// --- Run ---
|
|
1136
|
+
let runResult = await runFolderCli(folderTaskPrompt({ message, context, brief, folderPath }));
|
|
1137
|
+
if (runResult.aborted) {
|
|
1138
|
+
await tool("post_message", {
|
|
1139
|
+
channelId,
|
|
1140
|
+
parentId: threadRoot,
|
|
1141
|
+
broadcast: Boolean(threadRoot),
|
|
1142
|
+
body: `Stopped — I left \`${folderPath}\` as it was.`,
|
|
1143
|
+
});
|
|
1144
|
+
return { status: "cancelled" };
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
let changed = computeChanged();
|
|
1148
|
+
|
|
1149
|
+
// Honest early exits (no report/decision): a failed run that changed nothing, or
|
|
1150
|
+
// a clean success that changed nothing.
|
|
1151
|
+
if (runResult.failed && (!isGit || !hasChanges(changed))) {
|
|
1152
|
+
let body;
|
|
1153
|
+
if (runResult.errCode === "ENOENT") {
|
|
1154
|
+
body = `I couldn't start \`${cfg.codingCmd}\` — is it installed and on PATH?`;
|
|
1155
|
+
} else {
|
|
1156
|
+
const why = runResult.errMessage
|
|
1157
|
+
? ` (${runResult.errMessage})`
|
|
1158
|
+
: runResult.status != null
|
|
1159
|
+
? ` (the CLI exited ${runResult.status})`
|
|
1160
|
+
: "";
|
|
1161
|
+
const tail = runResult.stderrTail ? `: ${runResult.stderrTail}` : "";
|
|
1162
|
+
const left = isGit ? `\`${folderPath}\` is unchanged` : `check \`${folderPath}\` for any partial edits`;
|
|
1163
|
+
body = `The run didn't finish${why}${tail}. ${left} — mention me to retry.`;
|
|
1164
|
+
}
|
|
1165
|
+
await tool("post_message", { channelId, parentId: threadRoot, broadcast: Boolean(threadRoot), body });
|
|
1166
|
+
return { status: "run-failed" };
|
|
1167
|
+
}
|
|
1168
|
+
if (!runResult.failed && isGit && !hasChanges(changed)) {
|
|
1169
|
+
await tool("post_message", {
|
|
1170
|
+
channelId,
|
|
1171
|
+
parentId: threadRoot,
|
|
1172
|
+
broadcast: Boolean(threadRoot),
|
|
1173
|
+
body: `The run finished but nothing changed in \`${folderPath}\`. Mention me to try a different approach.`,
|
|
1174
|
+
});
|
|
1175
|
+
return { status: "no-changes" };
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// --- Report card + decision loop ---
|
|
1179
|
+
const postFolderReport = async (rr, ch) => {
|
|
1180
|
+
const report = buildReport(rr, ch);
|
|
1181
|
+
const res = await tool("post_report", { channelId, parentId: threadRoot, broadcast: true, ...report });
|
|
1182
|
+
return res?.messageId ?? null;
|
|
1183
|
+
};
|
|
1184
|
+
let reportMessageId = await postFolderReport(runResult, changed);
|
|
1185
|
+
let decision = await awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId: threadRoot, signal });
|
|
1186
|
+
|
|
1187
|
+
const maxRounds = cfg.maxRounds || 3;
|
|
1188
|
+
let round = 1;
|
|
1189
|
+
while (decision.kind === "changes" && round < maxRounds) {
|
|
1190
|
+
await tool("post_message", {
|
|
1191
|
+
channelId,
|
|
1192
|
+
parentId: threadRoot,
|
|
1193
|
+
body: `Revising in \`${folderPath}\` with your feedback${decision.note ? `: ${decision.note}` : ""} (round ${round + 1}/${maxRounds}).`,
|
|
1194
|
+
});
|
|
1195
|
+
runResult = await runFolderCli(
|
|
1196
|
+
folderTaskPrompt({ message, context, brief, folderPath }) +
|
|
1197
|
+
`\n\nReviewer feedback to address: ${decision.note || "(see the channel)"}`,
|
|
1198
|
+
);
|
|
1199
|
+
if (runResult.aborted) {
|
|
1200
|
+
await tool("post_message", { channelId, parentId: threadRoot, broadcast: Boolean(threadRoot), body: `Stopped — I left \`${folderPath}\` as it was.` });
|
|
1201
|
+
return { status: "cancelled" };
|
|
1202
|
+
}
|
|
1203
|
+
changed = computeChanged();
|
|
1204
|
+
reportMessageId = await postFolderReport(runResult, changed);
|
|
1205
|
+
decision = await awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId: threadRoot, signal });
|
|
1206
|
+
round += 1;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// --- Terminal ---
|
|
1210
|
+
if (decision.kind === "approved") {
|
|
1211
|
+
await tool("post_message", {
|
|
1212
|
+
channelId,
|
|
1213
|
+
parentId: threadRoot,
|
|
1214
|
+
broadcast: Boolean(threadRoot),
|
|
1215
|
+
body: `${lead}approved — the changes are already live in \`${folderPath}\`.`,
|
|
1216
|
+
});
|
|
1217
|
+
return { status: "folder-done", decision: "approved" };
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (decision.kind === "rejected") {
|
|
1221
|
+
if (!isGit) {
|
|
1222
|
+
const all = [...changed.created, ...changed.modified, ...changed.deleted];
|
|
1223
|
+
await tool("post_message", {
|
|
1224
|
+
channelId,
|
|
1225
|
+
parentId: threadRoot,
|
|
1226
|
+
broadcast: Boolean(threadRoot),
|
|
1227
|
+
body:
|
|
1228
|
+
`Rejected — but \`${folderPath}\` isn't a git repository, so I can't automatically undo the changes. ` +
|
|
1229
|
+
(all.length ? `I touched:\n${renderChangedList(changed)}\n\nRevert these by hand.` : `Please review the folder and revert by hand.`),
|
|
1230
|
+
});
|
|
1231
|
+
return { status: "folder-done", decision: "rejected", reverted: false };
|
|
1232
|
+
}
|
|
1233
|
+
// Revert ONLY what the run itself changed: checkout tracked modifications +
|
|
1234
|
+
// deletions, remove untracked files the run created. Never a blanket reset —
|
|
1235
|
+
// pre-existing local changes stay untouched.
|
|
1236
|
+
const trackedToRestore = [...changed.modified, ...changed.deleted];
|
|
1237
|
+
if (trackedToRestore.length) git(folderPath, ["checkout", "--", ...trackedToRestore]);
|
|
1238
|
+
if (changed.created.length) git(folderPath, ["clean", "-fd", "--", ...changed.created]);
|
|
1239
|
+
const revertedList = renderChangedList(changed);
|
|
1240
|
+
await tool("post_message", {
|
|
1241
|
+
channelId,
|
|
1242
|
+
parentId: threadRoot,
|
|
1243
|
+
broadcast: Boolean(threadRoot),
|
|
1244
|
+
body:
|
|
1245
|
+
`Rejected — reverted my changes in \`${folderPath}\`.` +
|
|
1246
|
+
(revertedList ? `\n\nRestored:\n${revertedList}` : "") +
|
|
1247
|
+
(dirtyBefore ? `\n\nYour pre-existing local changes were left untouched.` : ""),
|
|
1248
|
+
});
|
|
1249
|
+
return { status: "folder-done", decision: "rejected", reverted: true };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
if (decision.kind === "cancelled") {
|
|
1253
|
+
await tool("post_message", { channelId, parentId: threadRoot, broadcast: Boolean(threadRoot), body: `Stopped — the changes so far are still in \`${folderPath}\`.` });
|
|
1254
|
+
return { status: "cancelled" };
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Timeout (or a leftover 'changes' past the round cap): note + release, same as
|
|
1258
|
+
// the repo flow.
|
|
1259
|
+
await tool("post_message", {
|
|
1260
|
+
channelId,
|
|
1261
|
+
parentId: threadRoot,
|
|
1262
|
+
broadcast: Boolean(threadRoot),
|
|
1263
|
+
body: `Still awaiting your review of the changes in \`${folderPath}\`. They're already applied — mention me to revise or revert once you've decided.`,
|
|
1264
|
+
});
|
|
1265
|
+
return { status: "folder-done", decision: "timeout" };
|
|
1266
|
+
}
|
|
1267
|
+
|
|
817
1268
|
/** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
|
|
818
1269
|
* cancels an in-flight run — the queue fires it when a human says "stop". */
|
|
819
1270
|
export async function handleTask({ message, channelId, tool, me, caps = {} }, cfg, depsOverride, opts = {}) {
|
|
@@ -824,7 +1275,10 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
824
1275
|
const parentId = message.parentId ?? null;
|
|
825
1276
|
|
|
826
1277
|
const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
|
|
827
|
-
|
|
1278
|
+
// A folder link (0324) carries the folder PATH in repo_full_name for display —
|
|
1279
|
+
// it is NOT a GitHub repo, so it must never be picked as the channel's repo.
|
|
1280
|
+
// Exclude kind='folder'; pr/branch/repo rows with repo_full_name still count.
|
|
1281
|
+
const repoLink = links.find((l) => l.kind !== "folder" && l.repo_full_name);
|
|
828
1282
|
// Workspace memory ("soul") — shared project context the agent should know.
|
|
829
1283
|
const { memory: workspaceMemory = null } = await tool("get_workspace_memory", {}).catch(() => ({
|
|
830
1284
|
memory: null,
|
|
@@ -834,6 +1288,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
834
1288
|
// crucial: a reply like "yeah, do it" only means something against what was
|
|
835
1289
|
// just said.
|
|
836
1290
|
const context = await fetchContext({ channelId, tool, parentId });
|
|
1291
|
+
const mode = agentMode(message);
|
|
837
1292
|
|
|
838
1293
|
// REVIEW EXECUTION (0288): a review-request routes to the read-only reviewer
|
|
839
1294
|
// BEFORE the chat/code flow — it reads the PR's diff and posts an advisory review
|
|
@@ -863,13 +1318,67 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
863
1318
|
}
|
|
864
1319
|
}
|
|
865
1320
|
|
|
866
|
-
// No repo linked →
|
|
1321
|
+
// No repo linked → either FOLDER mode (0322: a channel mapped to a plain local
|
|
1322
|
+
// folder can still get coding work done) or, failing that, just reply. A linked
|
|
1323
|
+
// repo always wins — the folder map is only consulted when there's no repo link.
|
|
867
1324
|
if (!repoLink) {
|
|
1325
|
+
const folderPath = resolveFolderPath(cfg, channelId);
|
|
1326
|
+
if (folderPath) {
|
|
1327
|
+
// Same chat-vs-code decision the repo path uses: mode 'ask'/'ship' short-
|
|
1328
|
+
// circuit exactly as in the repo flow, otherwise the LLM router judges intent
|
|
1329
|
+
// (its CLI call goes through deps.runCli so folder mode is unit-testable).
|
|
1330
|
+
const routed =
|
|
1331
|
+
mode === "ask"
|
|
1332
|
+
? { code: false, reply: null }
|
|
1333
|
+
: mode === "ship"
|
|
1334
|
+
? { code: true, task: message.body }
|
|
1335
|
+
: await routeIntent({
|
|
1336
|
+
name: me?.agentName || "an assistant",
|
|
1337
|
+
repoFullName: folderPath,
|
|
1338
|
+
transcript: context.transcript,
|
|
1339
|
+
workspaceMemory,
|
|
1340
|
+
cfg,
|
|
1341
|
+
signal,
|
|
1342
|
+
runCliFn: deps.runCli,
|
|
1343
|
+
});
|
|
1344
|
+
if (routed.aborted || signal?.aborted) {
|
|
1345
|
+
await tool("post_message", { channelId, parentId, body: "Stopped." });
|
|
1346
|
+
return { status: "chat" };
|
|
1347
|
+
}
|
|
1348
|
+
if (routed.code) {
|
|
1349
|
+
return await handleFolderTask({
|
|
1350
|
+
message,
|
|
1351
|
+
channelId,
|
|
1352
|
+
tool,
|
|
1353
|
+
me,
|
|
1354
|
+
caps,
|
|
1355
|
+
cfg,
|
|
1356
|
+
deps,
|
|
1357
|
+
signal,
|
|
1358
|
+
parentId,
|
|
1359
|
+
folderPath,
|
|
1360
|
+
brief: routed.task,
|
|
1361
|
+
workspaceMemory,
|
|
1362
|
+
context,
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
// Not a coding task → post the router's reply if it produced one, else fall
|
|
1366
|
+
// through to a normal conversational reply.
|
|
1367
|
+
if (routed.reply) {
|
|
1368
|
+
await tool("post_message", { channelId, parentId, body: routed.reply });
|
|
1369
|
+
return { status: "chat" };
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
868
1372
|
await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
|
|
869
1373
|
return { status: "chat" };
|
|
870
1374
|
}
|
|
871
1375
|
const repoFullName = repoLink.repo_full_name;
|
|
872
1376
|
|
|
1377
|
+
if (mode === "ask") {
|
|
1378
|
+
await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
|
|
1379
|
+
return { status: "chat" };
|
|
1380
|
+
}
|
|
1381
|
+
|
|
873
1382
|
// Same-PR follow-up (0281): if this mention is a reply in a thread that already
|
|
874
1383
|
// owns a run (branch + PR), we may continue THAT run instead of forking a
|
|
875
1384
|
// duplicate. Look it up by the thread root — the parentId the reply carried.
|
|
@@ -896,15 +1405,18 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
896
1405
|
// signal is only ever acted on when the PR is open (resolveFollowupMode → 'new'
|
|
897
1406
|
// otherwise), so telling the router the thread "owns an open PR" when the run is
|
|
898
1407
|
// merged/closed would just mislead the model and waste a classification.
|
|
899
|
-
const routed =
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
1408
|
+
const routed =
|
|
1409
|
+
mode === "ship"
|
|
1410
|
+
? { code: true, task: message.body, followupSignal: null }
|
|
1411
|
+
: await routeIntent({
|
|
1412
|
+
name: me?.agentName || "an assistant",
|
|
1413
|
+
repoFullName,
|
|
1414
|
+
transcript: context.transcript,
|
|
1415
|
+
workspaceMemory,
|
|
1416
|
+
cfg,
|
|
1417
|
+
signal,
|
|
1418
|
+
hasActiveRun: activeRunOpen,
|
|
1419
|
+
});
|
|
908
1420
|
if (routed.aborted || signal?.aborted) {
|
|
909
1421
|
await tool("post_message", { channelId, parentId, body: "Stopped." });
|
|
910
1422
|
return { status: "chat" };
|
|
@@ -1108,6 +1620,10 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
1108
1620
|
// The ack itself stays top-level (the channel-visible "on it"); terminal
|
|
1109
1621
|
// outcomes + report cards broadcast back to the channel from the thread.
|
|
1110
1622
|
const threadRoot = parentId ?? ackId;
|
|
1623
|
+
const updateChannelMarker = async (body) => {
|
|
1624
|
+
if (!ackId || parentId || !body) return;
|
|
1625
|
+
await tool("edit_message", { messageId: ackId, body }).catch(() => {});
|
|
1626
|
+
};
|
|
1111
1627
|
// Bind this run to the thread root (0279/0280), now follow-up-aware (0281):
|
|
1112
1628
|
// iterate → REUSE the thread's existing run (don't record a second one); the
|
|
1113
1629
|
// same PR updates in place and its status stays awaiting_review.
|
|
@@ -1491,14 +2007,16 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
1491
2007
|
// Post the right cancel message: honest about whether cleanup actually worked.
|
|
1492
2008
|
const postStopped = async () => {
|
|
1493
2009
|
const clean = discardBranch();
|
|
2010
|
+
const body = clean
|
|
2011
|
+
? `Stopped — discarded \`${branch}\`.`
|
|
2012
|
+
: `Stopped, but couldn't fully clean \`${branch}\` — run \`git reset --hard && git switch ${startRef.ref || cfg.defaultBranch}\` in ${repoPath}.`;
|
|
1494
2013
|
await tool("post_message", {
|
|
1495
2014
|
channelId,
|
|
1496
2015
|
parentId: threadRoot,
|
|
1497
2016
|
broadcast: true,
|
|
1498
|
-
body
|
|
1499
|
-
? `Stopped — discarded \`${branch}\`.`
|
|
1500
|
-
: `Stopped, but couldn't fully clean \`${branch}\` — run \`git reset --hard && git switch ${startRef.ref || cfg.defaultBranch}\` in ${repoPath}.`,
|
|
2017
|
+
body,
|
|
1501
2018
|
});
|
|
2019
|
+
await updateChannelMarker(compactRunMarker("cancelled", branch));
|
|
1502
2020
|
return { status: "cancelled", branch };
|
|
1503
2021
|
};
|
|
1504
2022
|
|
|
@@ -1511,6 +2029,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
1511
2029
|
let teamMemoryBlock = "";
|
|
1512
2030
|
if (caps.recall) {
|
|
1513
2031
|
try {
|
|
2032
|
+
// Pass the task's channelId so the server scopes recall to this project AND
|
|
2033
|
+
// can apply the guest gate (0298): the daemon's token is long-lived with no
|
|
2034
|
+
// bound channel, so the server relies on this per-call channelId to decide
|
|
2035
|
+
// whether a guest is present. Residual: a recall that omits channelId can't
|
|
2036
|
+
// be guest-gated server-side (no channel to check) — the daemon always
|
|
2037
|
+
// passes it here, so that path is covered.
|
|
1514
2038
|
const recalled = await tool("recall", { channelId, limit: 20 }).catch(() => null);
|
|
1515
2039
|
const mems = Array.isArray(recalled?.memories) ? recalled.memories : [];
|
|
1516
2040
|
teamMemoryBlock = buildMemoryBlock(mems);
|
|
@@ -1559,6 +2083,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
1559
2083
|
`re-mention me to ship or discard.`,
|
|
1560
2084
|
});
|
|
1561
2085
|
await finalizeProgress(`\`${branch}\` has the agent's own commits — needs review.`);
|
|
2086
|
+
await updateChannelMarker(compactRunMarker("changes", branch));
|
|
1562
2087
|
return { status: "self-committed-gated", branch };
|
|
1563
2088
|
}
|
|
1564
2089
|
// When a live status card was streaming, settle the report onto it (0289) —
|
|
@@ -1580,6 +2105,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
1580
2105
|
runId,
|
|
1581
2106
|
});
|
|
1582
2107
|
await recordSession(selfResult.prUrl);
|
|
2108
|
+
await updateChannelMarker(compactRunMarker(selfResult.status, selfResult.branch));
|
|
1583
2109
|
return selfResult;
|
|
1584
2110
|
}
|
|
1585
2111
|
if (staged.empty) {
|
|
@@ -1613,6 +2139,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
1613
2139
|
git(repoPath, ["switch", "-"]);
|
|
1614
2140
|
git(repoPath, ["branch", "-D", branch]);
|
|
1615
2141
|
}
|
|
2142
|
+
await updateChannelMarker(
|
|
2143
|
+
compactRunMarker(staged.failed ? "run-failed" : "no-changes", branch),
|
|
2144
|
+
);
|
|
1616
2145
|
return { status: staged.failed ? "run-failed" : "no-changes" };
|
|
1617
2146
|
}
|
|
1618
2147
|
|
|
@@ -1627,7 +2156,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
1627
2156
|
// message. Only the DEFAULT gate:false report settles in place; gate:true's
|
|
1628
2157
|
// proposal → decision → report lifecycle stays on separate messages (a
|
|
1629
2158
|
// separate ticket). No status card (older server / status post failed) →
|
|
1630
|
-
// settleId is null and applyDecision posts a fresh
|
|
2159
|
+
// settleId is null and applyDecision posts a fresh final report instead.
|
|
1631
2160
|
const settleId = streamOn && progressId ? progressId : null;
|
|
1632
2161
|
const result = await applyDecision({
|
|
1633
2162
|
decision: { kind: "approved" },
|
|
@@ -1653,6 +2182,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
1653
2182
|
// Re-record with the now-known PR url so the local session record + the run row
|
|
1654
2183
|
// carry the PR the session belongs to (best-effort; session id unchanged).
|
|
1655
2184
|
await recordSession(result.prUrl);
|
|
2185
|
+
await updateChannelMarker(compactRunMarker(result.status, result.branch));
|
|
1656
2186
|
return { ...result, stat: staged.stat, sessionId: runSessionId };
|
|
1657
2187
|
}
|
|
1658
2188
|
|
|
@@ -1707,6 +2237,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
1707
2237
|
});
|
|
1708
2238
|
await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
|
|
1709
2239
|
await recordSession(result.prUrl);
|
|
2240
|
+
await updateChannelMarker(compactRunMarker(result.status, result.branch));
|
|
1710
2241
|
return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round, sessionId: runSessionId };
|
|
1711
2242
|
} finally {
|
|
1712
2243
|
// Whatever happened, give the user their uncommitted work back.
|