@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.
- package/CHANGELOG.md +38 -3
- package/README.md +3 -3
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +228 -0
- package/docs/agent-notes/README.md +14 -0
- package/docs/agent-notes/adapters.md +61 -42
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/daemon.md +16 -10
- package/docs/agent-notes/local-worker.md +4 -3
- package/docs/agent-notes/tasks.md +13 -6
- package/docs/agent-notes/tests.md +9 -7
- package/docs/operations.md +163 -12
- package/docs/quickstart.md +10 -2
- package/docs/sdlc.md +26 -8
- package/docs/security.md +11 -6
- package/docs/specs/2026-09-19-agent-hub-design.md +273 -8
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verified.json +38 -20
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10287 -9560
- package/src/adapters/acp-owned-group.ts +124 -0
- package/src/adapters/acp.ts +92 -25
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +4 -4
- package/src/adapters/agy/entry.ts +4 -1
- package/src/adapters/channel-peer.ts +143 -0
- package/src/adapters/claude-channel.ts +21 -12
- package/src/adapters/codex-appserver.ts +17 -2
- package/src/adapters/configured-acp.ts +124 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +60 -39
- package/src/adapters/definitions/native-channel-state.ts +41 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +9 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/native-identity-defaults.ts +14 -0
- package/src/adapters/definitions/qwen.ts +7 -9
- package/src/adapters/definitions/reviewer-variants.ts +3 -7
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/jcode.ts +4 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +6 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +28 -16
- package/src/adapters/registry.ts +115 -17
- package/src/adapters/reviewer-print.ts +4 -0
- package/src/cli/agy-mcp.ts +3 -2
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +9 -4
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +27 -16
- package/src/cli/identity.ts +8 -7
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.ts +101 -32
- package/src/cli/memory.ts +13 -10
- package/src/cli/output.ts +1 -0
- package/src/cli/preview.ts +9 -0
- package/src/cli/recovery-guidance.ts +1 -1
- package/src/cli/terminal-recovery.ts +16 -13
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +6 -4
- package/src/cli/upgrade-runtime.ts +25 -7
- package/src/cli/upgrade.ts +3 -2
- package/src/hub/board.ts +37 -8
- package/src/hub/budget.ts +9 -2
- package/src/hub/bus.ts +6 -2
- package/src/hub/claude-state.ts +2 -37
- package/src/hub/conductor.ts +8 -6
- package/src/hub/config-apply.ts +37 -17
- package/src/hub/config-reload.ts +42 -11
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +6 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +412 -328
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +57 -5
- package/src/hub/instance-config.ts +33 -12
- package/src/hub/instance-execution.ts +23 -11
- package/src/hub/instances.ts +47 -22
- package/src/hub/lifecycle-schema.ts +5 -3
- package/src/hub/memory-profile.ts +16 -3
- package/src/hub/no-writes.ts +50 -0
- package/src/hub/peers.ts +9 -0
- package/src/hub/permission-mode.ts +9 -7
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +35 -6
- package/src/hub/routing.ts +21 -20
- package/src/hub/sdlc-queue.ts +19 -4
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +27 -12
- package/src/hub/settings.ts +54 -16
- package/src/hub/snapshots.ts +32 -6
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +358 -60
- package/src/hub/ui.ts +2 -2
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +563 -77
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- 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
|
+
}
|
package/src/adapters/acp.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
+
}
|
|
@@ -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(
|
|
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; }
|