hilos-agent 0.1.15 → 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/agent-events.mjs +371 -0
- package/src/config.mjs +25 -0
- package/src/daemon.mjs +67 -0
- package/src/followup.mjs +161 -0
- package/src/handler.mjs +1351 -70
- package/src/hook.mjs +427 -0
- package/src/memory.mjs +95 -0
- package/src/progress-emitter.mjs +270 -0
- package/src/resume.mjs +143 -0
- package/src/review.mjs +237 -0
- package/src/run.mjs +133 -12
package/src/handler.mjs
CHANGED
|
@@ -9,20 +9,72 @@
|
|
|
9
9
|
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
|
-
import { rmSync } from "node:fs";
|
|
12
|
+
import { rmSync, mkdtempSync, existsSync } from "node:fs";
|
|
13
|
+
import { hostname, tmpdir } from "node:os";
|
|
13
14
|
import { join } from "node:path";
|
|
14
15
|
import {
|
|
15
16
|
branchSlug,
|
|
16
17
|
truncateDiff,
|
|
17
18
|
resolveRepoPath,
|
|
19
|
+
resolveFolderPath,
|
|
20
|
+
diffStatusSets,
|
|
18
21
|
parseShortstat,
|
|
19
22
|
buildProposalReport,
|
|
20
23
|
decisionKind,
|
|
21
24
|
commitMessage,
|
|
22
25
|
prTitleBody,
|
|
23
26
|
mentionHandle,
|
|
27
|
+
detectPrContinuation,
|
|
24
28
|
} from "./daemon.mjs";
|
|
25
|
-
import { runCli, buildHeartbeat, ackText, oneLine } from "./cli.mjs";
|
|
29
|
+
import { runCli, buildHeartbeat, ackText, oneLine, fmtElapsed } from "./cli.mjs";
|
|
30
|
+
import { makeStreamParser } from "./agent-events.mjs";
|
|
31
|
+
import { detectVendor, codeStreamArgs, createProgressEmitter } from "./progress-emitter.mjs";
|
|
32
|
+
import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
|
|
33
|
+
import { buildResumeArgs, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
|
|
34
|
+
import {
|
|
35
|
+
buildReviewPrompt,
|
|
36
|
+
parseReviewOutput,
|
|
37
|
+
reviewDedupeKey,
|
|
38
|
+
shouldReview,
|
|
39
|
+
} from "./review.mjs";
|
|
40
|
+
import { buildMemoryBlock } from "./memory.mjs";
|
|
41
|
+
|
|
42
|
+
/** PR number from a github pull-request URL, or null. */
|
|
43
|
+
function prNumberFromUrl(url) {
|
|
44
|
+
const m = /\/pull\/(\d+)/.exec(String(url || ""));
|
|
45
|
+
return m ? Number(m[1]) : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// How many times ONE reviewer will review the SAME PR before it stops — the
|
|
49
|
+
// ping-pong bound. Keyed by reviewDedupeKey(prUrl, reviewerId), counted in-process
|
|
50
|
+
// on the long-lived daemon so an author<->reviewer pair can't loop forever. A
|
|
51
|
+
// human re-triggering the review is the escape hatch past the cap.
|
|
52
|
+
const reviewRounds = new Map();
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
// Sentinel the router model emits (only when a thread already owns a run) to
|
|
56
|
+
// classify a follow-up: change | new-scope | ambiguous. Parsed out of the router
|
|
57
|
+
// output and stripped from the coding brief.
|
|
58
|
+
const FOLLOWUP_SIGNAL = "__FOLLOWUP__";
|
|
59
|
+
|
|
60
|
+
/** Pull the follow-up classification out of the router's output and strip the
|
|
61
|
+
* token from the text, so the signal never leaks into the coding brief / a chat
|
|
62
|
+
* reply. Returns { followupSignal, stripped }.
|
|
63
|
+
*
|
|
64
|
+
* Strip ONLY the `__FOLLOWUP__ <value>` token (plus any surrounding whitespace),
|
|
65
|
+
* NOT the whole line: if the model ever emits `__CODE__` and `__FOLLOWUP__` on the
|
|
66
|
+
* SAME line, removing the line would drop `__CODE__` too and misroute a real code
|
|
67
|
+
* run into a chat reply. Removing just the token preserves `__CODE__` while still
|
|
68
|
+
* keeping the signal out of the brief. */
|
|
69
|
+
export function extractFollowup(text) {
|
|
70
|
+
const m = /__FOLLOWUP__[:\s]+([a-z][a-z-]*)/i.exec(text);
|
|
71
|
+
const followupSignal = m ? normalizeSignal(m[1]) : null;
|
|
72
|
+
const stripped = text
|
|
73
|
+
.replace(/\s*__FOLLOWUP__(?:[:\s]+[a-z][a-z-]*)?/gi, "")
|
|
74
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
75
|
+
.trim();
|
|
76
|
+
return { followupSignal, stripped };
|
|
77
|
+
}
|
|
26
78
|
|
|
27
79
|
/** `@handle` for the person who asked, so the report tags them. */
|
|
28
80
|
function requesterTag(author) {
|
|
@@ -30,10 +82,26 @@ function requesterTag(author) {
|
|
|
30
82
|
return handle ? `@${handle}` : "";
|
|
31
83
|
}
|
|
32
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
|
+
|
|
33
94
|
function defaultDeps() {
|
|
34
95
|
return {
|
|
35
96
|
git: (cwd, args) =>
|
|
36
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),
|
|
37
105
|
openPR: (cwd, { title, body, branch, base }) => {
|
|
38
106
|
const r = spawnSync(
|
|
39
107
|
"gh",
|
|
@@ -54,6 +122,18 @@ function defaultDeps() {
|
|
|
54
122
|
const url = (r.stdout || "").trim();
|
|
55
123
|
return r.status === 0 && url ? url : null;
|
|
56
124
|
},
|
|
125
|
+
// The head branch of an existing PR (by URL or number), so a "request
|
|
126
|
+
// changes" rework can check it out and update the SAME PR rather than
|
|
127
|
+
// opening a duplicate.
|
|
128
|
+
prHeadRef: (cwd, ref) => {
|
|
129
|
+
const r = spawnSync(
|
|
130
|
+
"gh",
|
|
131
|
+
["pr", "view", String(ref), "--json", "headRefName", "--jq", ".headRefName // empty"],
|
|
132
|
+
{ cwd, encoding: "utf8" },
|
|
133
|
+
);
|
|
134
|
+
const out = (r.stdout || "").trim();
|
|
135
|
+
return r.status === 0 && out ? out : null;
|
|
136
|
+
},
|
|
57
137
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
58
138
|
now: () => Date.now(),
|
|
59
139
|
};
|
|
@@ -101,12 +181,12 @@ async function linkPrFromUrl({ tool, channelId, url }) {
|
|
|
101
181
|
await tool("link_pr", { channelId, repoFullName: m[1], prNumber: Number(m[2]) }).catch(() => {});
|
|
102
182
|
}
|
|
103
183
|
|
|
104
|
-
async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId }) {
|
|
184
|
+
async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, existingPrUrl, settleId, runId }) {
|
|
105
185
|
const tag = requesterTag(requester);
|
|
106
186
|
const lead = tag ? `${tag} — ` : "";
|
|
107
|
-
// `parentId` here is the run's thread root
|
|
108
|
-
//
|
|
109
|
-
//
|
|
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.
|
|
110
190
|
if (decision.kind === "approved") {
|
|
111
191
|
// Guard: nothing staged → don't push an empty branch and falsely report "shipped".
|
|
112
192
|
if (deps.git(repoPath, ["diff", "--cached", "--quiet"]).status === 0) {
|
|
@@ -139,21 +219,46 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
139
219
|
return { status: "push-failed", branch };
|
|
140
220
|
}
|
|
141
221
|
const { title, body } = prTitleBody(task, branch);
|
|
142
|
-
|
|
143
|
-
|
|
222
|
+
// Continuing an existing PR (request-changes rework): the push already
|
|
223
|
+
// updated it — reuse its URL instead of opening a duplicate. Otherwise open
|
|
224
|
+
// a fresh PR.
|
|
225
|
+
const pr = existingPrUrl
|
|
226
|
+
? { ok: true, url: existingPrUrl }
|
|
227
|
+
: deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
|
|
228
|
+
// Seamless single card (0289): when a live status message was streaming this
|
|
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.
|
|
232
|
+
const reportArgs = {
|
|
144
233
|
channelId,
|
|
145
|
-
parentId,
|
|
146
|
-
broadcast: true,
|
|
147
234
|
title: `Shipped: ${title}`,
|
|
148
235
|
summary:
|
|
149
236
|
pr.ok && pr.url
|
|
150
|
-
?
|
|
237
|
+
? existingPrUrl
|
|
238
|
+
? `${lead}pushed an update to \`${branch}\` and updated the pull request.`
|
|
239
|
+
: `${lead}pushed \`${branch}\` and opened a pull request.`
|
|
151
240
|
: `${lead}pushed \`${branch}\`. Open a PR manually — \`gh\` failed.`,
|
|
152
241
|
prUrl: pr.ok && pr.url ? pr.url : undefined,
|
|
153
242
|
caveats: pr.ok ? [] : [`gh pr create failed: ${(pr.stderr || "").trim().slice(0, 200)}`],
|
|
154
|
-
}
|
|
243
|
+
};
|
|
244
|
+
await tool(
|
|
245
|
+
"post_report",
|
|
246
|
+
settleId
|
|
247
|
+
? { ...reportArgs, messageId: settleId, broadcast: true }
|
|
248
|
+
: { ...reportArgs, parentId, broadcast: true },
|
|
249
|
+
);
|
|
155
250
|
// Work produced a PR → attach it to the channel so its live pill shows up.
|
|
156
251
|
await linkPrFromUrl({ tool, channelId, url: pr.ok ? pr.url : null });
|
|
252
|
+
// Bind the PR to the thread's run (0279/0280) so a follow-up continues it.
|
|
253
|
+
// Best-effort; only when a run was recorded (caps.runs on this server).
|
|
254
|
+
if (runId) {
|
|
255
|
+
await tool("update_run", {
|
|
256
|
+
runId,
|
|
257
|
+
...(pr.ok && pr.url ? { prUrl: pr.url } : {}),
|
|
258
|
+
status: "awaiting_review",
|
|
259
|
+
...(settleId ? { reportMsgId: settleId } : {}),
|
|
260
|
+
}).catch(() => {});
|
|
261
|
+
}
|
|
157
262
|
return { status: "pushed", branch, prUrl: pr.ok ? pr.url : null };
|
|
158
263
|
}
|
|
159
264
|
|
|
@@ -192,7 +297,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
192
297
|
* push the branch it's on (no-op if already pushed), reuse the PR it opened or
|
|
193
298
|
* open one, and report it. Used only when NOT gated (bias-to-action).
|
|
194
299
|
*/
|
|
195
|
-
async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId }) {
|
|
300
|
+
async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, settleId, runId }) {
|
|
196
301
|
const tag = requesterTag(requester);
|
|
197
302
|
const lead = tag ? `${tag} — ` : "";
|
|
198
303
|
// The agent may have committed on the daemon's branch or switched to one of its
|
|
@@ -207,10 +312,11 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
207
312
|
prUrl = pr.ok && pr.url ? pr.url : null;
|
|
208
313
|
}
|
|
209
314
|
const { title } = prTitleBody(task, cur);
|
|
210
|
-
|
|
315
|
+
// Seamless single card (0289): settle onto the live status message when one was
|
|
316
|
+
// streaming this run; the workspace setting decides channel visibility.
|
|
317
|
+
// Otherwise post a fresh final report.
|
|
318
|
+
const reportArgs = {
|
|
211
319
|
channelId,
|
|
212
|
-
parentId,
|
|
213
|
-
broadcast: true,
|
|
214
320
|
title: `Shipped: ${title}`,
|
|
215
321
|
summary: prUrl
|
|
216
322
|
? `${lead}the coding agent committed on \`${cur}\` and opened a pull request.`
|
|
@@ -219,8 +325,23 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
219
325
|
: `${lead}the coding agent committed on \`${cur}\` locally, but pushing it failed.`,
|
|
220
326
|
prUrl: prUrl || undefined,
|
|
221
327
|
caveats: push.status === 0 ? [] : [`git push failed: ${(push.stderr || "").trim().slice(0, 200)}`],
|
|
222
|
-
}
|
|
328
|
+
};
|
|
329
|
+
await tool(
|
|
330
|
+
"post_report",
|
|
331
|
+
settleId
|
|
332
|
+
? { ...reportArgs, messageId: settleId, broadcast: true }
|
|
333
|
+
: { ...reportArgs, parentId, broadcast: true },
|
|
334
|
+
);
|
|
223
335
|
if (prUrl) await linkPrFromUrl({ tool, channelId, url: prUrl });
|
|
336
|
+
// Bind the PR to the thread's run (0279/0280). Best-effort; only when recorded.
|
|
337
|
+
if (runId) {
|
|
338
|
+
await tool("update_run", {
|
|
339
|
+
runId,
|
|
340
|
+
...(prUrl ? { prUrl } : {}),
|
|
341
|
+
status: "awaiting_review",
|
|
342
|
+
...(settleId ? { reportMsgId: settleId } : {}),
|
|
343
|
+
}).catch(() => {});
|
|
344
|
+
}
|
|
224
345
|
return { status: push.status === 0 ? "pushed" : "commit-local", branch: cur, prUrl };
|
|
225
346
|
}
|
|
226
347
|
|
|
@@ -248,9 +369,24 @@ const CODE_SIGNAL = "__CODE__";
|
|
|
248
369
|
* `error` is set when the model produced nothing (so the caller can be honest
|
|
249
370
|
* about a timeout vs a missing binary instead of inventing a reply).
|
|
250
371
|
*/
|
|
251
|
-
async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal }) {
|
|
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
|
|
252
374
|
const cmd = cfg.chatCmd || cfg.codingCmd;
|
|
253
375
|
const parts = cmd.split(" ").filter(Boolean);
|
|
376
|
+
// When this thread already owns an OPEN pull request (a follow-up reply), the
|
|
377
|
+
// router ALSO reads whether the latest message continues that PR, wants a
|
|
378
|
+
// separate one, or is unclear — the primary signal for same-PR continuation
|
|
379
|
+
// (0281). resolveFollowupMode turns it into iterate/redirect/new; the cue list
|
|
380
|
+
// in followup.mjs is only the fallback when the model says nothing.
|
|
381
|
+
const followupBlock = hasActiveRun
|
|
382
|
+
? `\n\nThis message is a reply in a thread that ALREADY owns an OPEN pull request. If (and ` +
|
|
383
|
+
`only if) you emitted ${CODE_SIGNAL} above, ALSO output on its OWN line the token ` +
|
|
384
|
+
`${FOLLOWUP_SIGNAL} followed by exactly one of: change | new-scope | ambiguous — ` +
|
|
385
|
+
`"change" if the latest message continues or adjusts THAT pull request's work ("also make ` +
|
|
386
|
+
`it blue", "actually revert that", "tweak the copy"), "new-scope" if it EXPLICITLY asks for ` +
|
|
387
|
+
`a separate or new pull request, "ambiguous" otherwise. Default a plain follow-up to ` +
|
|
388
|
+
`"change". This line is metadata — keep it OUT of the imperative spec.`
|
|
389
|
+
: "";
|
|
254
390
|
const prompt =
|
|
255
391
|
`You are ${name}, a teammate in a team chat (hilos) connected to the git repository ` +
|
|
256
392
|
`${repoFullName}. Read the conversation and judge what the LATEST message wants from you.\n\n` +
|
|
@@ -263,11 +399,13 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
263
399
|
`that lists who reacted with each emoji.\n\n` +
|
|
264
400
|
`Otherwise — a question, a greeting, general discussion, or they explicitly don't want code ` +
|
|
265
401
|
`yet — just reply to them concisely and directly as a single chat message (no preamble, no ` +
|
|
266
|
-
`headings). When unsure,
|
|
402
|
+
`headings). When unsure, stay conversational; implementation and PR flow are for clear ` +
|
|
403
|
+
`action language.` +
|
|
404
|
+
`${followupBlock}\n\n` +
|
|
267
405
|
`You are only routing here — output ONLY your text response. Do NOT use any tools, do NOT ` +
|
|
268
406
|
`edit files, do NOT run commands; a separate step does the actual coding.\n\n` +
|
|
269
407
|
`${memoryPreamble(workspaceMemory)}Conversation so far:\n${transcript}`;
|
|
270
|
-
const run = await
|
|
408
|
+
const run = await doRun({
|
|
271
409
|
cmd: parts[0],
|
|
272
410
|
args: [...parts.slice(1), prompt],
|
|
273
411
|
timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
|
|
@@ -275,8 +413,14 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
275
413
|
signal,
|
|
276
414
|
});
|
|
277
415
|
if (run.aborted || signal?.aborted) return { aborted: true };
|
|
278
|
-
const
|
|
279
|
-
if (!
|
|
416
|
+
const raw = (run.stdout || "").trim();
|
|
417
|
+
if (!raw) return { code: false, reply: null, error: run.error || new Error("no output") };
|
|
418
|
+
// Lift the follow-up classification out (and strip its line) before parsing the
|
|
419
|
+
// brief, so the signal never pollutes the coding spec or a chat reply. Only
|
|
420
|
+
// meaningful when a run is active; otherwise there's no token to find.
|
|
421
|
+
const { followupSignal, stripped: out } = hasActiveRun
|
|
422
|
+
? extractFollowup(raw)
|
|
423
|
+
: { followupSignal: null, stripped: raw };
|
|
280
424
|
// Accept the sentinel ANYWHERE, not just the first line — models sometimes add a
|
|
281
425
|
// line of preamble before it ("Got it, …\n__CODE__\n<brief>"). If it appears,
|
|
282
426
|
// it's a code run; the brief is whatever follows the sentinel's line and any
|
|
@@ -286,9 +430,9 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
286
430
|
if (idx >= 0) {
|
|
287
431
|
const after = out.slice(idx + CODE_SIGNAL.length);
|
|
288
432
|
const nl = after.indexOf("\n");
|
|
289
|
-
return { code: true, task: (nl >= 0 ? after.slice(nl + 1) : after).trim() };
|
|
433
|
+
return { code: true, task: (nl >= 0 ? after.slice(nl + 1) : after).trim(), followupSignal };
|
|
290
434
|
}
|
|
291
|
-
return { code: false, reply: out };
|
|
435
|
+
return { code: false, reply: out, followupSignal };
|
|
292
436
|
}
|
|
293
437
|
|
|
294
438
|
/**
|
|
@@ -325,6 +469,34 @@ export function codeTaskPrompt(o) {
|
|
|
325
469
|
return p;
|
|
326
470
|
}
|
|
327
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
|
+
|
|
328
500
|
/** owner/name from a GitHub remote URL (https or ssh), or null. */
|
|
329
501
|
export function normalizeRemote(url) {
|
|
330
502
|
const m = String(url || "")
|
|
@@ -409,9 +581,15 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
|
|
|
409
581
|
const { transcript } = context || (await fetchContext({ channelId, tool, parentId }));
|
|
410
582
|
const name = me?.agentName || "an assistant";
|
|
411
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;
|
|
412
588
|
const repoLine = repoLink
|
|
413
589
|
? `This channel is connected to the repository ${repoLink.repo_full_name}; assume that repo for any code work — don't ask which one.`
|
|
414
|
-
:
|
|
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.`;
|
|
415
593
|
const prompt =
|
|
416
594
|
`You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
|
|
417
595
|
`concisely and directly as a single chat message — no preamble, no headings. ` +
|
|
@@ -495,6 +673,598 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
|
|
|
495
673
|
await deliver(body);
|
|
496
674
|
}
|
|
497
675
|
|
|
676
|
+
/** True when this mention is a request for THIS agent to review a PR (0286/0288). */
|
|
677
|
+
export function isReviewRequest(message) {
|
|
678
|
+
return (
|
|
679
|
+
message?.kind === "review-request" ||
|
|
680
|
+
Boolean(message?.metadata?.reviewRequest) ||
|
|
681
|
+
Boolean(message?.reviewRequest)
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function agentMode(message) {
|
|
686
|
+
const mode = message?.agentMode || message?.metadata?.agentMode;
|
|
687
|
+
return mode === "ask" || mode === "ship" ? mode : null;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** The PR url a review-request/review points at, from the mention's reviewOf or the
|
|
691
|
+
* channel's linked PR — whichever is present. */
|
|
692
|
+
function reviewTargetPrUrl(message) {
|
|
693
|
+
return message?.reviewOf?.prUrl || message?.pr?.url || null;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** Flatten get_reviews output into a compact, task-ready findings list the author's
|
|
697
|
+
* coding agent can act on (the review MESSAGE body only carries the summary). Uses
|
|
698
|
+
* the MOST RECENT review so a re-review supersedes stale findings. Returns "" when
|
|
699
|
+
* there's nothing structured to add. */
|
|
700
|
+
export function formatReviewFindings(got) {
|
|
701
|
+
const reviews = Array.isArray(got?.reviews) ? got.reviews : [];
|
|
702
|
+
if (!reviews.length) return "";
|
|
703
|
+
const latest = reviews[reviews.length - 1]; // get_reviews returns oldest→newest
|
|
704
|
+
const lines = [];
|
|
705
|
+
if (latest.summary) lines.push(latest.summary.trim());
|
|
706
|
+
for (const f of Array.isArray(latest.findings) ? latest.findings : []) {
|
|
707
|
+
if (!f || !f.note) continue;
|
|
708
|
+
const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""} — ` : "";
|
|
709
|
+
lines.push(`- [${f.severity || "info"}] ${loc}${f.note}`);
|
|
710
|
+
}
|
|
711
|
+
return lines.join("\n").slice(0, 4000);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* EXECUTE a cross-agent review (WS3, 0288). A review-request lands in this agent's
|
|
716
|
+
* mentions; here it actually reads the PR's diff and posts an ADVISORY review that
|
|
717
|
+
* @-tags the author. It is strictly READ-ONLY and never touches git:
|
|
718
|
+
* - the full diff comes from get_pr_diff (no checkout needed),
|
|
719
|
+
* - the CLI runs in a throwaway temp dir so any stray file write is contained and
|
|
720
|
+
* discarded (belt-and-suspenders with the prompt's edit/git ban), and
|
|
721
|
+
* - this function ONLY calls post_review — never post_report, a decision, a
|
|
722
|
+
* branch, a commit, a push, or a PR. The human stays the sole merge gate.
|
|
723
|
+
* Best-effort: any failure posts an honest note and returns; it never throws into
|
|
724
|
+
* the poll loop. Bounded by shouldReview()/reviewRounds so a reviewer can't
|
|
725
|
+
* re-review the same PR endlessly (the ping-pong bound).
|
|
726
|
+
*/
|
|
727
|
+
async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory, context, signal }) {
|
|
728
|
+
const parentId = message.parentId ?? null;
|
|
729
|
+
const prUrl = reviewTargetPrUrl(message);
|
|
730
|
+
const reviewOf =
|
|
731
|
+
message?.reviewOf && (message.reviewOf.prUrl || message.reviewOf.messageId)
|
|
732
|
+
? message.reviewOf
|
|
733
|
+
: prUrl
|
|
734
|
+
? { prUrl }
|
|
735
|
+
: null;
|
|
736
|
+
if (!prUrl || !reviewOf) {
|
|
737
|
+
await tool("post_message", {
|
|
738
|
+
channelId,
|
|
739
|
+
parentId,
|
|
740
|
+
body: `I was asked to review, but I couldn't find which PR — link the PR (or ask again with its URL) and I'll take a look.`,
|
|
741
|
+
}).catch(() => {});
|
|
742
|
+
return { status: "review-no-pr" };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Ping-pong bound: don't re-review the same PR past the round cap.
|
|
746
|
+
const reviewerId = me?.agentId || me?.id || me?.agentName || "reviewer";
|
|
747
|
+
const key = reviewDedupeKey(prUrl, reviewerId);
|
|
748
|
+
const rounds = reviewRounds.get(key) || 0;
|
|
749
|
+
const maxRounds = cfg.maxRounds || 3;
|
|
750
|
+
if (!shouldReview({ rounds, maxRounds })) {
|
|
751
|
+
await tool("post_message", {
|
|
752
|
+
channelId,
|
|
753
|
+
parentId,
|
|
754
|
+
body: `I've already reviewed this PR ${rounds} time(s) — I'll leave it to a human from here so we don't loop.`,
|
|
755
|
+
}).catch(() => {});
|
|
756
|
+
return { status: "review-capped", rounds };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const prNum = prNumberFromUrl(prUrl);
|
|
760
|
+
const label = prNum ? `PR #${prNum}` : "the PR";
|
|
761
|
+
// Instant, channel-visible ack; anchors the thread for the review card.
|
|
762
|
+
const ack = await tool("post_message", {
|
|
763
|
+
channelId,
|
|
764
|
+
parentId,
|
|
765
|
+
body: `Reviewing ${label} now — I'll post my read shortly.`,
|
|
766
|
+
}).catch(() => null);
|
|
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
|
+
};
|
|
772
|
+
|
|
773
|
+
// The diff — the whole review is fed off this (no clone, no gh creds).
|
|
774
|
+
// Capture the failure reason on BOTH error shapes: a soft failure returns
|
|
775
|
+
// { ok:false, error } (auth/no-token/fetch), a hard failure throws and we map its
|
|
776
|
+
// message here — so the honest note below can say WHY, not just that it couldn't.
|
|
777
|
+
const diff = await tool("get_pr_diff", { prUrl, channelId }).catch((e) => ({ ok: false, error: e?.message }));
|
|
778
|
+
if (!diff || diff.ok === false || !Array.isArray(diff.files) || diff.files.length === 0) {
|
|
779
|
+
const reason = diff?.error || diff?.message;
|
|
780
|
+
const why = reason ? ` (${reason})` : "";
|
|
781
|
+
await tool("post_message", {
|
|
782
|
+
channelId,
|
|
783
|
+
parentId: threadRoot,
|
|
784
|
+
broadcast: Boolean(threadRoot),
|
|
785
|
+
body: `I couldn't read ${label}'s diff to review it${why}. It may be private, merged, or not linked here.`,
|
|
786
|
+
}).catch(() => {});
|
|
787
|
+
await updateReviewMarker(`Review failed: ${label}`);
|
|
788
|
+
return { status: "review-no-diff" };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// The author's own summary (when the request pointed at their report message) —
|
|
792
|
+
// extra intent for the reviewer. Best-effort.
|
|
793
|
+
let reportSummary = null;
|
|
794
|
+
if (reviewOf.messageId) {
|
|
795
|
+
const gr = await tool("get_report", { messageId: reviewOf.messageId }).catch(() => null);
|
|
796
|
+
if (gr?.found && gr.report?.summary) reportSummary = gr.report.summary;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const prompt = buildReviewPrompt({
|
|
800
|
+
diffFiles: diff.files,
|
|
801
|
+
task: message.body,
|
|
802
|
+
reportSummary,
|
|
803
|
+
threadContext: context?.transcript,
|
|
804
|
+
workspaceMemory,
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// Read-only run: a throwaway cwd contains any stray write; the daemon reads ONLY
|
|
808
|
+
// stdout and never stages/commits anything. Review uses the fast chat command
|
|
809
|
+
// when set (no acceptEdits → can't write), falling back to the coding command.
|
|
810
|
+
const cmd = cfg.reviewCmd || cfg.chatCmd || cfg.codingCmd;
|
|
811
|
+
const parts = String(cmd).split(" ").filter(Boolean);
|
|
812
|
+
let sandboxDir = null;
|
|
813
|
+
try {
|
|
814
|
+
sandboxDir = mkdtempSync(join(tmpdir(), "hilos-review-"));
|
|
815
|
+
} catch {
|
|
816
|
+
sandboxDir = undefined; // fall back to the daemon's cwd; the prompt still bans edits
|
|
817
|
+
}
|
|
818
|
+
console.log(` review → running \`${cmd}\` on ${label} (read-only)…`);
|
|
819
|
+
const run = await runCli({
|
|
820
|
+
cmd: parts[0],
|
|
821
|
+
args: [...parts.slice(1), prompt],
|
|
822
|
+
cwd: sandboxDir || undefined,
|
|
823
|
+
timeoutMs: cfg.chatTimeoutMs ? Math.max(cfg.chatTimeoutMs, 120000) : cfg.runTimeoutMs,
|
|
824
|
+
label: "reviewing",
|
|
825
|
+
signal,
|
|
826
|
+
});
|
|
827
|
+
if (sandboxDir) {
|
|
828
|
+
try {
|
|
829
|
+
rmSync(sandboxDir, { recursive: true, force: true });
|
|
830
|
+
} catch {
|
|
831
|
+
/* temp dir cleanup is best-effort */
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (run.aborted || signal?.aborted) {
|
|
836
|
+
await tool("post_message", { channelId, parentId: threadRoot, body: `Stopped the review of ${label}.` }).catch(() => {});
|
|
837
|
+
await updateReviewMarker(`Review stopped: ${label}`);
|
|
838
|
+
return { status: "review-cancelled" };
|
|
839
|
+
}
|
|
840
|
+
const text = (run.stdout || "").trim();
|
|
841
|
+
if (!text) {
|
|
842
|
+
const enoent = run.error?.code === "ENOENT";
|
|
843
|
+
await tool("post_message", {
|
|
844
|
+
channelId,
|
|
845
|
+
parentId: threadRoot,
|
|
846
|
+
broadcast: Boolean(threadRoot),
|
|
847
|
+
body: enoent
|
|
848
|
+
? `I couldn't start \`${cmd}\` to review ${label} — is it installed and on PATH?`
|
|
849
|
+
: `I couldn't get a read on ${label} that time — mention me again to retry the review.`,
|
|
850
|
+
}).catch(() => {});
|
|
851
|
+
await updateReviewMarker(`Review failed: ${label}`);
|
|
852
|
+
return { status: "review-no-output" };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const { verdict, summary, findings } = parseReviewOutput(text);
|
|
856
|
+
// Advisory ONLY — post_review records a verdict + findings and @-tags the author.
|
|
857
|
+
// It NEVER merges, blocks, or writes a decision. The server rejects a self-review,
|
|
858
|
+
// so an agent reviewing its own PR degrades to an honest note here.
|
|
859
|
+
const posted = await tool("post_review", {
|
|
860
|
+
channelId,
|
|
861
|
+
reviewOf,
|
|
862
|
+
verdict,
|
|
863
|
+
summary: summary || `Reviewed ${label}.`,
|
|
864
|
+
findings,
|
|
865
|
+
parentId: threadRoot || undefined,
|
|
866
|
+
broadcast: Boolean(threadRoot),
|
|
867
|
+
}).catch((e) => ({ ok: false, error: e?.message }));
|
|
868
|
+
if (posted?.ok === false) {
|
|
869
|
+
// Most common: "You can't review your own work." — surface honestly, no crash.
|
|
870
|
+
await tool("post_message", {
|
|
871
|
+
channelId,
|
|
872
|
+
parentId: threadRoot,
|
|
873
|
+
body: `I looked at ${label} but couldn't post the review${posted?.error || posted?.message ? `: ${posted.error || posted.message}` : ""}.`,
|
|
874
|
+
}).catch(() => {});
|
|
875
|
+
await updateReviewMarker(`Review failed: ${label}`);
|
|
876
|
+
return { status: "review-post-failed", error: posted?.error || posted?.message };
|
|
877
|
+
}
|
|
878
|
+
reviewRounds.set(key, rounds + 1);
|
|
879
|
+
console.log(` review → posted ${verdict} on ${label} (${findings.length} finding(s))`);
|
|
880
|
+
await updateReviewMarker(`Review ready: ${label}`);
|
|
881
|
+
return { status: "reviewed", verdict, findings: findings.length, prUrl };
|
|
882
|
+
}
|
|
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
|
+
|
|
498
1268
|
/** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
|
|
499
1269
|
* cancels an in-flight run — the queue fires it when a human says "stop". */
|
|
500
1270
|
export async function handleTask({ message, channelId, tool, me, caps = {} }, cfg, depsOverride, opts = {}) {
|
|
@@ -505,7 +1275,10 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
505
1275
|
const parentId = message.parentId ?? null;
|
|
506
1276
|
|
|
507
1277
|
const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
|
|
508
|
-
|
|
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);
|
|
509
1282
|
// Workspace memory ("soul") — shared project context the agent should know.
|
|
510
1283
|
const { memory: workspaceMemory = null } = await tool("get_workspace_memory", {}).catch(() => ({
|
|
511
1284
|
memory: null,
|
|
@@ -515,26 +1288,135 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
515
1288
|
// crucial: a reply like "yeah, do it" only means something against what was
|
|
516
1289
|
// just said.
|
|
517
1290
|
const context = await fetchContext({ channelId, tool, parentId });
|
|
1291
|
+
const mode = agentMode(message);
|
|
1292
|
+
|
|
1293
|
+
// REVIEW EXECUTION (0288): a review-request routes to the read-only reviewer
|
|
1294
|
+
// BEFORE the chat/code flow — it reads the PR's diff and posts an advisory review
|
|
1295
|
+
// that @-tags the author. Gated on caps.review (post_review + get_pr_diff on this
|
|
1296
|
+
// server); without it (older deploy) this is skipped and behavior is unchanged.
|
|
1297
|
+
if (caps.review && isReviewRequest(message)) {
|
|
1298
|
+
return await reviewTask({ message, channelId, tool, me, cfg, workspaceMemory, context, signal });
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// AUTHOR SIDE (0288): a review that @-tags this agent about work it owns. The
|
|
1302
|
+
// review body carries the verdict + summary; the full findings live in metadata,
|
|
1303
|
+
// so pull them via get_reviews and fold them into the conversation the coding
|
|
1304
|
+
// agent sees — then fall through to the normal follow-up machinery, which (0281)
|
|
1305
|
+
// checks out the thread's PR branch and pushes onto the SAME human-gated PR. Best-
|
|
1306
|
+
// effort; a failure just leaves the summary line to drive the iterate.
|
|
1307
|
+
if (caps.review && message?.kind === "review") {
|
|
1308
|
+
const target =
|
|
1309
|
+
message?.reviewOf && (message.reviewOf.prUrl || message.reviewOf.messageId)
|
|
1310
|
+
? message.reviewOf
|
|
1311
|
+
: reviewTargetPrUrl(message)
|
|
1312
|
+
? { prUrl: reviewTargetPrUrl(message) }
|
|
1313
|
+
: null;
|
|
1314
|
+
if (target) {
|
|
1315
|
+
const got = await tool("get_reviews", { reviewOf: target }).catch(() => null);
|
|
1316
|
+
const detail = formatReviewFindings(got);
|
|
1317
|
+
if (detail) context.transcript = `${context.transcript}\n\nReviewer findings to address:\n${detail}`;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
518
1320
|
|
|
519
|
-
// 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.
|
|
520
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
|
+
}
|
|
521
1372
|
await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
|
|
522
1373
|
return { status: "chat" };
|
|
523
1374
|
}
|
|
524
1375
|
const repoFullName = repoLink.repo_full_name;
|
|
525
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
|
+
|
|
1382
|
+
// Same-PR follow-up (0281): if this mention is a reply in a thread that already
|
|
1383
|
+
// owns a run (branch + PR), we may continue THAT run instead of forking a
|
|
1384
|
+
// duplicate. Look it up by the thread root — the parentId the reply carried.
|
|
1385
|
+
// Best-effort + gated on the server supporting the runs entity (caps.runs); any
|
|
1386
|
+
// failure falls back to a fresh run (mode 'new'), never breaking the task.
|
|
1387
|
+
let activeRun = null;
|
|
1388
|
+
if (caps.runs && parentId) {
|
|
1389
|
+
const r = await tool("get_active_run", { channelId, threadRootId: parentId }).catch(() => null);
|
|
1390
|
+
if (r && r.found) activeRun = r;
|
|
1391
|
+
}
|
|
1392
|
+
// Only an OPEN PR is iterable: awaiting_review (the review gate) or
|
|
1393
|
+
// changes_requested (a rework in flight). A merged/closed run is never
|
|
1394
|
+
// continued (the resolver's recency guard).
|
|
1395
|
+
const activeRunOpen = Boolean(
|
|
1396
|
+
activeRun && (activeRun.status === "awaiting_review" || activeRun.status === "changes_requested"),
|
|
1397
|
+
);
|
|
1398
|
+
|
|
526
1399
|
// Chat vs code is the LLM's call, not a word list: it reads the whole
|
|
527
1400
|
// conversation and either returns a chat reply or signals a code run. Handles
|
|
528
1401
|
// any phrasing, any language, and "go for it" obviously means "do the thing we
|
|
529
|
-
// just discussed". When it replies (chat), post that and we're done.
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
1402
|
+
// just discussed". When it replies (chat), post that and we're done. When the
|
|
1403
|
+
// thread owns an OPEN PR it ALSO classifies the follow-up (change / new-scope /
|
|
1404
|
+
// ambiguous). Gate on activeRunOpen, not merely Boolean(activeRun): the follow-up
|
|
1405
|
+
// signal is only ever acted on when the PR is open (resolveFollowupMode → 'new'
|
|
1406
|
+
// otherwise), so telling the router the thread "owns an open PR" when the run is
|
|
1407
|
+
// merged/closed would just mislead the model and waste a classification.
|
|
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
|
+
});
|
|
538
1420
|
if (routed.aborted || signal?.aborted) {
|
|
539
1421
|
await tool("post_message", { channelId, parentId, body: "Stopped." });
|
|
540
1422
|
return { status: "chat" };
|
|
@@ -552,6 +1434,20 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
552
1434
|
}
|
|
553
1435
|
// routed.code → fall through to the coding flow below.
|
|
554
1436
|
|
|
1437
|
+
// How this code follow-up routes against the thread's run. The LLM's read
|
|
1438
|
+
// (routed.followupSignal) is primary; the cue list is the fallback when it said
|
|
1439
|
+
// nothing. resolveFollowupMode enforces the safety table — 'iterate' ONLY onto
|
|
1440
|
+
// an open PR, an explicit new-scope forks ('redirect'), everything else is a
|
|
1441
|
+
// fresh run ('new'). A chat reply never reaches here, so a chat is never an
|
|
1442
|
+
// iterate. (Recomputed as `effectiveMode` after branch-prep in case an intended
|
|
1443
|
+
// iterate can't check out its branch.)
|
|
1444
|
+
const followupSignal = routed.followupSignal || classifyFollowupCue(message.body);
|
|
1445
|
+
const followupMode = resolveFollowupMode({
|
|
1446
|
+
hasActiveRun: Boolean(activeRun),
|
|
1447
|
+
prOpen: activeRunOpen,
|
|
1448
|
+
signal: followupSignal,
|
|
1449
|
+
});
|
|
1450
|
+
|
|
555
1451
|
let repoPath = resolveRepoPath(cfg, repoFullName);
|
|
556
1452
|
if (!repoPath) {
|
|
557
1453
|
// Zero-config path: if the daemon is running INSIDE a checkout of this repo
|
|
@@ -627,23 +1523,95 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
627
1523
|
};
|
|
628
1524
|
|
|
629
1525
|
try {
|
|
630
|
-
const branch = branchSlug(message.body, randomBytes(3).toString("hex"));
|
|
631
1526
|
git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
1527
|
+
|
|
1528
|
+
// Same-branch iteration: continue an existing PR on its own branch and push
|
|
1529
|
+
// onto it, so the PR updates in place instead of a duplicate opening. Two ways
|
|
1530
|
+
// in, and an explicit token WINS when both apply (they agree — same PR):
|
|
1531
|
+
// 1. detectPrContinuation — an explicit "PR <url>" in the text (the server's
|
|
1532
|
+
// "request changes" rework ping, or a person naming a PR). The fallback.
|
|
1533
|
+
// 2. the run resolver's 'iterate' (0281) — a plain follow-up reply in a thread
|
|
1534
|
+
// whose run already owns an OPEN PR. The PRIMARY path.
|
|
1535
|
+
// Otherwise, a fresh branch off the default (today's behavior).
|
|
1536
|
+
const cont = detectPrContinuation(message.body, repoFullName);
|
|
1537
|
+
const iterateUrl = cont?.url || (followupMode === "iterate" && activeRun?.prUrl) || null;
|
|
1538
|
+
let branch = null;
|
|
1539
|
+
let continuingPrUrl = null;
|
|
1540
|
+
if (iterateUrl && deps.prHeadRef) {
|
|
1541
|
+
// Resolve the PR's head branch (source of truth); for a run-based iterate the
|
|
1542
|
+
// run's recorded branch is a fallback if gh can't view the PR.
|
|
1543
|
+
const headRef =
|
|
1544
|
+
deps.prHeadRef(repoPath, iterateUrl) ||
|
|
1545
|
+
(followupMode === "iterate" ? activeRun?.branch || null : null);
|
|
1546
|
+
if (headRef) {
|
|
1547
|
+
// Fetch the PR branch, then point a local branch at its tip via FETCH_HEAD
|
|
1548
|
+
// (works on single-branch clones where `origin/<headRef>` isn't tracked).
|
|
1549
|
+
// `-C` repoints whether or not a stale local copy exists — the daemon is
|
|
1550
|
+
// stateless across runs, so origin is the source of truth.
|
|
1551
|
+
const fetched = git(repoPath, ["fetch", "origin", headRef]);
|
|
1552
|
+
const sw =
|
|
1553
|
+
fetched.status === 0 ? git(repoPath, ["switch", "-C", headRef, "FETCH_HEAD"]) : fetched;
|
|
1554
|
+
if (fetched.status === 0 && sw.status === 0) {
|
|
1555
|
+
branch = headRef;
|
|
1556
|
+
continuingPrUrl = iterateUrl;
|
|
1557
|
+
console.log(` code → continuing existing PR ${iterateUrl} on \`${headRef}\``);
|
|
1558
|
+
} else {
|
|
1559
|
+
console.log(
|
|
1560
|
+
` ! couldn't check out PR branch \`${headRef}\` (falling back to a new branch): ${(sw.stderr || "").trim().slice(0, 200)}`,
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
// An explicit PR token (detectPrContinuation) that names a DIFFERENT PR than the
|
|
1566
|
+
// thread's active run must NOT reuse/update that run for the terminal
|
|
1567
|
+
// update_run/report: we push to the TOKEN's PR (via existingPrUrl below), so
|
|
1568
|
+
// binding the unrelated active run would push to the token PR (#99) yet mark the
|
|
1569
|
+
// active run (#142) awaiting_review — a mismatch. Treat this as 'new' for
|
|
1570
|
+
// run-binding: the token still WINS for which PR gets the push; only the
|
|
1571
|
+
// bookkeeping refuses to touch the mismatched run (start_run records a fresh run
|
|
1572
|
+
// for the token PR instead). A token naming the SAME PR the run owns keeps the
|
|
1573
|
+
// iterate-reuse.
|
|
1574
|
+
const explicitTokenMismatch = Boolean(cont?.url && activeRun?.prUrl && cont.url !== activeRun.prUrl);
|
|
1575
|
+
// If we INTENDED to iterate but couldn't check out the PR's branch, we're on a
|
|
1576
|
+
// fresh branch now — so don't reuse the old run either; treat it as a new run.
|
|
1577
|
+
const effectiveMode =
|
|
1578
|
+
explicitTokenMismatch || (followupMode === "iterate" && !continuingPrUrl) ? "new" : followupMode;
|
|
1579
|
+
if (!branch) {
|
|
1580
|
+
branch = branchSlug(message.body, randomBytes(3).toString("hex"));
|
|
1581
|
+
const co = git(repoPath, ["switch", "-c", branch]);
|
|
1582
|
+
if (co.status !== 0) {
|
|
1583
|
+
await tool("post_message", {
|
|
1584
|
+
channelId,
|
|
1585
|
+
parentId,
|
|
1586
|
+
body: `Couldn't create branch \`${branch}\`: ${co.stderr.trim()}`,
|
|
1587
|
+
});
|
|
1588
|
+
return { status: "branch-error" };
|
|
1589
|
+
}
|
|
640
1590
|
}
|
|
1591
|
+
// The branch tip BEFORE the agent runs — the base for detecting commits the
|
|
1592
|
+
// CLI makes itself. For a continuation this is the PR's existing tip (not the
|
|
1593
|
+
// default branch), so its prior commits aren't misread as "the agent
|
|
1594
|
+
// self-committed".
|
|
1595
|
+
const baseRef = (git(repoPath, ["rev-parse", "HEAD"]).stdout || "").trim() || (startRef.ref || cfg.defaultBranch);
|
|
641
1596
|
|
|
642
1597
|
// Instant acknowledgement: post a template in <1s so the channel shows life
|
|
643
1598
|
// before any model call. If the server supports edit_message and a fast chatCmd
|
|
644
1599
|
// is set, a bounded plan-ack edits in a sentence of specifics ("I'll do X…").
|
|
645
1600
|
// Best-effort — a slow or failed ack just leaves the template, never blocks.
|
|
646
|
-
|
|
1601
|
+
//
|
|
1602
|
+
// INSPECTABLE ACK (0281): when we're continuing or forking off an existing run,
|
|
1603
|
+
// SAY so plainly (with the PR number + branch) so a human can correct the LLM's
|
|
1604
|
+
// routing before any code lands. This is a key safety mitigation for a wrong
|
|
1605
|
+
// 'iterate'. A plain new run keeps the standard template.
|
|
1606
|
+
let ackBody = ackText(repoFullName);
|
|
1607
|
+
if (continuingPrUrl) {
|
|
1608
|
+
const n = prNumberFromUrl(continuingPrUrl);
|
|
1609
|
+
ackBody = `Continuing ${n ? `PR #${n}` : "the existing pull request"} on \`${branch}\` — I'll push this change there and update the report.`;
|
|
1610
|
+
} else if (effectiveMode === "redirect") {
|
|
1611
|
+
const n = activeRun?.prNumber || prNumberFromUrl(activeRun?.prUrl);
|
|
1612
|
+
ackBody = `Starting a new PR${n ? ` (separate from #${n})` : ""} on \`${branch}\`.`;
|
|
1613
|
+
}
|
|
1614
|
+
const ack = await tool("post_message", { channelId, parentId, body: ackBody });
|
|
647
1615
|
const ackId = ack?.messageId ?? null;
|
|
648
1616
|
// Where the run streams + reports. When the mention was already in a thread,
|
|
649
1617
|
// stay in it. When it was top-level, the ack becomes the thread anchor so the
|
|
@@ -652,7 +1620,44 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
652
1620
|
// The ack itself stays top-level (the channel-visible "on it"); terminal
|
|
653
1621
|
// outcomes + report cards broadcast back to the channel from the thread.
|
|
654
1622
|
const threadRoot = parentId ?? ackId;
|
|
655
|
-
|
|
1623
|
+
const updateChannelMarker = async (body) => {
|
|
1624
|
+
if (!ackId || parentId || !body) return;
|
|
1625
|
+
await tool("edit_message", { messageId: ackId, body }).catch(() => {});
|
|
1626
|
+
};
|
|
1627
|
+
// Bind this run to the thread root (0279/0280), now follow-up-aware (0281):
|
|
1628
|
+
// iterate → REUSE the thread's existing run (don't record a second one); the
|
|
1629
|
+
// same PR updates in place and its status stays awaiting_review.
|
|
1630
|
+
// redirect → supersede the old run (so a future follow-up no longer points at
|
|
1631
|
+
// it), then record a FRESH run for the new, separate PR.
|
|
1632
|
+
// new → record a fresh run, exactly as before.
|
|
1633
|
+
// Best-effort throughout — a bookkeeping failure (or an older server without the
|
|
1634
|
+
// runs tools) must NEVER break the run, so it proceeds without a runId.
|
|
1635
|
+
let runId = effectiveMode === "iterate" ? activeRun?.runId ?? null : null;
|
|
1636
|
+
if (caps.runs && threadRoot && effectiveMode !== "iterate") {
|
|
1637
|
+
try {
|
|
1638
|
+
if (effectiveMode === "redirect" && activeRun?.runId) {
|
|
1639
|
+
await tool("update_run", { runId: activeRun.runId, status: "superseded" }).catch(() => {});
|
|
1640
|
+
}
|
|
1641
|
+
const r = await tool("start_run", {
|
|
1642
|
+
channelId,
|
|
1643
|
+
threadRootId: threadRoot,
|
|
1644
|
+
taskText: message.body,
|
|
1645
|
+
branch,
|
|
1646
|
+
// Map an unrecognized command to null rather than the off-vocabulary
|
|
1647
|
+
// "unknown" — provider is documented as claude_code|codex|cursor|hilos.
|
|
1648
|
+
provider: (() => {
|
|
1649
|
+
const v = detectVendor(cfg.codingCmd);
|
|
1650
|
+
return v === "unknown" ? null : v;
|
|
1651
|
+
})(),
|
|
1652
|
+
});
|
|
1653
|
+
runId = r?.runId ?? null;
|
|
1654
|
+
} catch {
|
|
1655
|
+
/* never break the run on a run-record failure */
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
// Keep the inspectable continuation/redirect statement (don't overwrite it with
|
|
1659
|
+
// an LLM plan) so a human can correct the routing before code lands.
|
|
1660
|
+
if (ackId && caps.editMessage && cfg.chatCmd && !continuingPrUrl && effectiveMode !== "redirect") {
|
|
656
1661
|
// Feed the ack the router's distilled brief AND the conversation — not the raw
|
|
657
1662
|
// mention — so it states a real plan instead of "what's the task?".
|
|
658
1663
|
const plan = await proposePlanAck({
|
|
@@ -669,9 +1674,49 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
669
1674
|
// then EDIT it on later beats — alive without thread spam. Needs edit_message;
|
|
670
1675
|
// without it we skip rather than post a fresh message every beat. lastLine
|
|
671
1676
|
// carries the CLI's latest output into the beat.
|
|
672
|
-
|
|
1677
|
+
// Live progress (0274): when the server supports post_progress, the code run
|
|
1678
|
+
// STREAMS a coalesced "what I'm doing right now" card onto one thread status
|
|
1679
|
+
// message (via the 0272 stream parser) instead of the 3-min edit heartbeat. On
|
|
1680
|
+
// older servers (no post_progress) we fall back to the exact legacy behavior.
|
|
1681
|
+
const streamOn = Boolean(caps.postProgress);
|
|
1682
|
+
const vendor = detectVendor(cfg.codingCmd);
|
|
1683
|
+
const streamArgs = codeStreamArgs(vendor);
|
|
1684
|
+
let runSessionId = null; // captured from the stream for 0282 (resume)
|
|
1685
|
+
const machine = hostname();
|
|
1686
|
+
|
|
1687
|
+
// Session resume (0282): on an ITERATE we can resume the coding agent's SESSION so
|
|
1688
|
+
// the follow-up continues with the first run's reasoning/plan — not just its code
|
|
1689
|
+
// (which the branch checkout already restores). GATE on LOCAL confidence: only
|
|
1690
|
+
// resume when THIS machine's ~/.hilos/state.json recorded that thread's session
|
|
1691
|
+
// AND it matches this hostname. A providerSessionId that came ONLY from the server
|
|
1692
|
+
// (get_active_run) with no local match means the session likely lives on another
|
|
1693
|
+
// machine/instance — a bad `--resume` id makes claude error → empty diff → a failed
|
|
1694
|
+
// run, so we DON'T resume and degrade to today's branch+feedback (never worse).
|
|
1695
|
+
// Only claude_code has a proven resume flag; codex/cursor/unknown → [] anyway.
|
|
1696
|
+
let resumeSessionId = null;
|
|
1697
|
+
if (effectiveMode === "iterate" && vendor === "claude_code") {
|
|
1698
|
+
const local = (() => {
|
|
1699
|
+
try {
|
|
1700
|
+
return readStateEntry(HILOS_DIR, threadRoot);
|
|
1701
|
+
} catch {
|
|
1702
|
+
return null;
|
|
1703
|
+
}
|
|
1704
|
+
})();
|
|
1705
|
+
const serverSid = activeRun?.providerSessionId || null;
|
|
1706
|
+
// Confidence = a local record, for THIS machine, with a session id. If the server
|
|
1707
|
+
// also has one it must AGREE (a mismatch means the run moved — don't resume a
|
|
1708
|
+
// stale/foreign id). A server-only id (no local record) is never resumed.
|
|
1709
|
+
if (local && local.machine === machine && local.sessionId && (!serverSid || serverSid === local.sessionId)) {
|
|
1710
|
+
resumeSessionId = local.sessionId;
|
|
1711
|
+
console.log(` code → resuming session ${local.sessionId} for this iterate (local + machine match)`);
|
|
1712
|
+
} else if (serverSid) {
|
|
1713
|
+
console.log(" code → not resuming (session recorded on another machine or unverified locally); using branch + feedback");
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
const heartbeatOn = Boolean(caps.editMessage) && cfg.heartbeatMs > 0 && !streamOn;
|
|
673
1718
|
let lastLine = "";
|
|
674
|
-
let progressId = null; // the thread progress reply, once
|
|
1719
|
+
let progressId = null; // the thread progress/status reply, once one has posted
|
|
675
1720
|
const startHeartbeat = () => {
|
|
676
1721
|
if (!heartbeatOn) return () => {};
|
|
677
1722
|
const start = deps.now();
|
|
@@ -714,27 +1759,139 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
714
1759
|
// Run the CLI and stage everything it changed; return the diff stats (no post).
|
|
715
1760
|
// Workspace memory (the project's soul) is prepended so the coding agent has
|
|
716
1761
|
// the shared context before it starts.
|
|
717
|
-
const runAndStage = async (promptText) => {
|
|
1762
|
+
const runAndStage = async (promptText, { resume = true } = {}) => {
|
|
718
1763
|
console.log(
|
|
719
1764
|
` code → running \`${cfg.codingCmd}\` in ${repoPath} (this can take a few minutes; output appears when it finishes)…`,
|
|
720
1765
|
);
|
|
721
|
-
|
|
1766
|
+
// Streaming path: post ONE thread status message up front, then stream a live
|
|
1767
|
+
// card into it via post_progress. Legacy path: the 3-min edit heartbeat. Only
|
|
1768
|
+
// one of them runs — never both.
|
|
1769
|
+
let stopHeartbeat = () => {};
|
|
1770
|
+
let emitter = null;
|
|
1771
|
+
// Every post_progress write we dispatch, chained so the finally can await ALL
|
|
1772
|
+
// of them before the run returns. This closes the read-modify-write race with
|
|
1773
|
+
// the in-place settle (0289): if a trailing progress write were still landing
|
|
1774
|
+
// when post_report settles the report, it would re-write metadata WITHOUT the
|
|
1775
|
+
// report (it read the pre-settle snapshot) and clobber it. Draining every send
|
|
1776
|
+
// here guarantees no progress write is in flight once we settle.
|
|
1777
|
+
let progressInflight = Promise.resolve();
|
|
1778
|
+
if (streamOn) {
|
|
1779
|
+
if (!progressId) {
|
|
1780
|
+
try {
|
|
1781
|
+
const r = await tool("post_message", {
|
|
1782
|
+
channelId,
|
|
1783
|
+
parentId: threadRoot,
|
|
1784
|
+
body: buildHeartbeat({ repoFullName, branch, elapsedMs: 0, lastLine: "" }),
|
|
1785
|
+
});
|
|
1786
|
+
progressId = r?.messageId ?? null;
|
|
1787
|
+
} catch {
|
|
1788
|
+
progressId = null; // no status card → the run still proceeds
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
const statusId = progressId;
|
|
1792
|
+
if (statusId) {
|
|
1793
|
+
emitter = createProgressEmitter({
|
|
1794
|
+
parser: makeStreamParser(vendor),
|
|
1795
|
+
now: deps.now,
|
|
1796
|
+
throttleMs: cfg.progressMs,
|
|
1797
|
+
// The module never imports MCP — the send fn is injected here. It must
|
|
1798
|
+
// never throw into the run (createProgressEmitter also guards).
|
|
1799
|
+
send: (p) => {
|
|
1800
|
+
try {
|
|
1801
|
+
const r = tool("post_progress", { messageId: statusId, progress: p });
|
|
1802
|
+
if (r && typeof r.then === "function") {
|
|
1803
|
+
const done = r.then(() => {}, () => {});
|
|
1804
|
+
progressInflight = Promise.all([progressInflight, done]).then(
|
|
1805
|
+
() => {},
|
|
1806
|
+
() => {},
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
} catch {
|
|
1810
|
+
/* a progress send must never break the run */
|
|
1811
|
+
}
|
|
1812
|
+
},
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
} else {
|
|
1816
|
+
stopHeartbeat = startHeartbeat();
|
|
1817
|
+
}
|
|
1818
|
+
// The code run gets the vendor's stream flags appended to its ARGV (not the
|
|
1819
|
+
// display string), and ONLY the code run — chat/routing/plan-ack stay plain.
|
|
1820
|
+
// On an iterate we ALSO insert `--resume <id>` (0282) into the code ARGV
|
|
1821
|
+
// (after the base args, before the stream flags + the trailing prompt) so the
|
|
1822
|
+
// follow-up continues the first run's session; `resume:false` (the never-worse
|
|
1823
|
+
// fallback) suppresses it. buildResumeArgs is [] unless vendor+session make
|
|
1824
|
+
// resume safe, so a non-resume run is byte-identical to the pre-0282 ARGV.
|
|
1825
|
+
const resumeArgs = resume ? buildResumeArgs(vendor, resumeSessionId) : [];
|
|
1826
|
+
const codeArgs = streamOn
|
|
1827
|
+
? [...parts.slice(1), ...resumeArgs, ...streamArgs]
|
|
1828
|
+
: [...parts.slice(1), ...resumeArgs];
|
|
722
1829
|
let run;
|
|
723
1830
|
try {
|
|
724
1831
|
run = await runCli({
|
|
725
1832
|
cmd: parts[0],
|
|
726
|
-
args: [...
|
|
1833
|
+
args: [...codeArgs, memoryPreamble(workspaceMemory) + promptText],
|
|
727
1834
|
cwd: repoPath,
|
|
728
1835
|
timeoutMs: cfg.runTimeoutMs,
|
|
729
1836
|
label: "coding",
|
|
730
1837
|
signal,
|
|
731
1838
|
onData: (c) => {
|
|
1839
|
+
// Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
|
|
732
1840
|
const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
733
1841
|
if (lines.length) lastLine = lines[lines.length - 1];
|
|
1842
|
+
if (emitter) {
|
|
1843
|
+
try {
|
|
1844
|
+
emitter.feed(c);
|
|
1845
|
+
} catch {
|
|
1846
|
+
/* a progress fold must never break the run */
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
734
1849
|
},
|
|
735
1850
|
});
|
|
736
1851
|
} finally {
|
|
737
1852
|
stopHeartbeat();
|
|
1853
|
+
if (emitter) {
|
|
1854
|
+
// Terminal state: flip the status card off "working" (state 'done'/'error')
|
|
1855
|
+
// so it stops claiming the agent is alive. For a run that produced work,
|
|
1856
|
+
// the gate:false path then SETTLES the report onto this same card in place
|
|
1857
|
+
// (0289) — the card cross-fades run → report; for no-changes/failed/gate
|
|
1858
|
+
// outcomes this terminal 'done'/'error' is the card's final state.
|
|
1859
|
+
const errored = Boolean(run && (run.aborted || run.error || run.status !== 0));
|
|
1860
|
+
// On error, carry an honest reason onto the card (0294) — the same signal
|
|
1861
|
+
// the chat note uses: spawn error message, else exit status, plus a short
|
|
1862
|
+
// stderr tail. Sanitized again server-side; an old server just drops it.
|
|
1863
|
+
let reason = "";
|
|
1864
|
+
if (errored && run && !run.aborted) {
|
|
1865
|
+
const stderrTail = oneLine((run.stderr || "").trim().split("\n").slice(-3).join(" "), 200);
|
|
1866
|
+
if (run.error?.code === "ENOENT") {
|
|
1867
|
+
reason = `Couldn't start ${parts[0]} — is it installed and on PATH?`;
|
|
1868
|
+
} else if (run.error?.message) {
|
|
1869
|
+
reason = run.error.message;
|
|
1870
|
+
} else if (run.status != null) {
|
|
1871
|
+
reason = `The CLI exited ${run.status}`;
|
|
1872
|
+
}
|
|
1873
|
+
if (stderrTail) reason = reason ? `${reason}: ${stderrTail}` : stderrTail;
|
|
1874
|
+
}
|
|
1875
|
+
try {
|
|
1876
|
+
emitter.done(errored ? "error" : "done", reason);
|
|
1877
|
+
} catch {
|
|
1878
|
+
/* ignore */
|
|
1879
|
+
}
|
|
1880
|
+
// Wait for every progress write (including the terminal one above) to land
|
|
1881
|
+
// BEFORE runAndStage returns, so nothing is in flight when the caller
|
|
1882
|
+
// settles the report onto this card — no lost-update clobber (0289).
|
|
1883
|
+
try {
|
|
1884
|
+
await progressInflight;
|
|
1885
|
+
} catch {
|
|
1886
|
+
/* a drain failure must never break the run */
|
|
1887
|
+
}
|
|
1888
|
+
try {
|
|
1889
|
+
const snap = emitter.snapshot();
|
|
1890
|
+
if (snap && snap.sessionId) runSessionId = snap.sessionId;
|
|
1891
|
+
} catch {
|
|
1892
|
+
/* ignore */
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
738
1895
|
}
|
|
739
1896
|
if (run.aborted || signal?.aborted) {
|
|
740
1897
|
console.log(" code → cancelled");
|
|
@@ -763,9 +1920,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
763
1920
|
// (commit/push/PR) despite being told only to edit. Detect commits it made
|
|
764
1921
|
// on this branch so we report + ship them instead of falsely claiming "no
|
|
765
1922
|
// changes" and deleting a branch that has real work.
|
|
766
|
-
const base = startRef.ref || cfg.defaultBranch;
|
|
767
1923
|
const ahead =
|
|
768
|
-
Number((git(repoPath, ["rev-list", "--count", `${
|
|
1924
|
+
Number((git(repoPath, ["rev-list", "--count", `${baseRef}..HEAD`]).stdout || "0").trim()) || 0;
|
|
769
1925
|
if (ahead > 0) {
|
|
770
1926
|
console.log(` code → agent self-committed (${ahead} commit(s) ahead); reconciling`);
|
|
771
1927
|
return { empty: true, failed: false, ahead };
|
|
@@ -779,6 +1935,36 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
779
1935
|
return { empty: false, diffText, truncated, omittedLines, stat, runFailed: run.status !== 0 };
|
|
780
1936
|
};
|
|
781
1937
|
|
|
1938
|
+
// Persist the run's coding-agent session (0282) so a LATER iterate can resume it.
|
|
1939
|
+
// Two records, both best-effort — a failure here NEVER breaks the run:
|
|
1940
|
+
// - update_run({ providerSessionId }) → the server's durable record (survives a
|
|
1941
|
+
// daemon restart; also read by the hosted UI). Only when a runId was recorded.
|
|
1942
|
+
// - ~/.hilos/state.json for this thread root, stamped with THIS machine — the
|
|
1943
|
+
// LOCAL proof the session lives here, which the resume gate above requires.
|
|
1944
|
+
// Called after each run (a resumed run yields a possibly-new sessionId, so the
|
|
1945
|
+
// chain continues across multiple iterations) and again post-ship to fill prUrl.
|
|
1946
|
+
const recordSession = async (prUrl) => {
|
|
1947
|
+
const sid = runSessionId;
|
|
1948
|
+
if (!sid) return;
|
|
1949
|
+
if (runId) {
|
|
1950
|
+
await tool("update_run", { runId, providerSessionId: sid }).catch(() => {});
|
|
1951
|
+
}
|
|
1952
|
+
try {
|
|
1953
|
+
if (threadRoot) {
|
|
1954
|
+
writeState(HILOS_DIR, threadRoot, {
|
|
1955
|
+
runId: runId || null,
|
|
1956
|
+
branch,
|
|
1957
|
+
prUrl: prUrl || continuingPrUrl || null,
|
|
1958
|
+
sessionId: sid,
|
|
1959
|
+
machine,
|
|
1960
|
+
updatedAt: new Date().toISOString(),
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
} catch {
|
|
1964
|
+
/* a state write must never break the run */
|
|
1965
|
+
}
|
|
1966
|
+
};
|
|
1967
|
+
|
|
782
1968
|
// Post a proposal card (approve-before-push mode) from a staged change.
|
|
783
1969
|
const postProposal = async (staged) => {
|
|
784
1970
|
const report = buildProposalReport({
|
|
@@ -821,19 +2007,66 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
821
2007
|
// Post the right cancel message: honest about whether cleanup actually worked.
|
|
822
2008
|
const postStopped = async () => {
|
|
823
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}.`;
|
|
824
2013
|
await tool("post_message", {
|
|
825
2014
|
channelId,
|
|
826
2015
|
parentId: threadRoot,
|
|
827
2016
|
broadcast: true,
|
|
828
|
-
body
|
|
829
|
-
? `Stopped — discarded \`${branch}\`.`
|
|
830
|
-
: `Stopped, but couldn't fully clean \`${branch}\` — run \`git reset --hard && git switch ${startRef.ref || cfg.defaultBranch}\` in ${repoPath}.`,
|
|
2017
|
+
body,
|
|
831
2018
|
});
|
|
2019
|
+
await updateChannelMarker(compactRunMarker("cancelled", branch));
|
|
832
2020
|
return { status: "cancelled", branch };
|
|
833
2021
|
};
|
|
834
2022
|
|
|
835
|
-
|
|
2023
|
+
// Team memory recall (0297): best-effort fetch accumulated learnings for this
|
|
2024
|
+
// channel before the coding run so the coding agent has the team's conventions
|
|
2025
|
+
// and gotchas from the start. Capability-gated: older servers without the
|
|
2026
|
+
// `recall` tool degrade silently (caps.recall=false → skip, no change in
|
|
2027
|
+
// behavior). A recall failure — network hiccup, server error — NEVER fails the
|
|
2028
|
+
// task; it is caught and the run proceeds without the block.
|
|
2029
|
+
let teamMemoryBlock = "";
|
|
2030
|
+
if (caps.recall) {
|
|
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.
|
|
2038
|
+
const recalled = await tool("recall", { channelId, limit: 20 }).catch(() => null);
|
|
2039
|
+
const mems = Array.isArray(recalled?.memories) ? recalled.memories : [];
|
|
2040
|
+
teamMemoryBlock = buildMemoryBlock(mems);
|
|
2041
|
+
} catch {
|
|
2042
|
+
/* recall failure must never break the coding task */
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
const codePrompt =
|
|
2047
|
+
(teamMemoryBlock ? teamMemoryBlock + "\n\n" : "") +
|
|
2048
|
+
codeTaskPrompt({ message, context, brief: routed.task, repoFullName });
|
|
2049
|
+
let staged = await runAndStage(codePrompt);
|
|
836
2050
|
if (staged.aborted) return await postStopped();
|
|
2051
|
+
// Never-worse-than-today (0282): if we RESUMED a session and that run FAILED (a
|
|
2052
|
+
// stale/foreign `--resume` id makes claude error out → empty diff → failed), retry
|
|
2053
|
+
// ONCE without --resume — the exact branch+feedback iterate we'd have run before
|
|
2054
|
+
// 0282. A dead session can therefore never dead-end an iteration. Gated on
|
|
2055
|
+
// staged.failed (the reliable resume-error signal) so a legitimate no-op run isn't
|
|
2056
|
+
// re-run. Adopt the retry regardless — it IS the pre-0282 baseline.
|
|
2057
|
+
if (resumeSessionId && staged.empty && staged.failed) {
|
|
2058
|
+
console.log(" code → resumed run failed; retrying once without --resume (branch + feedback)");
|
|
2059
|
+
const retry = await runAndStage(codePrompt, { resume: false });
|
|
2060
|
+
if (retry.aborted) return await postStopped();
|
|
2061
|
+
staged = retry;
|
|
2062
|
+
}
|
|
2063
|
+
// Persist this run's session (best-effort) so the NEXT iterate can resume it and
|
|
2064
|
+
// machine-match. prUrl is filled in again at ship time.
|
|
2065
|
+
await recordSession(continuingPrUrl);
|
|
2066
|
+
// Resume is a ONE-shot for the first run of this iterate: the gate:true feedback
|
|
2067
|
+
// rounds below are intra-run refinements (same working tree, same PR turn), not a
|
|
2068
|
+
// fresh cross-turn resume, so they must NOT replay the original `--resume` id.
|
|
2069
|
+
resumeSessionId = null;
|
|
837
2070
|
// The CLI committed on its own (clean tree, but commits ahead of base). Don't
|
|
838
2071
|
// report "no changes" or delete the branch — surface the real work.
|
|
839
2072
|
if (staged.empty && !staged.failed && staged.ahead > 0) {
|
|
@@ -850,10 +2083,15 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
850
2083
|
`re-mention me to ship or discard.`,
|
|
851
2084
|
});
|
|
852
2085
|
await finalizeProgress(`\`${branch}\` has the agent's own commits — needs review.`);
|
|
2086
|
+
await updateChannelMarker(compactRunMarker("changes", branch));
|
|
853
2087
|
return { status: "self-committed-gated", branch };
|
|
854
2088
|
}
|
|
855
|
-
|
|
856
|
-
|
|
2089
|
+
// When a live status card was streaming, settle the report onto it (0289) —
|
|
2090
|
+
// the card cross-fades run → report. Skip finalizeProgress then (it edits the
|
|
2091
|
+
// card's body, which the settle overwrites with the report anyway).
|
|
2092
|
+
const selfSettleId = streamOn && progressId ? progressId : null;
|
|
2093
|
+
if (!selfSettleId) await finalizeProgress(`Agent shipped \`${branch}\` itself — report below.`);
|
|
2094
|
+
const selfResult = await shipSelfDriven({
|
|
857
2095
|
repoPath,
|
|
858
2096
|
branch,
|
|
859
2097
|
task: routed.task || message.body,
|
|
@@ -863,12 +2101,23 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
863
2101
|
channelId,
|
|
864
2102
|
deps,
|
|
865
2103
|
parentId: threadRoot,
|
|
2104
|
+
settleId: selfSettleId,
|
|
2105
|
+
runId,
|
|
866
2106
|
});
|
|
2107
|
+
await recordSession(selfResult.prUrl);
|
|
2108
|
+
await updateChannelMarker(compactRunMarker(selfResult.status, selfResult.branch));
|
|
2109
|
+
return selfResult;
|
|
867
2110
|
}
|
|
868
2111
|
if (staged.empty) {
|
|
2112
|
+
// When continuing an existing PR, never delete that branch — it's the open
|
|
2113
|
+
// PR — and don't say "cleaning up". Just report and leave the PR as it was.
|
|
2114
|
+
const cleanupNote = continuingPrUrl ? "" : ` Cleaning up \`${branch}\`.`;
|
|
2115
|
+
const retryTail = continuingPrUrl
|
|
2116
|
+
? ` — \`${branch}\` is unchanged; mention me to retry.`
|
|
2117
|
+
: ` Cleaning up \`${branch}\` — mention me to retry.`;
|
|
869
2118
|
let body;
|
|
870
2119
|
if (staged.failed && staged.errCode === "ENOENT") {
|
|
871
|
-
body = `I couldn't start \`${cfg.codingCmd}\` — is it installed and on PATH
|
|
2120
|
+
body = `I couldn't start \`${cfg.codingCmd}\` — is it installed and on PATH?${cleanupNote}`;
|
|
872
2121
|
} else if (staged.failed) {
|
|
873
2122
|
const why = staged.errMessage
|
|
874
2123
|
? ` (${staged.errMessage})`
|
|
@@ -876,16 +2125,23 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
876
2125
|
? ` (the CLI exited ${staged.status})`
|
|
877
2126
|
: "";
|
|
878
2127
|
const tail = staged.stderrTail ? `: ${staged.stderrTail}` : "";
|
|
879
|
-
body = `The run didn't finish${why}${tail}
|
|
2128
|
+
body = `The run didn't finish${why}${tail}.${retryTail}`;
|
|
880
2129
|
} else {
|
|
881
|
-
body =
|
|
2130
|
+
body = continuingPrUrl
|
|
2131
|
+
? `That feedback produced no further changes — \`${branch}\` is unchanged.`
|
|
2132
|
+
: `No changes were produced.${cleanupNote}`;
|
|
882
2133
|
}
|
|
883
2134
|
await tool("post_message", { channelId, parentId: threadRoot, broadcast: true, body });
|
|
884
2135
|
await finalizeProgress(
|
|
885
2136
|
staged.failed ? `Run ended early on \`${branch}\` — see the note below.` : `No changes needed on \`${branch}\`.`,
|
|
886
2137
|
);
|
|
887
|
-
|
|
888
|
-
|
|
2138
|
+
if (!continuingPrUrl) {
|
|
2139
|
+
git(repoPath, ["switch", "-"]);
|
|
2140
|
+
git(repoPath, ["branch", "-D", branch]);
|
|
2141
|
+
}
|
|
2142
|
+
await updateChannelMarker(
|
|
2143
|
+
compactRunMarker(staged.failed ? "run-failed" : "no-changes", branch),
|
|
2144
|
+
);
|
|
889
2145
|
return { status: staged.failed ? "run-failed" : "no-changes" };
|
|
890
2146
|
}
|
|
891
2147
|
|
|
@@ -895,20 +2151,39 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
895
2151
|
if (!cfg.gate) {
|
|
896
2152
|
// A "stop" that lands between the run finishing and the ship must still win.
|
|
897
2153
|
if (signal?.aborted) return await postStopped();
|
|
2154
|
+
// Seamless single card (0289): when a live status card streamed this run,
|
|
2155
|
+
// settle the report onto it in place instead of posting a separate report
|
|
2156
|
+
// message. Only the DEFAULT gate:false report settles in place; gate:true's
|
|
2157
|
+
// proposal → decision → report lifecycle stays on separate messages (a
|
|
2158
|
+
// separate ticket). No status card (older server / status post failed) →
|
|
2159
|
+
// settleId is null and applyDecision posts a fresh final report instead.
|
|
2160
|
+
const settleId = streamOn && progressId ? progressId : null;
|
|
898
2161
|
const result = await applyDecision({
|
|
899
2162
|
decision: { kind: "approved" },
|
|
900
2163
|
repoPath,
|
|
901
2164
|
branch,
|
|
902
|
-
|
|
2165
|
+
// For a continuation the raw mention is the rework ping ("please update the
|
|
2166
|
+
// PR …"); use the router's distilled brief for a clean commit/PR title.
|
|
2167
|
+
task: continuingPrUrl ? routed.task || message.body : message.body,
|
|
903
2168
|
requester: message.author,
|
|
904
2169
|
cfg,
|
|
905
2170
|
tool,
|
|
906
2171
|
channelId,
|
|
907
2172
|
deps,
|
|
908
2173
|
parentId: threadRoot,
|
|
2174
|
+
existingPrUrl: continuingPrUrl,
|
|
2175
|
+
settleId,
|
|
2176
|
+
runId,
|
|
909
2177
|
});
|
|
910
|
-
|
|
911
|
-
|
|
2178
|
+
// The settle already flipped the card to the report (body + metadata); editing
|
|
2179
|
+
// the body again would clobber the report summary, so only finalize when we
|
|
2180
|
+
// posted a separate report.
|
|
2181
|
+
if (!settleId) await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
|
|
2182
|
+
// Re-record with the now-known PR url so the local session record + the run row
|
|
2183
|
+
// carry the PR the session belongs to (best-effort; session id unchanged).
|
|
2184
|
+
await recordSession(result.prUrl);
|
|
2185
|
+
await updateChannelMarker(compactRunMarker(result.status, result.branch));
|
|
2186
|
+
return { ...result, stat: staged.stat, sessionId: runSessionId };
|
|
912
2187
|
}
|
|
913
2188
|
|
|
914
2189
|
await finalizeProgress(`Coding done on \`${branch}\` — proposal below for review.`);
|
|
@@ -948,16 +2223,22 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
948
2223
|
decision,
|
|
949
2224
|
repoPath,
|
|
950
2225
|
branch,
|
|
951
|
-
|
|
2226
|
+
// A gate:true approve on a continuation already has an open PR — reuse it so
|
|
2227
|
+
// we update in place instead of a failing `gh pr create`.
|
|
2228
|
+
task: continuingPrUrl ? routed.task || message.body : message.body,
|
|
952
2229
|
requester: message.author,
|
|
953
2230
|
cfg,
|
|
954
2231
|
tool,
|
|
955
2232
|
channelId,
|
|
956
2233
|
deps,
|
|
957
2234
|
parentId: threadRoot,
|
|
2235
|
+
existingPrUrl: continuingPrUrl,
|
|
2236
|
+
runId,
|
|
958
2237
|
});
|
|
959
2238
|
await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
|
|
960
|
-
|
|
2239
|
+
await recordSession(result.prUrl);
|
|
2240
|
+
await updateChannelMarker(compactRunMarker(result.status, result.branch));
|
|
2241
|
+
return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round, sessionId: runSessionId };
|
|
961
2242
|
} finally {
|
|
962
2243
|
// Whatever happened, give the user their uncommitted work back.
|
|
963
2244
|
await restoreStash();
|