@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/src/acp/driver.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // AcpDriver: the ACP turn engine. Implements the same TurnDriver surface as
2
- // the legacy stream-json driver (see src/driver-types.ts) so provider.ts and
2
+ // the stream-json driver (see src/driver-types.ts) so provider.ts and
3
3
  // the G9 round-trip store work unchanged.
4
4
  //
5
- // Engine differences vs legacy, all verified live (docs/ACP-PROTOCOL-REFERENCE.md):
5
+ // Engine differences vs stream-json, all verified live (docs/ACP-PROTOCOL-REFERENCE.md):
6
6
  // - no process recycle on profile drift: one server process, sessions
7
7
  // selected per turn via session/new / session/load
8
8
  // - model/effort via session/set_config_option (configId "model", FULL slug
@@ -19,9 +19,12 @@
19
19
  // in-connection (plan §9.3); no provider involvement
20
20
 
21
21
  import { randomUUID } from "node:crypto";
22
+ import type { UsageEstimate } from "../config.js";
22
23
  import { AcpConnection, resolveAcpBinary, type AcpMcpServer } from "./connection.js";
23
24
  import { mapStopReason, mapUpdate, TextAccumulator, type AcpEditDiff } from "./events.js";
25
+ import { estimateTokens, synthesizeUsage } from "./usage-estimate.js";
24
26
  import type {
27
+ AgyUsage,
25
28
  DriverActivity,
26
29
  DriverSnapshot,
27
30
  DriverState,
@@ -46,6 +49,10 @@ export interface AcpDriverOptions {
46
49
  authUrlFile?: string;
47
50
  /** Bridge registration for session/new AND session/load. */
48
51
  mcpServers?: () => AcpMcpServer[];
52
+ /** Gate B stopgap: how ACP turns synthesize usage while the server sends
53
+ * none. A function is resolved per turn (follows live config, like bin).
54
+ * Default "estimate". */
55
+ usageEstimate?: UsageEstimate | (() => UsageEstimate);
49
56
  log?: (msg: string, data?: unknown) => void;
50
57
  }
51
58
 
@@ -59,6 +66,13 @@ interface ActiveTurn {
59
66
  resolve: (o: TurnOutcome) => void;
60
67
  outcome: Promise<TurnOutcome>;
61
68
  response: TextAccumulator;
69
+ /** Usage-synthesis counters: per-delta token sums (estimate mode) and
70
+ * delta counts (direct mode). Sums are taken per delta, never over the
71
+ * joined text, so a word split across chunks stays two words. */
72
+ textTokens: number;
73
+ thoughtTokens: number;
74
+ textDeltas: number;
75
+ thoughtDeltas: number;
62
76
  sawResult: boolean;
63
77
  /** True once the prompt RPC was issued. Abort before this point has
64
78
  * nothing to cancel: probing would risk a success-as-noop answer from a
@@ -110,7 +124,6 @@ export class AcpDriver implements TurnDriver {
110
124
  #generation = 0;
111
125
  #active: ActiveTurn | undefined;
112
126
  #queueTail: Promise<void> = Promise.resolve();
113
- #shutdown = false;
114
127
  #lifecycle: string[] = [];
115
128
  #onTurnEnd: ((outcome: TurnOutcome) => void) | undefined;
116
129
  #stats = {
@@ -168,7 +181,7 @@ export class AcpDriver implements TurnDriver {
168
181
  }
169
182
 
170
183
  /** Turns are serialized; a parked turn stays open and the continuation
171
- * path uses reentry() (same contract as the legacy driver). */
184
+ * path uses reentry() (same contract as the stream-json driver). */
172
185
  run(request: DriverTurnRequest): Promise<TurnHandle> {
173
186
  let release!: () => void;
174
187
  const prev = this.#queueTail;
@@ -190,7 +203,10 @@ export class AcpDriver implements TurnDriver {
190
203
  }
191
204
 
192
205
  #runExclusive(request: DriverTurnRequest): Promise<TurnHandle> {
193
- if (this.#shutdown) return Promise.reject(new Error("ACP driver is shut down."));
206
+ // No shutdown latch here: pi fires session_shutdown on /new, /resume and
207
+ // /fork (not only process exit), so a closed driver must respawn on the
208
+ // next turn instead of rejecting forever. Regression 2026-09-07:
209
+ // /compact after a model switch failed with "ACP driver is shut down."
194
210
  if (request.signal?.aborted) return Promise.reject(new Error("aborted before start"));
195
211
 
196
212
  const turn = this.#createTurn(request);
@@ -218,7 +234,7 @@ export class AcpDriver implements TurnDriver {
218
234
  }
219
235
 
220
236
  // Execute asynchronously: the handle returns as soon as the prompt is
221
- // dispatched, and activities stream through next() (legacy contract).
237
+ // dispatched, and activities stream through next() (stream-json contract).
222
238
  void this.#executeTurn(turn).catch((err: unknown) => {
223
239
  this.#failTurn(turn, `ACP turn failed: ${describe(err)}`);
224
240
  });
@@ -357,10 +373,16 @@ export class AcpDriver implements TurnDriver {
357
373
  switch (mapped.kind) {
358
374
  case "text": {
359
375
  const emit = turn.response.append(mapped.delta);
360
- if (emit) this.#emit(turn, { type: "text", delta: emit });
376
+ if (emit) {
377
+ turn.textDeltas += 1;
378
+ turn.textTokens += estimateTokens(emit);
379
+ this.#emit(turn, { type: "text", delta: emit });
380
+ }
361
381
  return;
362
382
  }
363
383
  case "thought": {
384
+ turn.thoughtDeltas += 1;
385
+ turn.thoughtTokens += estimateTokens(mapped.delta);
364
386
  this.#emit(turn, { type: "thought", delta: mapped.delta });
365
387
  return;
366
388
  }
@@ -541,6 +563,10 @@ export class AcpDriver implements TurnDriver {
541
563
  resolve,
542
564
  outcome,
543
565
  response: new TextAccumulator(),
566
+ textTokens: 0,
567
+ thoughtTokens: 0,
568
+ textDeltas: 0,
569
+ thoughtDeltas: 0,
544
570
  sawResult: false,
545
571
  promptStarted: false,
546
572
  aborted: false,
@@ -641,6 +667,16 @@ export class AcpDriver implements TurnDriver {
641
667
 
642
668
  #settle(turn: ActiveTurn, outcome: TurnOutcome): void {
643
669
  if (turn.closed) return;
670
+ // Gate B stopgap: synthesize usage on clean turns while the server sends
671
+ // none. Runs BEFORE close so the usage activity drains through the
672
+ // normal #nextActivity loop (provider maps it onto partial.usage).
673
+ if (outcome.status === "OK" && !outcome.aborted && outcome.usage === undefined) {
674
+ const usage = this.#syntheticUsage(turn);
675
+ if (usage) {
676
+ outcome.usage = usage;
677
+ this.#emit(turn, { type: "usage", usage });
678
+ }
679
+ }
644
680
  turn.closed = true;
645
681
  if (turn.overallTimer) clearTimeout(turn.overallTimer);
646
682
  if (turn.idleTimer) clearTimeout(turn.idleTimer);
@@ -669,6 +705,29 @@ export class AcpDriver implements TurnDriver {
669
705
  });
670
706
  }
671
707
 
708
+ /** Usage synthesis mode, resolved per turn (follows live config). */
709
+ #usageMode(): UsageEstimate {
710
+ const opt = this.#opts.usageEstimate;
711
+ return (typeof opt === "function" ? opt() : opt) ?? "estimate";
712
+ }
713
+
714
+ #syntheticUsage(turn: ActiveTurn): AgyUsage | undefined {
715
+ const mode = this.#usageMode();
716
+ if (mode === "off") return undefined;
717
+ // Gate B latch: any server frame with usage/token keys means real
718
+ // usage exists upstream; estimates must never shadow it.
719
+ if (this.#conn?.usageSeen) return undefined;
720
+ return synthesizeUsage({
721
+ mode,
722
+ prompt: turn.request.prompt,
723
+ contextText: turn.request.contextBlock?.text,
724
+ textTokens: turn.textTokens,
725
+ thoughtTokens: turn.thoughtTokens,
726
+ textDeltas: turn.textDeltas,
727
+ thoughtDeltas: turn.thoughtDeltas,
728
+ });
729
+ }
730
+
672
731
  #log(msg: string, data?: unknown): void {
673
732
  const line = `${new Date().toISOString().slice(11, 19)} ${msg}${data !== undefined ? ` ${JSON.stringify(data)}` : ""}`;
674
733
  this.#lifecycle.push(line);
@@ -679,7 +738,6 @@ export class AcpDriver implements TurnDriver {
679
738
  // --- TurnDriver surface ----------------------------------------------------
680
739
 
681
740
  async close(reason: "recycle" | "shutdown", cause?: string): Promise<void> {
682
- if (reason === "shutdown") this.#shutdown = true;
683
741
  this.#log(`close:${reason}${cause ? `:${cause}` : ""}`);
684
742
  const turn = this.#active;
685
743
  if (turn && !turn.closed) {
@@ -687,7 +745,7 @@ export class AcpDriver implements TurnDriver {
687
745
  conversationId: turn.sessionId,
688
746
  status: "ERROR",
689
747
  response: turn.response.text,
690
- error: `ACP driver ${reason}ed mid-turn${cause ? ` (${cause})` : ""}`,
748
+ error: `ACP driver ${reason === "recycle" ? "recycled" : "shut down"} mid-turn${cause ? ` (${cause})` : ""}`,
691
749
  finished: true,
692
750
  aborted: false,
693
751
  });
@@ -0,0 +1,59 @@
1
+ // Gate B stopgap: synthesize AgyUsage for ACP turns until Google ships a
2
+ // real token-usage layer in agy_acp_server. Counting mechanism copied from
3
+ // the pi-token-speed extension (npm:pi-token-speed, src/engine.ts): a
4
+ // word/punctuation regex, 1 match = 1 token. The numbers are client-side
5
+ // ESTIMATES, never provider data: the connection's usageSeen latch (Gate B
6
+ // watch) keeps synthesis off the day real usage appears in any frame, so
7
+ // estimates can never shadow real numbers.
8
+
9
+ import type { UsageEstimate } from "../config.js";
10
+ import type { AgyUsage } from "../driver-types.js";
11
+
12
+ const TOKEN_REGEX = /\w+|[^\s\w]/g;
13
+
14
+ /** Word-boundary token estimate (pi-token-speed's estimateTokens). */
15
+ export function estimateTokens(text: string): number {
16
+ if (!text) return 0;
17
+ const matches = text.match(TOKEN_REGEX);
18
+ return matches ? matches.length : 0;
19
+ }
20
+
21
+ export interface UsageEstimateInput {
22
+ mode: UsageEstimate;
23
+ /** Full outgoing prompt text (input side; always regex-estimated). */
24
+ prompt: string;
25
+ /** ACP embeddedContext resource text (G1 digest): also reaches the model. */
26
+ contextText?: string;
27
+ /** Per-delta token sums (mode "estimate"). Summing per delta, like
28
+ * pi-token-speed's recordDelta, avoids word-merge artifacts when a
29
+ * chunk boundary splits a word. */
30
+ textTokens: number;
31
+ thoughtTokens: number;
32
+ /** Streamed delta counts (mode "direct": 1 token per delta). */
33
+ textDeltas: number;
34
+ thoughtDeltas: number;
35
+ }
36
+
37
+ /** Synthesize usage for a finished turn, or undefined when nothing is worth
38
+ * reporting (mode off, or a turn with no prompt and no output). */
39
+ export function synthesizeUsage(input: UsageEstimateInput): AgyUsage | undefined {
40
+ if (input.mode === "off") return undefined;
41
+ const inputTokens = estimateTokens(
42
+ input.contextText ? `${input.prompt}\n${input.contextText}` : input.prompt,
43
+ );
44
+ const thoughtTokens =
45
+ input.mode === "direct" ? input.thoughtDeltas : input.thoughtTokens;
46
+ const textTokens =
47
+ input.mode === "direct" ? input.textDeltas : input.textTokens;
48
+ // Thinking folds INTO output (OpenAI convention: reasoning tokens bill as
49
+ // output). toPiUsage drops thinking_tokens, and thinking time is inside
50
+ // elapsed wall time, so folding keeps the tokens/time ratio honest.
51
+ const outputTokens = textTokens + thoughtTokens;
52
+ if (inputTokens === 0 && outputTokens === 0) return undefined;
53
+ return {
54
+ input_tokens: inputTokens,
55
+ output_tokens: outputTokens,
56
+ thinking_tokens: thoughtTokens,
57
+ total_tokens: inputTokens + outputTokens,
58
+ };
59
+ }
@@ -0,0 +1,146 @@
1
+ // Third-party pi permission-extension detection for the approval gate
2
+ // (docs/TODO.md 2.5). The gate defaults to "auto": OFF until one of these
3
+ // extensions is present. Detection reads pi's settings (the packages array
4
+ // is the primary, name-exact signal) plus known on-disk config markers for
5
+ // the audited permission packages (sources: ~/tmp/pi-perm-research/, audit
6
+ // 2026-09-07). Best effort by design: a miss only means the user enables
7
+ // the gate manually; a false positive stages hooks.json, which is inert
8
+ // unless the ACP/CLI server loads it, and observation-only hooks are safe.
9
+ //
10
+ // Run: npm test
11
+
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+
16
+ /** npm names of the audited pi permission packages (docs/TODO.md 2.4). */
17
+ export const KNOWN_GATE_PACKAGES = [
18
+ "@gotgenes/pi-permission-system",
19
+ "@zhushanwen/pi-permission",
20
+ "pi-permission-system",
21
+ "@xzzpig/pi-permission-system",
22
+ "@diegopetrucci/pi-permission-gate",
23
+ "pi-permission-modes",
24
+ "@inobit/pi-permission",
25
+ "@thurstonsand/pi-permissions",
26
+ "@monroewilliams/pi-permission-system",
27
+ "@rhedbull/pi-permissions",
28
+ ] as const;
29
+
30
+ export interface GateExtensionHit {
31
+ /** The known package name matched. */
32
+ name: string;
33
+ /** How it was detected: "settings:<path>" or "config:<path>". */
34
+ evidence: string;
35
+ }
36
+
37
+ function readJsonIfPresent(file: string): { packages?: unknown } | undefined {
38
+ try {
39
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
40
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ function packagesFrom(settingsFile: string): string[] {
47
+ const parsed = readJsonIfPresent(settingsFile);
48
+ const raw = parsed?.packages;
49
+ if (!Array.isArray(raw)) return [];
50
+ return raw.filter((entry): entry is string => typeof entry === "string");
51
+ }
52
+
53
+ function nameMatchesPackage(entry: string): string | undefined {
54
+ // entries look like "npm:@scope/name@1.2.3", "git:...", or a bare path.
55
+ // Boundary-aware match: the known name must start after :/@ (or the
56
+ // string start) and must not be a prefix of a longer package name
57
+ // ("pi-permission-system-clone" must NOT match). Version suffix @x.y is
58
+ // fine. Peer review 2026-09-07 finding 4.
59
+ const lower = entry.toLowerCase();
60
+ let best: string | undefined;
61
+ for (const name of KNOWN_GATE_PACKAGES) {
62
+ if (containsName(lower, name) && (best === undefined || name.length > best.length)) best = name;
63
+ }
64
+ // Longest match wins: "@xzzpig/pi-permission-system" must resolve to the
65
+ // scoped name, not to the shorter unscoped fork "pi-permission-system".
66
+ return best;
67
+ }
68
+
69
+ function containsName(lower: string, name: string): boolean {
70
+ let idx = lower.indexOf(name);
71
+ while (idx >= 0) {
72
+ const before = idx === 0 ? "" : lower[idx - 1];
73
+ const after = lower[idx + name.length] ?? "";
74
+ const okBefore = before === "" || ":/@".includes(before);
75
+ const okAfter = after === "" || after === "@" || !/[a-z0-9_-]/.test(after);
76
+ if (okBefore && okAfter) return true;
77
+ idx = lower.indexOf(name, idx + 1);
78
+ }
79
+ return false;
80
+ }
81
+
82
+ /**
83
+ * Detect installed third-party permission extensions.
84
+ *
85
+ * @param opts.home HOME override for tests.
86
+ * @param opts.cwd project dir override for tests (project settings).
87
+ * @param opts.settingsFiles extra settings files to scan (tests).
88
+ */
89
+ export function detectPermissionGateExtensions(
90
+ opts: { home?: string; cwd?: string; settingsFiles?: string[] } = {},
91
+ ): GateExtensionHit[] {
92
+ const home = opts.home ?? os.homedir();
93
+ const cwd = opts.cwd ?? process.cwd();
94
+ const hits: GateExtensionHit[] = [];
95
+ const seen = new Set<string>();
96
+
97
+ const settingsCandidates = [
98
+ ...(opts.settingsFiles ?? []),
99
+ path.join(home, ".pi", "agent", "settings.json"),
100
+ path.join(cwd, ".pi", "settings.json"),
101
+ ];
102
+ for (const file of settingsCandidates) {
103
+ for (const entry of packagesFrom(file)) {
104
+ const name = nameMatchesPackage(entry);
105
+ if (name && !seen.has(name)) {
106
+ seen.add(name);
107
+ hits.push({ name, evidence: `settings:${file}` });
108
+ }
109
+ }
110
+ }
111
+
112
+ // Known config-file markers from the audit. Presence of the config does
113
+ // not prove the extension is installed, but every audited package writes
114
+ // its config only after install + first run, which is evidence enough for
115
+ // an opt-in default.
116
+ const markers: Array<{ file: string; name: string }> = [
117
+ { file: path.join(home, ".pi", "agent", "extensions", "pi-permission-system"), name: "@gotgenes/pi-permission-system" },
118
+ { file: path.join(home, ".agent", "pi-permissions.jsonc"), name: "@monroewilliams/pi-permission-system" },
119
+ { file: path.join(home, ".pi", "agent", "extensions", "permissions.json"), name: "@rhedbull/pi-permissions" },
120
+ { file: path.join(cwd, ".pi", "agent", "pi-permissions.jsonc"), name: "@gotgenes/pi-permission-system" },
121
+ ];
122
+ for (const marker of markers) {
123
+ if (!seen.has(marker.name)) {
124
+ try {
125
+ fs.statSync(marker.file);
126
+ seen.add(marker.name);
127
+ hits.push({ name: marker.name, evidence: `config:${marker.file}` });
128
+ } catch {
129
+ /* absent - fine */
130
+ }
131
+ }
132
+ }
133
+
134
+ return hits;
135
+ }
136
+
137
+ /** Resolve the effective gate shape from config + detection. "auto" defers
138
+ * to detection (shadow when any gate extension is present, else off). */
139
+ export function resolveGateMode(
140
+ gateMode: "auto" | "shadow" | "dedicated" | "off",
141
+ hits: GateExtensionHit[],
142
+ ): "shadow" | "dedicated" | "off" {
143
+ if (gateMode === "off") return "off";
144
+ if (gateMode === "shadow" || gateMode === "dedicated") return gateMode;
145
+ return hits.length > 0 ? "shadow" : "off";
146
+ }
@@ -0,0 +1,208 @@
1
+ // Approval-gate shadow tools (docs/TODO.md section 2.5, design v2).
2
+ //
3
+ // When the approval gate is active, the bridge re-registers pi's mutating
4
+ // builtins (bash, write, edit) as SHADOW tools: same name, same schema plus
5
+ // internal __agy* marker fields. Two behaviors, keyed on the marker:
6
+ //
7
+ // - marker absent: delegate to the captured real builtin. Normal pi
8
+ // behavior (including the G9 path, where pi tools execute for real) is
9
+ // untouched.
10
+ // - marker present: the call is an approval round-trip for an agy NATIVE
11
+ // tool. NEVER execute locally. Ask the policy for a decision; agy runs
12
+ // the tool in its own loop either way.
13
+ //
14
+ // Decision mapping (consumed by the provider's /approval park):
15
+ // resolve (success result) -> {"decision":"allow"}
16
+ // throw -> {"decision":"deny","reason": message}
17
+ // pi's tool executor converts thrown errors into error tool results with
18
+ // the message as text, matching how builtins report failures (write.js,
19
+ // edit-diff.js). A tool_call handler that blocks the shadow call upstream
20
+ // (any third-party permission extension) produces the same error result
21
+ // without execute() running, so both paths land on the same deny mapping.
22
+ //
23
+ // Run: npm test
24
+
25
+ import type {
26
+ AgentToolResult,
27
+ AgentToolUpdateCallback,
28
+ ExtensionContext,
29
+ ToolDefinition,
30
+ } from "@earendil-works/pi-coding-agent";
31
+
32
+ /** Marker flag: this shadow-tool call is an approval round-trip, not a real
33
+ * invocation. The bridge's provider sets it when composing the toolUse. */
34
+ export const GATE_MARKER = "__agyGate";
35
+
36
+ /** Internal context fields the provider may attach next to the marker.
37
+ * Stripped before delegating to the real builtin. */
38
+ export const MARKER_FIELDS = [GATE_MARKER, "__agyTicket", "__agyTool"] as const;
39
+
40
+ export type AnyToolDefinition = ToolDefinition<any, any, any>;
41
+
42
+ export interface GateDecision {
43
+ allow: boolean;
44
+ reason?: string;
45
+ }
46
+
47
+ /** Fallback policy: consulted only when NO extension blocked the shadow
48
+ * tool_call. Implementations map approvals.mode: ask -> ctx.ui.confirm
49
+ * (guarded by ctx.hasUI), allow -> {allow:true}, deny -> {allow:false}. */
50
+ export type GatePolicy = (call: {
51
+ tool: string;
52
+ params: Record<string, unknown>;
53
+ ctx: unknown;
54
+ }) => Promise<GateDecision> | GateDecision;
55
+
56
+ /** Marker schemas injected into the shadow parameters. Optional, so the
57
+ * model's own calls stay valid; audited permission extensions match only
58
+ * their known fields (command/path) and ignore these. */
59
+ const MARKER_SCHEMAS: Record<string, unknown> = {
60
+ [GATE_MARKER]: {
61
+ type: "boolean",
62
+ description:
63
+ "Internal bridge approval marker. Never set this yourself; calls without local execution intent must not set it.",
64
+ },
65
+ __agyTicket: { type: "string" },
66
+ __agyTool: { type: "string", description: "Native agy tool this approval round-trip is for." },
67
+ };
68
+
69
+ /** Clone a tool's parameter schema with the marker fields added as optional
70
+ * properties. Field-exact for everything the permission extensions match on
71
+ * (command, path, edits, ...). Does not mutate the base schema. */
72
+ export function withGateMarkerSchema(base: AnyToolDefinition["parameters"]): AnyToolDefinition["parameters"] {
73
+ const src = base as { properties?: Record<string, unknown> };
74
+ return { ...base, properties: { ...src.properties, ...MARKER_SCHEMAS } } as AnyToolDefinition["parameters"];
75
+ }
76
+
77
+ /** Copy of params without the internal marker fields, for delegation. */
78
+ export function stripMarkerFields(params: Record<string, unknown>): Record<string, unknown> {
79
+ const out = { ...params };
80
+ for (const field of MARKER_FIELDS) delete out[field];
81
+ return out;
82
+ }
83
+
84
+ export interface ShadowMapping {
85
+ shadow: "bash" | "write" | "edit";
86
+ input: Record<string, unknown>;
87
+ }
88
+
89
+ /** Map an agy native tool call (hook stdin payload) onto the shadow surface.
90
+ * Field names follow the TODO 2.5 table: create_file is live-captured (F1);
91
+ * the edit-class arg names are docs-attested and degrade gracefully - a
92
+ * wrong guess only weakens the confirm-dialog text, never the decision
93
+ * (the tool runs in agy's loop either way). Unknown names return null:
94
+ * read-only tools are not gated. */
95
+ export function mapNativeToShadow(name: string, args: Record<string, unknown>): ShadowMapping | null {
96
+ const a = args ?? {};
97
+ const str = (v: unknown): string => (typeof v === "string" ? v : v === undefined || v === null ? "" : String(v));
98
+ switch (name) {
99
+ case "run_command": {
100
+ const input: Record<string, unknown> = { command: str(a.CommandLine) };
101
+ if (a.Cwd !== undefined && a.Cwd !== null && a.Cwd !== "") input.cwd = str(a.Cwd);
102
+ return { shadow: "bash", input };
103
+ }
104
+ case "write_to_file":
105
+ case "create_file":
106
+ return { shadow: "write", input: { path: str(a.TargetFile), content: str(a.CodeContent) } };
107
+ case "replace_file_content":
108
+ case "edit_file":
109
+ return {
110
+ shadow: "edit",
111
+ input: {
112
+ path: str(a.TargetFile),
113
+ edits: [{ oldText: str(a.SearchText), newText: str(a.ReplacementContent) }],
114
+ },
115
+ };
116
+ case "multi_replace_file_content": {
117
+ const chunks = Array.isArray(a.ReplacementChunks) ? a.ReplacementChunks : [];
118
+ return {
119
+ shadow: "edit",
120
+ input: {
121
+ path: str(a.TargetFile),
122
+ edits: chunks.map((c) => {
123
+ const chunk = (c ?? {}) as Record<string, unknown>;
124
+ return { oldText: str(chunk.SearchText), newText: str(chunk.ReplacementContent) };
125
+ }),
126
+ },
127
+ };
128
+ }
129
+ default:
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /** Options for the shadow factory. */
135
+ export interface ShadowOptions {
136
+ /** Registry lookup for the park's ticket. When set, a marker call whose
137
+ * __agyTicket is missing or unrecognized throws (deny) BEFORE the policy
138
+ * runs: a model that sets __agyGate:true itself can then never produce a
139
+ * fake-approved result, even under approvals.mode "allow". */
140
+ verifyTicket?: (ticket: string) => boolean;
141
+ }
142
+
143
+ /** Build the shadow definition for one builtin. `base` MUST be a definition
144
+ * of the real builtin - the extension passes factory twins created with
145
+ * pi's public createBashToolDefinition/createWriteToolDefinition/
146
+ * createEditToolDefinition (pi.getAllTools() returns ToolInfo, which strips
147
+ * execute, so the live definition cannot be captured). */
148
+ export function createShadowTool(base: AnyToolDefinition, policy: GatePolicy, opts: ShadowOptions = {}): AnyToolDefinition {
149
+ const execute = async (
150
+ toolCallId: string,
151
+ params: any,
152
+ signal: AbortSignal | undefined,
153
+ onUpdate: AgentToolUpdateCallback<any> | undefined,
154
+ ctx: ExtensionContext,
155
+ ): Promise<AgentToolResult<unknown>> => {
156
+ const p = (params ?? {}) as Record<string, unknown>;
157
+ if (p[GATE_MARKER] !== true) {
158
+ return base.execute(toolCallId, stripMarkerFields(p), signal, onUpdate, ctx);
159
+ }
160
+ const native = typeof p.__agyTool === "string" && p.__agyTool.length > 0 ? p.__agyTool : base.name;
161
+ // Ticket binding (peer review 2026-09-07): only calls the provider parked
162
+ // carry a live ticket. Anything else with the marker set was forged by
163
+ // the model (or the park is gone); fail closed without consulting the
164
+ // policy, so approvals.mode "allow" can never bless it either.
165
+ const ticket = typeof p.__agyTicket === "string" ? p.__agyTicket : "";
166
+ if (!ticket || !opts.verifyTicket?.(ticket)) {
167
+ throw new Error(
168
+ `approval gate: unrecognized or stale approval ticket; refusing to decide (${native}).`,
169
+ );
170
+ }
171
+ if (signal?.aborted) {
172
+ throw new Error(`approval gate aborted before a decision was reached (${native}).`);
173
+ }
174
+ let decision: GateDecision;
175
+ // Race the policy against the abort signal: a cancelled turn must
176
+ // unblock execute() instead of hanging on a human decision.
177
+ const aborted = new Error(`approval gate aborted before a decision was reached (${native}).`);
178
+ const abortp = new Promise<never>((_, reject) => {
179
+ signal?.addEventListener("abort", () => reject(aborted), { once: true });
180
+ });
181
+ abortp.catch(() => {}); // late rejection must not become unhandled
182
+ try {
183
+ decision = await Promise.race([Promise.resolve(policy({ tool: native, params: p, ctx })), abortp]);
184
+ } catch (err) {
185
+ if (signal?.aborted) throw aborted;
186
+ // Fail closed: a broken policy must never look like an approval.
187
+ throw new Error(`approval gate policy failed: ${err instanceof Error ? err.message : String(err)}`);
188
+ }
189
+ if (signal?.aborted) {
190
+ // Sync-abort inside the policy settles the policy promise BEFORE the
191
+ // race starts, so the rejection loses the ordering tie. Re-check.
192
+ throw aborted;
193
+ }
194
+ if (!decision.allow) {
195
+ throw new Error(decision.reason || `blocked by approval gate (${native}).`);
196
+ }
197
+ return {
198
+ content: [
199
+ {
200
+ type: "text",
201
+ text: `Approved by approval gate: ${native}. No local execution happened; the tool runs in the Antigravity agent loop.`,
202
+ },
203
+ ],
204
+ details: { gate: "allow", native },
205
+ };
206
+ };
207
+ return { ...base, parameters: withGateMarkerSchema(base.parameters), execute } as AnyToolDefinition;
208
+ }