@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
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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() (
|
|
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)
|
|
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}
|
|
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
|
+
}
|