@staix/agent-hub 0.12.43 → 0.12.45

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 (180) hide show
  1. package/CHANGELOG.md +38 -3
  2. package/README.md +3 -3
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +228 -0
  5. package/docs/agent-notes/README.md +14 -0
  6. package/docs/agent-notes/adapters.md +61 -42
  7. package/docs/agent-notes/budget.md +2 -2
  8. package/docs/agent-notes/daemon.md +16 -10
  9. package/docs/agent-notes/local-worker.md +4 -3
  10. package/docs/agent-notes/tasks.md +13 -6
  11. package/docs/agent-notes/tests.md +9 -7
  12. package/docs/operations.md +163 -12
  13. package/docs/quickstart.md +10 -2
  14. package/docs/sdlc.md +26 -8
  15. package/docs/security.md +11 -6
  16. package/docs/specs/2026-09-19-agent-hub-design.md +273 -8
  17. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  18. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  19. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  20. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  21. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  22. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  23. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  24. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  25. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  26. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  27. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  28. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  29. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  30. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  31. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  32. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  33. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  34. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  35. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  36. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  37. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  39. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  40. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  44. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  45. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  48. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  53. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  54. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  55. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  56. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  57. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  58. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  60. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  61. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  62. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  65. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  66. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  68. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  69. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  71. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  72. package/docs/verified.json +38 -20
  73. package/package.json +5 -1
  74. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  75. package/plugins/agent-hub/server.js +10287 -9560
  76. package/src/adapters/acp-owned-group.ts +124 -0
  77. package/src/adapters/acp.ts +92 -25
  78. package/src/adapters/adapter-api.ts +2 -0
  79. package/src/adapters/agy/definition.ts +4 -4
  80. package/src/adapters/agy/entry.ts +4 -1
  81. package/src/adapters/channel-peer.ts +143 -0
  82. package/src/adapters/claude-channel.ts +21 -12
  83. package/src/adapters/codex-appserver.ts +17 -2
  84. package/src/adapters/configured-acp.ts +124 -0
  85. package/src/adapters/conformance/acp.ts +114 -0
  86. package/src/adapters/conformance/codex.ts +191 -0
  87. package/src/adapters/conformance/enrollment.ts +43 -0
  88. package/src/adapters/conformance/factories.ts +17 -0
  89. package/src/adapters/conformance/fake-paths.ts +12 -0
  90. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  91. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  92. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  93. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  94. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  95. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  96. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  97. package/src/adapters/conformance/index.ts +11 -0
  98. package/src/adapters/conformance/kit.ts +50 -0
  99. package/src/adapters/conformance/local-pi.ts +100 -0
  100. package/src/adapters/conformance/owned-acp.ts +44 -0
  101. package/src/adapters/definitions/builtin-metadata.ts +60 -39
  102. package/src/adapters/definitions/native-channel-state.ts +41 -0
  103. package/src/adapters/definitions/native-cli-entry-kimi.ts +9 -1
  104. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  105. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  106. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  107. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  108. package/src/adapters/definitions/qwen.ts +7 -9
  109. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  110. package/src/adapters/external-metadata.ts +48 -0
  111. package/src/adapters/external-module-scan.ts +126 -0
  112. package/src/adapters/external-modules.ts +240 -0
  113. package/src/adapters/jcode.ts +4 -0
  114. package/src/adapters/local-worker.ts +4 -0
  115. package/src/adapters/pi.ts +6 -0
  116. package/src/adapters/public.ts +7 -0
  117. package/src/adapters/registry-types.ts +28 -16
  118. package/src/adapters/registry.ts +115 -17
  119. package/src/adapters/reviewer-print.ts +4 -0
  120. package/src/cli/agy-mcp.ts +3 -2
  121. package/src/cli/configured-acp-entry.ts +58 -0
  122. package/src/cli/console-state.ts +9 -4
  123. package/src/cli/facts-hook.ts +2 -1
  124. package/src/cli/help.ts +27 -16
  125. package/src/cli/identity.ts +8 -7
  126. package/src/cli/launch.ts +5 -4
  127. package/src/cli/main.ts +101 -32
  128. package/src/cli/memory.ts +13 -10
  129. package/src/cli/output.ts +1 -0
  130. package/src/cli/preview.ts +9 -0
  131. package/src/cli/recovery-guidance.ts +1 -1
  132. package/src/cli/terminal-recovery.ts +16 -13
  133. package/src/cli/upgrade-labels.ts +1 -1
  134. package/src/cli/upgrade-presentation.ts +1 -1
  135. package/src/cli/upgrade-restore-state.ts +6 -4
  136. package/src/cli/upgrade-runtime.ts +25 -7
  137. package/src/cli/upgrade.ts +3 -2
  138. package/src/hub/board.ts +37 -8
  139. package/src/hub/budget.ts +9 -2
  140. package/src/hub/bus.ts +6 -2
  141. package/src/hub/claude-state.ts +2 -37
  142. package/src/hub/conductor.ts +8 -6
  143. package/src/hub/config-apply.ts +37 -17
  144. package/src/hub/config-reload.ts +42 -11
  145. package/src/hub/config-trust.ts +1 -1
  146. package/src/hub/context-window.ts +6 -0
  147. package/src/hub/control-client.ts +4 -1
  148. package/src/hub/crash.ts +8 -6
  149. package/src/hub/daemon.ts +412 -328
  150. package/src/hub/facts.ts +76 -13
  151. package/src/hub/hub-tools.ts +57 -5
  152. package/src/hub/instance-config.ts +33 -12
  153. package/src/hub/instance-execution.ts +23 -11
  154. package/src/hub/instances.ts +47 -22
  155. package/src/hub/lifecycle-schema.ts +5 -3
  156. package/src/hub/memory-profile.ts +16 -3
  157. package/src/hub/no-writes.ts +50 -0
  158. package/src/hub/peers.ts +9 -0
  159. package/src/hub/permission-mode.ts +9 -7
  160. package/src/hub/plan-conformance.ts +83 -0
  161. package/src/hub/review-findings.ts +104 -0
  162. package/src/hub/roster.ts +35 -6
  163. package/src/hub/routing.ts +21 -20
  164. package/src/hub/sdlc-queue.ts +19 -4
  165. package/src/hub/sdlc-release.ts +16 -1
  166. package/src/hub/sdlc.ts +27 -12
  167. package/src/hub/settings.ts +54 -16
  168. package/src/hub/snapshots.ts +32 -6
  169. package/src/hub/start-mode.ts +10 -8
  170. package/src/hub/task-sweep.ts +8 -2
  171. package/src/hub/tasks.ts +358 -60
  172. package/src/hub/ui.ts +2 -2
  173. package/src/memory/capture.ts +2 -1
  174. package/src/ui/index.html +563 -77
  175. package/src/ui/office-model.ts +3 -1
  176. package/src/ui/peer-palette.ts +44 -0
  177. package/templates/AGENTS.block.md +1 -1
  178. package/templates/profiles/general.json +1 -1
  179. package/templates/profiles/research.json +1 -1
  180. package/templates/profiles/writing.json +1 -1
@@ -0,0 +1,124 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ import { processSignature } from "@staix/agent-mem/platform";
3
+ import { readProcessTable, type ProcRow } from "../hub/child-process.ts";
4
+
5
+ export interface AcpOwnedGroupPorts {
6
+ table(): readonly ProcRow[] | undefined | Promise<readonly ProcRow[] | undefined>;
7
+ signature(pid: number): string | undefined;
8
+ signal(target: number, signal: NodeJS.Signals): void;
9
+ now(): number;
10
+ sleep(ms: number): Promise<void>;
11
+ }
12
+
13
+ const PORTS: AcpOwnedGroupPorts = {
14
+ table: readProcessTable, signature: processSignature,
15
+ signal: (target, signal) => { process.kill(target, signal); },
16
+ now: () => performance.now(), sleep: ms => Bun.sleep(ms),
17
+ };
18
+ const READ_MS = 1_000;
19
+ const ownershipError = () => new Error("Configured ACP process ownership could not be verified");
20
+ const shutdownError = () => new Error("Configured ACP process group shutdown incomplete");
21
+
22
+ /** A foreground child and its original group only, never a discovered descendant group. */
23
+ export class AcpOwnedGroup {
24
+ private confirmedGone = false;
25
+
26
+ private constructor(
27
+ private readonly proc: ChildProcess,
28
+ private readonly leader: Pick<ProcRow, "pid" | "ppid" | "pgid" | "started">,
29
+ private readonly ports: AcpOwnedGroupPorts,
30
+ ) {}
31
+
32
+ static async capture(proc: ChildProcess, ports: AcpOwnedGroupPorts = PORTS): Promise<AcpOwnedGroup> {
33
+ const pid = proc.pid;
34
+ if (!Number.isSafeInteger(pid) || !pid || pid <= 1 || proc.exitCode !== null || proc.signalCode !== null) throw ownershipError();
35
+ const rows = await AcpOwnedGroup.read(ports);
36
+ const leader = rows.find(row => row.pid === pid);
37
+ if (!leader || leader.pgid !== pid || leader.ppid !== process.pid) throw ownershipError();
38
+ const signature = AcpOwnedGroup.identity(ports, pid);
39
+ if (!signature || proc.pid !== pid || proc.exitCode !== null || proc.signalCode !== null) throw ownershipError();
40
+ return new AcpOwnedGroup(proc, { pid, ppid: leader.ppid, pgid: leader.pgid, started: leader.started }, ports);
41
+ }
42
+
43
+ /** Capture failure grants no signals. Only positive no-spawn or exit-and-empty proof releases its handle. */
44
+ static async confirmGone(proc: ChildProcess, ports: AcpOwnedGroupPorts = PORTS): Promise<boolean> {
45
+ const pid = proc.pid;
46
+ if (pid === undefined) return true;
47
+ if (!Number.isSafeInteger(pid) || pid <= 1 || !(typeof proc.exitCode === "number" || typeof proc.signalCode === "string")) return false;
48
+ try {
49
+ const rows = await AcpOwnedGroup.read(ports);
50
+ return proc.pid === pid && (typeof proc.exitCode === "number" || typeof proc.signalCode === "string")
51
+ && !rows.some(row => row.pid === pid || row.pgid === pid);
52
+ } catch { return false; }
53
+ }
54
+
55
+ /** Verify before accepting the session, including after an awaited initialize handshake. */
56
+ async verify(): Promise<void> {
57
+ if (this.confirmedGone || !await this.inspect(false)) throw ownershipError();
58
+ }
59
+
60
+ async stop({ killMs = 2_000 }: { killMs?: number } = {}): Promise<void> {
61
+ if (this.confirmedGone) return;
62
+ if (!Number.isFinite(killMs) || killMs <= 0 || killMs > 10_000) throw shutdownError();
63
+ if (!await this.inspect(true)) { this.confirmedGone = true; return; }
64
+ this.signal("SIGSTOP");
65
+ // A stopped leader cannot leave the group or start a new child between this proof and SIGKILL.
66
+ if (!await this.inspect(true)) { this.confirmedGone = true; return; }
67
+ this.signal("SIGKILL");
68
+ const end = this.ports.now() + killMs;
69
+ for (;;) {
70
+ const left = end - this.ports.now();
71
+ if (left <= 0) throw shutdownError();
72
+ const rows = await AcpOwnedGroup.read(this.ports, Math.min(READ_MS, left));
73
+ const leader = rows.find(row => row.pid === this.leader.pid);
74
+ // After SIGKILL, an exiting row may be reparented or render a defunct command. No more signals
75
+ // are sent: retain only its original PID/start/group fence while awaiting positive disappearance.
76
+ if (leader && (leader.started !== this.leader.started || leader.pgid !== this.leader.pgid)) throw ownershipError();
77
+ if (!rows.some(row => row.pgid === this.leader.pgid) && !leader) { this.confirmedGone = true; return; }
78
+ if (this.ports.now() >= end) throw shutdownError();
79
+ await this.ports.sleep(Math.min(25, end - this.ports.now()));
80
+ }
81
+ }
82
+
83
+ private async inspect(allowGone: boolean): Promise<boolean> {
84
+ const rows = await AcpOwnedGroup.read(this.ports);
85
+ const leader = rows.find(row => row.pid === this.leader.pid);
86
+ const members = rows.some(row => row.pgid === this.leader.pgid);
87
+ if (!leader && !members && allowGone) return false;
88
+ if (!leader || !this.sameLeader(leader) || this.proc.pid !== this.leader.pid
89
+ || this.proc.exitCode !== null || this.proc.signalCode !== null
90
+ || !AcpOwnedGroup.identity(this.ports, this.leader.pid)) throw ownershipError();
91
+ return true;
92
+ }
93
+
94
+ private sameLeader(row: ProcRow): boolean {
95
+ // exec keeps the child handle, parent, group and start time, but changes command/comm (and its
96
+ // shared signature). Require a readable signature, while binding ownership to the stable tuple.
97
+ return row.pid === this.leader.pid && row.pgid === this.leader.pgid && row.ppid === this.leader.ppid
98
+ && row.started === this.leader.started;
99
+ }
100
+
101
+ private signal(signal: NodeJS.Signals): void {
102
+ try { this.ports.signal(-this.leader.pgid, signal); }
103
+ catch { throw ownershipError(); }
104
+ }
105
+
106
+ private static identity(ports: AcpOwnedGroupPorts, pid: number): string | undefined {
107
+ // ponytail: the shared synchronous signature probe inherits its ps wait ceiling. A bounded async
108
+ // identity port in agent-mem is the upgrade path; do not duplicate the identity implementation here.
109
+ try { return ports.signature(pid); } catch { return undefined; }
110
+ }
111
+
112
+ private static async read(ports: AcpOwnedGroupPorts, ms = READ_MS): Promise<readonly ProcRow[]> {
113
+ let timer: ReturnType<typeof setTimeout> | undefined;
114
+ try {
115
+ const rows = await Promise.race([
116
+ Promise.resolve().then(() => ports.table()),
117
+ new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), ms); }),
118
+ ]);
119
+ if (!rows) throw ownershipError();
120
+ return rows;
121
+ } catch { throw ownershipError(); }
122
+ finally { clearTimeout(timer); }
123
+ }
124
+ }
@@ -9,6 +9,7 @@ import { QWEN_MODE_IDS, qwenNativeIdentity } from "./definitions/qwen-native.ts"
9
9
  export { QwenIdentityError } from "./definitions/qwen-native.ts";
10
10
  import { BasePeer } from "../hub/peers.ts";
11
11
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
12
+ import { AcpOwnedGroup } from "./acp-owned-group.ts";
12
13
 
13
14
  export interface PermissionOption {
14
15
  optionId: string;
@@ -19,6 +20,8 @@ export interface PermissionRequest {
19
20
  peer: PeerId;
20
21
  title: string;
21
22
  options: PermissionOption[];
23
+ /** Native ACP tool kind, when the request reports it. Never inferred from its title. */
24
+ kind?: string;
22
25
  /** The tool's name alone, when the adapter knows it apart from the title (which can quote the payload). */
23
26
  tool?: string;
24
27
  }
@@ -65,6 +68,10 @@ export interface AcpOptions {
65
68
  /** Coordinator-visible selected model only; contains no prompts or command arguments. */
66
69
  launchModel?: string;
67
70
  permissionMode?: PermissionMode;
71
+ /** Presence replaces all identity-inferred native mode and quota policy. */
72
+ nativePolicy?: { modes?: Partial<Record<PermissionMode, string>>; readOnly?: string; lockedReadOnly?: boolean };
73
+ /** Configured ACP execution requires a host-captured process-group ownership proof. */
74
+ requireOwnedGroup?: boolean;
68
75
  /** Production Qwen only; legacy benchmark adapters retain their own native policy. */
69
76
  qwen025?: boolean;
70
77
  /** A production adapter's source-pinned identity; no table is inferred from it. */
@@ -139,11 +146,13 @@ function jsonObject(text: string | undefined): object | undefined {
139
146
  /** ACP client (JSON-RPC 2.0, newline-delimited, over the child's stdio). One session, one prompt in flight. */
140
147
  export class AcpPeer extends BasePeer {
141
148
  private permissionMode: PermissionMode;
149
+ private readonly nativePolicy: AcpOptions["nativePolicy"];
142
150
  private modeUnknown = false;
143
151
  private kimiAgent = false;
144
152
  private qwenAgent = false;
145
153
  private availableModes = new Set<string>();
146
154
  private proc: ChildProcessWithoutNullStreams | undefined;
155
+ private ownedGroup: { proc: ChildProcessWithoutNullStreams; proof: AcpOwnedGroup } | undefined;
147
156
  private sessionId = "";
148
157
  private nextId = 1;
149
158
  private readonly pending = new Map<number, Pending>();
@@ -222,7 +231,8 @@ export class AcpPeer extends BasePeer {
222
231
  private readonly opts: AcpOptions,
223
232
  ) {
224
233
  super(id, opts.watchdogMs);
225
- this.permissionMode = opts.permissionMode ?? "ask";
234
+ this.nativePolicy = opts.nativePolicy === undefined ? undefined : { ...opts.nativePolicy, ...(opts.nativePolicy.modes === undefined ? {} : { modes: { ...opts.nativePolicy.modes } }) };
235
+ this.permissionMode = this.nativePolicy?.lockedReadOnly ? "ask" : opts.permissionMode ?? "ask";
226
236
  }
227
237
 
228
238
  recoveryMetadata(): Record<string, unknown> {
@@ -230,16 +240,17 @@ export class AcpPeer extends BasePeer {
230
240
  }
231
241
 
232
242
  async start(): Promise<void> {
243
+ if (this.opts.requireOwnedGroup && this.proc) throw new Error("Configured ACP still has a process; stop it before starting another");
233
244
  this.stopping = false;
234
245
  const [bin, ...args] = this.opts.cmd;
235
246
  // Its own process group, stopped as a whole (#115, as Codex's in #113): an agent CLI may be a launcher with a native child.
236
247
  const proc = spawn(bin!, args, { cwd: this.opts.cwd, env: peerChildEnv(this.id, { ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"], detached: true });
237
248
  this.proc = proc;
238
249
  trackGroup(proc);
239
- proc.on("error", (e) => this.down(`spawn failed: ${e.message}`));
240
- proc.on("exit", (code) => this.down(`exited with code ${code}`));
250
+ proc.on("error", (e) => { if (!this.opts.requireOwnedGroup || this.proc === proc) this.down(`spawn failed: ${e.message}`); });
251
+ proc.on("exit", (code) => { if (!this.opts.requireOwnedGroup || this.proc === proc) this.down(`exited with code ${code}`); });
241
252
  proc.stdin.on("error", () => {}); // EPIPE from a child that died; `exit` / `error` already report it
242
- proc.stderr.on("data", (d) => this.opts.log?.(`[${this.id}] ${String(d).trimEnd()}`));
253
+ proc.stderr.on("data", (d) => this.opts.log?.(this.nativePolicy !== undefined || this.opts.requireOwnedGroup ? "Configured ACP stderr output withheld" : `[${this.id}] ${String(d).trimEnd()}`));
243
254
  createInterface({ input: proc.stdout }).on("line", (line) => this.onLine(line));
244
255
 
245
256
  const handshake = async () => {
@@ -249,10 +260,11 @@ export class AcpPeer extends BasePeer {
249
260
  });
250
261
  // The configured peer id and command are labels, not an agent-specific mode contract.
251
262
  // Installed Kimi Code CLI's ACP initialize advertises this exact name.
252
- this.kimiAgent = init?.agentInfo?.name === "Kimi Code CLI";
263
+ this.kimiAgent = this.nativePolicy === undefined && init?.agentInfo?.name === "Kimi Code CLI";
253
264
  const expected = this.opts.expectedAgent, version = init?.agentInfo?.version;
254
265
  if (expected && (init?.agentInfo?.name !== expected.name || !(typeof expected.version === "function" ? expected.version(version) : version === expected.version))) throw new Error("native ACP identity is not verified for this production adapter");
255
- this.qwenAgent = qwenNativeIdentity(this.id, this.opts.qwen025, init, this.opts.kind);
266
+ const qwen = qwenNativeIdentity(this.id, this.opts.qwen025, init, this.opts.kind);
267
+ this.qwenAgent = this.nativePolicy === undefined && qwen;
256
268
  const resume = this.opts.resumeSessionId;
257
269
  if (!resume) return this.request("session/new", { cwd: this.opts.cwd, mcpServers: this.opts.mcpServers ?? [] });
258
270
  // The agent replays the session as updates while it loads; they arrive before the peer is idle, so none of
@@ -262,11 +274,20 @@ export class AcpPeer extends BasePeer {
262
274
  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");
263
275
  return { ...loaded, sessionId: resume };
264
276
  };
265
- const timeout = new Promise<never>((_, reject) => {
266
- setTimeout(() => reject(new Error(`${this.id} did not complete the ACP handshake within ${HANDSHAKE_MS / 1000} s`)), HANDSHAKE_MS).unref();
267
- });
268
277
  try {
278
+ if (this.opts.requireOwnedGroup) {
279
+ const proof = await AcpOwnedGroup.capture(proc);
280
+ this.ownedGroup = { proc, proof };
281
+ if (this.proc !== proc || this.stopping) throw new Error("Configured ACP process ownership could not be verified");
282
+ }
283
+ const timeout = new Promise<never>((_, reject) => {
284
+ setTimeout(() => reject(new Error(`${this.id} did not complete the ACP handshake within ${HANDSHAKE_MS / 1000} s`)), HANDSHAKE_MS).unref();
285
+ });
269
286
  const session = await Promise.race([handshake(), timeout]);
287
+ if (this.nativePolicy !== undefined && (typeof session?.sessionId !== "string" || !session.sessionId.trim()
288
+ || Buffer.byteLength(session.sessionId) > 4096 || /[\p{Cc}\p{Cf}]/u.test(session.sessionId))) {
289
+ throw new Error("Configured ACP did not provide a valid native session identity");
290
+ }
270
291
  this.sessionId = session.sessionId;
271
292
  const modes: unknown = session.modes?.availableModes;
272
293
  this.availableModes = new Set(Array.isArray(modes) ? modes.flatMap((mode) => typeof mode?.id === "string" ? [mode.id] : []) : []);
@@ -274,11 +295,27 @@ export class AcpPeer extends BasePeer {
274
295
  // must be reset for the configured ask default before any prompt; a fresh ask session stays untouched.
275
296
  const resetResumed = this.opts.resumeSessionId && typeof session.modes?.currentModeId === "string" && session.modes.currentModeId !== KIMI_MODE_IDS.ask;
276
297
  // Other ACP vendors retain their native policy, including reported non-default modes on resume.
277
- if (this.qwenAgent || (this.kimiAgent && (this.permissionMode !== "ask" || resetResumed))) await this.setPermissionMode(this.permissionMode);
298
+ if (this.nativePolicy?.lockedReadOnly) await this.applyNativeMode(this.nativePolicy.readOnly, "ask");
299
+ else if (this.nativePolicy?.modes !== undefined || this.qwenAgent || (this.kimiAgent && (this.permissionMode !== "ask" || resetResumed))) await this.setPermissionMode(this.permissionMode);
300
+ if (this.opts.requireOwnedGroup) {
301
+ const owned = this.ownedGroup;
302
+ if (!owned || owned.proc !== proc || this.proc !== proc || this.stopping) throw new Error("Configured ACP process ownership could not be verified");
303
+ await owned.proof.verify();
304
+ if (this.proc !== proc || this.stopping) throw new Error("Configured ACP process ownership could not be verified");
305
+ }
278
306
  } catch (e) {
279
307
  this.setState("offline");
280
308
  if (this.proc === proc) {
281
- await stopOwnedProcess(proc, { group: true }).then(() => { if (this.proc === proc) this.proc = undefined; }, (stop: Error) => this.opts.log?.(`[${this.id}] ${stop.message}`));
309
+ if (this.opts.requireOwnedGroup) {
310
+ const owned = this.ownedGroup;
311
+ if (owned?.proc === proc) {
312
+ await owned.proof.stop().then(() => { if (this.proc === proc) { this.proc = undefined; this.ownedGroup = undefined; } }, () => this.opts.log?.("Configured ACP process group shutdown incomplete"));
313
+ } else {
314
+ proc.stdin.end(); // No ownership proof: only close our pipe, never fall back to signals or tree discovery.
315
+ if (await AcpOwnedGroup.confirmGone(proc) && this.proc === proc) { this.proc = undefined; this.ownedGroup = undefined; }
316
+ else this.opts.log?.("Configured ACP process ownership could not be verified; no process signals were sent");
317
+ }
318
+ } else await stopOwnedProcess(proc, { group: true }).then(() => { if (this.proc === proc) this.proc = undefined; }, (stop: Error) => this.opts.log?.(`[${this.id}] ${stop.message}`));
282
319
  }
283
320
  throw e;
284
321
  }
@@ -286,15 +323,23 @@ export class AcpPeer extends BasePeer {
286
323
  }
287
324
 
288
325
  getPermissionMode(): PermissionMode { return this.permissionMode; }
326
+ /** Offline does not certify shutdown: retain the original runtime until its owned group is confirmed gone. */
327
+ get pendingOwnedProcess(): boolean { return this.opts.requireOwnedGroup === true && this.proc !== undefined; }
289
328
  get permissionModeState(): PermissionMode | "unknown" | "unmanaged" {
290
- return !this.kimiAgent && !this.qwenAgent ? "unmanaged" : this.modeUnknown ? "unknown" : this.permissionMode;
329
+ return this.nativePolicy?.lockedReadOnly || this.nativePolicy?.modes !== undefined || this.kimiAgent || this.qwenAgent
330
+ ? this.modeUnknown ? "unknown" : this.permissionMode : "unmanaged";
291
331
  }
292
332
 
293
333
  async setPermissionMode(mode: PermissionMode): Promise<void> {
294
- 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`);
334
+ if (this.nativePolicy?.lockedReadOnly) throw new Error(`${this.id} permission mode is locked read-only; runtime changes are refused`);
335
+ const modes = this.nativePolicy !== undefined ? this.nativePolicy.modes : this.qwenAgent ? QWEN_MODE_IDS : this.kimiAgent ? KIMI_MODE_IDS : undefined;
336
+ if (modes === undefined) 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`);
337
+ await this.applyNativeMode(modes[mode], mode);
338
+ }
339
+
340
+ private async applyNativeMode(id: string | undefined, mode: PermissionMode): Promise<void> {
295
341
  if (this.modeUnknown) throw new Error(`${this.id} permission mode unknown after an unanswered change; restart the peer before changing modes`);
296
- const id = (this.qwenAgent ? QWEN_MODE_IDS : KIMI_MODE_IDS)[mode];
297
- if (!this.sessionId || !this.proc || !this.availableModes.has(id)) throw new Error(`${this.id} permission mode ${mode} unavailable: session does not offer ${id}`);
342
+ if (typeof id !== "string" || !id || !this.sessionId || !this.proc || !this.availableModes.has(id)) throw new Error(`${this.id} permission mode ${mode} unavailable: session does not offer ${id}`);
298
343
  try {
299
344
  await this.request("session/set_mode", { sessionId: this.sessionId, modeId: id }, this.opts.permissionModeTimeoutMs ?? HANDSHAKE_MS);
300
345
  } catch (error) {
@@ -305,9 +350,9 @@ export class AcpPeer extends BasePeer {
305
350
  this.turn++;
306
351
  this.setState("offline");
307
352
  await this.stop().catch((stop: Error) => this.opts.log?.(`[${this.id}] ${stop.message}`));
308
- throw new Error(`${this.id} permission mode ${mode} unknown: ${error.message}; peer is offline, restart it before sending work`);
353
+ throw new Error(this.nativePolicy !== undefined || this.opts.requireOwnedGroup ? "Configured ACP native permission mode unknown; peer is offline, restart it before sending work" : `${this.id} permission mode ${mode} unknown: ${error.message}; peer is offline, restart it before sending work`);
309
354
  }
310
- throw new Error(`${this.id} permission mode ${mode} refused: ${(error as Error).message}`);
355
+ throw new Error(this.nativePolicy !== undefined || this.opts.requireOwnedGroup ? "Configured ACP native permission mode refused" : `${this.id} permission mode ${mode} refused: ${(error as Error).message}`);
311
356
  }
312
357
  // A mode reply after a cancelled prompt must also observe its queued cleanup.
313
358
  if (this.cancelWait?.reported) await this.cancelWait.released;
@@ -331,7 +376,17 @@ export class AcpPeer extends BasePeer {
331
376
  this.activeDeliveryId = undefined;
332
377
  const proc = this.proc;
333
378
  if (!proc) { this.setState("offline"); return; }
334
- await stopOwnedProcess(proc, { group: true }); // also when it exited: what it left in its group fails the stop
379
+ if (this.opts.requireOwnedGroup) {
380
+ this.setState("offline");
381
+ const owned = this.ownedGroup;
382
+ if (!owned) {
383
+ if (await AcpOwnedGroup.confirmGone(proc) && this.proc === proc) { this.proc = undefined; return; }
384
+ throw new Error("Configured ACP process ownership could not be verified");
385
+ }
386
+ if (owned.proc !== proc) throw new Error("Configured ACP process ownership could not be verified");
387
+ await owned.proof.stop();
388
+ if (this.ownedGroup === owned) this.ownedGroup = undefined;
389
+ } else await stopOwnedProcess(proc, { group: true }); // Builtins keep their existing verified descendant-tree stop.
335
390
  if (this.proc === proc) this.proc = undefined;
336
391
  this.setState("offline");
337
392
  }
@@ -384,13 +439,13 @@ export class AcpPeer extends BasePeer {
384
439
  })
385
440
  .catch((e: Error) => {
386
441
  if (turn !== this.turn) {
387
- if (this.cancelWait?.turn === turn) this.opts.log?.(`[${this.id}] cancelled prompt rejected: ${sanitize(e.message)}`);
442
+ if (this.cancelWait?.turn === turn) this.opts.log?.(this.nativePolicy !== undefined || this.opts.requireOwnedGroup ? "Configured ACP cancelled prompt rejected; native details withheld" : `[${this.id}] cancelled prompt rejected: ${sanitize(e.message)}`);
388
443
  return; // cancelled prompt errors are discarded, like their late results
389
444
  }
390
445
  this.endActivity("failed", true);
391
- const reason = sanitize(e.message);
446
+ const reason = this.nativePolicy !== undefined || this.opts.requireOwnedGroup ? "Configured ACP prompt failed" : sanitize(e.message);
392
447
  this.settleSteers("needs_review", reason);
393
- this.opts.log?.(`[${this.id}] prompt failed: ${reason}`);
448
+ this.opts.log?.(this.nativePolicy !== undefined || this.opts.requireOwnedGroup ? "Configured ACP prompt failed; native details withheld" : `[${this.id}] prompt failed: ${reason}`);
394
449
  const quota = !this.stopping && this.kimiAgent ? acpQuotaRefusal(e.message) : undefined;
395
450
  if (quota) {
396
451
  let paused = false;
@@ -420,7 +475,7 @@ export class AcpPeer extends BasePeer {
420
475
 
421
476
  private reportTurnFailure(envs: Envelope[], reason: string): void {
422
477
  try {
423
- const result = this.opts.onTurnFailure?.(envs, reason);
478
+ const result = this.opts.onTurnFailure?.(envs, this.nativePolicy !== undefined || this.opts.requireOwnedGroup ? "Configured ACP prompt failed" : reason);
424
479
  if (result) void result.catch(() => this.opts.log?.("ACP failure handoff could not be completed"));
425
480
  } catch {
426
481
  this.opts.log?.("ACP failure handoff could not be completed");
@@ -514,6 +569,7 @@ export class AcpPeer extends BasePeer {
514
569
 
515
570
  private down(reason: string): void {
516
571
  this.clearCancelWait();
572
+ if (this.nativePolicy !== undefined || this.opts.requireOwnedGroup) reason = "Configured ACP transport stopped";
517
573
  this.opts.log?.(`[${this.id}] ${reason}`);
518
574
  for (const p of this.pending.values()) p.reject(new Error(reason));
519
575
  this.pending.clear();
@@ -589,6 +645,11 @@ export class AcpPeer extends BasePeer {
589
645
  private async answerPermission(msg: any): Promise<void> {
590
646
  this.flushActivity();
591
647
  const call = msg.params?.toolCall ?? {};
648
+ const kind = typeof call.kind === "string" ? call.kind : undefined;
649
+ if (this.nativePolicy?.lockedReadOnly && !["read", "search", "think"].includes(kind ?? "")) {
650
+ const rejected = (msg.params?.options ?? []).find((option: PermissionOption) => option.kind === "reject_once");
651
+ return this.send({ jsonrpc: "2.0", id: msg.id, result: { outcome: rejected ? { outcome: "selected", optionId: rejected.optionId } : { outcome: "cancelled" } } });
652
+ }
592
653
  const once = (msg.params?.options ?? []).find((o: PermissionOption) => o.kind === "allow_once");
593
654
  const id = typeof call.toolCallId === "string" ? call.toolCallId : undefined;
594
655
  const announced = id === undefined ? undefined : this.toolTitles.get(id);
@@ -598,7 +659,9 @@ export class AcpPeer extends BasePeer {
598
659
  // `mcp__<server>__<tool>` name is the only derived candidate the exact-match whitelist ever sees, and
599
660
  // argument text is never one.
600
661
  const canonical = canonicalMcpToolName(announced, this.opts.mcpServers?.map((s) => s.name));
601
- const identity = [typeof call.title === "string" ? call.title : undefined, canonical].find((candidate) => candidate !== undefined && this.opts.autoApprove?.(candidate));
662
+ // Configured ACP titles are not trusted MCP identity, even when they spell a hub tool exactly.
663
+ const identity = this.nativePolicy !== undefined || this.opts.requireOwnedGroup ? undefined
664
+ : [typeof call.title === "string" ? call.title : undefined, canonical].find((candidate) => candidate !== undefined && this.opts.autoApprove?.(candidate));
602
665
  if (once && identity) {
603
666
  this.opts.log?.(`permission auto-approved for ${this.id}: ${identity}`); // the name only: arguments may quote a PII turn
604
667
  this.emitActivity("ask", `${String(msg.id)}: ${activityText(announced ?? call.title)} (automatic)`);
@@ -618,7 +681,7 @@ export class AcpPeer extends BasePeer {
618
681
  // An unknown payload is never dressed up as a description, and it must not buy a blanket grant.
619
682
  const title: string = raw === undefined ? `${named ?? "tool call"} (payload not reported by the agent)` : `${named ?? "tool call"}${input}`;
620
683
  this.emitActivity("ask", `${String(msg.id)}: ${activityText(title)}; answer in ahub console`);
621
- const options: PermissionOption[] = (msg.params?.options ?? []).filter((o: PermissionOption) => (raw !== undefined && !cut) || o.kind !== "allow_always");
684
+ const options: PermissionOption[] = (msg.params?.options ?? []).filter((o: PermissionOption) => o.kind !== "allow_always" || !this.nativePolicy?.lockedReadOnly && raw !== undefined && !cut);
622
685
  // Only a bare tool name travels on its own (a desktop notice shows it); anything prose-like stays in the title.
623
686
  const tool = typeof named === "string" && /^[\w.:@-]{1,80}$/.test(named) ? named : undefined;
624
687
  // Waiting for a person is not the agent going silent: keep the watchdog from cancelling the turn meanwhile.
@@ -629,7 +692,7 @@ export class AcpPeer extends BasePeer {
629
692
  let picked: string | undefined;
630
693
  try {
631
694
  const cancelled = new Promise<undefined>(resolve => controller.signal.addEventListener("abort", () => resolve(undefined), { once: true }));
632
- picked = await Promise.race([this.opts.onPermission?.({ peer: this.id, title, options, ...(tool ? { tool } : {}) }, controller.signal).catch(() => undefined), cancelled]);
695
+ picked = await Promise.race([this.opts.onPermission?.({ peer: this.id, title, options, ...(tool ? { tool } : {}), ...(kind ? { kind } : {}) }, controller.signal).catch(() => undefined), cancelled]);
633
696
  } finally {
634
697
  clearInterval(alive); // also when there is no handler at all
635
698
  this.permissionWaits.delete(controller);
@@ -667,3 +730,7 @@ export class AcpPeer extends BasePeer {
667
730
  if (this.proc?.stdin.writable) this.proc.stdin.write(`${JSON.stringify(msg)}\n`);
668
731
  }
669
732
  }
733
+
734
+ export function isAcpPeer(value: unknown): value is AcpPeer {
735
+ return value instanceof AcpPeer;
736
+ }
@@ -0,0 +1,2 @@
1
+ /** Version of the public, person-authorized adapter definition contract. */
2
+ export const ADAPTER_API = 1;
@@ -1,3 +1,4 @@
1
+ import { runAgyEntry } from "./entry.ts";
1
2
  import type { AdapterDefinition, LaunchContext, LaunchPlan } from "../registry-types.ts";
2
3
  import { AGY_REVIEWER_VARIANT } from "../definitions/reviewer-variants.ts";
3
4
  import { AGY_FACTS_HOOK } from "./hooks.ts";
@@ -15,6 +16,7 @@ function agyLaunch(ctx: LaunchContext): LaunchPlan {
15
16
  export const AGY_DEFINITION: AdapterDefinition = {
16
17
  api: 1,
17
18
  kind: "agy",
19
+ projection: { roleRank: 7, helpRank: 4, plannerEligible: false },
18
20
  displayName: "Antigravity",
19
21
  family: "agy",
20
22
  transport: "ws-pull",
@@ -25,7 +27,6 @@ export const AGY_DEFINITION: AdapterDefinition = {
25
27
  start: {
26
28
  forms: ["tui"],
27
29
  defaultForm: "tui",
28
- ownsTerminal: form => form === "tui",
29
30
  launch: agyLaunch,
30
31
  conductorStartable: false,
31
32
  hubSwitches: [],
@@ -33,13 +34,12 @@ export const AGY_DEFINITION: AdapterDefinition = {
33
34
  conductorStartArgs: args => args.length ? new Error("conductor starts accept no launch overrides; use ahub agy") : {},
34
35
  },
35
36
  identity: { mcp: "channel-plugin" },
36
- attach: {
37
+ attach: { externalHello: true,
37
38
  pullOnlyForms: ["tui"],
38
39
  recoveryLaunch: instance => ({ kind: "agy", peer: instance }),
39
40
  pullOnlyNotice: "pull-only: messages wait for hub_inbox; permission modes and session recovery are not managed",
40
41
  },
41
42
  permission: {
42
- modes: {},
43
43
  unmanaged: "Antigravity selects its own permission mode in its TUI; no hub permission mode",
44
44
  unmanagedForms: ["tui"],
45
45
  unmanagedNote: "Antigravity approves tools in its own TUI; no hub permission mode",
@@ -52,7 +52,7 @@ export const AGY_DEFINITION: AdapterDefinition = {
52
52
  recoveryRefusal: () => "Antigravity session continuity and resume are unavailable until native behavior is verified",
53
53
  },
54
54
  turnEnd: "native-hook",
55
- cli: { command: "agy", authority: "person", startWords: () => ["agy"] },
55
+ cli: { command: "agy", entry: { lifecycleAdmission: true, execute: runAgyEntry }, usage: [["ahub agy [--print-command | --dry-run] [args...]", "optional Antigravity CLI 1.4.x TUI (1.4.2 verified) in this terminal (roster.agy, default off): pull-only via hub_inbox, implementer/verifier only; native permissions unmanaged, no hub sandbox"]], authority: "person", startWords: () => ["agy"] },
56
56
  display: DISPLAY,
57
57
  routing: { allowedRoles: ["implementer", "verifier"] },
58
58
  reviewer: { defaultPool: false, declaration: "automatic", unavailable: "agy is an implementer peer (#485)" },
@@ -33,6 +33,9 @@ function runAgyTui(argv: string[], cwd: string, env: NodeJS.ProcessEnv): Promise
33
33
  });
34
34
  }
35
35
 
36
+ /** #607: agy 1.4.x attaches the plugin MCP server at the first prompt, so the hub tools are listed from the second turn. */
37
+ export const AGY_LAUNCH_NOTICE = "Antigravity: pull-only TUI; native permissions unmanaged, no hub sandbox; hub tools appear from the second turn (send any first prompt, then ask for hub work)";
38
+
36
39
  /** agy auto-updates, so the 1.4.x line launches; only 1.4.2 has a T0 receipt and other patches warn (#589). */
37
40
  export function agyVersionGate(stdout: string): { ok: true; message?: string } | { ok: false; message: string } {
38
41
  const version = stdout.trim();
@@ -70,7 +73,7 @@ export async function runAgyEntry(ctx: NativeCliEntryContext): Promise<void> {
70
73
  const written = ensureAgyMcp(options);
71
74
  pluginWritten = written.changed;
72
75
  if (written.tracked) console.error("The managed agy plugin is tracked; review its local machine paths before committing");
73
- console.error("Antigravity: pull-only TUI; native permissions unmanaged, no hub sandbox");
76
+ console.error(AGY_LAUNCH_NOTICE);
74
77
  const env: NodeJS.ProcessEnv = { ...ctx.helpers.nativeLaunchEnv("agy", ctx.helpers.childEnv()), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
75
78
  delete env.AGENTHUB_CHANNEL;
76
79
  const code = await runAgyTui(argv, cwd, env);
@@ -0,0 +1,143 @@
1
+ import { BasePeer, type PeerAdapter } from "../hub/peers.ts";
2
+ import type { Envelope } from "../hub/envelope.ts";
3
+ import { definitionOf } from "./registry.ts";
4
+
5
+ /** Only the control socket operations the peer owns; authentication remains in the daemon. */
6
+ export interface ChannelSocket {
7
+ readyState: number;
8
+ data: { launchId?: string };
9
+ send(data: string): unknown;
10
+ close(code?: number, reason?: string): void;
11
+ }
12
+
13
+ /** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
14
+ export class WsPeer extends BasePeer {
15
+ private generation = crypto.randomUUID();
16
+ // Checkpoints become stale at hello/claim, before asynchronous recall finishes and attach changes delivery generation.
17
+ private claimGeneration = crypto.randomUUID();
18
+ get sessionGeneration(): string { return this.claimGeneration; }
19
+ terminalOwner(launchId: string): { generation: string } | undefined {
20
+ return this.sock?.readyState === WebSocket.OPEN && this.sock.data.launchId === launchId && this.state !== "offline" && !this.claiming
21
+ ? { generation: this.claimGeneration } : undefined;
22
+ }
23
+ private delivered = new Set<string>();
24
+ private sock: ChannelSocket | undefined;
25
+ private claimed: ChannelSocket | undefined;
26
+ private nativeSession: string | undefined;
27
+ private nativeActive = false;
28
+ /** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
29
+ pullOnly = false;
30
+ steer: PeerAdapter["steer"] = undefined;
31
+ lastInboxAt: number | undefined;
32
+ private claimedPullOnly = false;
33
+ private readonly attachmentWaiters: { generation: string; resolve: (attached: boolean) => void }[] = [];
34
+ private settleAttachmentWaiters(attached: boolean): void {
35
+ for (const waiter of this.attachmentWaiters.splice(0)) waiter.resolve(attached && waiter.generation === this.claimGeneration);
36
+ }
37
+ /** Bounded native-hook observations wait for this exact claim, never a later socket. */
38
+ waitForAttach(generation: string): Promise<boolean> {
39
+ if (generation !== this.claimGeneration || !this.claiming || this.attachmentWaiters.length >= 64) return Promise.resolve(false);
40
+ return new Promise(resolve => { this.attachmentWaiters.push({ generation, resolve }); });
41
+ }
42
+ /** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
43
+ claim(sock: ChannelSocket, pullOnly = false): void {
44
+ this.settleAttachmentWaiters(false);
45
+ this.claimGeneration = crypto.randomUUID();
46
+ this.claimed = sock;
47
+ this.claimedPullOnly = pullOnly;
48
+ }
49
+ /** A session that shows pushes holds the peer or is arriving: a session without them must not take it (issue #205). */
50
+ get channelHeld(): boolean {
51
+ return (!!this.sock && !this.pullOnly) || (this.claiming && !this.claimedPullOnly);
52
+ }
53
+ /** Status-file observations are advisory; a replaced client cannot evict a live socket or pending claim. */
54
+ standbyHeld(pullOnly: boolean): boolean {
55
+ return this.claiming || (!!this.sock && this.sock.readyState === WebSocket.OPEN && (pullOnly || !this.pullOnly));
56
+ }
57
+ /** A hello that has not finished its preface yet. The peer reads as offline until then, and a session standing by
58
+ * for the id must not take it from a claimant that is still arriving. */
59
+ get claiming(): boolean {
60
+ return !!this.claimed && this.claimed !== this.sock && this.claimed.readyState === WebSocket.OPEN;
61
+ }
62
+ attach(sock: ChannelSocket): void {
63
+ if (sock !== this.claimed) return void sock.close(4000, "replaced"); // a newer session said hello meanwhile
64
+ if (this.sock) this.setState("offline"); // unresolved work belongs to the previous connection
65
+ this.sock?.close(4000, "replaced");
66
+ this.sock = sock;
67
+ this.generation = crypto.randomUUID();
68
+ this.delivered.clear();
69
+ this.nativeSession = undefined;
70
+ this.nativeActive = false;
71
+ this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
72
+ // Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
73
+ // Pull-only transports expose no steer capability; the bus keeps their important work queued.
74
+ this.steer = this.pullOnly ? undefined : async (envs, deliveryId) => {
75
+ if (this.state !== "busy" || !envs.length || envs.some(env => env.priority !== "important")) throw new Error("channel steering requires important work in the current busy turn");
76
+ await this.deliver(envs, deliveryId);
77
+ };
78
+ this.lastInboxAt = undefined;
79
+ this.setState("idle");
80
+ this.settleAttachmentWaiters(true);
81
+ }
82
+ detach(sock: ChannelSocket): void {
83
+ if (this.claimed === sock) { this.claimed = undefined; this.settleAttachmentWaiters(false); }
84
+ if (this.sock !== sock) return;
85
+ this.sock = undefined;
86
+ this.steer = undefined;
87
+ this.nativeActive = false;
88
+ this.pullOnly = false;
89
+ this.setState("offline");
90
+ }
91
+ detachForRoster(): void {
92
+ this.settleAttachmentWaiters(false);
93
+ this.claimed?.close(4000, "peer is off roster; retry when a person adds it");
94
+ this.sock?.close(4000, "peer is off roster; retry when a person adds it");
95
+ this.claimed = undefined;
96
+ this.sock = undefined;
97
+ this.steer = undefined;
98
+ this.nativeActive = false;
99
+ this.pullOnly = false;
100
+ this.setState("offline");
101
+ }
102
+ async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
103
+ if (!this.sock) throw new Error(`${this.id} is offline`);
104
+ if (deliveryId) this.delivered.add(deliveryId);
105
+ this.sock.send(JSON.stringify({ t: "deliver", envs, generation: this.generation, ...(deliveryId ? { deliveryId } : {}) }));
106
+ }
107
+ /** Advisory nudge (issue #398): important work waits in the pull queue. Not a delivery: nothing to settle or journal. */
108
+ notifyQueued(counts: { queued: number; important: number }): void {
109
+ if (!this.sock || !this.pullOnly) return;
110
+ this.sock.send(JSON.stringify({ t: "queued", queued: counts.queued, important: counts.important }));
111
+ }
112
+ owns(sock: ChannelSocket): boolean { return this.sock === sock; }
113
+ nativeStarted(session: string): void {
114
+ if (!this.sock) return;
115
+ this.nativeSession = session;
116
+ this.nativeActive = true;
117
+ this.setState("busy");
118
+ }
119
+ nativeStopped(session: string): boolean {
120
+ if (!this.sock || (this.nativeSession !== undefined && this.nativeSession !== session)) return false;
121
+ this.nativeSession = session;
122
+ this.nativeActive = false;
123
+ this.setState("idle");
124
+ return true;
125
+ }
126
+ protected override onWatchdog(): void {
127
+ // A silent native model/approval wait does not prove Stop. Retain the observed active turn until Stop/detach.
128
+ if (this.nativeActive) { this.touch(); return; }
129
+ super.onWatchdog();
130
+ }
131
+ ownsDelivery(sock: ChannelSocket, generation: unknown, id: string): boolean { return this.owns(sock) && generation === this.generation && this.delivered.has(id); }
132
+ async start(): Promise<void> {}
133
+ async stop(): Promise<void> {
134
+ this.settleAttachmentWaiters(false);
135
+ this.sock?.close();
136
+ }
137
+
138
+ recoveryMetadata(): Record<string, unknown> {
139
+ return { launch: definitionOf(this.id)?.attach?.recoveryLaunch?.(this.id) ?? { kind: "claude-channel", peer: this.id } };
140
+ }
141
+ }
142
+
143
+ export function isWsPeer(value: unknown): value is WsPeer { return value instanceof WsPeer; }