hilos-agent 0.7.0 → 0.9.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 +15 -0
- package/package.json +1 -1
- package/src/acp-session.mjs +538 -0
- package/src/agent-events.mjs +119 -0
- package/src/config.mjs +8 -0
- package/src/daemon.mjs +23 -0
- package/src/handler.mjs +306 -33
- package/src/progress-emitter.mjs +11 -1
- package/src/run.mjs +5 -0
- package/src/thread-pr.mjs +305 -0
package/src/config.mjs
CHANGED
|
@@ -57,6 +57,12 @@ const DEFAULTS = {
|
|
|
57
57
|
// it still won't run arbitrary commands. Override in hilos-agent.json if you
|
|
58
58
|
// want a stricter (or `--dangerously-skip-permissions`) command.
|
|
59
59
|
codingCmd: "claude -p --permission-mode acceptEdits",
|
|
60
|
+
// ACP transport opt-in (0759): drive the coding CLI over the Agent Client
|
|
61
|
+
// Protocol (JSON-RPC on stdio) instead of argv + stdout scraping. Covers
|
|
62
|
+
// opencode (`opencode acp`) and cursor (`cursor-agent acp`), and only for
|
|
63
|
+
// runs the workspace already gates with runtime permissions; every other
|
|
64
|
+
// run keeps the existing paths. Also enabled by HILOS_ACP=1.
|
|
65
|
+
acpTransport: false,
|
|
60
66
|
// Environment handed to the coding/chat CLI. hilos's own token (HILOS_*) is
|
|
61
67
|
// ALWAYS stripped either way. "inherit" (default) passes the rest of your
|
|
62
68
|
// shell env so the coding tool behaves exactly as if you ran it yourself.
|
|
@@ -124,6 +130,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
|
|
|
124
130
|
chatTimeoutMs: process.env.HILOS_CHAT_TIMEOUT_MS ? Number(process.env.HILOS_CHAT_TIMEOUT_MS) : undefined,
|
|
125
131
|
backfill: process.env.HILOS_BACKFILL === "1" ? true : undefined,
|
|
126
132
|
once: process.env.HILOS_ONCE === "1" ? true : undefined,
|
|
133
|
+
acpTransport: process.env.HILOS_ACP === "1" ? true : undefined,
|
|
127
134
|
};
|
|
128
135
|
const merged = { ...DEFAULTS, ...file };
|
|
129
136
|
for (const [k, v] of Object.entries(env)) if (v !== undefined && v !== "") merged[k] = v;
|
|
@@ -155,6 +162,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
|
|
|
155
162
|
// edited file can NEVER drop the daemon's connection.
|
|
156
163
|
const LIVE_FIELDS = [
|
|
157
164
|
"codingCmd",
|
|
165
|
+
"acpTransport",
|
|
158
166
|
"codingModel",
|
|
159
167
|
"chatCmd",
|
|
160
168
|
"codingEnv",
|
package/src/daemon.mjs
CHANGED
|
@@ -16,6 +16,29 @@ export function branchSlug(text, suffix) {
|
|
|
16
16
|
return `hilos/${base}-${suffix}`;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* How to push work when the coding child committed instead of leaving a diff.
|
|
21
|
+
*
|
|
22
|
+
* The daemon owns `intendedBranch`. A child that changes branches must not also
|
|
23
|
+
* change the PR head: if it switches to the configured base branch, using the
|
|
24
|
+
* child's current branch would ask GitHub to open `main` against `main`. Push
|
|
25
|
+
* the current HEAD commit under the daemon-owned branch name instead. Omitting
|
|
26
|
+
* `-u` on recovery also avoids rewriting the accidental branch's upstream.
|
|
27
|
+
*/
|
|
28
|
+
export function selfDrivenShipPlan(currentBranch, intendedBranch) {
|
|
29
|
+
const current = String(currentBranch || "").trim();
|
|
30
|
+
const intended = String(intendedBranch || "").trim();
|
|
31
|
+
if (!intended) throw new Error("An intended task branch is required");
|
|
32
|
+
const recovered = !current || current !== intended;
|
|
33
|
+
return {
|
|
34
|
+
headBranch: intended,
|
|
35
|
+
pushArgs: recovered
|
|
36
|
+
? ["push", "origin", `HEAD:refs/heads/${intended}`]
|
|
37
|
+
: ["push", "-u", "origin", intended],
|
|
38
|
+
recoveredFrom: recovered ? current || "detached HEAD" : null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
19
42
|
/** Truncate a diff to a byte budget at a line boundary. */
|
|
20
43
|
export function truncateDiff(diff, maxBytes = 12000) {
|
|
21
44
|
if (diff.length <= maxBytes) return { text: diff, truncated: false, omittedLines: 0 };
|
package/src/handler.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
prTitleBody,
|
|
26
26
|
mentionHandle,
|
|
27
27
|
detectPrContinuation,
|
|
28
|
+
selfDrivenShipPlan,
|
|
28
29
|
} from "./daemon.mjs";
|
|
29
30
|
import {
|
|
30
31
|
runCli,
|
|
@@ -47,6 +48,13 @@ import {
|
|
|
47
48
|
fastChatCmd,
|
|
48
49
|
} from "./progress-emitter.mjs";
|
|
49
50
|
import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
|
|
51
|
+
import {
|
|
52
|
+
anchorIterateDecision,
|
|
53
|
+
confirmAnchorOpen,
|
|
54
|
+
prActionRelayDecision,
|
|
55
|
+
prActionReply,
|
|
56
|
+
staleAnchorNote,
|
|
57
|
+
} from "./thread-pr.mjs";
|
|
50
58
|
import { buildResumeArgs, resumeDecision, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
|
|
51
59
|
import { createModelArgsResolver } from "./model-resolve.mjs";
|
|
52
60
|
import {
|
|
@@ -58,6 +66,7 @@ import {
|
|
|
58
66
|
import { buildMemoryBlock } from "./memory.mjs";
|
|
59
67
|
import { deployFolder, resolveDeployTarget } from "./deploy.mjs";
|
|
60
68
|
import { runOpenCodeHttpSession } from "./opencode-session.mjs";
|
|
69
|
+
import { runAcpSession } from "./acp-session.mjs";
|
|
61
70
|
|
|
62
71
|
/**
|
|
63
72
|
* The environment for a coding/chat CLI run. runCli always strips HILOS_* on top
|
|
@@ -152,6 +161,7 @@ function defaultDeps() {
|
|
|
152
161
|
// selected trust boundary without spawning a real model process.
|
|
153
162
|
runCli: (opts) => runCli(opts),
|
|
154
163
|
runOpenCodeHttpSession: (opts) => runOpenCodeHttpSession(opts),
|
|
164
|
+
runAcpSession: (opts) => runAcpSession(opts),
|
|
155
165
|
// Does a path exist on disk? Injectable so folder mode's "missing folder"
|
|
156
166
|
// guard is unit-testable without touching the real filesystem.
|
|
157
167
|
pathExists: (p) => existsSync(p),
|
|
@@ -164,7 +174,7 @@ function defaultDeps() {
|
|
|
164
174
|
{ cwd, env: cwdEnv(cwd), encoding: "utf8" },
|
|
165
175
|
);
|
|
166
176
|
const url = (r.stdout || "").trim().split("\n").filter(Boolean).pop() || null;
|
|
167
|
-
return { ok: r.status === 0, url, stderr: r.stderr || "" };
|
|
177
|
+
return { ok: r.status === 0, url, stdout: r.stdout || "", stderr: r.stderr || "" };
|
|
168
178
|
},
|
|
169
179
|
// The open PR for a branch, if one already exists — so when the CLI opened a
|
|
170
180
|
// PR itself we report THAT instead of opening a duplicate.
|
|
@@ -189,6 +199,37 @@ function defaultDeps() {
|
|
|
189
199
|
const out = (r.stdout || "").trim();
|
|
190
200
|
return r.status === 0 && out ? out : null;
|
|
191
201
|
},
|
|
202
|
+
// The live state of a PR (0704): is it still OPEN, what is its head branch,
|
|
203
|
+
// and does that head live in the base repo? The staleness guard for adopting
|
|
204
|
+
// the thread's pull request — a merged/closed anchor must never take a push,
|
|
205
|
+
// and a fork head is another repository's branch. Returns null when `gh`
|
|
206
|
+
// can't answer, which the caller reads as "no proof, no adoption".
|
|
207
|
+
prState: (cwd, ref) => {
|
|
208
|
+
const r = spawnSync(
|
|
209
|
+
"gh",
|
|
210
|
+
[
|
|
211
|
+
"pr",
|
|
212
|
+
"view",
|
|
213
|
+
String(ref),
|
|
214
|
+
"--json",
|
|
215
|
+
"state,headRefName,headRepository,headRepositoryOwner",
|
|
216
|
+
],
|
|
217
|
+
{ cwd, env: cwdEnv(cwd), encoding: "utf8" },
|
|
218
|
+
);
|
|
219
|
+
if (r.status !== 0) return null;
|
|
220
|
+
try {
|
|
221
|
+
const j = JSON.parse(r.stdout || "{}");
|
|
222
|
+
const owner = j.headRepositoryOwner?.login || j.headRepositoryOwner?.name || null;
|
|
223
|
+
const repo = j.headRepository?.name || null;
|
|
224
|
+
return {
|
|
225
|
+
state: typeof j.state === "string" ? j.state : "",
|
|
226
|
+
headRefName: typeof j.headRefName === "string" ? j.headRefName : null,
|
|
227
|
+
headRepoFullName: owner && repo ? `${owner}/${repo}` : null,
|
|
228
|
+
};
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
},
|
|
192
233
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
193
234
|
now: () => Date.now(),
|
|
194
235
|
};
|
|
@@ -199,7 +240,13 @@ function defaultDeps() {
|
|
|
199
240
|
* Both repo and direct-folder runs use this exact callback contract so neither
|
|
200
241
|
* path can accidentally become the ungated exception.
|
|
201
242
|
*/
|
|
202
|
-
function openCodePermissionCallbacks({
|
|
243
|
+
function openCodePermissionCallbacks({
|
|
244
|
+
tool,
|
|
245
|
+
channelId,
|
|
246
|
+
threadRoot,
|
|
247
|
+
runId = null,
|
|
248
|
+
provider = "opencode",
|
|
249
|
+
}) {
|
|
203
250
|
return {
|
|
204
251
|
requestPermission: async (request) => {
|
|
205
252
|
const detail =
|
|
@@ -216,7 +263,7 @@ function openCodePermissionCallbacks({ tool, channelId, threadRoot, runId = null
|
|
|
216
263
|
channelId,
|
|
217
264
|
threadRootId: threadRoot,
|
|
218
265
|
...(runId ? { runId } : {}),
|
|
219
|
-
provider
|
|
266
|
+
provider,
|
|
220
267
|
vendorSessionId: request.sessionId,
|
|
221
268
|
vendorRequestId: request.vendorRequestId,
|
|
222
269
|
action: request.action,
|
|
@@ -241,7 +288,7 @@ function openCodePermissionCallbacks({ tool, channelId, threadRoot, runId = null
|
|
|
241
288
|
}
|
|
242
289
|
return tool("get_permission_decision", {
|
|
243
290
|
requestId,
|
|
244
|
-
provider
|
|
291
|
+
provider,
|
|
245
292
|
vendorSessionId: request.sessionId,
|
|
246
293
|
vendorRequestId: request.vendorRequestId,
|
|
247
294
|
...(failClosed ? { failClosed: true } : {}),
|
|
@@ -268,6 +315,39 @@ export function shouldUseRuntimePermissionBridge({
|
|
|
268
315
|
);
|
|
269
316
|
}
|
|
270
317
|
|
|
318
|
+
/** ACP transport opt-in (0759; slice 1 opencode, slice 2 cursor). Only runs
|
|
319
|
+
* the workspace already gates with runtime permissions qualify, PLUS the
|
|
320
|
+
* operator's explicit acpTransport flag, and only when the vendor's own
|
|
321
|
+
* auto-allow escape hatches are absent — over ACP, hilos must be the one
|
|
322
|
+
* answering asks, so a run configured to never ask has nothing to gate here.
|
|
323
|
+
* Resumed runs stay on the existing paths: their session continuity contract
|
|
324
|
+
* is argv/HTTP-shaped and the ACP path does not carry it yet. */
|
|
325
|
+
export function shouldUseAcpTransport({
|
|
326
|
+
vendor,
|
|
327
|
+
acpTransport,
|
|
328
|
+
runtimePermissions,
|
|
329
|
+
codeArgs,
|
|
330
|
+
codingCmd,
|
|
331
|
+
resumeSessionId = /** @type {string | null} */ (null),
|
|
332
|
+
}) {
|
|
333
|
+
if (acpTransport !== true || resumeSessionId != null) return false;
|
|
334
|
+
if (runtimePermissions !== true) return false;
|
|
335
|
+
if (vendor === "opencode") {
|
|
336
|
+
return shouldUseRuntimePermissionBridge({
|
|
337
|
+
vendor,
|
|
338
|
+
runtimePermissions,
|
|
339
|
+
codeArgs,
|
|
340
|
+
codingCmd,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
if (vendor === "cursor") {
|
|
344
|
+
// -f/--force is cursor's "allow everything" switch — the exact analog of
|
|
345
|
+
// opencode's --auto.
|
|
346
|
+
return !codeArgs.includes("-f") && !codeArgs.includes("--force");
|
|
347
|
+
}
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
271
351
|
async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId, signal }) {
|
|
272
352
|
if (!reportMessageId) return { kind: "timeout" };
|
|
273
353
|
const deadline = deps.now() + cfg.decisionTimeoutMs;
|
|
@@ -431,37 +511,63 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
431
511
|
* The coding CLI committed the work itself (autonomous run with skip-permissions
|
|
432
512
|
* in a repo whose docs prescribe a commit+PR flow), so the working tree is clean
|
|
433
513
|
* and the daemon's diff is empty. Don't claim "no changes": adopt what it did —
|
|
434
|
-
* push
|
|
435
|
-
* open one, and report it.
|
|
514
|
+
* push its HEAD under the daemon-owned task branch, reuse the PR it opened or
|
|
515
|
+
* open one, and report it. Keeping the intended head matters: a child that
|
|
516
|
+
* switched to `main` would otherwise make `gh` try to open main → main. Used
|
|
517
|
+
* only when NOT gated (bias-to-action).
|
|
436
518
|
*/
|
|
437
519
|
async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, settleId, runId }) {
|
|
438
520
|
const tag = requesterTag(requester);
|
|
439
521
|
const lead = tag ? `${tag} — ` : "";
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
let
|
|
522
|
+
const currentBranch =
|
|
523
|
+
(deps.git(repoPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]).stdout || "").trim();
|
|
524
|
+
const ship = selfDrivenShipPlan(currentBranch, branch);
|
|
525
|
+
const push = deps.git(repoPath, ship.pushArgs);
|
|
526
|
+
let prUrl = deps.findPR ? deps.findPR(repoPath, ship.headBranch) : null;
|
|
527
|
+
let prAttempt = null;
|
|
446
528
|
if (!prUrl && push.status === 0) {
|
|
447
|
-
const { title, body } = prTitleBody(task,
|
|
448
|
-
|
|
449
|
-
|
|
529
|
+
const { title, body } = prTitleBody(task, ship.headBranch);
|
|
530
|
+
prAttempt = deps.openPR(repoPath, {
|
|
531
|
+
title,
|
|
532
|
+
body,
|
|
533
|
+
branch: ship.headBranch,
|
|
534
|
+
base: cfg.defaultBranch,
|
|
535
|
+
});
|
|
536
|
+
prUrl = prAttempt.ok && prAttempt.url ? prAttempt.url : null;
|
|
450
537
|
}
|
|
451
|
-
const { title } = prTitleBody(task,
|
|
538
|
+
const { title } = prTitleBody(task, ship.headBranch);
|
|
539
|
+
const recoveredNote = ship.recoveredFrom
|
|
540
|
+
? ` The child had switched to \`${ship.recoveredFrom}\`; hilos recovered its HEAD onto \`${ship.headBranch}\`.`
|
|
541
|
+
: "";
|
|
542
|
+
const prFailure =
|
|
543
|
+
prAttempt && !prUrl
|
|
544
|
+
? oneLine(
|
|
545
|
+
(prAttempt.stderr || prAttempt.stdout || "gh did not return a pull request URL").trim(),
|
|
546
|
+
200,
|
|
547
|
+
)
|
|
548
|
+
: "";
|
|
452
549
|
// Seamless single card (0289): settle onto the live status message when one was
|
|
453
550
|
// streaming this run; the workspace setting decides channel visibility.
|
|
454
551
|
// Otherwise post a fresh final report.
|
|
455
552
|
const reportArgs = {
|
|
456
553
|
channelId,
|
|
457
|
-
title:
|
|
554
|
+
title: prUrl
|
|
555
|
+
? `Shipped: ${title}`
|
|
556
|
+
: push.status === 0
|
|
557
|
+
? `PR failed: ${title}`
|
|
558
|
+
: `Push failed: ${title}`,
|
|
458
559
|
summary: prUrl
|
|
459
|
-
? `${lead}the coding agent committed
|
|
560
|
+
? `${lead}the coding agent committed the work itself and opened a pull request from \`${ship.headBranch}\`.${recoveredNote}`
|
|
460
561
|
: push.status === 0
|
|
461
|
-
? `${lead}the coding agent committed and pushed \`${
|
|
462
|
-
: `${lead}the coding agent committed
|
|
562
|
+
? `${lead}the coding agent committed and pushed \`${ship.headBranch}\`, but GitHub rejected PR creation.${recoveredNote}`
|
|
563
|
+
: `${lead}the coding agent committed locally, but pushing \`${ship.headBranch}\` failed.${recoveredNote}`,
|
|
463
564
|
prUrl: prUrl || undefined,
|
|
464
|
-
caveats:
|
|
565
|
+
caveats:
|
|
566
|
+
push.status !== 0
|
|
567
|
+
? [`git push failed: ${(push.stderr || "").trim().slice(0, 200)}`]
|
|
568
|
+
: prFailure
|
|
569
|
+
? [`gh pr create failed: ${prFailure}`]
|
|
570
|
+
: [],
|
|
465
571
|
};
|
|
466
572
|
await tool(
|
|
467
573
|
"post_report",
|
|
@@ -475,11 +581,25 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
475
581
|
await tool("update_run", {
|
|
476
582
|
runId,
|
|
477
583
|
...(prUrl ? { prUrl } : {}),
|
|
478
|
-
status: "awaiting_review",
|
|
584
|
+
status: prUrl ? "awaiting_review" : "failed",
|
|
585
|
+
...(!prUrl
|
|
586
|
+
? {
|
|
587
|
+
reason:
|
|
588
|
+
push.status !== 0
|
|
589
|
+
? oneLine((push.stderr || "git push failed").trim(), 300)
|
|
590
|
+
: prFailure || "GitHub did not return a pull request URL",
|
|
591
|
+
}
|
|
592
|
+
: {}),
|
|
479
593
|
...(settleId ? { reportMsgId: settleId } : {}),
|
|
480
594
|
}).catch(() => {});
|
|
481
595
|
}
|
|
482
|
-
|
|
596
|
+
const status =
|
|
597
|
+
push.status !== 0 ? "commit-local" : prUrl ? "pushed" : "pr-failed";
|
|
598
|
+
return {
|
|
599
|
+
status,
|
|
600
|
+
branch: ship.headBranch,
|
|
601
|
+
prUrl,
|
|
602
|
+
};
|
|
483
603
|
}
|
|
484
604
|
|
|
485
605
|
// Sentinel the router model emits when the latest message is a request to change
|
|
@@ -1406,13 +1526,39 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
|
|
|
1406
1526
|
}
|
|
1407
1527
|
};
|
|
1408
1528
|
try {
|
|
1409
|
-
const
|
|
1529
|
+
const useAcpTransport = shouldUseAcpTransport({
|
|
1410
1530
|
vendor,
|
|
1531
|
+
acpTransport: cfg.acpTransport,
|
|
1411
1532
|
runtimePermissions: caps.runtimePermissions,
|
|
1412
1533
|
codeArgs,
|
|
1413
1534
|
codingCmd: cfg.codingCmd,
|
|
1414
1535
|
});
|
|
1415
|
-
|
|
1536
|
+
const useRuntimePermissionBridge =
|
|
1537
|
+
!useAcpTransport &&
|
|
1538
|
+
shouldUseRuntimePermissionBridge({
|
|
1539
|
+
vendor,
|
|
1540
|
+
runtimePermissions: caps.runtimePermissions,
|
|
1541
|
+
codeArgs,
|
|
1542
|
+
codingCmd: cfg.codingCmd,
|
|
1543
|
+
});
|
|
1544
|
+
if (useAcpTransport) {
|
|
1545
|
+
run = await deps.runAcpSession({
|
|
1546
|
+
cmd: parts[0],
|
|
1547
|
+
vendor,
|
|
1548
|
+
cwd: folderPath,
|
|
1549
|
+
prompt: memoryPreamble(workspaceMemory) + promptText,
|
|
1550
|
+
timeoutMs: cfg.runTimeoutMs,
|
|
1551
|
+
signal,
|
|
1552
|
+
env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
|
|
1553
|
+
onData: handleCliData,
|
|
1554
|
+
...openCodePermissionCallbacks({
|
|
1555
|
+
tool,
|
|
1556
|
+
channelId,
|
|
1557
|
+
threadRoot,
|
|
1558
|
+
provider: vendor,
|
|
1559
|
+
}),
|
|
1560
|
+
});
|
|
1561
|
+
} else if (useRuntimePermissionBridge) {
|
|
1416
1562
|
run = await deps.runOpenCodeHttpSession({
|
|
1417
1563
|
cmd: parts[0],
|
|
1418
1564
|
args: codeArgs,
|
|
@@ -1738,6 +1884,43 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
1738
1884
|
}
|
|
1739
1885
|
}
|
|
1740
1886
|
|
|
1887
|
+
// MERGE / CLOSE RELAY (0704): "merge it" in a PR thread used to get an answer
|
|
1888
|
+
// describing the button a human should press. Now it is executed — through the
|
|
1889
|
+
// server, which is the authority: merge_pr / close_pr take the id of the human
|
|
1890
|
+
// message that asked, re-classify that text with a forced-tool model call, and
|
|
1891
|
+
// check the author's workspace role before spending the App's write token. The
|
|
1892
|
+
// daemon carries no new trust; it only stops swallowing the request.
|
|
1893
|
+
//
|
|
1894
|
+
// Runs BEFORE the chat/code split for the same reason the server runs it before
|
|
1895
|
+
// the coding gates: a merge instruction must never be read as a request to
|
|
1896
|
+
// start a new run. Every "no" here falls through to today's behavior.
|
|
1897
|
+
{
|
|
1898
|
+
const relay = prActionRelayDecision({
|
|
1899
|
+
body: message.body,
|
|
1900
|
+
threadPr: message.threadPr,
|
|
1901
|
+
mode,
|
|
1902
|
+
// A manager-routed dispatch is a brief to implement, not an instruction to
|
|
1903
|
+
// land something — its body is assembled text, never a person's sentence.
|
|
1904
|
+
canRelay: Boolean(caps.prActions) && !message.dispatch,
|
|
1905
|
+
});
|
|
1906
|
+
if (relay.relay) {
|
|
1907
|
+
const verb = relay.action;
|
|
1908
|
+
console.log(` ${verb} → relaying PR #${relay.anchor.prNumber} to the server (it decides)`);
|
|
1909
|
+
const result = await tool(verb === "merge" ? "merge_pr" : "close_pr", {
|
|
1910
|
+
channelId,
|
|
1911
|
+
// The mention itself is the instruction on record. The server verifies
|
|
1912
|
+
// it is a human's message, in this channel, from an owner or admin, and
|
|
1913
|
+
// that it really asks for THIS action — citing anything else fails.
|
|
1914
|
+
instructionMessageId: message.id,
|
|
1915
|
+
}).catch((e) => ({ ok: false, error: `I couldn't reach hilos to ${verb} that pull request: ${e?.message || e}` }));
|
|
1916
|
+
const reply = prActionReply(result);
|
|
1917
|
+
if (reply.post) {
|
|
1918
|
+
await tool("post_message", { channelId, parentId, body: reply.post }).catch(() => {});
|
|
1919
|
+
}
|
|
1920
|
+
return { status: reply.ok ? `pr-${verb}d` : "pr-action-refused", action: verb };
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1741
1924
|
// No repo linked → either FOLDER mode (0322: a channel mapped to a plain local
|
|
1742
1925
|
// folder can still get coding work done) or, failing that, just reply. A linked
|
|
1743
1926
|
// repo always wins — the folder map is only consulted when there's no repo link.
|
|
@@ -2034,22 +2217,65 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2034
2217
|
git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
|
|
2035
2218
|
|
|
2036
2219
|
// Same-branch iteration: continue an existing PR on its own branch and push
|
|
2037
|
-
// onto it, so the PR updates in place instead of a duplicate opening.
|
|
2220
|
+
// onto it, so the PR updates in place instead of a duplicate opening. Three ways
|
|
2038
2221
|
// in, and an explicit token WINS when both apply (they agree — same PR):
|
|
2039
2222
|
// 1. detectPrContinuation — an explicit "PR <url>" in the text (the server's
|
|
2040
2223
|
// "request changes" rework ping, or a person naming a PR). The fallback.
|
|
2041
2224
|
// 2. the run resolver's 'iterate' (0281) — a plain follow-up reply in a thread
|
|
2042
2225
|
// whose run already owns an OPEN PR. The PRIMARY path.
|
|
2226
|
+
// 3. the THREAD ANCHOR (0704) — no active run owns this thread, but the server
|
|
2227
|
+
// says (on list_mentions, as `threadPr`) that the thread is about a pull
|
|
2228
|
+
// request: a second agent asked here, or a reply long after the first run
|
|
2229
|
+
// settled, still amends THAT PR. Same shape as the server's own anchor
|
|
2230
|
+
// fallback, and it never overrides 1 or 2.
|
|
2043
2231
|
// Otherwise, a fresh branch off the default (today's behavior).
|
|
2044
2232
|
const cont = detectPrContinuation(message.body, repoFullName);
|
|
2045
|
-
const
|
|
2233
|
+
const runIterateUrl = (followupMode === "iterate" && activeRun?.prUrl) || null;
|
|
2234
|
+
// The anchor is a HYPOTHESIS (its recorded state may be stale or unknown);
|
|
2235
|
+
// `gh` is the proof. No proof, no adoption — the run cuts a fresh branch and
|
|
2236
|
+
// says so on the status card rather than pushing onto a merged PR.
|
|
2237
|
+
let anchorAdopt = null;
|
|
2238
|
+
let anchorNote = "";
|
|
2239
|
+
if (!cont?.url && !runIterateUrl) {
|
|
2240
|
+
const decision = anchorIterateDecision({
|
|
2241
|
+
threadPr: message.threadPr,
|
|
2242
|
+
repoFullName,
|
|
2243
|
+
signal: followupSignal,
|
|
2244
|
+
});
|
|
2245
|
+
if (decision.adopt && deps.prState) {
|
|
2246
|
+
const live = deps.prState(repoPath, decision.anchor.prUrl);
|
|
2247
|
+
const confirmed = confirmAnchorOpen({ live, anchor: decision.anchor });
|
|
2248
|
+
if (confirmed.ok) {
|
|
2249
|
+
anchorAdopt = {
|
|
2250
|
+
prUrl: decision.anchor.prUrl,
|
|
2251
|
+
prNumber: decision.anchor.prNumber,
|
|
2252
|
+
branch: confirmed.branch,
|
|
2253
|
+
};
|
|
2254
|
+
console.log(
|
|
2255
|
+
` code → thread anchor: PR #${decision.anchor.prNumber} is open on \`${confirmed.branch}\` — adopting it`,
|
|
2256
|
+
);
|
|
2257
|
+
} else {
|
|
2258
|
+
anchorNote = staleAnchorNote({ reason: confirmed.reason, anchor: decision.anchor });
|
|
2259
|
+
console.log(
|
|
2260
|
+
` ! thread anchor PR #${decision.anchor.prNumber} not adopted (${confirmed.reason}) — new branch`,
|
|
2261
|
+
);
|
|
2262
|
+
}
|
|
2263
|
+
} else if (decision.adopt) {
|
|
2264
|
+
anchorNote = staleAnchorNote({ reason: "unverified", anchor: decision.anchor });
|
|
2265
|
+
} else if (decision.reason === "anchor-merged" || decision.reason === "anchor-closed") {
|
|
2266
|
+
anchorNote = staleAnchorNote({ reason: decision.reason, anchor: decision.anchor });
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
const iterateUrl = cont?.url || runIterateUrl || anchorAdopt?.prUrl || null;
|
|
2046
2270
|
let branch = null;
|
|
2047
2271
|
let continuingPrUrl = null;
|
|
2048
|
-
if (iterateUrl && deps.prHeadRef) {
|
|
2272
|
+
if (iterateUrl && (deps.prHeadRef || anchorAdopt)) {
|
|
2049
2273
|
// Resolve the PR's head branch (source of truth); for a run-based iterate the
|
|
2050
|
-
// run's recorded branch is a fallback if gh can't view the PR.
|
|
2274
|
+
// run's recorded branch is a fallback if gh can't view the PR. An adopted
|
|
2275
|
+
// anchor already has its head branch straight from the live PR.
|
|
2051
2276
|
const headRef =
|
|
2052
|
-
|
|
2277
|
+
(anchorAdopt?.prUrl === iterateUrl ? anchorAdopt.branch : null) ||
|
|
2278
|
+
deps.prHeadRef?.(repoPath, iterateUrl) ||
|
|
2053
2279
|
(followupMode === "iterate" ? activeRun?.branch || null : null);
|
|
2054
2280
|
if (headRef) {
|
|
2055
2281
|
// Fetch the PR branch, then point a local branch at its tip via FETCH_HEAD
|
|
@@ -2067,6 +2293,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2067
2293
|
console.log(
|
|
2068
2294
|
` ! couldn't check out PR branch \`${headRef}\` (falling back to a new branch): ${(sw.stderr || "").trim().slice(0, 200)}`,
|
|
2069
2295
|
);
|
|
2296
|
+
// An adopted anchor that can't be checked out is the same broken promise
|
|
2297
|
+
// as a stale one — the thread asked about a PR and gets a new branch, so
|
|
2298
|
+
// the status card has to say it.
|
|
2299
|
+
if (anchorAdopt?.prUrl === iterateUrl) {
|
|
2300
|
+
anchorNote = staleAnchorNote({ reason: "checkout-failed", anchor: anchorAdopt });
|
|
2301
|
+
}
|
|
2070
2302
|
}
|
|
2071
2303
|
}
|
|
2072
2304
|
}
|
|
@@ -2118,6 +2350,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2118
2350
|
} else if (effectiveMode === "redirect") {
|
|
2119
2351
|
const n = activeRun?.prNumber || prNumberFromUrl(activeRun?.prUrl);
|
|
2120
2352
|
ackBody = `Starting a new PR${n ? ` (separate from #${n})` : ""} on \`${branch}\`.`;
|
|
2353
|
+
} else if (anchorNote) {
|
|
2354
|
+
// The thread is about a pull request this run could NOT adopt (0704). Say it
|
|
2355
|
+
// on the status card itself — silence here would read as "pushing to my PR"
|
|
2356
|
+
// while a fresh branch is being cut. Still one card, per the run-output
|
|
2357
|
+
// contract: no extra chat message.
|
|
2358
|
+
ackBody = `${anchorNote} ${ackBody}`;
|
|
2121
2359
|
}
|
|
2122
2360
|
const ack = await tool("post_message", { channelId, parentId, body: ackBody });
|
|
2123
2361
|
const ackId = ack?.messageId ?? null;
|
|
@@ -2186,7 +2424,14 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2186
2424
|
}
|
|
2187
2425
|
// Keep the inspectable continuation/redirect statement (don't overwrite it with
|
|
2188
2426
|
// an LLM plan) so a human can correct the routing before code lands.
|
|
2189
|
-
if (
|
|
2427
|
+
if (
|
|
2428
|
+
ackId &&
|
|
2429
|
+
caps.editMessage &&
|
|
2430
|
+
chatCmdFor(cfg) &&
|
|
2431
|
+
!continuingPrUrl &&
|
|
2432
|
+
!anchorNote &&
|
|
2433
|
+
effectiveMode !== "redirect"
|
|
2434
|
+
) {
|
|
2190
2435
|
// Feed the ack the router's distilled brief AND the conversation — not the raw
|
|
2191
2436
|
// mention — so it states a real plan instead of "what's the task?".
|
|
2192
2437
|
const plan = await proposePlanAck({
|
|
@@ -2392,13 +2637,41 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2392
2637
|
// unless --auto is present. For the gated tiers, bypass that responder
|
|
2393
2638
|
// and own the authenticated HTTP session + SSE stream directly so hilos
|
|
2394
2639
|
// is the sole authority answering the paused tool call (0593).
|
|
2395
|
-
const
|
|
2640
|
+
const useAcpTransport = shouldUseAcpTransport({
|
|
2396
2641
|
vendor,
|
|
2642
|
+
acpTransport: cfg.acpTransport,
|
|
2397
2643
|
runtimePermissions: caps.runtimePermissions,
|
|
2398
2644
|
codeArgs,
|
|
2399
2645
|
codingCmd: cfg.codingCmd,
|
|
2646
|
+
resumeSessionId: resume ? resumeSessionId : null,
|
|
2400
2647
|
});
|
|
2401
|
-
|
|
2648
|
+
const useRuntimePermissionBridge =
|
|
2649
|
+
!useAcpTransport &&
|
|
2650
|
+
shouldUseRuntimePermissionBridge({
|
|
2651
|
+
vendor,
|
|
2652
|
+
runtimePermissions: caps.runtimePermissions,
|
|
2653
|
+
codeArgs,
|
|
2654
|
+
codingCmd: cfg.codingCmd,
|
|
2655
|
+
});
|
|
2656
|
+
if (useAcpTransport) {
|
|
2657
|
+
run = await deps.runAcpSession({
|
|
2658
|
+
cmd: parts[0],
|
|
2659
|
+
vendor,
|
|
2660
|
+
cwd: repoPath,
|
|
2661
|
+
prompt: memoryPreamble(workspaceMemory) + promptText,
|
|
2662
|
+
timeoutMs: cfg.runTimeoutMs,
|
|
2663
|
+
signal,
|
|
2664
|
+
env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
|
|
2665
|
+
onData: handleCliData,
|
|
2666
|
+
...openCodePermissionCallbacks({
|
|
2667
|
+
tool,
|
|
2668
|
+
channelId,
|
|
2669
|
+
threadRoot,
|
|
2670
|
+
runId,
|
|
2671
|
+
provider: vendor,
|
|
2672
|
+
}),
|
|
2673
|
+
});
|
|
2674
|
+
} else if (useRuntimePermissionBridge) {
|
|
2402
2675
|
run = await deps.runOpenCodeHttpSession({
|
|
2403
2676
|
cmd: parts[0],
|
|
2404
2677
|
args: codeArgs,
|
package/src/progress-emitter.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// - Defense-in-depth: every string is re-sanitized here (the parser already
|
|
17
17
|
// redacts, the server will redact again) and arrays are clamped.
|
|
18
18
|
|
|
19
|
-
import { createStepRing, sanitizeText } from "./agent-events.mjs";
|
|
19
|
+
import { createActivityFold, createStepRing, sanitizeText } from "./agent-events.mjs";
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Which parser/stream-flags a coding command wants, from its FIRST token.
|
|
@@ -225,6 +225,9 @@ export function createProgressEmitter({
|
|
|
225
225
|
clearTimer = defaultClearTimer,
|
|
226
226
|
} = {}) {
|
|
227
227
|
const ring = createStepRing(stepLimit);
|
|
228
|
+
// The ring's richer sibling (0750): structured rows for the activity feed.
|
|
229
|
+
// Older servers drop the unknown field; newer ones render it (0537).
|
|
230
|
+
const activity = createActivityFold();
|
|
228
231
|
const files = [];
|
|
229
232
|
const fileSet = new Set();
|
|
230
233
|
const startedAt = now();
|
|
@@ -250,6 +253,7 @@ export function createProgressEmitter({
|
|
|
250
253
|
function fold(ev) {
|
|
251
254
|
if (!ev || typeof ev !== "object") return;
|
|
252
255
|
ring.push(ev); // steps[] (coalesces same-file edits + dup labels)
|
|
256
|
+
activity.push(ev); // feed rows (tense follows status; server re-validates)
|
|
253
257
|
switch (ev.t) {
|
|
254
258
|
case "session":
|
|
255
259
|
if (ev.sessionId) sessionId = ev.sessionId;
|
|
@@ -275,6 +279,7 @@ export function createProgressEmitter({
|
|
|
275
279
|
/**
|
|
276
280
|
* The current live progress payload (also what `send` receives).
|
|
277
281
|
* @returns {{ state: string, phase?: string, step?: string, steps: string[],
|
|
282
|
+
* activity?: {kind: string, text: string, status: string, n?: number}[],
|
|
278
283
|
* filesTouched: string[], lastLine?: string, elapsedMs: number, sessionId?: string }}
|
|
279
284
|
*/
|
|
280
285
|
function snapshot() {
|
|
@@ -287,6 +292,8 @@ export function createProgressEmitter({
|
|
|
287
292
|
};
|
|
288
293
|
if (phase) snap.phase = sanitizeText(phase);
|
|
289
294
|
if (step) snap.step = sanitizeText(step);
|
|
295
|
+
const rows = activity.list();
|
|
296
|
+
if (rows.length) snap.activity = rows;
|
|
290
297
|
const ll = sanitizeText(lastLine);
|
|
291
298
|
if (ll) snap.lastLine = ll;
|
|
292
299
|
if (sessionId) snap.sessionId = sanitizeText(sessionId); // for 0282 (server drops it today)
|
|
@@ -373,6 +380,9 @@ export function createProgressEmitter({
|
|
|
373
380
|
}
|
|
374
381
|
for (const ev of tail) fold(ev);
|
|
375
382
|
state = finalState === "error" ? "error" : "done";
|
|
383
|
+
// Settle the in-flight row instead of leaving "Editing…" forever; only
|
|
384
|
+
// an error finish marks it failed (mirrors lib/hosted-progress.ts).
|
|
385
|
+
activity.settle(state === "error" ? "failed" : "done");
|
|
376
386
|
if (state === "error") {
|
|
377
387
|
const r = sanitizeText(reason);
|
|
378
388
|
if (r) errorMessage = r;
|
package/src/run.mjs
CHANGED
|
@@ -99,6 +99,11 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
99
99
|
// Semantic local-folder intent (0515): the server guarantees a forced-tool
|
|
100
100
|
// ask/ship/deploy decision. Older servers fall back to the local router.
|
|
101
101
|
agentIntent: toolNames.includes("classify_agent_intent"),
|
|
102
|
+
// Relaying a human's merge/close instruction (0701/0704). Both tools are
|
|
103
|
+
// required; on an older server the daemon simply never relays and behaves
|
|
104
|
+
// exactly as before. The authority is the server's either way — the daemon
|
|
105
|
+
// only passes along the id of the message that asked.
|
|
106
|
+
prActions: toolNames.includes("merge_pr") && toolNames.includes("close_pr"),
|
|
102
107
|
// Harness-enforced runtime permission bridge (0593). Both tools are
|
|
103
108
|
// required: a request without a durable reply path must fail closed.
|
|
104
109
|
runtimePermissions:
|