@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.
Files changed (183) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +22 -9
  3. package/docs/addons/claude-mem.md +24 -0
  4. package/docs/addons/codex-plugin.md +8 -0
  5. package/docs/addons/dot-admit.md +8 -0
  6. package/docs/addons/open-code-review.md +8 -0
  7. package/docs/addons/ponytail.md +23 -0
  8. package/docs/addons/ripwire.md +22 -0
  9. package/docs/agent-notes/README.md +5 -0
  10. package/docs/agent-notes/adapters.md +18 -6
  11. package/docs/agent-notes/benchmarks.md +3 -0
  12. package/docs/agent-notes/budget.md +4 -0
  13. package/docs/agent-notes/bus.md +3 -1
  14. package/docs/agent-notes/daemon.md +14 -1
  15. package/docs/agent-notes/local-worker.md +6 -3
  16. package/docs/agent-notes/tasks.md +6 -1
  17. package/docs/agent-notes/tests.md +4 -1
  18. package/docs/bench.md +28 -3
  19. package/docs/cooperbench.md +4 -0
  20. package/docs/data-policy.md +36 -0
  21. package/docs/events.md +10 -3
  22. package/docs/gsd-collaboration-analysis.md +48 -0
  23. package/docs/memory.md +231 -0
  24. package/docs/operations.md +873 -30
  25. package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
  26. package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
  27. package/docs/profiles.md +57 -0
  28. package/docs/quickstart.md +9 -4
  29. package/docs/research.md +31 -1
  30. package/docs/roles.md +85 -0
  31. package/docs/sdlc.md +170 -0
  32. package/docs/security.md +124 -8
  33. package/docs/smoke.md +354 -5
  34. package/docs/specs/2026-09-19-agent-hub-design.md +676 -15
  35. package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
  36. package/docs/specs/2026-10-12-office-view-design.md +212 -0
  37. package/docs/text-projection.md +19 -0
  38. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  39. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  40. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  41. package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
  42. package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
  43. package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
  44. package/docs/verification/2026-10-13-0.12.24.md +94 -0
  45. package/docs/verification/2026-10-13-board-phone.md +31 -0
  46. package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
  47. package/docs/verified.json +34 -16
  48. package/package.json +2 -1
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
  51. package/plugins/agent-hub/server.js +1108 -261
  52. package/scripts/check-docs.mjs +129 -0
  53. package/src/adapters/acp-quota.ts +13 -0
  54. package/src/adapters/acp.ts +201 -20
  55. package/src/adapters/claude-channel.ts +48 -11
  56. package/src/adapters/codex-appserver.ts +54 -13
  57. package/src/adapters/jcode.ts +110 -0
  58. package/src/adapters/local-worker.ts +10 -7
  59. package/src/adapters/pi.ts +94 -20
  60. package/src/cli/attention.ts +60 -0
  61. package/src/cli/bench.ts +26 -6
  62. package/src/cli/board-read.ts +12 -0
  63. package/src/cli/console-state.ts +131 -39
  64. package/src/cli/console.ts +73 -17
  65. package/src/cli/facts-hook.ts +12 -4
  66. package/src/cli/help.ts +52 -21
  67. package/src/cli/identity.ts +19 -8
  68. package/src/cli/init.ts +48 -10
  69. package/src/cli/jcode-mcp.ts +58 -0
  70. package/src/cli/kimi-mcp.ts +53 -0
  71. package/src/cli/launch.ts +16 -1
  72. package/src/cli/main.ts +641 -193
  73. package/src/cli/memory.ts +193 -0
  74. package/src/cli/output.ts +404 -0
  75. package/src/cli/pet-config.ts +1 -0
  76. package/src/cli/pet.ts +83 -0
  77. package/src/cli/preview.ts +40 -6
  78. package/src/cli/reset.ts +3 -0
  79. package/src/cli/status-lines.ts +1 -29
  80. package/src/cli/tail-render.ts +1 -0
  81. package/src/cli/terminal-recovery.ts +27 -1
  82. package/src/cli/upgrade-runtime.ts +56 -10
  83. package/src/cli/upgrade.ts +23 -5
  84. package/src/cli/watch.ts +31 -0
  85. package/src/hub/activity.ts +53 -0
  86. package/src/hub/ask.ts +10 -5
  87. package/src/hub/bench.ts +10 -3
  88. package/src/hub/board.ts +224 -16
  89. package/src/hub/budget.ts +39 -7
  90. package/src/hub/bus.ts +58 -20
  91. package/src/hub/child-process.ts +11 -0
  92. package/src/hub/citations.ts +7 -0
  93. package/src/hub/conductor.ts +34 -17
  94. package/src/hub/config-trust.ts +20 -7
  95. package/src/hub/conflicts.ts +2 -0
  96. package/src/hub/context-window.ts +8 -1
  97. package/src/hub/crash.ts +11 -1
  98. package/src/hub/daemon.ts +1218 -113
  99. package/src/hub/db-timeout.ts +3 -0
  100. package/src/hub/delivery-journal.ts +7 -1
  101. package/src/hub/envelope.ts +1 -1
  102. package/src/hub/events.ts +14 -2
  103. package/src/hub/execution-budget.ts +2 -0
  104. package/src/hub/facts.ts +28 -8
  105. package/src/hub/git.ts +38 -0
  106. package/src/hub/github-import.ts +28 -0
  107. package/src/hub/hub-tools.ts +49 -10
  108. package/src/hub/inference.ts +1 -1
  109. package/src/hub/lifecycle-schema.ts +17 -0
  110. package/src/hub/lifecycle.ts +10 -4
  111. package/src/hub/listen.ts +15 -0
  112. package/src/hub/manager.ts +26 -6
  113. package/src/hub/path-policy.ts +96 -0
  114. package/src/hub/peer-catalog.ts +28 -0
  115. package/src/hub/peers.ts +5 -3
  116. package/src/hub/permission-mode.ts +33 -5
  117. package/src/hub/planning.ts +70 -0
  118. package/src/hub/profiles.ts +74 -0
  119. package/src/hub/project.ts +2 -1
  120. package/src/hub/projected-diff.ts +73 -0
  121. package/src/hub/projection-exec.ts +24 -0
  122. package/src/hub/recovery-error.ts +31 -0
  123. package/src/hub/recovery-store.ts +6 -5
  124. package/src/hub/registry.ts +2 -1
  125. package/src/hub/research.ts +144 -21
  126. package/src/hub/roles.ts +123 -0
  127. package/src/hub/roster.ts +23 -0
  128. package/src/hub/routing.ts +132 -18
  129. package/src/hub/sdlc-queue.ts +112 -0
  130. package/src/hub/sdlc-release.ts +125 -0
  131. package/src/hub/sdlc.ts +250 -0
  132. package/src/hub/settings.ts +411 -0
  133. package/src/hub/shipped-roles.ts +25 -0
  134. package/src/hub/snapshots.ts +92 -16
  135. package/src/hub/start-mode.ts +50 -0
  136. package/src/hub/supervision.ts +1 -0
  137. package/src/hub/task-sweep.ts +1 -1
  138. package/src/hub/tasks.ts +1014 -111
  139. package/src/hub/terminal-open.ts +64 -0
  140. package/src/hub/text-projection.ts +126 -0
  141. package/src/hub/tooling.ts +159 -0
  142. package/src/hub/ui.ts +40 -14
  143. package/src/hub/workflow.ts +71 -0
  144. package/src/local/deny.ts +29 -7
  145. package/src/local/sandbox.ts +50 -10
  146. package/src/local/tools.ts +11 -6
  147. package/src/memory/brief.ts +28 -14
  148. package/src/memory/import.ts +55 -0
  149. package/src/memory/recall.ts +86 -2
  150. package/src/memory/store.ts +750 -0
  151. package/src/memory/sync.ts +145 -0
  152. package/src/models/mlx.ts +2 -1
  153. package/src/pet/NOTICE.md +10 -0
  154. package/src/pet/characters.ts +1438 -0
  155. package/src/pet/config.ts +31 -0
  156. package/src/pet/frame.ts +17 -0
  157. package/src/pet/pixels.ts +28 -0
  158. package/src/pi/tool-receipts.ts +2 -1
  159. package/src/role-text.d.ts +1 -0
  160. package/src/ui/index.html +983 -40
  161. package/src/ui/needs.ts +58 -0
  162. package/src/ui/office-model.ts +440 -0
  163. package/src/ui/peer-palette.ts +16 -0
  164. package/src/ui/task-board.ts +49 -0
  165. package/src/ui/task-progress.ts +5 -1
  166. package/templates/AGENTS.block.md +3 -2
  167. package/templates/config.json +5 -1
  168. package/templates/profiles/dev.json +22 -0
  169. package/templates/profiles/general.json +26 -0
  170. package/templates/profiles/research.json +57 -0
  171. package/templates/profiles/writing.json +28 -0
  172. package/templates/roles/NOTICE.md +26 -0
  173. package/templates/roles/dev/backend-engineer.md +26 -0
  174. package/templates/roles/dev/code-reviewer.md +26 -0
  175. package/templates/roles/dev/frontend-engineer.md +26 -0
  176. package/templates/roles/dev/security-reviewer.md +26 -0
  177. package/templates/roles/dev/software-architect.md +26 -0
  178. package/templates/roles/dev/test-engineer.md +26 -0
  179. package/templates/roles/research/analyst.md +26 -0
  180. package/templates/roles/research/fact-checker.md +26 -0
  181. package/templates/roles/research/research-lead.md +26 -0
  182. package/templates/roles/research/researcher.md +26 -0
  183. 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
+ }
@@ -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 { KIMI_MODE_IDS, type PermissionMode } from "../hub/permission-mode.ts";
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
- onUsageDiagnostic?: (observation: ACPUsageDiagnostic) => void;
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
- this.opts.log?.(`[${this.id}] prompt failed: ${e.message}`);
268
- if (turn === this.turn) this.reportTurnFailure(envs, e.message); // a watchdog-cancelled turn reports stale, never as this turn's failure
269
- if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: e.message });
270
- else if (!deliveryId) this.onFailed?.(envs);
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
- if (turn === this.turn && this.state === "busy") this.setState("idle");
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 super.onWatchdog();
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
- picked = await this.opts.onPermission?.({ peer: this.id, title, options, ...(tool ? { tool } : {}) }).catch(() => undefined);
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,