@estebanforge/pi-antigravity-bridge 1.6.1 → 1.6.3
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 +12 -0
- package/docs/APPROVAL-GATE.md +3 -1
- package/extensions/index.ts +17 -8
- package/package.json +5 -5
- package/src/approval-hook.ts +116 -98
- package/src/driver.ts +6 -1
- package/src/provider.ts +19 -11
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.6.3] - 2026-09-22
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **The approval gate no longer leaks into standalone sessions, and its verdicts parse.** Thanks @explesy for the report (https://github.com/EstebanForge/pi-antigravity-bridge/issues/5). Two defects in the generated hook script and its staging. Every terminal path printed the bare decision scalar (`"deny"` / `"allow"`) instead of a JSON object, so any session that loaded a staged gate failed hook parsing outright (`protojson: syntax error`): the direct-POST and polling branches now print the full response object guarded by a `typeof json.decision === "string"` check, fall back to a fail-closed deny object on malformed bodies, and read ticket and poll status through `json?.`. The gate group itself was staged into the shared workspace `.agents/hooks.json`, so any standalone IDE or CLI session in that workspace loaded pi's gate: tool calls were denied with "no active antigravity turn" while pi sat idle, and mid-turn they parked into pi's approval queue. A live probe of the CLI settled the discovery rule (hooks load from `--add-dir` directories in stream-json and interactive sessions; print mode reads only the global config), and the gate now stages `.agents/hooks.json` into the session-private per-pid bridge dir beside `mcp_config.json`, which only this session's agy receives as an extra `--add-dir`. Staging into a pid-private file dropped the merge/backup/symlink surface to a plain atomic replace; the driver passes the dir when the gate is staged even without an MCP config; a legacy sweep at session start strips groups that dead sessions left in workspace files (live and foreign groups are never touched). The hook also gained per-fetch aborts (`AbortSignal.timeout`, POST capped at 10s, polls bounded by the remaining park budget) so a hung bridge request can never outlive the park deadline into an ungated soft-pass. Seven stub-server hook-execution regression tests pin the object contract; the staging suite covers the private-dir semantics and the sweep. 364 tests, tsc clean.
|
|
10
|
+
|
|
11
|
+
## [1.6.2] - 2026-09-19
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **Compatibility with host 0.86.0's normalized provider stream input.** Stream entry points now receive a branded `TranscriptContext` (`{ messages }` only): the `context.systemPrompt` / `context.tools` fields are gone, and only `normalizeContext()` produces the new type. G10 fresh-conversation delivery read `context.systemPrompt`, so every fresh agy conversation silently lost the system prompt block (operating instructions, AGENTS.md project context, tool-priority note); the prompt now comes from the transcript via `getCurrentSystemPrompt(context.messages)`. `emitToolUse` arguments are restricted to `Record<string, JsonValue>` per the tightened `ToolCall.arguments` contract (driver args arrive JSON-decoded off the wire, so the two call-site casts are sound). Test fixtures in `provider-{sysprompt,streaming,late-result,escalation}.test.ts` and the `scripts/test-provider.ts` smoke context wrap raw objects with `normalizeContext()`. Dev pins `@earendil-works/*` raised `^0.85.0` to `^0.86.0`; `tsc` clean, 360 tests green.
|
|
16
|
+
|
|
5
17
|
## [1.6.1] - 2026-09-18
|
|
6
18
|
|
|
7
19
|
### Added
|
package/docs/APPROVAL-GATE.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
agy is not a plain model: it runs its own agent loop with its own native tools (`run_command`, `create_file`, `edit_file`, ...). Those calls execute inside agy with no pi involvement, so pi's permission extensions never saw them. The approval gate closes that gap: agy native tool calls pass through a pi-side approval in a form the existing permission-extension ecosystem gates with zero changes.
|
|
4
4
|
|
|
5
|
-
Mechanics: the extension stages an `.agents/hooks.json` group
|
|
5
|
+
Mechanics: the extension stages an `.agents/hooks.json` group into its session-private bridge dir (the per-pid directory that is passed to this session's agy as an extra `--add-dir`); the Antigravity server/CLI discovers hooks in its add-dir directories and fires a `PreToolUse` hook before each mutating native tool runs. Because only this session's agy receives the directory, standalone IDE or CLI sessions in the same workspace never load the gate: their tool calls cannot be denied while pi is idle, and cannot be parked into a live pi turn (issue #5 isolation). The hook script (generated, per-pid, mode 0600 because it embeds the bridge token) POSTs the call to the bridge and polls for a decision. The bridge parks it, the provider interrupts the pi-side view of the agy turn with a `toolUse` for a SHADOW tool named `bash`/`write`/`edit` (same schema as the real builtin plus internal `__agy*` marker fields), and pi's whole `tool_call` surface applies: any installed permission extension gates the call unchanged, and only if nothing blocks does the fallback policy run. The decision travels back to the hook and agy enforces it; on deny the reason text is what agy's model sees. Marker calls never execute locally (a ticket check denies forged ones); non-marker calls delegate to a factory twin of the real builtin, so normal pi bash/write/edit behavior is unchanged. Read-only agy tools stay ungated. Every decision lands in the daily log with tool names, source, and latency.
|
|
6
|
+
|
|
7
|
+
Upgrades from 1.6.x: that line staged the gate group into the shared workspace `.agents/hooks.json`, where standalone sessions loaded it. The extension now sweeps gate groups left in the workspace file by sessions whose pid is dead at every session start; groups of live sessions and foreign groups are never touched.
|
|
6
8
|
|
|
7
9
|
Configuration (`/agy` config keys or environment):
|
|
8
10
|
|
package/extensions/index.ts
CHANGED
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
type GatePolicy,
|
|
59
59
|
} from "../src/approval-gate.js";
|
|
60
60
|
import { detectPermissionGateExtensions, resolveGateMode } from "../src/approval-detect.js";
|
|
61
|
-
import { hookScriptSource, removeGateHooks, stageGateHooks } from "../src/approval-hook.js";
|
|
61
|
+
import { hookScriptSource, removeGateHooks, stageGateHooks, sweepWorkspaceGateGroups } from "../src/approval-hook.js";
|
|
62
62
|
import { StreamDriver } from "../src/driver.js";
|
|
63
63
|
import { AcpDriver } from "../src/acp/driver.js";
|
|
64
64
|
import { runAcpAuth } from "../src/acp/auth.js";
|
|
@@ -69,7 +69,7 @@ import { CONFIG_PATH, loadConfig, logsDir, MAX_TURN_CAP_MIN, parseCapMinutes, sa
|
|
|
69
69
|
import { agyMissingMessage, isAgyInstalled, savedEngineMessage, showEnginePicker, shouldOfferEnginePicker } from "../src/engine-picker.js";
|
|
70
70
|
import { createDailyLogger, type DailyLogger } from "../src/daily-log.js";
|
|
71
71
|
import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
|
|
72
|
-
import { startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
|
|
72
|
+
import { bridgeMcpConfigDir, startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
|
|
73
73
|
import {
|
|
74
74
|
registerBridgeServer,
|
|
75
75
|
healBridgeSuppression,
|
|
@@ -752,11 +752,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
752
752
|
// config value never lies silently.
|
|
753
753
|
fileLog.log("approval-dedicated-as-shadow", {}, "warn");
|
|
754
754
|
}
|
|
755
|
+
// Legacy cleanup (issue #5): 1.6.x staged the gate into the shared
|
|
756
|
+
// workspace hooks.json, where standalone IDE/CLI sessions load it.
|
|
757
|
+
// Staging now lives in the session-private add-dir; sweep groups left
|
|
758
|
+
// behind by dead sessions from the old scheme. Live sessions' and
|
|
759
|
+
// foreign groups are never touched.
|
|
760
|
+
const sweptLegacy = sweepWorkspaceGateGroups(process.cwd());
|
|
761
|
+
if (sweptLegacy > 0) fileLog.log("approval-workspace-swept", { swept: sweptLegacy }, "info");
|
|
755
762
|
if (mode === "off") {
|
|
756
|
-
// Gate off:
|
|
757
|
-
//
|
|
758
|
-
|
|
759
|
-
const unstaged = removeGateHooks(process.cwd());
|
|
763
|
+
// Gate off: drop this session's private hooks file. Other sessions
|
|
764
|
+
// own their own per-pid files; nothing shared to preserve.
|
|
765
|
+
const unstaged = removeGateHooks();
|
|
760
766
|
if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
|
|
761
767
|
} else {
|
|
762
768
|
// Script: per-pid file; 0600 because the bridge token is
|
|
@@ -773,7 +779,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
773
779
|
{ mode: 0o600 },
|
|
774
780
|
);
|
|
775
781
|
gateScriptPath = scriptPath;
|
|
776
|
-
|
|
782
|
+
// Private per-pid staging (issue #5 isolation): the gate group goes
|
|
783
|
+
// into the bridge add-dir that ONLY this session's agy receives.
|
|
784
|
+
// Standalone sessions in the workspace never load it.
|
|
785
|
+
const staged = stageGateHooks(bridgeMcpConfigDir(), {
|
|
777
786
|
port: r.handle.port,
|
|
778
787
|
token: r.handle.token,
|
|
779
788
|
scriptPath,
|
|
@@ -856,7 +865,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
856
865
|
unregisterBridgeServer(process.pid);
|
|
857
866
|
// Approval gate: unstage hooks and remove the per-pid script. Pending
|
|
858
867
|
// approvals already failed closed via handle close (bridge shutdown deny).
|
|
859
|
-
const unstaged = removeGateHooks(
|
|
868
|
+
const unstaged = removeGateHooks();
|
|
860
869
|
if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
|
|
861
870
|
if (gateScriptPath) {
|
|
862
871
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.3",
|
|
4
4
|
"description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -59,10 +59,10 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@earendil-works/pi-ai": "^0.
|
|
63
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
64
|
-
"@earendil-works/pi-server": "^0.
|
|
65
|
-
"@earendil-works/pi-tui": "^0.
|
|
62
|
+
"@earendil-works/pi-ai": "^0.87.0",
|
|
63
|
+
"@earendil-works/pi-coding-agent": "^0.87.0",
|
|
64
|
+
"@earendil-works/pi-server": "^0.87.0",
|
|
65
|
+
"@earendil-works/pi-tui": "^0.87.0",
|
|
66
66
|
"@types/node": "^22.0.0",
|
|
67
67
|
"tsx": "^4.19.0",
|
|
68
68
|
"typebox": "^1.1.38",
|
package/src/approval-hook.ts
CHANGED
|
@@ -1,30 +1,42 @@
|
|
|
1
1
|
// Staging for the approval-gate hooks.json (docs/TODO.md 2.5).
|
|
2
2
|
//
|
|
3
|
-
// The
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// The agy CLI discovers `.agents/hooks.json` in its --add-dir directories
|
|
4
|
+
// (live-probed 2026-09-22: stream-json and interactive sessions load and
|
|
5
|
+
// execute PreToolUse hooks from any add-dir; print mode reads only the
|
|
6
|
+
// global config) and fires them before a gated tool runs (V2: deny
|
|
7
|
+
// honored, reason reaches the model; V3: hook TIMEOUT = soft-pass, agy
|
|
8
|
+
// proceeds ungated). Therefore:
|
|
6
9
|
// - the staged handler timeout must exceed the whole park budget with
|
|
7
10
|
// margin (never rely on timeout as a deny), and
|
|
8
11
|
// - the staged command delegates to the bundled poll script, which
|
|
9
12
|
// early-acks and polls the bridge for the terminal decision.
|
|
10
13
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
14
|
+
// ISOLATION (issue #5): the gate group is staged into the session-private
|
|
15
|
+
// per-pid bridge dir (bridgeMcpConfigDir()), NOT the shared workspace. Only
|
|
16
|
+
// this session's agy gets that dir as an extra --add-dir (driver.ts), so
|
|
17
|
+
// standalone IDE/CLI sessions in the workspace can never load the gate -
|
|
18
|
+
// neither denying their tools while pi is idle nor parking their calls
|
|
19
|
+
// into a live pi turn. The private file is ours alone: no merge, no
|
|
20
|
+
// foreign-file backup dance.
|
|
21
|
+
//
|
|
22
|
+
// Legacy: 1.6.x staged into the shared workspace `.agents/hooks.json`.
|
|
23
|
+
// sweepWorkspaceGateGroups() removes gate groups left there by sessions
|
|
24
|
+
// whose pid is dead; groups of live sessions and foreign groups are never
|
|
25
|
+
// touched.
|
|
15
26
|
//
|
|
16
27
|
// Run: npm test
|
|
17
28
|
|
|
18
29
|
import fs from "node:fs";
|
|
19
30
|
import path from "node:path";
|
|
31
|
+
import { bridgeMcpConfigDir } from "./mcp-server.js";
|
|
20
32
|
|
|
21
33
|
export const HOOK_GROUP = "pi-bridge-gate";
|
|
22
34
|
|
|
23
|
-
/** Group-key namespace. Each
|
|
24
|
-
* (`pi-bridge-gate-<pid>`):
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
35
|
+
/** Group-key namespace. Each session's group is keyed per-pid
|
|
36
|
+
* (`pi-bridge-gate-<pid>`): the key doubles as the ownership proof for the
|
|
37
|
+
* legacy workspace sweep, which may only remove groups whose owning
|
|
38
|
+
* session is dead (a stale shared key must never strip a live session's
|
|
39
|
+
* gate - audit 2026-09-07). */
|
|
28
40
|
export const GATE_GROUP_PREFIX = "pi-bridge-gate";
|
|
29
41
|
|
|
30
42
|
/** This session's group key. */
|
|
@@ -85,6 +97,10 @@ export function hookScriptSource(opts: { port: number; token: string; deadlineMs
|
|
|
85
97
|
const PORT = ${opts.port};
|
|
86
98
|
const TOKEN = ${JSON.stringify(opts.token)};
|
|
87
99
|
const DEADLINE = Date.now() + ${opts.deadlineMs};
|
|
100
|
+
// Per-fetch abort: a hung fetch must never outlive the park deadline. A hook
|
|
101
|
+
// killed by agy's staged timeout soft-passes (V3), so the script itself must
|
|
102
|
+
// always reach a printed deny.
|
|
103
|
+
const POST_TIMEOUT_MS = Math.min(10_000, ${opts.deadlineMs});
|
|
88
104
|
let body = "";
|
|
89
105
|
process.stdin.setEncoding("utf8");
|
|
90
106
|
for await (const chunk of process.stdin) body += chunk;
|
|
@@ -94,14 +110,15 @@ try {
|
|
|
94
110
|
method: "POST",
|
|
95
111
|
headers: { "content-type": "application/json", "x-bridge-token": TOKEN },
|
|
96
112
|
body,
|
|
113
|
+
signal: AbortSignal.timeout(POST_TIMEOUT_MS),
|
|
97
114
|
});
|
|
98
115
|
const json = await res.json();
|
|
99
116
|
// Ungated payloads get a terminal decision right on the POST (no park).
|
|
100
|
-
if (json && typeof json === "object" &&
|
|
101
|
-
console.log(JSON.stringify(json
|
|
117
|
+
if (json && typeof json === "object" && typeof json.decision === "string") {
|
|
118
|
+
console.log(JSON.stringify(json));
|
|
102
119
|
process.exit(0);
|
|
103
120
|
}
|
|
104
|
-
ticket = json
|
|
121
|
+
ticket = json?.ticket ?? "";
|
|
105
122
|
} catch {}
|
|
106
123
|
if (!ticket) {
|
|
107
124
|
console.log(JSON.stringify({ decision: "deny", reason: "approval gate unreachable (bridge down?)" }));
|
|
@@ -112,10 +129,17 @@ while (Date.now() < DEADLINE) {
|
|
|
112
129
|
try {
|
|
113
130
|
const res = await fetch(\`http://127.0.0.1:\${PORT}/approval/\${encodeURIComponent(ticket)}\`, {
|
|
114
131
|
headers: { "x-bridge-token": TOKEN },
|
|
132
|
+
// Bounded by the remaining park budget: an aborted poll falls through
|
|
133
|
+
// to the loop condition and lands on the deadline deny.
|
|
134
|
+
signal: AbortSignal.timeout(Math.max(1, DEADLINE - Date.now())),
|
|
115
135
|
});
|
|
116
136
|
const json = await res.json();
|
|
117
|
-
if (json
|
|
118
|
-
|
|
137
|
+
if (json?.status !== "pending") {
|
|
138
|
+
if (json && typeof json === "object" && typeof json.decision === "string") {
|
|
139
|
+
console.log(JSON.stringify(json));
|
|
140
|
+
} else {
|
|
141
|
+
console.log(JSON.stringify({ decision: "deny", reason: "gate returned no decision" }));
|
|
142
|
+
}
|
|
119
143
|
process.exit(0);
|
|
120
144
|
}
|
|
121
145
|
} catch {}
|
|
@@ -131,7 +155,7 @@ export function stagedTimeoutSeconds(parkBudgetMs: number): number {
|
|
|
131
155
|
return Math.max(60, Math.ceil(parkBudgetMs / 1000) + 60);
|
|
132
156
|
}
|
|
133
157
|
|
|
134
|
-
/** Build our hooks.json group for
|
|
158
|
+
/** Build our hooks.json group for private-dir staging. */
|
|
135
159
|
export function buildGateGroup(opts: StageOptions): Record<string, unknown> {
|
|
136
160
|
const command = `node ${JSON.stringify(opts.scriptPath)}`;
|
|
137
161
|
return {
|
|
@@ -153,100 +177,94 @@ export function buildGateGroup(opts: StageOptions): Record<string, unknown> {
|
|
|
153
177
|
|
|
154
178
|
export interface StageResult {
|
|
155
179
|
wrote: boolean;
|
|
156
|
-
/**
|
|
157
|
-
backup?: string;
|
|
158
|
-
/** Why nothing was written (parse failure, already current, ...). */
|
|
180
|
+
/** Why nothing was written (already current, ...). */
|
|
159
181
|
reason?: string;
|
|
160
182
|
}
|
|
161
183
|
|
|
162
|
-
/**
|
|
163
|
-
*
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
+
/** The session-private hooks.json for gate staging (beside the bridge's
|
|
185
|
+
* mcp_config.json in the per-pid add-dir). */
|
|
186
|
+
function gateHooksFile(dir: string = bridgeMcpConfigDir()): string {
|
|
187
|
+
return path.join(dir, ".agents", "hooks.json");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** True if this session's gate hooks are currently staged in the private
|
|
191
|
+
* add-dir. The driver uses this to decide whether to pass the extra
|
|
192
|
+
* --add-dir even when the MCP config is absent. */
|
|
193
|
+
export function gateHooksStaged(dir: string = bridgeMcpConfigDir()): boolean {
|
|
194
|
+
return fs.existsSync(gateHooksFile(dir));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Stage the gate group into the session-private per-pid dir:
|
|
198
|
+
* <bridgeMcpConfigDir()>/.agents/hooks.json. The dir (and everything in
|
|
199
|
+
* it) belongs to this pid alone, so the write is a plain atomic replace -
|
|
200
|
+
* no merge, no foreign-file guards. */
|
|
201
|
+
export function stageGateHooks(dir: string, opts: StageOptions): StageResult {
|
|
202
|
+
const file = gateHooksFile(dir);
|
|
203
|
+
const next = JSON.stringify({ [gateGroupKey()]: buildGateGroup(opts) }, null, 2) + "\n";
|
|
204
|
+
try {
|
|
205
|
+
if (fs.readFileSync(file, "utf8") === next) return { wrote: false, reason: "already staged" };
|
|
206
|
+
} catch {
|
|
207
|
+
/* absent or unreadable: write */
|
|
208
|
+
}
|
|
209
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
210
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
211
|
+
try {
|
|
212
|
+
fs.writeFileSync(tmp, next, { mode: 0o600 });
|
|
213
|
+
fs.renameSync(tmp, file);
|
|
214
|
+
} catch (err) {
|
|
184
215
|
try {
|
|
185
|
-
|
|
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>;
|
|
216
|
+
fs.unlinkSync(tmp);
|
|
190
217
|
} catch {
|
|
191
|
-
|
|
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" };
|
|
218
|
+
/* nothing */
|
|
213
219
|
}
|
|
220
|
+
throw err;
|
|
214
221
|
}
|
|
215
|
-
|
|
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 };
|
|
222
|
+
return { wrote: true };
|
|
224
223
|
}
|
|
225
224
|
|
|
226
|
-
/** Remove
|
|
227
|
-
*
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
* place (harmless). */
|
|
231
|
-
export function removeGateHooks(workspaceDir: string, pid: number = process.pid): StageResult {
|
|
232
|
-
const file = path.join(workspaceDir, ".agents", "hooks.json");
|
|
225
|
+
/** Remove the private gate hooks file. The rest of the per-pid dir (the MCP
|
|
226
|
+
* config) is managed by the bridge server lifecycle and stays until close. */
|
|
227
|
+
export function removeGateHooks(dir: string = bridgeMcpConfigDir()): StageResult {
|
|
228
|
+
const file = gateHooksFile(dir);
|
|
233
229
|
if (!fs.existsSync(file)) return { wrote: false, reason: "no hooks.json" };
|
|
234
230
|
try {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
231
|
+
fs.rmSync(file, { force: true });
|
|
232
|
+
return { wrote: true };
|
|
233
|
+
} catch {
|
|
234
|
+
return { wrote: false, reason: "remove failed" };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Legacy cleanup (issue #5): remove gate groups that 1.6.x staged into the
|
|
239
|
+
* SHARED workspace `.agents/hooks.json` when their owning session is dead
|
|
240
|
+
* (its bridge is gone; the hook would fail closed forever). Live sessions'
|
|
241
|
+
* groups and groups we cannot attribute are never touched; foreign groups
|
|
242
|
+
* and foreign files are never touched. Returns the number of swept groups. */
|
|
243
|
+
export function sweepWorkspaceGateGroups(workspaceDir: string): number {
|
|
244
|
+
const file = path.join(workspaceDir, ".agents", "hooks.json");
|
|
245
|
+
let current: Record<string, unknown>;
|
|
246
|
+
try {
|
|
247
|
+
if (fs.lstatSync(file).isSymbolicLink()) return 0;
|
|
248
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
249
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 0;
|
|
250
|
+
current = parsed as Record<string, unknown>;
|
|
238
251
|
} catch {
|
|
239
|
-
return
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
let swept = 0;
|
|
255
|
+
for (const key of Object.keys(current)) {
|
|
256
|
+
const isGateGroup = key === GATE_GROUP_PREFIX || key.startsWith(`${GATE_GROUP_PREFIX}-`);
|
|
257
|
+
if (!isGateGroup) continue;
|
|
258
|
+
const pid = gateGroupPid(current[key]);
|
|
259
|
+
if (pid === null || pidAlive(pid)) continue;
|
|
260
|
+
delete current[key];
|
|
261
|
+
swept += 1;
|
|
240
262
|
}
|
|
263
|
+
if (swept === 0) return 0;
|
|
241
264
|
try {
|
|
242
|
-
|
|
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 };
|
|
265
|
+
fs.writeFileSync(file, JSON.stringify(current, null, 2) + "\n");
|
|
249
266
|
} catch {
|
|
250
|
-
return
|
|
267
|
+
return 0;
|
|
251
268
|
}
|
|
269
|
+
return swept;
|
|
252
270
|
}
|
package/src/driver.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { spawn, type ChildProcess } from "node:child_process";
|
|
|
18
18
|
import { randomUUID } from "node:crypto";
|
|
19
19
|
import { parseAgyLine } from "./stream-events.js";
|
|
20
20
|
import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
|
|
21
|
+
import { gateHooksStaged } from "./approval-hook.js";
|
|
21
22
|
import type {
|
|
22
23
|
AgyUsage,
|
|
23
24
|
DriverActivity,
|
|
@@ -335,7 +336,11 @@ export class StreamDriver implements TurnDriver {
|
|
|
335
336
|
this.#stderrTail = "";
|
|
336
337
|
|
|
337
338
|
const args: string[] = ["--add-dir", request.cwd];
|
|
338
|
-
|
|
339
|
+
// The bridge dir carries .agents/mcp_config.json (tool bridge) and, when
|
|
340
|
+
// the approval gate is on, .agents/hooks.json (gate staging). Only this
|
|
341
|
+
// session's agy ever gets it: standalone IDE/CLI sessions in the
|
|
342
|
+
// workspace must never load our gate (issue #5 isolation).
|
|
343
|
+
if (bridgeMcpConfigExists() || gateHooksStaged()) args.push("--add-dir", bridgeMcpConfigDir());
|
|
339
344
|
args.push("--model", request.model);
|
|
340
345
|
if (request.effort) args.push("--effort", request.effort);
|
|
341
346
|
args.push("--mode", request.mode);
|
package/src/provider.ts
CHANGED
|
@@ -19,13 +19,15 @@
|
|
|
19
19
|
|
|
20
20
|
import {
|
|
21
21
|
createAssistantMessageEventStream,
|
|
22
|
+
getCurrentSystemPrompt,
|
|
22
23
|
type AssistantMessage,
|
|
23
24
|
type AssistantMessageEventStream,
|
|
24
|
-
type
|
|
25
|
+
type JsonValue,
|
|
25
26
|
type Message,
|
|
26
27
|
type Model,
|
|
27
28
|
type SimpleStreamOptions,
|
|
28
29
|
type ThinkingLevel,
|
|
30
|
+
type TranscriptContext,
|
|
29
31
|
type Usage,
|
|
30
32
|
} from "@earendil-works/pi-ai";
|
|
31
33
|
import fs from "node:fs";
|
|
@@ -59,7 +61,7 @@ function zeroUsage(): Usage {
|
|
|
59
61
|
/** Extract the latest user message as a flat prompt string. agy maintains its
|
|
60
62
|
* own conversation history via --conversation, so we collapse pi's structured
|
|
61
63
|
* message to text. Returns null if the last message isn't a user message. */
|
|
62
|
-
function extractUserPrompt(context:
|
|
64
|
+
function extractUserPrompt(context: TranscriptContext): string | null {
|
|
63
65
|
const last = context.messages[context.messages.length - 1];
|
|
64
66
|
if (!last || last.role !== "user") return null;
|
|
65
67
|
const content = last.content;
|
|
@@ -75,7 +77,7 @@ function extractUserPrompt(context: Context): string | null {
|
|
|
75
77
|
/** Image blocks of the latest user message (pi-ai ImageContent: base64 data
|
|
76
78
|
* + mimeType). The ACP engine forwards them as typed content blocks; the
|
|
77
79
|
* stream-json CLI prompt is text-only, so its driver simply ignores these. */
|
|
78
|
-
function extractImages(context:
|
|
80
|
+
function extractImages(context: TranscriptContext): Array<{ data: string; mimeType: string }> {
|
|
79
81
|
const last = context.messages[context.messages.length - 1];
|
|
80
82
|
if (!last || last.role !== "user" || typeof last.content === "string") return [];
|
|
81
83
|
return last.content
|
|
@@ -100,7 +102,8 @@ const DIGEST_PREAMBLE =
|
|
|
100
102
|
|
|
101
103
|
// --- pi system prompt (G10) ----------------------------------------------------
|
|
102
104
|
//
|
|
103
|
-
// pi
|
|
105
|
+
// pi normalizes the system prompt into the transcript every turn (read via
|
|
106
|
+
// getCurrentSystemPrompt): its own operating instructions
|
|
104
107
|
// plus every AGENTS.md/CLAUDE.md it loaded (global agent dir first, then
|
|
105
108
|
// ancestors). The provider used to drop it, so agy models never saw the user's
|
|
106
109
|
// machine-level or project-level instructions. agy has no system-prompt flag
|
|
@@ -921,13 +924,14 @@ function nextRtId(kind: "nat" | "wrap"): string {
|
|
|
921
924
|
return `${kind}-${++RT_SEQ}`;
|
|
922
925
|
}
|
|
923
926
|
|
|
924
|
-
/** Emit a complete toolCall block and end the pi call with toolUse.
|
|
927
|
+
/** Emit a complete toolCall block and end the pi call with toolUse.
|
|
928
|
+
* pi 0.86 restricts ToolCall.arguments to JSON-compatible values. */
|
|
925
929
|
function emitToolUse(
|
|
926
930
|
stream: AssistantMessageEventStream,
|
|
927
931
|
blocks: BlockState,
|
|
928
932
|
id: string,
|
|
929
933
|
name: string,
|
|
930
|
-
args: Record<string,
|
|
934
|
+
args: Record<string, JsonValue>,
|
|
931
935
|
): void {
|
|
932
936
|
const partial = blocks.partial;
|
|
933
937
|
closeThinking(stream, blocks);
|
|
@@ -1050,7 +1054,7 @@ function acpEditFileArg(args: Record<string, unknown>): string | undefined {
|
|
|
1050
1054
|
emitToolUse(stream, blocks, id, mapped.tool, {
|
|
1051
1055
|
reasoning: `re-exec of agy ${activity.name} for display`,
|
|
1052
1056
|
...mapped.args,
|
|
1053
|
-
});
|
|
1057
|
+
} as Record<string, JsonValue>);
|
|
1054
1058
|
return "parked";
|
|
1055
1059
|
}
|
|
1056
1060
|
{
|
|
@@ -1067,7 +1071,9 @@ function acpEditFileArg(args: Record<string, unknown>): string | undefined {
|
|
|
1067
1071
|
case "bridge_call": {
|
|
1068
1072
|
// Park the pi call: real tool name + args, toolUse stopReason. pi
|
|
1069
1073
|
// executes; the toolResult returns on the next stream call.
|
|
1070
|
-
|
|
1074
|
+
// Driver args are JSON-decoded off the agy wire, so the JsonValue
|
|
1075
|
+
// cast is sound.
|
|
1076
|
+
emitToolUse(stream, blocks, activity.callId, activity.name, activity.args as Record<string, JsonValue>);
|
|
1071
1077
|
return "parked";
|
|
1072
1078
|
}
|
|
1073
1079
|
}
|
|
@@ -1077,7 +1083,7 @@ function acpEditFileArg(args: Record<string, unknown>): string | undefined {
|
|
|
1077
1083
|
async function runTurnDriver(
|
|
1078
1084
|
stream: AssistantMessageEventStream,
|
|
1079
1085
|
model: Model<Api>,
|
|
1080
|
-
context:
|
|
1086
|
+
context: TranscriptContext,
|
|
1081
1087
|
options: SimpleStreamOptions | undefined,
|
|
1082
1088
|
entries: AgyModelEntry[],
|
|
1083
1089
|
store: SessionStore,
|
|
@@ -1179,7 +1185,9 @@ async function runTurnDriver(
|
|
|
1179
1185
|
// Fresh conversation only: agy stores the block in its own history, so
|
|
1180
1186
|
// re-sending it every turn would bloat each prompt and bust the cache.
|
|
1181
1187
|
const sysPrompt =
|
|
1182
|
-
config.systemPrompt && !existing?.conversationId
|
|
1188
|
+
config.systemPrompt && !existing?.conversationId
|
|
1189
|
+
? getCurrentSystemPrompt(context.messages) || undefined
|
|
1190
|
+
: undefined;
|
|
1183
1191
|
const fullPrompt =
|
|
1184
1192
|
late.length > 0
|
|
1185
1193
|
? buildLateResultPrompt(late, prompt || undefined)
|
|
@@ -1258,7 +1266,7 @@ async function runTurnDriver(
|
|
|
1258
1266
|
* fallback). */
|
|
1259
1267
|
export function createStreamSimple(
|
|
1260
1268
|
deps: StreamSimpleDeps,
|
|
1261
|
-
): (model: Model<Api>, context:
|
|
1269
|
+
): (model: Model<Api>, context: TranscriptContext, options?: SimpleStreamOptions) => AssistantMessageEventStream {
|
|
1262
1270
|
const { entries, store, roundTrips } = deps;
|
|
1263
1271
|
|
|
1264
1272
|
return function streamSimple(model, context, options) {
|