@estebanforge/pi-antigravity-bridge 1.4.8 → 1.4.10

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/src/daily-log.ts CHANGED
@@ -15,10 +15,10 @@
15
15
  // redacted and long strings are truncated before they reach disk.
16
16
  // - Retention: files older than `retentionDays` are pruned once per
17
17
  // process, so the dir cannot grow unbounded.
18
- // - Two tiers, to keep SSD wear negligible for regular users: only
19
- // info/warn/error records (failures, turn/tool boundaries, commands,
20
- // setup) are written by default. Full verbose trails (per-event driver
21
- // lifecycle, raw bridge traffic) require AGY_DEBUG=1.
18
+ // - Volume tiers: default installs write ONLY errors routine logging
19
+ // costs the disk nothing. Warns surface as UI toasts instead (the
20
+ // extension wraps this logger); AGY_DEBUG=1 restores the full trail
21
+ // (debug/info/warn/error).
22
22
 
23
23
  import { appendFile, mkdir, readdir, unlink } from "node:fs/promises";
24
24
  import path from "node:path";
@@ -30,9 +30,9 @@ export interface DailyLoggerOptions {
30
30
  dir: string;
31
31
  /** Files older than this many days are pruned once per process. Default 14. */
32
32
  retentionDays?: number;
33
- /** Verbose gate. When false (default), debug-level records are dropped:
34
- * only info/warn/error land on disk, the light stream regular users
35
- * keep. AGY_DEBUG=1 (or this option) restores the full trail. */
33
+ /** Verbose gate. When false (default), only error-level records land on
34
+ * disk; debug/info/warn are dropped so regular users write nothing
35
+ * routine. AGY_DEBUG=1 (or this option) restores the full trail. */
36
36
  debug?: boolean;
37
37
  /** Injectable clock for tests. */
38
38
  now?: () => Date;
@@ -166,9 +166,10 @@ export function createDailyLogger(opts: DailyLoggerOptions): DailyLogger {
166
166
 
167
167
  return {
168
168
  log(event, data, level = "debug") {
169
- // Volume gate: debug is the verbose tier. Default installs write
170
- // only info/warn/error so the disk cost stays negligible.
171
- if (level === "debug" && !verbose) return;
169
+ // Volume gate: default installs write only errors so routine disk
170
+ // traffic is zero for regular users; the extension toasts warns
171
+ // instead. AGY_DEBUG=1 restores the full trail.
172
+ if (!verbose && level !== "error") return;
172
173
  write(level, event, data);
173
174
  },
174
175
  flush() {
@@ -1,9 +1,9 @@
1
1
  // Engine-agnostic turn-driver contract.
2
2
  //
3
- // Both turn engines (legacy stream-json driver in `driver.ts`, ACP driver in
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 legacy module can be
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. Legacy: agy conversation id via
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 legacy CLI prompt is text-only and ignores them. */
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). Legacy
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
- /** Legacy emits a token count only; ACP carries the actual thought text in
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}). Legacy never
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 legacy. */
111
+ /** Present on ACP snapshots; absent on stream-json. */
112
112
  engine?: "acp";
113
113
  acp?: {
114
114
  sessionId?: string;
@@ -125,11 +125,15 @@ export interface DriverSnapshot {
125
125
  /** From the initialize handshake agentInfo block. */
126
126
  agentName?: string;
127
127
  agentTitle?: string;
128
+ /** Gate B watch: true once this server process sent usage/token fields
129
+ * in any session/update frame. /agy doctor surfaces it when true and
130
+ * stays silent otherwise. */
131
+ usageSeen: boolean;
128
132
  };
129
133
  }
130
134
 
131
135
  /** The engine contract. Everything above the driver depends on this interface
132
- * only; `AgyDriver` and `AcpDriver` both implement it. */
136
+ * only; `StreamDriver` and `AcpDriver` both implement it. */
133
137
  export interface TurnDriver {
134
138
  readonly state: DriverState;
135
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 AgyDriver implements TurnDriver {
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
- if (this.#shutdown) throw new Error("agy driver is shut down.");
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}ed mid-turn${cause ? ` (${cause})` : ""}`);
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,127 @@
1
+ // Per-pid bridge registration for the stream-json engine (docs/TODO.md
2
+ // section 1, step 2). The stream-json agy CLI discovers MCP servers from
3
+ // ~/.gemini/config/mcp_config.json (verified live 2026-09-07: a server
4
+ // registered via `agy mcp add --type http` was called by agy through its
5
+ // native call_mcp_tool wrapper, exact entry shape captured from agy's own
6
+ // writes):
7
+ //
8
+ // { "mcpServers": { "<name>": { "disabled": false,
9
+ // "headers": { "x-bridge-token": "..." }, "serverUrl": "http://..." } } }
10
+ //
11
+ // The ACP engine does not use this file (mcpServers ride session/new).
12
+ //
13
+ // Merge rules: foreign servers are preserved; corrupt JSON is refused (the
14
+ // file is shared user config - never clobber); writes are atomic.
15
+ //
16
+ // Run: npm test
17
+
18
+ import fs from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+
22
+ /** Name convention for the bridge's per-pid server entries. */
23
+ export function bridgeServerName(pid: number): string {
24
+ return `pi-bridge-${pid}`;
25
+ }
26
+
27
+ export function mcpConfigPath(home: string = os.homedir()): string {
28
+ return path.join(home, ".gemini", "config", "mcp_config.json");
29
+ }
30
+
31
+ export interface BridgeServerEntry {
32
+ disabled: boolean;
33
+ headers: Record<string, string>;
34
+ serverUrl: string;
35
+ }
36
+
37
+ type McpConfig = { mcpServers: Record<string, unknown> };
38
+
39
+ function readConfig(file: string): { ok: true; config: McpConfig } | { ok: false; reason: string } {
40
+ let parsed: unknown;
41
+ try {
42
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
43
+ } catch (err) {
44
+ const code = (err as NodeJS.ErrnoException).code;
45
+ if (code === "ENOENT") return { ok: true, config: { mcpServers: {} } };
46
+ return { ok: false, reason: `mcp_config.json is not valid JSON; refusing to touch it (${String(err)})` };
47
+ }
48
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
49
+ return { ok: false, reason: "mcp_config.json is not an object; refusing to touch it" };
50
+ }
51
+ const config = parsed as McpConfig;
52
+ if (!config.mcpServers || typeof config.mcpServers !== "object" || Array.isArray(config.mcpServers)) {
53
+ config.mcpServers = {};
54
+ }
55
+ return { ok: true, config };
56
+ }
57
+
58
+ function writeConfig(file: string, config: McpConfig): void {
59
+ // 0700/0600: the file carries the bridge's shared-secret token in its
60
+ // headers, and it lives in the USER'S global agy config (audit 2026-09-07:
61
+ // it previously landed at the umask default, typically world-readable).
62
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
63
+ const tmp = `${file}.${process.pid}.tmp`;
64
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
65
+ fs.renameSync(tmp, file);
66
+ }
67
+
68
+ /** Register (or refresh) the bridge's per-pid server entry. Foreign servers
69
+ * in the file are preserved. */
70
+ export function registerBridgeServer(
71
+ entry: { pid: number; port: number; token: string; tokenHeader: string },
72
+ configPath: string = mcpConfigPath(),
73
+ ): { wrote: boolean; reason?: string } {
74
+ const read = readConfig(configPath);
75
+ if (!read.ok) return { wrote: false, reason: read.reason };
76
+ read.config.mcpServers[bridgeServerName(entry.pid)] = {
77
+ disabled: false,
78
+ headers: { [entry.tokenHeader]: entry.token },
79
+ serverUrl: `http://127.0.0.1:${entry.port}/mcp`,
80
+ } satisfies BridgeServerEntry;
81
+ writeConfig(configPath, read.config);
82
+ return { wrote: true };
83
+ }
84
+
85
+ /** Remove the bridge's per-pid server entry (close path). */
86
+ export function unregisterBridgeServer(pid: number, configPath: string = mcpConfigPath()): { wrote: boolean } {
87
+ const read = readConfig(configPath);
88
+ if (!read.ok) return { wrote: false };
89
+ const name = bridgeServerName(pid);
90
+ if (!(name in read.config.mcpServers)) return { wrote: false };
91
+ delete read.config.mcpServers[name];
92
+ writeConfig(configPath, read.config);
93
+ return { wrote: true };
94
+ }
95
+
96
+ /** Default liveness probe: can the signal be delivered? */
97
+ function pidAlive(pid: number): boolean {
98
+ try {
99
+ process.kill(pid, 0);
100
+ return true;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /** Remove bridge entries whose owning pi process is gone (stale sweep, run
107
+ * at extension start). Foreign servers and live-pid entries are preserved.
108
+ * Entries not matching the per-pid name convention are never touched. */
109
+ export function sweepStaleBridgeServers(
110
+ configPath: string = mcpConfigPath(),
111
+ isAlive: (pid: number) => boolean = pidAlive,
112
+ ): { removed: string[]; reason?: string } {
113
+ const read = readConfig(configPath);
114
+ if (!read.ok) return { removed: [], reason: read.reason };
115
+ const removed: string[] = [];
116
+ for (const name of Object.keys(read.config.mcpServers)) {
117
+ const match = /^pi-bridge-(\d+)$/.exec(name);
118
+ if (!match) continue;
119
+ const pid = Number(match[1]);
120
+ if (Number.isFinite(pid) && !isAlive(pid)) {
121
+ delete read.config.mcpServers[name];
122
+ removed.push(name);
123
+ }
124
+ }
125
+ if (removed.length > 0) writeConfig(configPath, read.config);
126
+ return { removed };
127
+ }
package/src/mcp-server.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  LATEST_PROTOCOL_VERSION,
32
32
  SUPPORTED_PROTOCOL_VERSIONS,
33
33
  } from "@modelcontextprotocol/sdk/types.js";
34
+ import { GATED_AGY_TOOL_SET } from "./approval-hook.js";
34
35
 
35
36
  /** Tools we do NOT expose to agy: it would just error (the provider is already
36
37
  * antigravity, so the tool's own guard refuses; advertising it is noise). */
@@ -38,16 +39,49 @@ const SKIP_CIRCULAR = new Set(["AskAntigravity"]);
38
39
 
39
40
  const BRIDGE_MCP_KEY = "pi-antigravity-bridge";
40
41
  /** Shared-secret header every bridge request must carry. Exported: the ACP
41
- * engine's mcpServers registration needs the same header name (the legacy
42
+ * engine's mcpServers registration needs the same header name (the stream
42
43
  * engine gets it via .agents/mcp_config.json; ACP gets it via headers[]). */
43
44
  export const TOKEN_HEADER = "x-bridge-token";
44
45
  const MAX_BODY_BYTES = 1_000_000;
45
46
 
47
+ // --- approval gate (docs/TODO.md 2.5) ---------------------------------------
48
+
49
+ /** stdin JSON of a PreToolUse hook, forwarded verbatim by the bundled poll
50
+ * script. Only toolCall is load-bearing here. */
51
+ export interface ApprovalPayload {
52
+ toolCall: { name: string; args: Record<string, unknown> };
53
+ stepIdx?: number;
54
+ conversationId?: string;
55
+ [key: string]: unknown;
56
+ }
57
+
58
+ /** Terminal decision for a parked approval (mirrors GateDecision from
59
+ * approval-gate.ts; deny MUST carry a reason - it is the only feedback
60
+ * agy's model gets, see V2). */
61
+ export type ApprovalDecision = { allow: true } | { allow: false; reason: string };
62
+
63
+ /** Provider-facing park controls: ticket verification for the shadow tools'
64
+ * marker calls, and completion when pi's tool result maps to a decision. */
65
+ export interface ApprovalParkApi {
66
+ /** True while the ticket is still parked (unanswered, unexpired). */
67
+ has(ticket: string): boolean;
68
+ /** Settle a ticket. False when the id is unknown or already terminal. */
69
+ resolve(ticket: string, decision: ApprovalDecision): boolean;
70
+ }
71
+
72
+ /** Human-decision latency budget for one parked approval. The staged hook
73
+ * timeout (approval-hook.stagedTimeoutSeconds) exceeds this with margin:
74
+ * a timed-out hook soft-passes (V3), so the park must time out FIRST and
75
+ * print a deny. Mirrors the G9 park budget. */
76
+ export const APPROVAL_PARK_TIMEOUT_MS = 480_000;
77
+
46
78
  export interface McpServerHandle {
47
79
  port: number;
48
80
  /** Shared secret for TOKEN_HEADER. Callers that register the bridge with
49
- * an engine other than the legacy stream-json discovery file need it. */
81
+ * an engine other than the stream-json discovery file need it. */
50
82
  token: string;
83
+ /** Approval-gate park controls (docs/TODO.md 2.5). */
84
+ approvals: ApprovalParkApi;
51
85
  close: () => Promise<void>;
52
86
  }
53
87
 
@@ -71,7 +105,12 @@ export interface McpBridgeDeps {
71
105
  name: string,
72
106
  args: Record<string, unknown>,
73
107
  signal: AbortSignal,
74
- ): Promise<{ content: Array<{ type: string; text?: string }>; isError: boolean }>;
108
+ ): Promise<import("./provider.js").BridgeCallResultShape>;
109
+ /** Approval gate: called once per parked POST /approval, right after the
110
+ * early-ack. The provider interrupts the pi-side view of the agy turn and
111
+ * emits the shadow toolUse; the decision returns via approvals.resolve.
112
+ * Optional: absent = every approval POST is denied directly (fail closed). */
113
+ onApproval?(ticket: string, payload: ApprovalPayload): void;
75
114
  }
76
115
 
77
116
  /** Clamp an unsupported MCP-Protocol-Version header down to the SDK's LATEST.
@@ -244,9 +283,15 @@ export function registerExitCleanup(
244
283
 
245
284
  export async function startMcpServer(
246
285
  deps: McpBridgeDeps,
247
- opts: { preferredPort?: number; log?: (s: string, d?: unknown) => void } = {},
286
+ opts: {
287
+ preferredPort?: number;
288
+ log?: (s: string, d?: unknown) => void;
289
+ /** Test override for the per-park timeout (deny, fail closed). */
290
+ approvalTimeoutMs?: number;
291
+ } = {},
248
292
  ): Promise<McpStartResult> {
249
293
  const log = opts.log ?? (() => {});
294
+ const approvalTimeoutMs = opts.approvalTimeoutMs ?? APPROVAL_PARK_TIMEOUT_MS;
250
295
 
251
296
  const listHandler = async () => {
252
297
  const tools = deps.listTools();
@@ -300,6 +345,136 @@ export async function startMcpServer(
300
345
  const token = crypto.randomUUID();
301
346
  sweepStaleBridgeDirs();
302
347
 
348
+ // --- approval park (docs/TODO.md 2.5) ------------------------------------
349
+ // Ticket -> parked approval. A settled ticket STAYS in the map until its
350
+ // terminal decision is delivered to a poll, so the hook never 404s on the
351
+ // answer; an unknown/expired ticket 404s and the hook fails closed.
352
+ const parks = new Map<
353
+ string,
354
+ { name: string; since: number; timer: NodeJS.Timeout; terminal?: ApprovalDecision }
355
+ >();
356
+ const settlePark = (ticket: string, decision: ApprovalDecision): boolean => {
357
+ const p = parks.get(ticket);
358
+ if (!p || p.terminal) return false;
359
+ p.terminal = decision;
360
+ clearTimeout(p.timer);
361
+ return true;
362
+ };
363
+ const approvalsApi: ApprovalParkApi = {
364
+ has: (ticket) => {
365
+ const p = parks.get(ticket);
366
+ return p !== undefined && p.terminal === undefined;
367
+ },
368
+ resolve: (ticket, decision) => settlePark(ticket, decision),
369
+ };
370
+ const decisionBody = (d: ApprovalDecision): string =>
371
+ d.allow ? JSON.stringify({ decision: "allow" }) : JSON.stringify({ decision: "deny", reason: d.reason });
372
+ const denyDirect = (res: http.ServerResponse, reason: string): void => {
373
+ res.writeHead(200, { "content-type": "application/json" });
374
+ res.end(decisionBody({ allow: false, reason }));
375
+ };
376
+ const tokenOk = (req: http.IncomingMessage): boolean => {
377
+ const received = req.headers[TOKEN_HEADER];
378
+ return (
379
+ typeof received === "string" &&
380
+ received.length === token.length &&
381
+ crypto.timingSafeEqual(Buffer.from(received), Buffer.from(token))
382
+ );
383
+ };
384
+ const readBody = async (req: http.IncomingMessage): Promise<string | null> => {
385
+ let body = "";
386
+ let bytes = 0;
387
+ for await (const chunk of req) {
388
+ body += chunk;
389
+ bytes += chunk.length;
390
+ if (bytes > MAX_BODY_BYTES) return null;
391
+ }
392
+ return body;
393
+ };
394
+ const approvalRoute = async (
395
+ req: http.IncomingMessage,
396
+ res: http.ServerResponse,
397
+ route: string,
398
+ ): Promise<void> => {
399
+ if (!tokenOk(req)) {
400
+ log("unauthorized", { url: req.url });
401
+ res.writeHead(403, { "content-type": "application/json" }).end('{"error":"forbidden"}');
402
+ return;
403
+ }
404
+ if (req.method === "POST" && route === "/approval") {
405
+ const body = await readBody(req);
406
+ if (body === null) {
407
+ res.writeHead(413, { "content-type": "application/json", connection: "close" }).end('{"error":"payload too large"}');
408
+ return;
409
+ }
410
+ let payload: ApprovalPayload;
411
+ try {
412
+ const parsed = JSON.parse(body) as ApprovalPayload;
413
+ const name = parsed?.toolCall?.name;
414
+ if (typeof name !== "string" || name.length === 0) throw new Error("no toolCall.name");
415
+ if (!parsed.toolCall.args || typeof parsed.toolCall.args !== "object") {
416
+ parsed.toolCall.args = {};
417
+ }
418
+ payload = parsed;
419
+ } catch {
420
+ res.writeHead(400, { "content-type": "application/json" }).end('{"error":"invalid payload"}');
421
+ return;
422
+ }
423
+ // Defense in depth: the hooks matcher should never let an ungated
424
+ // tool through; deny directly instead of parking.
425
+ if (!GATED_AGY_TOOL_SET.has(payload.toolCall.name)) {
426
+ log("approval-ungated", { name: payload.toolCall.name });
427
+ denyDirect(res, `tool ${payload.toolCall.name} is not in the approval matcher set`);
428
+ return;
429
+ }
430
+ if (typeof deps.onApproval !== "function") {
431
+ log("approval-unwired", { name: payload.toolCall.name });
432
+ denyDirect(res, "approval gate is not wired; denying");
433
+ return;
434
+ }
435
+ const ticket = crypto.randomUUID();
436
+ const timer = setTimeout(() => {
437
+ // Fail closed FIRST: the staged hook timeout is longer than this
438
+ // park budget (V3: a hook outliving its timeout soft-passes, so the
439
+ // park must answer the deny before the hook is killed).
440
+ settlePark(ticket, { allow: false, reason: `approval gate timed out after ${Math.round(approvalTimeoutMs / 1000)}s` });
441
+ log("approval-timeout", { ticket, name: payload.toolCall.name });
442
+ }, approvalTimeoutMs);
443
+ parks.set(ticket, { name: payload.toolCall.name, since: Date.now(), timer });
444
+ log("approval-parked", { ticket, name: payload.toolCall.name });
445
+ try {
446
+ deps.onApproval(ticket, payload);
447
+ } catch (e) {
448
+ // A throwing provider must never hang the hook: settle deny now.
449
+ log("approval-onapproval-fail", { ticket, msg: e instanceof Error ? e.message : String(e) });
450
+ settlePark(ticket, { allow: false, reason: "approval gate internal error" });
451
+ }
452
+ res.writeHead(200, { "content-type": "application/json" });
453
+ res.end(JSON.stringify({ ticket }));
454
+ return;
455
+ }
456
+ if (req.method === "GET" && route.startsWith("/approval/")) {
457
+ const ticket = decodeURIComponent(route.slice("/approval/".length));
458
+ const p = parks.get(ticket);
459
+ if (!p) {
460
+ // Unknown or already delivered: the hook fails closed on a 404.
461
+ res.writeHead(404, { "content-type": "application/json" }).end('{"error":"unknown ticket"}');
462
+ return;
463
+ }
464
+ if (p.terminal) {
465
+ parks.delete(ticket); // delivered; a repeat poll 404s (fail closed)
466
+ log("approval-delivered", { ticket, name: p.name });
467
+ res.writeHead(200, { "content-type": "application/json" });
468
+ res.end(decisionBody(p.terminal));
469
+ return;
470
+ }
471
+ res.writeHead(200, { "content-type": "application/json" });
472
+ res.end(JSON.stringify({ status: "pending" }));
473
+ return;
474
+ }
475
+ res.writeHead(405).end();
476
+ };
477
+
303
478
  return new Promise<McpStartResult>((resolve) => {
304
479
  const httpServer = http.createServer(async (req, res) => {
305
480
  // #1: a client-side stream error must never crash pi.
@@ -317,18 +492,18 @@ export async function startMcpServer(
317
492
  res.writeHead(404, { "content-type": "application/json" }).end('{"error":"not found"}');
318
493
  return;
319
494
  }
495
+ const route = (req.url ?? "").split("?")[0];
496
+ if (route === "/approval" || route.startsWith("/approval/")) {
497
+ await approvalRoute(req, res, route);
498
+ return;
499
+ }
320
500
  if (req.method !== "POST") {
321
501
  res.writeHead(405).end();
322
502
  return;
323
503
  }
324
504
  // #3: require the shared-secret header. Constant-time compare so a
325
505
  // timing oracle can't recover the token byte-by-byte.
326
- const received = req.headers[TOKEN_HEADER];
327
- if (
328
- typeof received !== "string" ||
329
- received.length !== token.length ||
330
- !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(token))
331
- ) {
506
+ if (!tokenOk(req)) {
332
507
  log("unauthorized", { url: req.url });
333
508
  res.writeHead(403, { "content-type": "application/json" }).end('{"error":"forbidden"}');
334
509
  return;
@@ -424,7 +599,14 @@ export async function startMcpServer(
424
599
  handle: {
425
600
  port,
426
601
  token,
602
+ approvals: approvalsApi,
427
603
  close: async () => {
604
+ // Pending approvals fail closed on shutdown: the hook gets a
605
+ // terminal deny instead of a 404 on its next poll.
606
+ for (const [ticket, p] of [...parks]) {
607
+ if (p.terminal) continue;
608
+ settlePark(ticket, { allow: false, reason: "approval gate bridge shut down" });
609
+ }
428
610
  await new Promise<void>((r) => httpServer.close(() => r()));
429
611
  removeBridgeMcpConfig();
430
612
  disposeExitCleanup();
package/src/models.ts CHANGED
@@ -275,7 +275,7 @@ function thinkingLevelMapFor(efforts: readonly AgyEffort[]): ThinkingLevelMap {
275
275
 
276
276
  /** Project an agy entry to pi's Model shape. `input` advertises accepted
277
277
  * inputs: text-only (default) or text+image. The ACP engine forwards image
278
- * blocks natively (probe 2026-09-03); the legacy CLI prompt is text-only, so
278
+ * blocks natively (probe 2026-09-03); the stream-json CLI prompt is text-only, so
279
279
  * the extension decides by engine at load time. */
280
280
  export function toPiModel(entry: AgyModelEntry, input: Array<"text" | "image"> = ["text"]): Model<Api> {
281
281
  const effortDriven = !!entry.efforts && entry.efforts.length > 0;
@@ -294,7 +294,7 @@ export function toPiModel(entry: AgyModelEntry, input: Array<"text" | "image"> =
294
294
  reasoning: effortDriven,
295
295
  ...(effortDriven ? { thinkingLevelMap: thinkingLevelMapFor(entry.efforts!) } : {}),
296
296
  // Input advertising comes from the caller (engine-dependent): the ACP
297
- // engine forwards image blocks; advertising images on the legacy engine
297
+ // engine forwards image blocks; advertising images on the stream engine
298
298
  // would let pi offer image attach only for them to be dropped.
299
299
  input,
300
300
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },