@staix/agent-hub 0.12.22 → 0.12.24
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/CHANGELOG.md +117 -0
- package/README.md +22 -9
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +18 -6
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +14 -1
- package/docs/agent-notes/local-worker.md +6 -3
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +4 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +10 -3
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +873 -30
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +9 -4
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +124 -8
- package/docs/smoke.md +354 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verified.json +34 -16
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1108 -261
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +201 -20
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +54 -13
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +94 -20
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +26 -6
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +131 -39
- package/src/cli/console.ts +73 -17
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +52 -21
- package/src/cli/identity.ts +19 -8
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +641 -193
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +404 -0
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +40 -6
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +1 -29
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-runtime.ts +56 -10
- package/src/cli/upgrade.ts +23 -5
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +224 -16
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +58 -20
- package/src/hub/child-process.ts +11 -0
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +34 -17
- package/src/hub/config-trust.ts +20 -7
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +11 -1
- package/src/hub/daemon.ts +1218 -113
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +7 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +14 -2
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +28 -8
- package/src/hub/git.ts +38 -0
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +26 -6
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/peers.ts +5 -3
- package/src/hub/permission-mode.ts +33 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +6 -5
- package/src/hub/registry.ts +2 -1
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +132 -18
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +411 -0
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +92 -16
- package/src/hub/start-mode.ts +50 -0
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +1014 -111
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +40 -14
- package/src/hub/workflow.ts +71 -0
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +50 -10
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +86 -2
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/models/mlx.ts +2 -1
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/pi/tool-receipts.ts +2 -1
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +983 -40
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync, lstatSync, readlinkSync } from "node:fs";
|
|
2
|
+
import { join, resolve, dirname, relative } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
// Stamps record a human source review, not an automatic proof of the prose.
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
const requireFresh = args.includes("--require-fresh");
|
|
10
|
+
const jsonOutput = args.includes("--json");
|
|
11
|
+
const committed = args.includes("--committed");
|
|
12
|
+
const root = resolve(args.find((arg) => !["--require-fresh", "--json", "--committed"].includes(arg)) ?? fileURLToPath(new URL("..", import.meta.url)));
|
|
13
|
+
const errors = [];
|
|
14
|
+
const reports = [];
|
|
15
|
+
const documents = [];
|
|
16
|
+
let headId;
|
|
17
|
+
let packageVersion;
|
|
18
|
+
// Node-side equivalent of hub/git.ts: a readback must not execute repository-configured helpers.
|
|
19
|
+
const safeGitConfig = ["core.fsmonitor=false", "core.hooksPath=/dev/null", "diff.external=", "core.pager=cat", "credential.helper=", "core.askpass="];
|
|
20
|
+
const git = (...args) => spawnSync("git", ["--no-pager", ...safeGitConfig.flatMap(value => ["-c", value]), ...args], { cwd: root, encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, timeout: 2000, maxBuffer: 256 * 1024 });
|
|
21
|
+
const read = (path) => {
|
|
22
|
+
if (!committed) return readFileSync(join(root, path), "utf8");
|
|
23
|
+
const result = git("show", `${headId}:${path}`);
|
|
24
|
+
if (result.status !== 0) throw new Error("committed documentation evidence unavailable");
|
|
25
|
+
return result.stdout;
|
|
26
|
+
};
|
|
27
|
+
const safePath = (path) => typeof path === "string" && path.length > 0 && !path.startsWith("/") && !path.startsWith("-") && !path.split("/").includes("..") && !path.includes("\\") && !path.includes(":");
|
|
28
|
+
const evidenceExists = (path) => committed ? git("cat-file", "-e", `${headId}:${path}`).status === 0 : existsSync(join(root, path));
|
|
29
|
+
const documentExists = (path) => {
|
|
30
|
+
if (!committed) return existsSync(join(root, path)) && statSync(join(root, path)).isFile();
|
|
31
|
+
const type = git("cat-file", "-t", `${headId}:${path}`);
|
|
32
|
+
return type.status === 0 && type.stdout.trim() === "blob";
|
|
33
|
+
};
|
|
34
|
+
/** Never refresh the index through git status: it can run configured clean/process filters. */
|
|
35
|
+
const dirtiness = (paths) => {
|
|
36
|
+
const unknown = { status: 1, stdout: "" }, dirty = { status: 0, stdout: "dirty" };
|
|
37
|
+
const staged = git("diff-index", "--cached", "--quiet", "--no-ext-diff", "--no-textconv", "HEAD", "--", ...paths);
|
|
38
|
+
if (staged.status === 1) return dirty;
|
|
39
|
+
if (staged.status !== 0) return unknown;
|
|
40
|
+
const untracked = git("ls-files", "--others", "--exclude-standard", "-z", "--", ...paths);
|
|
41
|
+
if (untracked.status !== 0) return unknown;
|
|
42
|
+
if (untracked.stdout) return dirty;
|
|
43
|
+
const tracked = git("ls-files", "--stage", "-z", "--", ...paths);
|
|
44
|
+
if (tracked.status !== 0) return unknown;
|
|
45
|
+
const entries = tracked.stdout.split("\0").filter(Boolean);
|
|
46
|
+
if (entries.length > 4096) return unknown;
|
|
47
|
+
try {
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const match = /^([0-7]{6}) ([0-9a-f]{40}) ([0-3])\t([\s\S]+)$/.exec(entry);
|
|
50
|
+
if (!match) return unknown;
|
|
51
|
+
const [, mode, oid, stage, path] = match;
|
|
52
|
+
if (stage !== "0") return dirty;
|
|
53
|
+
// ponytail: gitlinks/large or transformed files stay unverified; never invoke a filter to establish equivalence.
|
|
54
|
+
if (!["100644", "100755", "120000"].includes(mode)) return unknown;
|
|
55
|
+
const file = resolve(root, path), within = relative(root, file);
|
|
56
|
+
if (!within || within.startsWith("../") || within === "..") return unknown;
|
|
57
|
+
for (let parent = dirname(file); parent !== root; parent = dirname(parent)) if (!lstatSync(parent).isDirectory() || lstatSync(parent).isSymbolicLink()) return unknown;
|
|
58
|
+
if (!existsSync(file) && mode !== "120000") return dirty;
|
|
59
|
+
const info = lstatSync(file);
|
|
60
|
+
let bytes;
|
|
61
|
+
if (mode === "120000") { if (!info.isSymbolicLink()) return dirty; bytes = readlinkSync(file, { encoding: "buffer" }); }
|
|
62
|
+
else { if (!info.isFile() || info.isSymbolicLink()) return dirty; if (info.size > 4 * 1024 * 1024) return unknown; bytes = readFileSync(file); }
|
|
63
|
+
const hash = createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex");
|
|
64
|
+
if (hash !== oid || mode !== "120000" && !!(info.mode & 0o111) !== (mode === "100755")) return dirty;
|
|
65
|
+
}
|
|
66
|
+
return { status: 0, stdout: "" };
|
|
67
|
+
} catch { return unknown; }
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
if (committed) {
|
|
72
|
+
const head = git("rev-parse", "--verify", "HEAD");
|
|
73
|
+
if (head.status !== 0 || !/^[0-9a-f]{40}$/.test(head.stdout.trim())) throw new Error("HEAD does not resolve");
|
|
74
|
+
headId = head.stdout.trim();
|
|
75
|
+
const dirty = dirtiness(["docs", "README.md", "package.json"]);
|
|
76
|
+
if (dirty.status !== 0 || dirty.stdout) errors.push("documentation evidence files are dirty or unavailable");
|
|
77
|
+
}
|
|
78
|
+
const manifest = JSON.parse(read("docs/verified.json"));
|
|
79
|
+
if (manifest.schemaVersion !== 1 || !manifest.documents || typeof manifest.documents !== "object" || Array.isArray(manifest.documents)) throw new Error("invalid docs/verified.json schema");
|
|
80
|
+
let noteNames;
|
|
81
|
+
if (committed) {
|
|
82
|
+
const notes = git("ls-tree", "-z", "--name-only", `${headId}:docs/agent-notes`);
|
|
83
|
+
if (notes.status !== 0) throw new Error("committed agent notes unavailable");
|
|
84
|
+
noteNames = notes.stdout.split("\0").filter(Boolean);
|
|
85
|
+
} else noteNames = readdirSync(join(root, "docs/agent-notes"));
|
|
86
|
+
const required = ["README.md", "docs/security.md", "docs/operations.md", "docs/quickstart.md", ...noteNames.filter((name) => name.endsWith(".md")).map((name) => `docs/agent-notes/${name}`)];
|
|
87
|
+
for (const path of required.sort()) if (!Object.hasOwn(manifest.documents, path)) errors.push(`missing manifest entry: ${path}`);
|
|
88
|
+
const pkg = JSON.parse(read("package.json"));
|
|
89
|
+
if (typeof pkg.version === "string" && /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:[+][0-9A-Za-z.-]+)?$/.test(pkg.version)) packageVersion = pkg.version;
|
|
90
|
+
const version = read("README.md").match(/^Status: ([^,\s]+),/m)?.[1];
|
|
91
|
+
if (version !== pkg.version) errors.push(`README status version ${version ?? "missing"} differs from package.json ${pkg.version}`);
|
|
92
|
+
const head = git("rev-parse", "--verify", "HEAD");
|
|
93
|
+
if (head.status !== 0 || !/^[0-9a-f]{40}$/.test(head.stdout.trim())) throw new Error("HEAD does not resolve");
|
|
94
|
+
if (committed && headId !== head.stdout.trim()) throw new Error("HEAD changed during documentation readback");
|
|
95
|
+
headId = head.stdout.trim();
|
|
96
|
+
for (const [doc, entry] of Object.entries(manifest.documents).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
97
|
+
const record = { ...(safePath(doc) ? { path: doc } : {}), state: "unknown" }; documents.push(record);
|
|
98
|
+
if (!safePath(doc) || !documentExists(doc)) { errors.push(`missing or invalid document: ${doc}`); continue; }
|
|
99
|
+
const stamp = entry?.verifiedAgainst;
|
|
100
|
+
if (typeof stamp !== "string" || !/^[0-9a-f]{40}$/.test(stamp) || git("cat-file", "-e", `${stamp}^{commit}`).status !== 0) { errors.push(`${doc}: verified commit does not resolve: ${String(stamp)}`); continue; }
|
|
101
|
+
record.verifiedAgainst = stamp;
|
|
102
|
+
if (git("merge-base", "--is-ancestor", stamp, "HEAD").status !== 0) { record.state = "nonancestor"; errors.push(`${doc}: verified commit is not an ancestor of HEAD: ${stamp}`); continue; }
|
|
103
|
+
if (!Array.isArray(entry.paths) || entry.paths.length === 0 || entry.paths.some((path) => !safePath(path))) { errors.push(`${doc}: invalid covered paths`); continue; }
|
|
104
|
+
let valid = true;
|
|
105
|
+
for (const path of entry.paths) {
|
|
106
|
+
// Git pathspecs let a scope cover a directory or an explicit wildcard (upgrade*.ts).
|
|
107
|
+
if (!/[?*\[]/.test(path) && evidenceExists(path)) continue;
|
|
108
|
+
// Preserve Git pathspec matching; every selected entry must exist in the captured committed tree.
|
|
109
|
+
const files = git("ls-files", ...(committed ? [`--with-tree=${headId}`] : []), "-z", "--", path);
|
|
110
|
+
const matches = files.stdout.split("\0").filter(Boolean);
|
|
111
|
+
if (files.status !== 0 || matches.length === 0 || matches.some((file) => !evidenceExists(file))) { errors.push(`${doc}: covered path no longer exists: ${path}`); valid = false; }
|
|
112
|
+
}
|
|
113
|
+
if (!valid) continue;
|
|
114
|
+
const count = git("rev-list", "--count", `${stamp}..HEAD`, "--", ...entry.paths);
|
|
115
|
+
const dirty = dirtiness(entry.paths);
|
|
116
|
+
if (count.status !== 0 || dirty.status !== 0) { errors.push(`${doc}: cannot compute source drift`); continue; }
|
|
117
|
+
const commits = Number(count.stdout.trim());
|
|
118
|
+
if (!Number.isSafeInteger(commits) || commits < 0) { errors.push(`${doc}: invalid source commit count`); continue; }
|
|
119
|
+
record.commits = commits; record.dirty = !!dirty.stdout; record.state = commits || dirty.stdout ? "stale" : "fresh";
|
|
120
|
+
if (commits || dirty.stdout) reports.push(`${doc}: stale (${commits} source commit${commits === 1 ? "" : "s"}${dirty.stdout ? "; uncommitted source changes" : ""})`);
|
|
121
|
+
}
|
|
122
|
+
} catch (error) { errors.push(error instanceof Error ? error.message : String(error)); }
|
|
123
|
+
if (!jsonOutput) for (const report of reports) console.log(`docs: ${report}`);
|
|
124
|
+
if (requireFresh && reports.length) errors.push("release requires fresh documentation stamps");
|
|
125
|
+
if (requireFresh && errors.length) errors.push("release refused: merge the release PR with a merge commit and verify all documentation stamps before tagging");
|
|
126
|
+
if (jsonOutput) console.log(JSON.stringify({ schemaVersion: 1, ...(headId ? { head: headId } : {}), ...(packageVersion ? { version: packageVersion } : {}), documents, errors: errors.length, fresh: !errors.length && !reports.length }));
|
|
127
|
+
else for (const error of errors) console.error(`docs: ERROR ${error}`);
|
|
128
|
+
if (errors.length) process.exitCode = 1;
|
|
129
|
+
else if (!jsonOutput) console.log(`docs: OK (${reports.length} stale document${reports.length === 1 ? "" : "s"})`);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Provider refusal observed from Kimi Code CLI 2.1.1 on 2026-10-11. No weekly/monthly or Qwen text is verified. */
|
|
2
|
+
export const KIMI_2_1_1_5H_REFUSAL_PREFIX = "Authentication required: 403 You've reached your 5-hour usage limit. Your quota will reset when the current 5-hour window ends.";
|
|
3
|
+
export interface ACPQuotaRefusal {
|
|
4
|
+
id: "5h";
|
|
5
|
+
used: 1;
|
|
6
|
+
windowMins: 300;
|
|
7
|
+
source: "kimi ACP usage-limit refusal";
|
|
8
|
+
}
|
|
9
|
+
/** Identity and turn/effect fencing are the caller's responsibility; an arbitrary 'quota' error is not a known refusal. */
|
|
10
|
+
export function acpQuotaRefusal(message: string): ACPQuotaRefusal | undefined {
|
|
11
|
+
if (!message.startsWith(KIMI_2_1_1_5H_REFUSAL_PREFIX)) return undefined;
|
|
12
|
+
return { id: "5h", used: 1, windowMins: 300, source: "kimi ACP usage-limit refusal" };
|
|
13
|
+
}
|
package/src/adapters/acp.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { activityText, updateActivity, type Activity, type ActivityKind } from "../hub/activity.ts";
|
|
1
2
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
3
|
import { createInterface } from "node:readline";
|
|
3
|
-
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { renderDigest, replyAudience, replyParent, sanitize, type Envelope, type PeerId, type PeerState } from "../hub/envelope.ts";
|
|
5
|
+
import { acpQuotaRefusal, type ACPQuotaRefusal } from "./acp-quota.ts";
|
|
6
|
+
import { KIMI_MODE_IDS, QWEN_MODE_IDS, type PermissionMode } from "../hub/permission-mode.ts";
|
|
5
7
|
import { BasePeer } from "../hub/peers.ts";
|
|
6
8
|
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
7
9
|
|
|
@@ -58,6 +60,12 @@ export interface AcpOptions {
|
|
|
58
60
|
/** Coordinator-visible selected model only; contains no prompts or command arguments. */
|
|
59
61
|
launchModel?: string;
|
|
60
62
|
permissionMode?: PermissionMode;
|
|
63
|
+
/** Production Qwen only; legacy benchmark adapters retain their own native policy. */
|
|
64
|
+
qwen025?: boolean;
|
|
65
|
+
/** A production adapter's source-pinned identity; no table is inferred from it. */
|
|
66
|
+
expectedAgent?: { name: string; version: string };
|
|
67
|
+
/** Vendors with uncertain load semantics must explicitly echo the restored identity. */
|
|
68
|
+
requireLoadSessionEcho?: boolean;
|
|
61
69
|
/** Bound for native mode acknowledgement; defaults to the handshake deadline. */
|
|
62
70
|
permissionModeTimeoutMs?: number;
|
|
63
71
|
/** Load this earlier session (ACP `session/load`) instead of starting a new one: crash recovery, issue #37. */
|
|
@@ -66,6 +74,8 @@ export interface AcpOptions {
|
|
|
66
74
|
/** Optional launch environment; recovery authority is always removed before spawn. */
|
|
67
75
|
env?: NodeJS.ProcessEnv;
|
|
68
76
|
watchdogMs?: number;
|
|
77
|
+
/** Bound for a journal-less watchdog cancellation to report its prompt; defaults to the 30 s ACP handshake cap. */
|
|
78
|
+
cancelWaitMs?: number;
|
|
69
79
|
/** ACP stdio MCP servers for the session (the hub's task tools). */
|
|
70
80
|
mcpServers?: { name: string; command: string; args: string[]; env: { name: string; value: string }[] }[];
|
|
71
81
|
/** Appended to the standing instruction of the first delivery (role contract). */
|
|
@@ -75,12 +85,18 @@ export interface AcpOptions {
|
|
|
75
85
|
/** The prompt rejected or ended without normal completion, even when it streamed partial text.
|
|
76
86
|
* Same shape as Pi's onTurnFailure; a stale cancelled turn never reports. */
|
|
77
87
|
onTurnFailure?: (envs: Envelope[], reason: string) => Promise<void> | void;
|
|
78
|
-
|
|
88
|
+
/** The sessionId is the adapter's own: the daemon binds a context reading to it, not to a lookup of its own (#285). */
|
|
89
|
+
onUsageDiagnostic?: (observation: ACPUsageDiagnostic, sessionId: string) => void;
|
|
90
|
+
/** Return true only when the current-session refusal has an authoritative quota pause; otherwise hold delivery. */
|
|
91
|
+
onQuotaRefusal?: (window: ACPQuotaRefusal) => boolean;
|
|
79
92
|
/** Resolve with an optionId, or undefined to cancel. Absent = every request is cancelled.
|
|
80
93
|
* A request whose payload could not be resolved is titled as such and carries no session-wide allow option. */
|
|
81
|
-
onPermission?: (req: PermissionRequest) => Promise<string | undefined>;
|
|
94
|
+
onPermission?: (req: PermissionRequest, signal?: AbortSignal) => Promise<string | undefined>;
|
|
82
95
|
/** Tool titles approved once without asking: the hub's own tools, as Codex gets them (issue #72). Exact names only. */
|
|
83
96
|
autoApprove?: (toolTitle: string) => boolean;
|
|
97
|
+
/** Transient person-only stream; no native update escapes through another observer. */
|
|
98
|
+
onActivity?: (item: Activity) => void;
|
|
99
|
+
isPrivateTurn?: () => boolean;
|
|
84
100
|
log?: (line: string) => void;
|
|
85
101
|
}
|
|
86
102
|
|
|
@@ -99,6 +115,9 @@ export function canonicalMcpToolName(announcedTitle: string | undefined, serverN
|
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
class RpcTimeoutError extends Error {}
|
|
118
|
+
export class QwenIdentityError extends Error {
|
|
119
|
+
constructor() { super("Qwen Code 0.25.0 native identity is not verified; check the executable and its advertised ACP version"); }
|
|
120
|
+
}
|
|
102
121
|
|
|
103
122
|
type Pending = { resolve: (v: any) => void; reject: (e: Error) => void };
|
|
104
123
|
|
|
@@ -117,6 +136,7 @@ export class AcpPeer extends BasePeer {
|
|
|
117
136
|
private permissionMode: PermissionMode;
|
|
118
137
|
private modeUnknown = false;
|
|
119
138
|
private kimiAgent = false;
|
|
139
|
+
private qwenAgent = false;
|
|
120
140
|
private availableModes = new Set<string>();
|
|
121
141
|
private proc: ChildProcessWithoutNullStreams | undefined;
|
|
122
142
|
private sessionId = "";
|
|
@@ -130,7 +150,62 @@ export class AcpPeer extends BasePeer {
|
|
|
130
150
|
private turn = 0; // generation: a prompt cancelled by the watchdog must not touch the turn that followed it
|
|
131
151
|
private stopping = false;
|
|
132
152
|
private activeDeliveryId: string | undefined;
|
|
153
|
+
private activeLegacy: { turn: number; envs: Envelope[] } | undefined;
|
|
133
154
|
private deliveryAccepted = false;
|
|
155
|
+
private cancelWait: { turn: number; successor: number; timer: ReturnType<typeof setTimeout>; reported: boolean; released: Promise<void>; release: () => void } | undefined;
|
|
156
|
+
private promptActivity = false;
|
|
157
|
+
private activityOpen = false;
|
|
158
|
+
private activityPrivate = false;
|
|
159
|
+
private activityStub = false;
|
|
160
|
+
private activityStarted = 0;
|
|
161
|
+
private activityPending: { kind: "message" | "thought"; text: string } | undefined;
|
|
162
|
+
private activityTimer: ReturnType<typeof setTimeout> | undefined;
|
|
163
|
+
private emitActivity(kind: ActivityKind, text: string): void {
|
|
164
|
+
if (!this.opts.onActivity || !this.activityOpen) return;
|
|
165
|
+
// Privacy latches for the whole turn, even when the associated task closes mid-turn.
|
|
166
|
+
try { this.activityPrivate ||= this.opts.isPrivateTurn?.() === true; } catch { this.activityPrivate = true; }
|
|
167
|
+
if (this.activityPrivate) {
|
|
168
|
+
if (this.activityStub) return;
|
|
169
|
+
this.activityStub = true; kind = "private"; text = "Private turn: activity withheld";
|
|
170
|
+
}
|
|
171
|
+
try { this.opts.onActivity({ kind, text: activityText(text), at: Date.now(), turn: this.turn }); } catch { /* watch cannot affect delivery */ }
|
|
172
|
+
}
|
|
173
|
+
private flushActivity(): void {
|
|
174
|
+
clearTimeout(this.activityTimer); this.activityTimer = undefined;
|
|
175
|
+
const pending = this.activityPending; this.activityPending = undefined;
|
|
176
|
+
if (pending) this.emitActivity(pending.kind, pending.text);
|
|
177
|
+
}
|
|
178
|
+
private updateWatch(u: unknown): void {
|
|
179
|
+
if (!this.opts.onActivity) return;
|
|
180
|
+
const item = updateActivity(u);
|
|
181
|
+
if (!item) return;
|
|
182
|
+
if (item.kind === "message" || item.kind === "thought") {
|
|
183
|
+
if (this.activityPending?.kind !== item.kind || (this.activityPending.text.length + item.text.length > 4096)) this.flushActivity();
|
|
184
|
+
if (this.activityPending) this.activityPending.text += item.text;
|
|
185
|
+
else this.activityPending = { kind: item.kind, text: item.text };
|
|
186
|
+
this.activityTimer ??= setTimeout(() => this.flushActivity(), 50);
|
|
187
|
+
this.activityTimer.unref?.();
|
|
188
|
+
} else { this.flushActivity(); this.emitActivity(item.kind, item.text); }
|
|
189
|
+
}
|
|
190
|
+
private clearActivity(): void {
|
|
191
|
+
clearTimeout(this.activityTimer); this.activityTimer = undefined; this.activityPending = undefined;
|
|
192
|
+
}
|
|
193
|
+
private endActivity(reason: string, discardPending = false): void {
|
|
194
|
+
if (discardPending) this.clearActivity(); else this.flushActivity();
|
|
195
|
+
this.emitActivity("turn_end", `${reason}; ${Date.now() - this.activityStarted} ms`);
|
|
196
|
+
this.activityOpen = false;
|
|
197
|
+
}
|
|
198
|
+
private readonly permissionWaits = new Set<AbortController>();
|
|
199
|
+
|
|
200
|
+
protected override setState(next: PeerState): void {
|
|
201
|
+
if (next === "offline") { this.clearActivity(); this.cancelPermissions(); }
|
|
202
|
+
super.setState(next);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private cancelPermissions(): void {
|
|
206
|
+
for (const wait of this.permissionWaits) wait.abort();
|
|
207
|
+
this.permissionWaits.clear();
|
|
208
|
+
}
|
|
134
209
|
|
|
135
210
|
constructor(
|
|
136
211
|
id: PeerId,
|
|
@@ -165,12 +240,16 @@ export class AcpPeer extends BasePeer {
|
|
|
165
240
|
// The configured peer id and command are labels, not an agent-specific mode contract.
|
|
166
241
|
// Installed Kimi Code CLI's ACP initialize advertises this exact name.
|
|
167
242
|
this.kimiAgent = init?.agentInfo?.name === "Kimi Code CLI";
|
|
243
|
+
if (this.opts.expectedAgent && (init?.agentInfo?.name !== this.opts.expectedAgent.name || init?.agentInfo?.version !== this.opts.expectedAgent.version)) throw new Error("native ACP identity is not verified for this production adapter");
|
|
244
|
+
this.qwenAgent = this.opts.qwen025 === true && this.id === "qwen" && init?.agentInfo?.name === "qwen-code" && init?.agentInfo?.version === "0.25.0";
|
|
245
|
+
if (this.opts.qwen025 && !this.qwenAgent) throw new QwenIdentityError();
|
|
168
246
|
const resume = this.opts.resumeSessionId;
|
|
169
247
|
if (!resume) return this.request("session/new", { cwd: this.opts.cwd, mcpServers: this.opts.mcpServers ?? [] });
|
|
170
248
|
// The agent replays the session as updates while it loads; they arrive before the peer is idle, so none of
|
|
171
249
|
// them is taken for an answer.
|
|
172
250
|
if (!init?.agentCapabilities?.loadSession) throw new Error(`${this.id} cannot load an earlier session (the agent offers no loadSession)`);
|
|
173
251
|
const loaded = await this.request("session/load", { sessionId: resume, cwd: this.opts.cwd, mcpServers: this.opts.mcpServers ?? [] });
|
|
252
|
+
if ((loaded?.sessionId !== undefined && loaded.sessionId !== resume) || (this.opts.requireLoadSessionEcho && loaded?.sessionId !== resume)) throw new Error("native ACP load did not confirm the requested conversation; no restored session is claimed");
|
|
174
253
|
return { ...loaded, sessionId: resume };
|
|
175
254
|
};
|
|
176
255
|
const timeout = new Promise<never>((_, reject) => {
|
|
@@ -185,7 +264,7 @@ export class AcpPeer extends BasePeer {
|
|
|
185
264
|
// must be reset for the configured ask default before any prompt; a fresh ask session stays untouched.
|
|
186
265
|
const resetResumed = this.opts.resumeSessionId && typeof session.modes?.currentModeId === "string" && session.modes.currentModeId !== KIMI_MODE_IDS.ask;
|
|
187
266
|
// Other ACP vendors retain their native policy, including reported non-default modes on resume.
|
|
188
|
-
if (this.kimiAgent && (this.permissionMode !== "ask" || resetResumed)) await this.setPermissionMode(this.permissionMode);
|
|
267
|
+
if (this.qwenAgent || (this.kimiAgent && (this.permissionMode !== "ask" || resetResumed))) await this.setPermissionMode(this.permissionMode);
|
|
189
268
|
} catch (e) {
|
|
190
269
|
this.setState("offline");
|
|
191
270
|
if (this.proc === proc) {
|
|
@@ -198,13 +277,13 @@ export class AcpPeer extends BasePeer {
|
|
|
198
277
|
|
|
199
278
|
getPermissionMode(): PermissionMode { return this.permissionMode; }
|
|
200
279
|
get permissionModeState(): PermissionMode | "unknown" | "unmanaged" {
|
|
201
|
-
return !this.kimiAgent ? "unmanaged" : this.modeUnknown ? "unknown" : this.permissionMode;
|
|
280
|
+
return !this.kimiAgent && !this.qwenAgent ? "unmanaged" : this.modeUnknown ? "unknown" : this.permissionMode;
|
|
202
281
|
}
|
|
203
282
|
|
|
204
283
|
async setPermissionMode(mode: PermissionMode): Promise<void> {
|
|
205
|
-
if (!this.kimiAgent) throw new Error(`${this.id} permission mode ${mode} unmanaged: this ACP agent has no verified mode mapping; use Kimi Code CLI or add an agent-specific mapping`);
|
|
284
|
+
if (!this.kimiAgent && !this.qwenAgent) throw new Error(`${this.id} permission mode ${mode} unmanaged: this ACP agent has no verified mode mapping; use Kimi Code CLI or add an agent-specific mapping`);
|
|
206
285
|
if (this.modeUnknown) throw new Error(`${this.id} permission mode unknown after an unanswered change; restart the peer before changing modes`);
|
|
207
|
-
const id = KIMI_MODE_IDS[mode];
|
|
286
|
+
const id = (this.qwenAgent ? QWEN_MODE_IDS : KIMI_MODE_IDS)[mode];
|
|
208
287
|
if (!this.sessionId || !this.proc || !this.availableModes.has(id)) throw new Error(`${this.id} permission mode ${mode} unavailable: session does not offer ${id}`);
|
|
209
288
|
try {
|
|
210
289
|
await this.request("session/set_mode", { sessionId: this.sessionId, modeId: id }, this.opts.permissionModeTimeoutMs ?? HANDSHAKE_MS);
|
|
@@ -220,12 +299,22 @@ export class AcpPeer extends BasePeer {
|
|
|
220
299
|
}
|
|
221
300
|
throw new Error(`${this.id} permission mode ${mode} refused: ${(error as Error).message}`);
|
|
222
301
|
}
|
|
302
|
+
// A mode reply after a cancelled prompt must also observe its queued cleanup.
|
|
303
|
+
if (this.cancelWait?.reported) await this.cancelWait.released;
|
|
223
304
|
if (this.modeUnknown) throw new Error(`${this.id} permission mode unknown; restart the peer`);
|
|
224
305
|
this.permissionMode = mode;
|
|
225
306
|
}
|
|
226
307
|
|
|
227
308
|
async stop(reason?: string): Promise<void> {
|
|
309
|
+
if (this.activityOpen) this.endActivity("stopped", true);
|
|
228
310
|
this.stopping = true;
|
|
311
|
+
this.cancelPermissions();
|
|
312
|
+
this.clearCancelWait();
|
|
313
|
+
const legacy = this.activeLegacy;
|
|
314
|
+
this.activeLegacy = undefined;
|
|
315
|
+
// Journal-less work has no durable receipt: return it before a requested stop
|
|
316
|
+
// invalidates its generation, while stopping still prevents a drain.
|
|
317
|
+
if (legacy?.turn === this.turn) this.onFailed?.(legacy.envs);
|
|
229
318
|
if (reason !== undefined) this.turn++; // a requested stop cannot escalate or complete the cancelled turn
|
|
230
319
|
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: reason ?? "ACP session stopped before settlement" });
|
|
231
320
|
this.activeDeliveryId = undefined;
|
|
@@ -244,14 +333,28 @@ export class AcpPeer extends BasePeer {
|
|
|
244
333
|
}
|
|
245
334
|
const turn = ++this.turn;
|
|
246
335
|
this.activeDeliveryId = deliveryId;
|
|
336
|
+
this.activeLegacy = deliveryId ? undefined : { turn, envs };
|
|
247
337
|
this.deliveryAccepted = false;
|
|
338
|
+
this.promptActivity = false;
|
|
248
339
|
this.chunks = [];
|
|
340
|
+
this.activityOpen = true;
|
|
341
|
+
this.activityPrivate = envs.some(env => env.private === true);
|
|
342
|
+
try { this.activityPrivate ||= this.opts.isPrivateTurn?.() === true; } catch { this.activityPrivate = true; }
|
|
343
|
+
this.activityStub = false; this.activityStarted = Date.now();
|
|
249
344
|
this.setState("busy");
|
|
345
|
+
this.emitActivity("delivery", envs.map(env => `${env.from} ${env.id}`).join(", "));
|
|
346
|
+
|
|
250
347
|
// session/prompt answers only when the turn ends, so deliver resolves now and failures come back through onFailed.
|
|
251
348
|
const prompt = this.request("session/prompt", { sessionId: this.sessionId, prompt: [{ type: "text", text: this.primed || !this.opts.preamble ? renderDigest(envs, this.primed) : `${this.opts.preamble}\n\n${renderDigest(envs, false)}` }] });
|
|
349
|
+
const reported = () => { if (this.cancelWait?.turn === turn) this.cancelWait.reported = true; };
|
|
350
|
+
void prompt.then(reported, reported);
|
|
252
351
|
prompt
|
|
253
352
|
.then((result) => {
|
|
254
353
|
if (turn !== this.turn) return; // superseded: these chunks belong to a later turn
|
|
354
|
+
// This prompt has reported. A synchronous observer stop must not return
|
|
355
|
+
// completed work as if its native request were still in flight.
|
|
356
|
+
if (this.activeLegacy?.turn === turn) this.activeLegacy = undefined;
|
|
357
|
+
this.endActivity(typeof result?.stopReason === "string" ? result.stopReason : "unknown", result?.stopReason === "cancelled");
|
|
255
358
|
this.observeUsage(result, "prompt_result");
|
|
256
359
|
this.primed = true;
|
|
257
360
|
const body = this.chunks.join("").trim();
|
|
@@ -264,13 +367,36 @@ export class AcpPeer extends BasePeer {
|
|
|
264
367
|
}
|
|
265
368
|
})
|
|
266
369
|
.catch((e: Error) => {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
370
|
+
if (turn !== this.turn) {
|
|
371
|
+
if (this.cancelWait?.turn === turn) this.opts.log?.(`[${this.id}] cancelled prompt rejected: ${sanitize(e.message)}`);
|
|
372
|
+
return; // cancelled prompt errors are discarded, like their late results
|
|
373
|
+
}
|
|
374
|
+
this.endActivity("failed", true);
|
|
375
|
+
const reason = sanitize(e.message);
|
|
376
|
+
this.opts.log?.(`[${this.id}] prompt failed: ${reason}`);
|
|
377
|
+
const quota = !this.stopping && this.kimiAgent ? acpQuotaRefusal(e.message) : undefined;
|
|
378
|
+
if (quota) {
|
|
379
|
+
let paused = false;
|
|
380
|
+
try { paused = this.opts.onQuotaRefusal?.(quota) === true; }
|
|
381
|
+
catch (error) { this.opts.log?.(`[${this.id}] quota pause failed: ${sanitize(error instanceof Error ? error.message : String(error))}`); }
|
|
382
|
+
const safe = paused && !this.deliveryAccepted && !this.promptActivity;
|
|
383
|
+
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: safe ? "failed_safe" : "needs_review", reason, ...(safe ? { quota: true as const } : {}) });
|
|
384
|
+
else if (!deliveryId && this.activeLegacy?.turn === turn) { this.activeLegacy = undefined; this.onFailed?.(envs, safe ? { quota: true } : undefined); }
|
|
385
|
+
return; // quota relay owns reassignment, not the ordinary turn-failure path
|
|
386
|
+
}
|
|
387
|
+
this.reportTurnFailure(envs, reason);
|
|
388
|
+
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason });
|
|
389
|
+
else if (!deliveryId && this.activeLegacy?.turn === turn) { this.activeLegacy = undefined; this.onFailed?.(envs); }
|
|
271
390
|
})
|
|
272
391
|
.finally(() => {
|
|
273
|
-
|
|
392
|
+
const waiting = this.cancelWait;
|
|
393
|
+
if (waiting?.turn === turn) {
|
|
394
|
+
this.clearCancelWait();
|
|
395
|
+
if (!this.stopping && this.turn === waiting.successor && this.state === "busy") this.setState("idle");
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (turn === this.turn && !this.stopping && this.state === "busy") this.setState("idle");
|
|
399
|
+
if (this.activeLegacy?.turn === turn) this.activeLegacy = undefined;
|
|
274
400
|
if (turn === this.turn && this.activeDeliveryId === deliveryId) this.activeDeliveryId = undefined;
|
|
275
401
|
});
|
|
276
402
|
}
|
|
@@ -285,6 +411,12 @@ export class AcpPeer extends BasePeer {
|
|
|
285
411
|
}
|
|
286
412
|
|
|
287
413
|
protected override onWatchdog(): void {
|
|
414
|
+
if (this.state !== "busy") return;
|
|
415
|
+
if (this.cancelWait) { this.clearWatchdog(); return; }
|
|
416
|
+
this.endActivity("watchdog cancelled", true);
|
|
417
|
+
const cancelledTurn = this.turn;
|
|
418
|
+
this.cancelPermissions();
|
|
419
|
+
this.activeLegacy = undefined; // the cancelled prompt is discarded, never returned by a later stop
|
|
288
420
|
const durable = !!this.activeDeliveryId;
|
|
289
421
|
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "ACP turn watchdog timeout" });
|
|
290
422
|
this.activeDeliveryId = undefined;
|
|
@@ -293,12 +425,34 @@ export class AcpPeer extends BasePeer {
|
|
|
293
425
|
// ACP updates carry only a session ID, not a turn ID. After a durable turn times out,
|
|
294
426
|
// keep the adapter offline until it is restarted so late chunks cannot contaminate a new turn.
|
|
295
427
|
if (durable) this.setState("offline");
|
|
296
|
-
else
|
|
428
|
+
else {
|
|
429
|
+
this.clearWatchdog(); // waiting for cancellation is not a second inactive model turn
|
|
430
|
+
const successor = this.turn;
|
|
431
|
+
const timer = setTimeout(() => {
|
|
432
|
+
const waiting = this.cancelWait;
|
|
433
|
+
if (!waiting || waiting.turn !== cancelledTurn || waiting.successor !== successor) return;
|
|
434
|
+
this.clearCancelWait();
|
|
435
|
+
if (!this.stopping && this.turn === successor) {
|
|
436
|
+
this.setState("offline"); // keep queued work until restart
|
|
437
|
+
this.opts.log?.(`[${this.id}] cancelled prompt did not report within the cancellation deadline; peer offline, queued work retained; stop and restart the peer to resume delivery`);
|
|
438
|
+
}
|
|
439
|
+
}, this.opts.cancelWaitMs ?? HANDSHAKE_MS);
|
|
440
|
+
timer.unref?.();
|
|
441
|
+
let release!: () => void;
|
|
442
|
+
const released = new Promise<void>(resolve => { release = resolve; });
|
|
443
|
+
this.cancelWait = { turn: cancelledTurn, successor, timer, reported: false, released, release };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private clearCancelWait(): void {
|
|
448
|
+
const waiting = this.cancelWait;
|
|
449
|
+
this.cancelWait = undefined;
|
|
450
|
+
if (waiting) { clearTimeout(waiting.timer); waiting.release(); }
|
|
297
451
|
}
|
|
298
452
|
|
|
299
453
|
private observeUsage(value: unknown, source: ACPUsageDiagnostic["source"]): void {
|
|
300
454
|
const observation = normalizeACPUsage(value, source);
|
|
301
|
-
this.opts.onUsageDiagnostic?.(observation);
|
|
455
|
+
this.opts.onUsageDiagnostic?.(observation, this.sessionId);
|
|
302
456
|
if (observation.availability === "known") this.opts.onTokens?.(observation.total!, this.sessionId);
|
|
303
457
|
}
|
|
304
458
|
|
|
@@ -309,6 +463,7 @@ export class AcpPeer extends BasePeer {
|
|
|
309
463
|
}
|
|
310
464
|
|
|
311
465
|
private down(reason: string): void {
|
|
466
|
+
this.clearCancelWait();
|
|
312
467
|
this.opts.log?.(`[${this.id}] ${reason}`);
|
|
313
468
|
for (const p of this.pending.values()) p.reject(new Error(reason));
|
|
314
469
|
this.pending.clear();
|
|
@@ -325,13 +480,28 @@ export class AcpPeer extends BasePeer {
|
|
|
325
480
|
} catch {
|
|
326
481
|
return; // not protocol output
|
|
327
482
|
}
|
|
328
|
-
if (this.state === "busy") this.touch();
|
|
483
|
+
if (this.state === "busy" && !this.cancelWait) this.touch();
|
|
329
484
|
|
|
330
485
|
if (msg.method === "session/update") {
|
|
331
486
|
if (msg.params?.sessionId && msg.params.sessionId !== this.sessionId) return;
|
|
332
|
-
if (this.state !== "busy") return;
|
|
333
|
-
this.acceptDelivery();
|
|
334
487
|
const u = msg.params?.update;
|
|
488
|
+
// Occupancy is the session's, not the turn's: 2.1.1's source text emits usage_update after the prompt
|
|
489
|
+
// resolves, when the peer may already be idle (read from the source, not observed live; #285). So a
|
|
490
|
+
// context-used update is accepted while idle too, but then it must name the current session; in a turn the
|
|
491
|
+
// lenient session check above stands, as before. Every other update stays behind the busy gate, and a
|
|
492
|
+
// watchdog-cancelled turn's late chunks still lose their turn (the prompt result path is turn-fenced); an
|
|
493
|
+
// occupancy reading carries no turn state.
|
|
494
|
+
if (u?.sessionUpdate === "usage_update" && this.state !== "offline") {
|
|
495
|
+
const observation = normalizeACPUsage(u, "usage_update");
|
|
496
|
+
if (observation.shape === "context-used") {
|
|
497
|
+
if ((this.state === "busy" && !this.cancelWait) || msg.params?.sessionId === this.sessionId) this.opts.onUsageDiagnostic?.(observation, this.sessionId);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (this.state !== "busy" || this.cancelWait) return;
|
|
502
|
+
this.promptActivity = true;
|
|
503
|
+
this.acceptDelivery();
|
|
504
|
+
this.updateWatch(u);
|
|
335
505
|
if (u?.sessionUpdate === "agent_message_chunk" && u.content?.type === "text") this.chunks.push(u.content.text);
|
|
336
506
|
// The permission request that follows may carry no `rawInput` (Kimi 2.0.1 does not), and then the
|
|
337
507
|
// console would be asked to approve a bare tool name. Keep what the call said it would run (issue #31).
|
|
@@ -350,6 +520,8 @@ export class AcpPeer extends BasePeer {
|
|
|
350
520
|
}
|
|
351
521
|
else if (u?.sessionUpdate === "usage_update") this.observeUsage(u, "usage_update");
|
|
352
522
|
} else if (msg.method === "session/request_permission") {
|
|
523
|
+
if (this.cancelWait || this.state === "offline") { this.send({ jsonrpc: "2.0", id: msg.id, result: { outcome: { outcome: "cancelled" } } }); return; }
|
|
524
|
+
if (this.state === "busy" && (!msg.params?.sessionId || msg.params.sessionId === this.sessionId)) this.promptActivity = true;
|
|
353
525
|
void this.answerPermission(msg);
|
|
354
526
|
} else if (msg.method && msg.id !== undefined) {
|
|
355
527
|
this.send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "method not supported by agent-hub" } });
|
|
@@ -362,6 +534,7 @@ export class AcpPeer extends BasePeer {
|
|
|
362
534
|
}
|
|
363
535
|
|
|
364
536
|
private async answerPermission(msg: any): Promise<void> {
|
|
537
|
+
this.flushActivity();
|
|
365
538
|
const call = msg.params?.toolCall ?? {};
|
|
366
539
|
const once = (msg.params?.options ?? []).find((o: PermissionOption) => o.kind === "allow_once");
|
|
367
540
|
const id = typeof call.toolCallId === "string" ? call.toolCallId : undefined;
|
|
@@ -375,6 +548,8 @@ export class AcpPeer extends BasePeer {
|
|
|
375
548
|
const identity = [typeof call.title === "string" ? call.title : undefined, canonical].find((candidate) => candidate !== undefined && this.opts.autoApprove?.(candidate));
|
|
376
549
|
if (once && identity) {
|
|
377
550
|
this.opts.log?.(`permission auto-approved for ${this.id}: ${identity}`); // the name only: arguments may quote a PII turn
|
|
551
|
+
this.emitActivity("ask", `${String(msg.id)}: ${activityText(announced ?? call.title)} (automatic)`);
|
|
552
|
+
this.emitActivity("answered", `${String(msg.id)}: allow_once (automatic)`);
|
|
378
553
|
return this.send({ jsonrpc: "2.0", id: msg.id, result: { outcome: { outcome: "selected", optionId: once.optionId } } });
|
|
379
554
|
}
|
|
380
555
|
// The approver has to see what runs, not only the tool's name ("Bash"). The request itself carries it
|
|
@@ -389,19 +564,25 @@ export class AcpPeer extends BasePeer {
|
|
|
389
564
|
const named = typeof call.title === "string" && jsonObject(call.title) === undefined ? call.title : announced;
|
|
390
565
|
// An unknown payload is never dressed up as a description, and it must not buy a blanket grant.
|
|
391
566
|
const title: string = raw === undefined ? `${named ?? "tool call"} (payload not reported by the agent)` : `${named ?? "tool call"}${input}`;
|
|
567
|
+
this.emitActivity("ask", `${String(msg.id)}: ${activityText(title)}; answer in ahub console`);
|
|
392
568
|
const options: PermissionOption[] = (msg.params?.options ?? []).filter((o: PermissionOption) => (raw !== undefined && !cut) || o.kind !== "allow_always");
|
|
393
569
|
// Only a bare tool name travels on its own (a desktop notice shows it); anything prose-like stays in the title.
|
|
394
570
|
const tool = typeof named === "string" && /^[\w.:@-]{1,80}$/.test(named) ? named : undefined;
|
|
395
571
|
// Waiting for a person is not the agent going silent: keep the watchdog from cancelling the turn meanwhile.
|
|
396
572
|
const turn = this.turn;
|
|
397
573
|
const alive = setInterval(() => this.state === "busy" && turn === this.turn && this.touch(), Math.max(10, Math.min(30_000, Math.floor(this.watchdogMs / 3))));
|
|
574
|
+
const controller = new AbortController();
|
|
575
|
+
this.permissionWaits.add(controller);
|
|
398
576
|
let picked: string | undefined;
|
|
399
577
|
try {
|
|
400
|
-
|
|
578
|
+
const cancelled = new Promise<undefined>(resolve => controller.signal.addEventListener("abort", () => resolve(undefined), { once: true }));
|
|
579
|
+
picked = await Promise.race([this.opts.onPermission?.({ peer: this.id, title, options, ...(tool ? { tool } : {}) }, controller.signal).catch(() => undefined), cancelled]);
|
|
401
580
|
} finally {
|
|
402
581
|
clearInterval(alive); // also when there is no handler at all
|
|
582
|
+
this.permissionWaits.delete(controller);
|
|
403
583
|
}
|
|
404
|
-
const valid = options.some((o) => o.optionId === picked);
|
|
584
|
+
const valid = !controller.signal.aborted && this.state !== "offline" && turn === this.turn && !this.stopping && options.some((o) => o.optionId === picked);
|
|
585
|
+
if (turn === this.turn) this.emitActivity("answered", `${String(msg.id)}: ${valid ? options.find(o => o.optionId === picked)?.kind : "cancelled"}`);
|
|
405
586
|
this.send({
|
|
406
587
|
jsonrpc: "2.0",
|
|
407
588
|
id: msg.id,
|