@estebanforge/pi-antigravity-bridge 1.4.9 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +16 -29
- package/docs/ACP-ADOPTION-PLAN.md +83 -18
- package/docs/ACP-PROTOCOL-REFERENCE.md +4 -3
- package/docs/APPROVAL-GATE.md +33 -0
- package/docs/ARCHITECTURE.md +13 -5
- package/docs/DEVELOPMENT.md +17 -0
- package/docs/ENGINES.md +46 -0
- package/docs/PI-BRIDGE-GAPS.md +26 -5
- package/docs/TODO.md +21 -0
- package/extensions/index.ts +314 -19
- package/package.json +1 -1
- package/src/acp/driver.ts +67 -9
- package/src/acp/usage-estimate.ts +59 -0
- package/src/approval-detect.ts +146 -0
- package/src/approval-gate.ts +208 -0
- package/src/approval-hook.ts +252 -0
- package/src/config.ts +62 -1
- package/src/driver-types.ts +9 -9
- package/src/driver.ts +6 -5
- package/src/engine-picker.ts +155 -0
- package/src/mcp-registration.ts +127 -0
- package/src/mcp-server.ts +192 -10
- package/src/models.ts +2 -2
- package/src/provider.ts +209 -26
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Staging for the approval-gate hooks.json (docs/TODO.md 2.5).
|
|
2
|
+
//
|
|
3
|
+
// The ACP server and the agy CLI fire workspace `.agents/hooks.json`
|
|
4
|
+
// PreToolUse hooks (V2: deny honored, reason reaches the model; V3: hook
|
|
5
|
+
// TIMEOUT = soft-pass, agy proceeds ungated). Therefore:
|
|
6
|
+
// - the staged handler timeout must exceed the whole park budget with
|
|
7
|
+
// margin (never rely on timeout as a deny), and
|
|
8
|
+
// - the staged command delegates to the bundled poll script, which
|
|
9
|
+
// early-acks and polls the bridge for the terminal decision.
|
|
10
|
+
//
|
|
11
|
+
// Merge rules: never clobber a foreign hooks.json. Parse failures abort
|
|
12
|
+
// staging; first-time modification of an existing file writes a
|
|
13
|
+
// timestamped backup next to it (the 2026-09-05 incident rule: every
|
|
14
|
+
// destructive path gets a guard).
|
|
15
|
+
//
|
|
16
|
+
// Run: npm test
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
export const HOOK_GROUP = "pi-bridge-gate";
|
|
22
|
+
|
|
23
|
+
/** Group-key namespace. Each pi session stages its OWN group keyed per-pid
|
|
24
|
+
* (`pi-bridge-gate-<pid>`): hooks.json lives in the SHARED workspace, and
|
|
25
|
+
* two concurrent sessions must never remove or overwrite each other's gate
|
|
26
|
+
* (audit 2026-09-07: a single shared key let a gate-off session silently
|
|
27
|
+
* strip a gate-on session's PreToolUse matchers). */
|
|
28
|
+
export const GATE_GROUP_PREFIX = "pi-bridge-gate";
|
|
29
|
+
|
|
30
|
+
/** This session's group key. */
|
|
31
|
+
export function gateGroupKey(pid: number = process.pid): string {
|
|
32
|
+
return `${GATE_GROUP_PREFIX}-${pid}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True if the process is running (EPERM counts: alive but not ours). */
|
|
36
|
+
function pidAlive(pid: number): boolean {
|
|
37
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
38
|
+
try {
|
|
39
|
+
process.kill(pid, 0);
|
|
40
|
+
return true;
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return (e as NodeJS.ErrnoException).code === "EPERM";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Which session a gate group belongs to, parsed from the script path its
|
|
47
|
+
* command embeds (`.../approval-hook-<pid>.js`). The pid is the ownership
|
|
48
|
+
* proof: the script is per-pid (0600, written by that session). Returns
|
|
49
|
+
* null for groups we cannot attribute (foreign/future formats - never
|
|
50
|
+
* touched). */
|
|
51
|
+
function gateGroupPid(group: unknown): number | null {
|
|
52
|
+
const m = /approval-hook-(\d+)\.js/.exec(JSON.stringify(group));
|
|
53
|
+
return m ? Number(m[1]) : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** agy native tools worth gating: everything that mutates the machine. */
|
|
57
|
+
export const GATED_AGY_TOOLS =
|
|
58
|
+
"create_file|write_to_file|replace_file_content|multi_replace_file_content|edit_file|run_command";
|
|
59
|
+
|
|
60
|
+
/** The same list as a set, for the bridge's POST /approval validation: a
|
|
61
|
+
* payload for anything else is answered with a direct deny (defense in
|
|
62
|
+
* depth - the hooks matcher should never let one through). */
|
|
63
|
+
export const GATED_AGY_TOOL_SET: ReadonlySet<string> = new Set(GATED_AGY_TOOLS.split("|"));
|
|
64
|
+
|
|
65
|
+
export interface StageOptions {
|
|
66
|
+
/** Bridge HTTP port (the approval endpoints live on the bridge server). */
|
|
67
|
+
port: number;
|
|
68
|
+
/** Bridge shared secret (x-bridge-token). */
|
|
69
|
+
token: string;
|
|
70
|
+
/** Path to the bundled poll script (written by the caller). */
|
|
71
|
+
scriptPath: string;
|
|
72
|
+
/** Full park budget in ms; the staged hook timeout exceeds it. */
|
|
73
|
+
parkBudgetMs: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Source of the staged poll script. Written to disk by the caller (data
|
|
77
|
+
* dir), referenced by absolute path from the staged hooks.json. Early-acks
|
|
78
|
+
* via POST /approval, then polls GET /approval/<ticket> until a terminal
|
|
79
|
+
* decision or the deadline. Terminal: prints the JSON decision on stdout.
|
|
80
|
+
* Deadline hit: prints {"decision":"deny", ...} (fail closed) - agy may
|
|
81
|
+
* still soft-pass a timed-out hook, but a printed deny is honored (V2). */
|
|
82
|
+
export function hookScriptSource(opts: { port: number; token: string; deadlineMs: number }): string {
|
|
83
|
+
return `#!/usr/bin/env node
|
|
84
|
+
// Bridge approval hook (generated; do not edit). Polls the pi-antigravity-bridge.
|
|
85
|
+
const PORT = ${opts.port};
|
|
86
|
+
const TOKEN = ${JSON.stringify(opts.token)};
|
|
87
|
+
const DEADLINE = Date.now() + ${opts.deadlineMs};
|
|
88
|
+
let body = "";
|
|
89
|
+
process.stdin.setEncoding("utf8");
|
|
90
|
+
for await (const chunk of process.stdin) body += chunk;
|
|
91
|
+
let ticket = "";
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetch(\`http://127.0.0.1:\${PORT}/approval\`, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { "content-type": "application/json", "x-bridge-token": TOKEN },
|
|
96
|
+
body,
|
|
97
|
+
});
|
|
98
|
+
const json = await res.json();
|
|
99
|
+
// Ungated payloads get a terminal decision right on the POST (no park).
|
|
100
|
+
if (json && typeof json === "object" && "decision" in json) {
|
|
101
|
+
console.log(JSON.stringify(json.decision));
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
ticket = json.ticket ?? "";
|
|
105
|
+
} catch {}
|
|
106
|
+
if (!ticket) {
|
|
107
|
+
console.log(JSON.stringify({ decision: "deny", reason: "approval gate unreachable (bridge down?)" }));
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
while (Date.now() < DEADLINE) {
|
|
111
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch(\`http://127.0.0.1:\${PORT}/approval/\${encodeURIComponent(ticket)}\`, {
|
|
114
|
+
headers: { "x-bridge-token": TOKEN },
|
|
115
|
+
});
|
|
116
|
+
const json = await res.json();
|
|
117
|
+
if (json.status !== "pending") {
|
|
118
|
+
console.log(JSON.stringify(json.decision ?? { decision: "deny", reason: "gate returned no decision" }));
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
console.log(JSON.stringify({ decision: "deny", reason: "approval gate deadline exceeded" }));
|
|
124
|
+
`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Hook timeout (seconds) staged for a given park budget: the budget plus a
|
|
128
|
+
* 60s margin, minimum 60s. V3: a timed-out hook soft-passes, so this must
|
|
129
|
+
* never be smaller than the human can plausibly need. */
|
|
130
|
+
export function stagedTimeoutSeconds(parkBudgetMs: number): number {
|
|
131
|
+
return Math.max(60, Math.ceil(parkBudgetMs / 1000) + 60);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Build our hooks.json group for one workspace staging. */
|
|
135
|
+
export function buildGateGroup(opts: StageOptions): Record<string, unknown> {
|
|
136
|
+
const command = `node ${JSON.stringify(opts.scriptPath)}`;
|
|
137
|
+
return {
|
|
138
|
+
enabled: true,
|
|
139
|
+
PreToolUse: [
|
|
140
|
+
{
|
|
141
|
+
matcher: GATED_AGY_TOOLS,
|
|
142
|
+
hooks: [
|
|
143
|
+
{
|
|
144
|
+
type: "command",
|
|
145
|
+
command,
|
|
146
|
+
timeout: stagedTimeoutSeconds(opts.parkBudgetMs),
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface StageResult {
|
|
155
|
+
wrote: boolean;
|
|
156
|
+
/** Backup file written before first modification of a foreign file. */
|
|
157
|
+
backup?: string;
|
|
158
|
+
/** Why nothing was written (parse failure, already current, ...). */
|
|
159
|
+
reason?: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Stage the gate group into <workspaceDir>/.agents/hooks.json under THIS
|
|
163
|
+
* session's per-pid key. Merge-safe:
|
|
164
|
+
* - foreign groups are preserved;
|
|
165
|
+
* - gate groups of DEAD sessions are swept (their bridge is gone; the hook
|
|
166
|
+
* would fail closed forever), groups of live sessions never touched;
|
|
167
|
+
* - a foreign file is backed up before its first modification;
|
|
168
|
+
* - unparseable files are never touched. */
|
|
169
|
+
export function stageGateHooks(workspaceDir: string, opts: StageOptions): StageResult {
|
|
170
|
+
const dir = path.join(workspaceDir, ".agents");
|
|
171
|
+
const file = path.join(dir, "hooks.json");
|
|
172
|
+
const group = buildGateGroup(opts);
|
|
173
|
+
const ownKey = gateGroupKey();
|
|
174
|
+
let current: Record<string, unknown> = {};
|
|
175
|
+
const existed = fs.existsSync(file);
|
|
176
|
+
if (existed) {
|
|
177
|
+
try {
|
|
178
|
+
if (fs.lstatSync(file).isSymbolicLink()) {
|
|
179
|
+
return { wrote: false, reason: "hooks.json is a symlink; refusing to follow it" };
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
return { wrote: false, reason: "hooks.json vanished while staging" };
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
186
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
187
|
+
return { wrote: false, reason: "hooks.json is not an object; refusing to touch it" };
|
|
188
|
+
}
|
|
189
|
+
current = parsed as Record<string, unknown>;
|
|
190
|
+
} catch {
|
|
191
|
+
return { wrote: false, reason: "hooks.json is not valid JSON; refusing to touch it" };
|
|
192
|
+
}
|
|
193
|
+
// Sweep gate groups whose owning session is gone. Never touch groups of
|
|
194
|
+
// live sessions (concurrent pi sessions share this workspace) or groups
|
|
195
|
+
// we cannot attribute.
|
|
196
|
+
let swept = 0;
|
|
197
|
+
for (const key of Object.keys(current)) {
|
|
198
|
+
if (key === ownKey) continue;
|
|
199
|
+
const isGateGroup = key === GATE_GROUP_PREFIX || key.startsWith(`${GATE_GROUP_PREFIX}-`);
|
|
200
|
+
if (!isGateGroup) continue;
|
|
201
|
+
const pid = gateGroupPid(current[key]);
|
|
202
|
+
if (pid === null || pidAlive(pid)) continue;
|
|
203
|
+
delete current[key];
|
|
204
|
+
swept += 1;
|
|
205
|
+
}
|
|
206
|
+
if (swept > 0 && JSON.stringify(current[ownKey]) === JSON.stringify(group)) {
|
|
207
|
+
// Own group already current; the pass only swept dead peers.
|
|
208
|
+
fs.writeFileSync(file, JSON.stringify(current, null, 2) + "\n");
|
|
209
|
+
return { wrote: true, reason: `swept ${swept} dead gate group(s)` };
|
|
210
|
+
}
|
|
211
|
+
if (JSON.stringify(current[ownKey]) === JSON.stringify(group)) {
|
|
212
|
+
return { wrote: false, reason: "already staged" };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const backup =
|
|
216
|
+
existed && current[ownKey] === undefined
|
|
217
|
+
? `${file}.backup-${new Date().toISOString().replace(/[:.]/g, "-")}`
|
|
218
|
+
: undefined;
|
|
219
|
+
if (backup) fs.copyFileSync(file, backup);
|
|
220
|
+
current[ownKey] = group;
|
|
221
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
222
|
+
fs.writeFileSync(file, JSON.stringify(current, null, 2) + "\n");
|
|
223
|
+
return { wrote: true, backup };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Remove ONLY this session's gate group (or the given pid's). Other
|
|
227
|
+
* sessions' groups - including live ones in a shared workspace - are never
|
|
228
|
+
* touched: a gate-off session must not strip a gate-on session's matchers
|
|
229
|
+
* (audit 2026-09-07). Foreign content stays; an object file is left in
|
|
230
|
+
* place (harmless). */
|
|
231
|
+
export function removeGateHooks(workspaceDir: string, pid: number = process.pid): StageResult {
|
|
232
|
+
const file = path.join(workspaceDir, ".agents", "hooks.json");
|
|
233
|
+
if (!fs.existsSync(file)) return { wrote: false, reason: "no hooks.json" };
|
|
234
|
+
try {
|
|
235
|
+
if (fs.lstatSync(file).isSymbolicLink()) {
|
|
236
|
+
return { wrote: false, reason: "hooks.json is a symlink; refusing to follow it" };
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
return { wrote: false, reason: "hooks.json vanished while removing" };
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
243
|
+
if (!parsed || typeof parsed !== "object") return { wrote: false, reason: "not an object; refusing" };
|
|
244
|
+
const ownKey = gateGroupKey(pid);
|
|
245
|
+
if (parsed[ownKey] === undefined) return { wrote: false, reason: "not staged" };
|
|
246
|
+
delete parsed[ownKey];
|
|
247
|
+
fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + "\n");
|
|
248
|
+
return { wrote: true };
|
|
249
|
+
} catch {
|
|
250
|
+
return { wrote: false, reason: "not valid JSON; refusing" };
|
|
251
|
+
}
|
|
252
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -44,6 +44,35 @@ export type AgyMode = "accept-edits" | "plan";
|
|
|
44
44
|
export type ThinkingTier = "low" | "medium" | "high";
|
|
45
45
|
export type BridgeTools = "none" | "mcp" | "all";
|
|
46
46
|
|
|
47
|
+
/** How ACP turns report token usage while Gate B stands (agy sends none).
|
|
48
|
+
* "estimate" (default): word-boundary regex over prompt/response/thought
|
|
49
|
+
* text (pi-token-speed's mechanism). "direct": 1 token per streamed delta.
|
|
50
|
+
* "off": keep zero-usage. Real server usage (usageSeen latch) always wins. */
|
|
51
|
+
export type UsageEstimate = "estimate" | "direct" | "off";
|
|
52
|
+
|
|
53
|
+
/** How the approval gate activates (docs/TODO.md section 2.5).
|
|
54
|
+
*
|
|
55
|
+
* "auto" (default): OFF until a third-party pi permission extension is
|
|
56
|
+
* detected (src/approval-detect.ts). When one is found, the gate runs in
|
|
57
|
+
* "shadow" mode so that extension gates agy's native tool calls with zero
|
|
58
|
+
* configuration on its side.
|
|
59
|
+
*
|
|
60
|
+
* "shadow" / "dedicated": force the gate on in that shape. "off": never
|
|
61
|
+
* gate, even when a permission extension is installed. NOTE: "dedicated"
|
|
62
|
+
* currently stages the same shadow tools as "shadow" (warn-logged remap);
|
|
63
|
+
* the explicit antigravity_approve variant is planned - see docs/TODO.md. */
|
|
64
|
+
export type GateMode = "auto" | "shadow" | "dedicated" | "off";
|
|
65
|
+
|
|
66
|
+
/** Fallback decision when no extension blocked a gated call: "ask" uses
|
|
67
|
+
* ctx.ui.confirm (headless = deny, fail-closed), "allow" auto-approves,
|
|
68
|
+
* "deny" auto-rejects. */
|
|
69
|
+
export type GateAskMode = "ask" | "allow" | "deny";
|
|
70
|
+
|
|
71
|
+
export interface GateConfig {
|
|
72
|
+
gateMode: GateMode;
|
|
73
|
+
mode: GateAskMode;
|
|
74
|
+
}
|
|
75
|
+
|
|
47
76
|
export interface AcpConfig {
|
|
48
77
|
/** Path to agy_acp_server.par. Empty = env AGY_ACP_BIN > PATH. */
|
|
49
78
|
bin: string;
|
|
@@ -51,6 +80,10 @@ export interface AcpConfig {
|
|
|
51
80
|
* (parity with skipPermissions). Kept as a key so future policies do not
|
|
52
81
|
* change the config shape. */
|
|
53
82
|
permissions: "auto";
|
|
83
|
+
/** Gate B stopgap: client-side token estimates for ACP turns so pi's
|
|
84
|
+
* usage surfaces show nonzero numbers. Estimates are labeled as such in
|
|
85
|
+
* /agy doctor and auto-disable when the server sends real usage. */
|
|
86
|
+
usageEstimate: UsageEstimate;
|
|
54
87
|
}
|
|
55
88
|
|
|
56
89
|
export interface AgyConfig {
|
|
@@ -108,6 +141,11 @@ export interface AgyConfig {
|
|
|
108
141
|
* digest (per-turn) is not. Turn off for agy-native behavior (agy's own
|
|
109
142
|
* system prompt only). */
|
|
110
143
|
systemPrompt: boolean;
|
|
144
|
+
/** Approval gate over agy NATIVE tool calls (create_file, run_command,
|
|
145
|
+
* ...). pi tools agy calls already pass through pi's gates via the G9
|
|
146
|
+
* round-trip; this covers the rest. Default auto (off until a
|
|
147
|
+
* third-party permission extension is detected). See docs/TODO.md 2.5. */
|
|
148
|
+
approvals: GateConfig;
|
|
111
149
|
}
|
|
112
150
|
|
|
113
151
|
const DEFAULTS: AgyConfig = {
|
|
@@ -120,7 +158,8 @@ const DEFAULTS: AgyConfig = {
|
|
|
120
158
|
bridgeTools: "all",
|
|
121
159
|
digest: false,
|
|
122
160
|
systemPrompt: true,
|
|
123
|
-
|
|
161
|
+
approvals: { gateMode: "auto", mode: "ask" },
|
|
162
|
+
acp: { bin: "", permissions: "auto", usageEstimate: "estimate" },
|
|
124
163
|
};
|
|
125
164
|
|
|
126
165
|
/** Load config merged over defaults. Env vars override the file when set. */
|
|
@@ -189,12 +228,33 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
189
228
|
? ["1", "true", "on"].includes(envSys.toLowerCase())
|
|
190
229
|
: file.systemPrompt ?? DEFAULTS.systemPrompt;
|
|
191
230
|
|
|
231
|
+
// Approval gate (docs/TODO.md 2.5). Unknown values fall back to "auto"
|
|
232
|
+
// so a typo can never silently force the gate on.
|
|
233
|
+
const gateRaw = (process.env.AGY_APPROVALS ?? file.approvals?.gateMode ?? DEFAULTS.approvals.gateMode).toLowerCase();
|
|
234
|
+
const gateMode: GateMode =
|
|
235
|
+
gateRaw === "shadow" || gateRaw === "dedicated" || gateRaw === "off"
|
|
236
|
+
? gateRaw
|
|
237
|
+
: "auto";
|
|
238
|
+
const askRaw = (process.env.AGY_APPROVALS_MODE ?? file.approvals?.mode ?? DEFAULTS.approvals.mode).toLowerCase();
|
|
239
|
+
const gateAskMode: GateAskMode =
|
|
240
|
+
askRaw === "allow" || askRaw === "deny"
|
|
241
|
+
? askRaw
|
|
242
|
+
: "ask";
|
|
243
|
+
|
|
192
244
|
const fileAcp = (typeof file.acp === "object" && file.acp !== null ? file.acp : {}) as Partial<AcpConfig>;
|
|
245
|
+
// Unknown values fall back to "estimate" (same narrow-parse pattern as
|
|
246
|
+
// gateMode: a typo must never silently change behavior).
|
|
247
|
+
const usageRaw = String(
|
|
248
|
+
process.env.AGY_USAGE_ESTIMATE ?? fileAcp.usageEstimate ?? DEFAULTS.acp.usageEstimate,
|
|
249
|
+
).toLowerCase();
|
|
250
|
+
const usageEstimate: UsageEstimate =
|
|
251
|
+
usageRaw === "direct" || usageRaw === "off" ? usageRaw : "estimate";
|
|
193
252
|
const acp: AcpConfig = {
|
|
194
253
|
bin:
|
|
195
254
|
process.env.AGY_ACP_BIN ??
|
|
196
255
|
(typeof fileAcp.bin === "string" ? fileAcp.bin : DEFAULTS.acp.bin),
|
|
197
256
|
permissions: "auto",
|
|
257
|
+
usageEstimate,
|
|
198
258
|
};
|
|
199
259
|
|
|
200
260
|
return {
|
|
@@ -208,6 +268,7 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
208
268
|
bridgeTools,
|
|
209
269
|
digest,
|
|
210
270
|
systemPrompt,
|
|
271
|
+
approvals: { gateMode, mode: gateAskMode },
|
|
211
272
|
patchCleanupNotified: file.patchCleanupNotified === true,
|
|
212
273
|
};
|
|
213
274
|
}
|
package/src/driver-types.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Engine-agnostic turn-driver contract.
|
|
2
2
|
//
|
|
3
|
-
// Both turn engines (
|
|
3
|
+
// Both turn engines (stream-json driver in `driver.ts`, ACP driver in
|
|
4
4
|
// `acp/driver.ts`) implement `TurnDriver`, and everything above them — the
|
|
5
5
|
// provider's stream loop, the G9 round-trip store, the extension wiring —
|
|
6
|
-
// depends on this interface only. Types live here so the
|
|
6
|
+
// depends on this interface only. Types live here so the stream module can be
|
|
7
7
|
// deleted (phase 4) without breaking imports.
|
|
8
8
|
//
|
|
9
9
|
// The ACP driver implements the same surface with protocol-native mechanics:
|
|
@@ -21,17 +21,17 @@ export interface DriverProfile {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export interface DriverTurnRequest extends DriverProfile {
|
|
24
|
-
/** Existing conversation/session to resume.
|
|
24
|
+
/** Existing conversation/session to resume. Stream-json: agy conversation id via
|
|
25
25
|
* `--conversation`. ACP: sessionId via `session/load` (falls back to
|
|
26
26
|
* `session/new` when the server no longer knows it). */
|
|
27
27
|
conversationId?: string | null;
|
|
28
28
|
prompt: string;
|
|
29
29
|
/** Image blocks riding with the prompt. ACP forwards them as typed
|
|
30
30
|
* content blocks (probe 2026-09-03: 64x64 two-tone PNG answered
|
|
31
|
-
* correctly); the
|
|
31
|
+
* correctly); the stream-json CLI prompt is text-only and ignores them. */
|
|
32
32
|
images?: Array<{ data: string; mimeType: string }>;
|
|
33
33
|
/** ACP only: pi-side context delivered as a native `embeddedContext`
|
|
34
|
-
* resource block instead of inline prompt text (G1 on ACP).
|
|
34
|
+
* resource block instead of inline prompt text (G1 on ACP). Stream-json
|
|
35
35
|
* embeds the digest in the prompt string and ignores this. */
|
|
36
36
|
contextBlock?: { uri: string; text: string };
|
|
37
37
|
signal?: AbortSignal;
|
|
@@ -52,7 +52,7 @@ export type AgyUsage = {
|
|
|
52
52
|
|
|
53
53
|
export type DriverActivity =
|
|
54
54
|
| { type: "text"; delta: string }
|
|
55
|
-
/**
|
|
55
|
+
/** Stream-json emits a token count only; ACP carries the actual thought text in
|
|
56
56
|
* `delta`. The provider renders whichever is present. */
|
|
57
57
|
| { type: "thought"; tokens?: number; delta?: string }
|
|
58
58
|
| { type: "tool_start"; stepId?: number; name: string; args: Record<string, unknown> }
|
|
@@ -64,7 +64,7 @@ export type DriverActivity =
|
|
|
64
64
|
output?: string;
|
|
65
65
|
durationSeconds?: number;
|
|
66
66
|
/** ACP only: the server's native edit diff from `tool_call`
|
|
67
|
-
* content[] ({type:"diff", path, oldText?, newText}).
|
|
67
|
+
* content[] ({type:"diff", path, oldText?, newText}). Stream-json never
|
|
68
68
|
* sets it; the provider renders it without any git subprocess. */
|
|
69
69
|
diff?: { path: string; oldText?: string; newText: string };
|
|
70
70
|
}
|
|
@@ -108,7 +108,7 @@ export interface DriverSnapshot {
|
|
|
108
108
|
recycleReasons: Record<string, number>;
|
|
109
109
|
};
|
|
110
110
|
lifecycle: string[];
|
|
111
|
-
/** Present on ACP snapshots; absent on
|
|
111
|
+
/** Present on ACP snapshots; absent on stream-json. */
|
|
112
112
|
engine?: "acp";
|
|
113
113
|
acp?: {
|
|
114
114
|
sessionId?: string;
|
|
@@ -133,7 +133,7 @@ export interface DriverSnapshot {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
/** The engine contract. Everything above the driver depends on this interface
|
|
136
|
-
* only; `
|
|
136
|
+
* only; `StreamDriver` and `AcpDriver` both implement it. */
|
|
137
137
|
export interface TurnDriver {
|
|
138
138
|
readonly state: DriverState;
|
|
139
139
|
readonly activeHandle: TurnHandle | null;
|
package/src/driver.ts
CHANGED
|
@@ -122,7 +122,7 @@ export function shouldFlipToCumulative(accumulated: string, next: string): boole
|
|
|
122
122
|
return accumulated.length >= CUMULATIVE_FLIP_MIN_CHARS && isCumulativeResend(accumulated, next);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
export class
|
|
125
|
+
export class StreamDriver implements TurnDriver {
|
|
126
126
|
#state: DriverState = "idle";
|
|
127
127
|
#child: ChildProcess | undefined;
|
|
128
128
|
#generation = 0;
|
|
@@ -130,7 +130,6 @@ export class AgyDriver implements TurnDriver {
|
|
|
130
130
|
#boundConversation: string | undefined;
|
|
131
131
|
#active: ActiveTurn | undefined;
|
|
132
132
|
#queueTail: Promise<void> = Promise.resolve();
|
|
133
|
-
#shutdown = false;
|
|
134
133
|
#stderrTail = "";
|
|
135
134
|
// Frames can split across pipe chunks; the trailing partial line lives here
|
|
136
135
|
// until its newline arrives (same scheme as JsonRpcSession.feed). Dropping
|
|
@@ -218,7 +217,10 @@ export class AgyDriver implements TurnDriver {
|
|
|
218
217
|
}
|
|
219
218
|
|
|
220
219
|
async #runExclusive(request: DriverTurnRequest): Promise<TurnHandle> {
|
|
221
|
-
|
|
220
|
+
// No shutdown latch: pi fires session_shutdown on /new, /resume and
|
|
221
|
+
// /fork (not only process exit), so a closed driver must respawn on the
|
|
222
|
+
// next turn instead of rejecting forever. Parity with the ACP driver
|
|
223
|
+
// fix (regression 2026-09-07).
|
|
222
224
|
if (request.signal?.aborted) throw new Error("aborted before start");
|
|
223
225
|
|
|
224
226
|
const cause = this.#recycleCause(request);
|
|
@@ -588,7 +590,6 @@ export class AgyDriver implements TurnDriver {
|
|
|
588
590
|
}
|
|
589
591
|
|
|
590
592
|
async close(reason: "recycle" | "shutdown", cause?: string): Promise<void> {
|
|
591
|
-
if (reason === "shutdown") this.#shutdown = true;
|
|
592
593
|
const child = this.#child;
|
|
593
594
|
if (!child) {
|
|
594
595
|
this.#state = reason === "shutdown" ? "dead" : "idle";
|
|
@@ -602,7 +603,7 @@ export class AgyDriver implements TurnDriver {
|
|
|
602
603
|
this.#log(`close:${reason}${cause ? `:${cause}` : ""}`);
|
|
603
604
|
const turn = this.#active;
|
|
604
605
|
if (turn && !turn.closed) {
|
|
605
|
-
this.#failTurn(turn, `agy driver ${reason}
|
|
606
|
+
this.#failTurn(turn, `agy driver ${reason === "recycle" ? "recycled" : "shut down"} mid-turn${cause ? ` (${cause})` : ""}`);
|
|
606
607
|
}
|
|
607
608
|
this.#killChild();
|
|
608
609
|
this.#state = reason === "shutdown" ? "dead" : "idle";
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// First-run onboarding: the engine picker plus the agy-presence warning.
|
|
2
|
+
//
|
|
3
|
+
// On the first interactive start (no config file yet, no AGY_ENGINE env) pi
|
|
4
|
+
// asks which turn engine to use: the stream-json `agy` CLI or Google's
|
|
5
|
+
// official ACP server. The choice persists via saveConfig({ engine }) and,
|
|
6
|
+
// like /agy engine, takes effect on the next pi start (drivers wire at load).
|
|
7
|
+
// Every start with the stream-json engine active also re-checks that the
|
|
8
|
+
// `agy` binary exists and warns until it does (re-auth is out of scope).
|
|
9
|
+
//
|
|
10
|
+
// UI: tui.md "Pattern 1" - SelectList framed by DynamicBorder inside pi's
|
|
11
|
+
// native overlay (the window feel pi-rtk builds its modal on).
|
|
12
|
+
|
|
13
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import {
|
|
16
|
+
Container,
|
|
17
|
+
type SelectItem,
|
|
18
|
+
SelectList,
|
|
19
|
+
Spacer,
|
|
20
|
+
Text,
|
|
21
|
+
} from "@earendil-works/pi-tui";
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import type { Engine } from "./config.js";
|
|
25
|
+
|
|
26
|
+
/** Picker order is the default answer order: stream-json first. */
|
|
27
|
+
export const ENGINE_PICKER_ITEMS: SelectItem[] = [
|
|
28
|
+
{
|
|
29
|
+
value: "stream-json",
|
|
30
|
+
label: "Stream-JSON CLI",
|
|
31
|
+
description: "default, recommended",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
value: "acp",
|
|
35
|
+
label: "ACP server (official Google)",
|
|
36
|
+
description: "second sign-in, ~1.5 GB binary download",
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** Intro paragraph above the list. Text wraps, so explanations stay here and
|
|
41
|
+
* SelectItem descriptions stay one-liners (SelectList truncates long lines). */
|
|
42
|
+
export const ENGINE_PICKER_INTRO = [
|
|
43
|
+
"Pick the engine that runs your Antigravity turns. Switch anytime with /agy engine (restart applies it).",
|
|
44
|
+
"",
|
|
45
|
+
"stream-json: the `agy` CLI you already installed and authenticated. Persistent process, streamed output. Tested default.",
|
|
46
|
+
"ACP: Google's official server (agy_acp_server.par). Needs a second Google sign-in and a ~1.5 GB server binary downloaded from Google (automatic, one-time, unavoidable: the server is not part of the agy CLI).",
|
|
47
|
+
].join("\n");
|
|
48
|
+
|
|
49
|
+
/** True only for a genuine first interactive run: no saved config yet (any
|
|
50
|
+
* existing file means the user has been here before) and no AGY_ENGINE env
|
|
51
|
+
* (env wins over the file, so the wizard would fight it). Fails closed to
|
|
52
|
+
* false - an fs error must never nag the user with a dialog. */
|
|
53
|
+
export function shouldOfferEnginePicker(
|
|
54
|
+
configPath: string,
|
|
55
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
56
|
+
): boolean {
|
|
57
|
+
if (env.AGY_ENGINE !== undefined) return false;
|
|
58
|
+
try {
|
|
59
|
+
return !fs.existsSync(configPath);
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Narrow a picker value to an Engine. Items are our own constants, but an
|
|
66
|
+
* unknown value must never reach config as a cast string: it falls back to
|
|
67
|
+
* the tested default. */
|
|
68
|
+
export function toEngine(value: string): Engine {
|
|
69
|
+
return value === "acp" ? "acp" : "stream-json";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Toast copy shown after the choice is saved. ACP names the promise that
|
|
73
|
+
* matters: the binary download starts NOW (not on restart), sign-in follows
|
|
74
|
+
* when it lands, restart applies the engine. */
|
|
75
|
+
export function savedEngineMessage(engine: Engine): string {
|
|
76
|
+
return engine === "acp"
|
|
77
|
+
? "Engine saved: acp. The ~1.5 GB server binary downloads now; the Google sign-in opens when it lands. Restart applies the engine."
|
|
78
|
+
: "Engine saved: stream-json. Restart pi to apply.";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** True when the `agy` CLI binary can be found. A binRef with a path
|
|
82
|
+
* separator (AGY_BIN=/opt/agy/agy) must exist as a file; a bare name is
|
|
83
|
+
* searched on PATH. statSync cannot throw through the guards, but a race
|
|
84
|
+
* (file removed between listing and stat) fails closed to false. */
|
|
85
|
+
export function isAgyInstalled(binRef: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
86
|
+
if (binRef.includes("/")) {
|
|
87
|
+
try {
|
|
88
|
+
return fs.statSync(binRef).isFile();
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return (env.PATH ?? "")
|
|
94
|
+
.split(path.delimiter)
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.some((dir) => {
|
|
97
|
+
try {
|
|
98
|
+
return fs.statSync(path.join(dir, binRef)).isFile();
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Toast copy for the missing-CLI warning. Fires every pi start while the
|
|
106
|
+
* stream-json engine is active and the binary is absent. */
|
|
107
|
+
export function agyMissingMessage(): string {
|
|
108
|
+
return "The `agy` CLI is not installed. Install it from https://antigravity.google/product/antigravity-cli and log in on it to use Antigravity models.";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Render the picker overlay. Resolves with the chosen engine, or null when
|
|
112
|
+
* the user pressed esc (decide later - nothing is persisted). */
|
|
113
|
+
export async function showEnginePicker(ctx: ExtensionUIContext): Promise<Engine | null> {
|
|
114
|
+
return ctx.custom<Engine | null>(
|
|
115
|
+
(tui, theme, _keybindings, done) => {
|
|
116
|
+
const container = new Container();
|
|
117
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
118
|
+
container.addChild(
|
|
119
|
+
new Text(theme.fg("accent", theme.bold("Antigravity Bridge: choose your engine")), 1, 0),
|
|
120
|
+
);
|
|
121
|
+
container.addChild(new Text(theme.fg("muted", ENGINE_PICKER_INTRO), 1, 0));
|
|
122
|
+
container.addChild(new Spacer(1));
|
|
123
|
+
|
|
124
|
+
const list = new SelectList(ENGINE_PICKER_ITEMS, ENGINE_PICKER_ITEMS.length, {
|
|
125
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
126
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
127
|
+
description: (t: string) => theme.fg("muted", t),
|
|
128
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
129
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
130
|
+
});
|
|
131
|
+
list.onSelect = (item) => done(toEngine(item.value));
|
|
132
|
+
list.onCancel = () => done(null);
|
|
133
|
+
container.addChild(list);
|
|
134
|
+
|
|
135
|
+
container.addChild(new Spacer(1));
|
|
136
|
+
container.addChild(
|
|
137
|
+
new Text(theme.fg("dim", "↑↓ navigate · enter select · esc decide later"), 1, 0),
|
|
138
|
+
);
|
|
139
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
render: (width: number) => container.render(width),
|
|
143
|
+
invalidate: () => container.invalidate(),
|
|
144
|
+
handleInput: (data: string) => {
|
|
145
|
+
list.handleInput(data);
|
|
146
|
+
tui.requestRender();
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
overlay: true,
|
|
152
|
+
overlayOptions: { anchor: "center" as const, width: 80, maxHeight: "85%" as const, margin: 1 },
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
}
|