@firespoon/pi-antigravity-bridge 0.0.0-stage → 1.10.7
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 +902 -0
- package/LICENSE +21 -0
- package/README.md +235 -3
- package/extensions/index.ts +1993 -0
- package/package.json +71 -6
- package/src/acp/connection.ts +623 -0
- package/src/acp/driver.ts +953 -0
- package/src/acp/events.ts +331 -0
- package/src/acp/home.ts +238 -0
- package/src/acp/jsonrpc.ts +232 -0
- package/src/acp/setup.ts +107 -0
- package/src/acp/usage-estimate.ts +61 -0
- package/src/agents.ts +64 -0
- package/src/agy-install.ts +85 -0
- package/src/agy-paths.ts +30 -0
- package/src/agy-setup.ts +72 -0
- package/src/agy-version.ts +283 -0
- package/src/approval-detect.ts +146 -0
- package/src/approval-gate.ts +208 -0
- package/src/approval-hook.ts +270 -0
- package/src/artifacts-ui.ts +88 -0
- package/src/artifacts.ts +144 -0
- package/src/ask-tool.ts +1222 -0
- package/src/background-watch.ts +225 -0
- package/src/bridge-catalog.ts +27 -0
- package/src/config.ts +485 -0
- package/src/daily-log.ts +196 -0
- package/src/dialog-lock.ts +48 -0
- package/src/diff-render.ts +205 -0
- package/src/driver-types.ts +172 -0
- package/src/driver.ts +907 -0
- package/src/engine-picker.ts +160 -0
- package/src/frame-guard.ts +38 -0
- package/src/mcp-registration.ts +382 -0
- package/src/mcp-server.ts +620 -0
- package/src/models.ts +407 -0
- package/src/native-tools.ts +104 -0
- package/src/parked-turn.ts +125 -0
- package/src/patch-cleanup.ts +519 -0
- package/src/poll-fold.ts +55 -0
- package/src/prompt-images.ts +92 -0
- package/src/provider.ts +2066 -0
- package/src/queued-messages.ts +152 -0
- package/src/redact.ts +27 -0
- package/src/sessions.ts +185 -0
- package/src/skills.ts +192 -0
- package/src/stream-events.ts +122 -0
- package/src/stream-sanitizer.ts +180 -0
- package/src/subagent-roster.ts +245 -0
- package/src/sysprompt-agent.ts +163 -0
- package/src/tasks-ui.ts +90 -0
- package/src/tasks.ts +174 -0
- package/src/usage.ts +174 -0
- package/src/web-tools.ts +320 -0
package/package.json
CHANGED
|
@@ -1,6 +1,71 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@firespoon/pi-antigravity-bridge",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@firespoon/pi-antigravity-bridge",
|
|
3
|
+
"version": "1.10.7",
|
|
4
|
+
"description": "Gemini provider and AskAntigravity tool for Pi on the Antigravity ACP server (restricted custom agent with Pi tools via MCP) or the stream-json agy CLI fallback. Use antigravity/* models in Pi with /model picker support.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"antigravity",
|
|
9
|
+
"antigravity-acp",
|
|
10
|
+
"agy",
|
|
11
|
+
"gemini",
|
|
12
|
+
"google",
|
|
13
|
+
"google-acp",
|
|
14
|
+
"acp",
|
|
15
|
+
"agent-client-protocol",
|
|
16
|
+
"mcp",
|
|
17
|
+
"mcp-server",
|
|
18
|
+
"provider",
|
|
19
|
+
"streaming"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"author": {
|
|
23
|
+
"name": "FireSpoonYZ",
|
|
24
|
+
"url": "https://github.com/FireSpoonYZ"
|
|
25
|
+
},
|
|
26
|
+
"contributors": [
|
|
27
|
+
{
|
|
28
|
+
"name": "EstebanForge",
|
|
29
|
+
"email": "esteban@attitude.cl",
|
|
30
|
+
"url": "https://actitud.xyz"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"homepage": "https://github.com/FireSpoonYZ/pi-extensions#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/FireSpoonYZ/pi-extensions/issues"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/FireSpoonYZ/pi-extensions.git",
|
|
40
|
+
"directory": "packages/pi-antigravity-bridge"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"type": "module",
|
|
46
|
+
"files": [
|
|
47
|
+
"extensions",
|
|
48
|
+
"src",
|
|
49
|
+
"README.md",
|
|
50
|
+
"CHANGELOG.md",
|
|
51
|
+
"LICENSE"
|
|
52
|
+
],
|
|
53
|
+
"pi": {
|
|
54
|
+
"extensions": [
|
|
55
|
+
"./extensions"
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"@earendil-works/pi-coding-agent": "^1.5.1"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"@earendil-works/pi-coding-agent": {
|
|
63
|
+
"optional": true
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"@estebanforge/pi-ask-shared": "^0.1.6",
|
|
68
|
+
"@estebanforge/pi-tui-dialogs": "^0.1.3",
|
|
69
|
+
"@modelcontextprotocol/sdk": "^1.31.0"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
// ACP connection: owns the agy acp process, the JSON-RPC session over its
|
|
2
|
+
// stdio, and the protocol methods the driver needs.
|
|
3
|
+
//
|
|
4
|
+
// Verified live against agy 1.5.3:
|
|
5
|
+
// - initialize: {protocolVersion:1, clientCapabilities{fs:false,terminal:false}}
|
|
6
|
+
// - model and thought_level are separate session config options
|
|
7
|
+
// - session/cancel is a notification; the prompt settles with stopReason cancelled
|
|
8
|
+
// - session/request_permission: skipPermissions turns answer the first
|
|
9
|
+
// allow option synchronously; otherwise a wired human handler PARKS the
|
|
10
|
+
// request (the JSON-RPC reply waits for the decision), and everything
|
|
11
|
+
// else fail-closes to the first reject option
|
|
12
|
+
// - session/load replays history as notifications BEFORE its response; the
|
|
13
|
+
// driver suppresses updates while the load is in flight
|
|
14
|
+
// - mcpServers entries: {name, type:"http", url, headers:[]} — headers is a
|
|
15
|
+
// LIST; a dead URL is accepted at session time (lazy connect)
|
|
16
|
+
|
|
17
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
18
|
+
import { frameCarriesUsage } from "./events.js";
|
|
19
|
+
import type { AgyUsage } from "../driver-types.js";
|
|
20
|
+
import { redactText } from "../redact.js";
|
|
21
|
+
import { JsonRpcResponseError, JsonRpcSession } from "./jsonrpc.js";
|
|
22
|
+
import { removeStagedAgyHome, stageAgyHome } from "./home.js";
|
|
23
|
+
|
|
24
|
+
export interface AcpMcpServer {
|
|
25
|
+
name: string;
|
|
26
|
+
type: "http" | "sse";
|
|
27
|
+
url: string;
|
|
28
|
+
headers: Array<{ name: string; value: string }>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AcpSessionInfo {
|
|
32
|
+
sessionId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Auth failed (-32000 family). Message carries the remediation the server
|
|
36
|
+
* provided plus the /agy auth-manual pointer. */
|
|
37
|
+
export class AcpAuthError extends Error {
|
|
38
|
+
constructor(message: string) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "AcpAuthError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AcpConnectionOptions {
|
|
45
|
+
/** Process working directory for the spawned server. */
|
|
46
|
+
cwd: string;
|
|
47
|
+
/** Resolved server binary (or node executable when binArgs carries the
|
|
48
|
+
* fake-server script for tests). */
|
|
49
|
+
bin: string;
|
|
50
|
+
binArgs?: string[];
|
|
51
|
+
/** Native agy only: isolate inherited global MCP from other pi sessions. */
|
|
52
|
+
isolateHome?: boolean | { geminiHome?: string; parentDir?: string; agentsRoot?: string };
|
|
53
|
+
/** Carrier agent name passed as `--agent <name>`. */
|
|
54
|
+
agent?: string;
|
|
55
|
+
/** When false, passes `--include-default-customizations=false`. */
|
|
56
|
+
includeDefaultCustomizations?: boolean;
|
|
57
|
+
/** Explicit agent discovery root for isolated home staging (tests). */
|
|
58
|
+
agentsRoot?: string;
|
|
59
|
+
extraEnv?: Record<string, string>;
|
|
60
|
+
log: (msg: string, data?: unknown) => void;
|
|
61
|
+
/** Verified `session/update` payloads (post-suppression). */
|
|
62
|
+
onUpdate: (sessionId: string | null, update: unknown) => void; /** Process exit. Fires exactly once. */
|
|
63
|
+
onExit: (info: { code: number | null; signal: string | null; stderrTail: string }) => void;
|
|
64
|
+
/** mcpServers entries for session/new AND session/load (run 6: load takes
|
|
65
|
+
* the same param). Evaluated lazily per call. */
|
|
66
|
+
mcpServers?: () => AcpMcpServer[];
|
|
67
|
+
/** Permission policy for session/request_permission, evaluated per request:
|
|
68
|
+
* "auto" selects the first allow option; "deny" consults the human
|
|
69
|
+
* handler (parking) or fail-closes to the first reject option. Absent =
|
|
70
|
+
* deny (fail closed). */
|
|
71
|
+
permissions?: () => "auto" | "deny";
|
|
72
|
+
/** Human gate for "deny"-policy turns. The request is PARKED: the JSON-RPC
|
|
73
|
+
* reply waits until the handler settles. The returned optionId is
|
|
74
|
+
* validated against the server's options; undefined/null deny. Absent =
|
|
75
|
+
* synchronous deny (fail closed). */
|
|
76
|
+
onPermissionRequest?: AcpPermissionHandler;
|
|
77
|
+
/** Park budget for one permission dialog. Tests shrink it. */
|
|
78
|
+
permissionParkMs?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const INIT_TIMEOUT_MS = 30_000;
|
|
82
|
+
const SESSION_OP_TIMEOUT_MS = 20_000;
|
|
83
|
+
|
|
84
|
+
/** Human-decision budget for one parked permission request. Mirrors the
|
|
85
|
+
* approval-gate park budget (APPROVAL_PARK_TIMEOUT_MS): the dialog must
|
|
86
|
+
* deny by itself, never hold the agy prompt open forever. */
|
|
87
|
+
const PERMISSION_PARK_MS = 480_000;
|
|
88
|
+
|
|
89
|
+
/** One session/request_permission option block (the subset we consume). */
|
|
90
|
+
export interface AcpPermissionOption {
|
|
91
|
+
optionId?: string;
|
|
92
|
+
kind?: string;
|
|
93
|
+
name?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Payload handed to the permission handler when the server asks for a human
|
|
97
|
+
* decision. Options are server-defined data; the handler returns the chosen
|
|
98
|
+
* optionId, or undefined/null to deny. */
|
|
99
|
+
export interface AcpPermissionRequest {
|
|
100
|
+
sessionId?: string;
|
|
101
|
+
options: AcpPermissionOption[];
|
|
102
|
+
toolCall?: { toolCallId?: string; kind?: string; title?: string };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type AcpPermissionHandler = (req: AcpPermissionRequest) => Promise<string | null | undefined>;
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
export class AcpConnection {
|
|
109
|
+
#opts: AcpConnectionOptions;
|
|
110
|
+
#child: ChildProcess | undefined;
|
|
111
|
+
#rpc: JsonRpcSession | undefined;
|
|
112
|
+
#stderrTail = "";
|
|
113
|
+
#stagedHome: string | undefined;
|
|
114
|
+
#usageSeen = false;
|
|
115
|
+
/** Set ONLY by a parsed PromptResponse.usage; this gates estimate
|
|
116
|
+
* suppression. #usageSeen stays diagnostic: any usage-shaped frame trips
|
|
117
|
+
* it, and the connection outlives turns. */
|
|
118
|
+
#exactUsageSeen = false;
|
|
119
|
+
#exited = false;
|
|
120
|
+
#killed = false;
|
|
121
|
+
#suppressUpdates = false;
|
|
122
|
+
#updateSessionId: string | null = null;
|
|
123
|
+
agentInfo: Record<string, unknown> | undefined;
|
|
124
|
+
/** Last known mode/model config echo from set_config_option results. */
|
|
125
|
+
lastConfigOptions: unknown = undefined;
|
|
126
|
+
/** Parked permission dialogs: each entry settles its own request as
|
|
127
|
+
* cancelled. Cancellation and connection death drain the set. */
|
|
128
|
+
#permissionPending = new Set<() => void>();
|
|
129
|
+
/** allow_always / reject_always answers, keyed by toolCall kind|title for
|
|
130
|
+
* the life of this connection: identical requests skip the dialog. */
|
|
131
|
+
#permissionMemory = new Map<string, string>();
|
|
132
|
+
|
|
133
|
+
constructor(opts: AcpConnectionOptions) {
|
|
134
|
+
this.#opts = opts;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get alive(): boolean {
|
|
138
|
+
return this.#child !== undefined && !this.#exited && !this.#killed;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
get pid(): number | undefined {
|
|
142
|
+
return this.#child?.pid;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
get stderrTail(): string {
|
|
146
|
+
// Redacted at the boundary: the raw tail stays internal, every consumer
|
|
147
|
+
// (doctor display, driver exit logs, failure messages) sees redacted text.
|
|
148
|
+
return redactText(this.#stderrTail);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Gate B watch: latched true once any session/update frame carried
|
|
152
|
+
* usage/token fields. /agy doctor surfaces it the day upstream starts
|
|
153
|
+
* sending token counts; silent until then. */
|
|
154
|
+
get usageSeen(): boolean {
|
|
155
|
+
return this.#usageSeen;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** True once the server sent real per-turn usage (PromptResponse.usage). */
|
|
159
|
+
get exactUsageSeen(): boolean {
|
|
160
|
+
return this.#exactUsageSeen;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** While true, session/update notifications are dropped: they are the
|
|
164
|
+
* full-text history replay that precedes a session/load response, never
|
|
165
|
+
* live generation (run 6). */
|
|
166
|
+
set updateSuppression(flag: boolean) {
|
|
167
|
+
this.#suppressUpdates = flag;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Spawn the server and perform the initialize handshake. Idempotent per
|
|
171
|
+
* instance: call once. */
|
|
172
|
+
async start(): Promise<void> {
|
|
173
|
+
if (this.#child) return;
|
|
174
|
+
const args = [...(this.#opts.binArgs ?? [])];
|
|
175
|
+
if (this.#opts.agent) {
|
|
176
|
+
args.push("--agent", this.#opts.agent);
|
|
177
|
+
}
|
|
178
|
+
if (this.#opts.includeDefaultCustomizations === false) {
|
|
179
|
+
args.push("--include-default-customizations=false");
|
|
180
|
+
}
|
|
181
|
+
if (this.#opts.isolateHome) {
|
|
182
|
+
const homeOpts = typeof this.#opts.isolateHome === "object" ? this.#opts.isolateHome : {};
|
|
183
|
+
const sessionMcp = this.#opts.mcpServers?.() ?? [];
|
|
184
|
+
const agentMcpServers =
|
|
185
|
+
this.#opts.agent && sessionMcp.length > 0
|
|
186
|
+
? sessionMcp.map((s) => ({
|
|
187
|
+
name: s.name,
|
|
188
|
+
serverUrl: s.url,
|
|
189
|
+
headers: Object.fromEntries(s.headers.map((h) => [h.name, h.value])),
|
|
190
|
+
}))
|
|
191
|
+
: undefined;
|
|
192
|
+
this.#stagedHome = stageAgyHome({
|
|
193
|
+
...homeOpts,
|
|
194
|
+
inheritGlobalMcp: false,
|
|
195
|
+
...(this.#opts.agent ? { agent: this.#opts.agent } : {}),
|
|
196
|
+
...(this.#opts.agentsRoot ? { agentsRoot: this.#opts.agentsRoot } : {}),
|
|
197
|
+
...(agentMcpServers ? { agentMcpServers } : {}),
|
|
198
|
+
});
|
|
199
|
+
args.push("--gemini-dir", this.#stagedHome);
|
|
200
|
+
}
|
|
201
|
+
let child: ChildProcess;
|
|
202
|
+
try {
|
|
203
|
+
child = spawn(this.#opts.bin, args, {
|
|
204
|
+
cwd: this.#opts.cwd,
|
|
205
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
206
|
+
detached: process.platform !== "win32",
|
|
207
|
+
windowsHide: true,
|
|
208
|
+
...(this.#opts.extraEnv ? { env: { ...process.env, ...this.#opts.extraEnv } } : {}),
|
|
209
|
+
});
|
|
210
|
+
} catch (err) {
|
|
211
|
+
this.#releaseHome();
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
this.#child = child;
|
|
215
|
+
child.stdout?.setEncoding("utf8");
|
|
216
|
+
child.stderr?.setEncoding("utf8");
|
|
217
|
+
// Pipe failures arrive asynchronously as stream 'error' events; the sync
|
|
218
|
+
// try/catch in #write cannot see them. Without this listener an EPIPE
|
|
219
|
+
// (server died mid-handshake) is uncaught and kills pi.
|
|
220
|
+
child.stdin?.on("error", (err) => {
|
|
221
|
+
this.#opts.log("stdin-error", { message: redactText(err.message) });
|
|
222
|
+
if (!this.#exited && !this.#killed) this.#finish(redactText(err.message));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const rpc = new JsonRpcSession({
|
|
226
|
+
send: (frame) => this.#write(frame),
|
|
227
|
+
onRequest: (method, params) => this.#onServerRequest(method, params),
|
|
228
|
+
onNotification: (method, params) => this.#onNotification(method, params),
|
|
229
|
+
onParseError: (line) => this.#opts.log("parse-error", { line }),
|
|
230
|
+
onOverflow: (detail) => {
|
|
231
|
+
// The session already aborted its pending requests with the typed
|
|
232
|
+
// reason; finish so later events are ignored, then take the whole
|
|
233
|
+
// process GROUP down: a flooder is broken or hostile, and the
|
|
234
|
+
// binary may be a wrapper script with live descendants.
|
|
235
|
+
this.#opts.log("frame-overflow", { detail });
|
|
236
|
+
this.#finish(`stdout frame overflow: ${detail}`);
|
|
237
|
+
const child = this.#child;
|
|
238
|
+
try {
|
|
239
|
+
if (child?.pid && process.platform !== "win32") {
|
|
240
|
+
process.kill(-child.pid, "SIGKILL");
|
|
241
|
+
} else {
|
|
242
|
+
child?.kill("SIGKILL");
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
/* already gone */
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
this.#rpc = rpc;
|
|
250
|
+
|
|
251
|
+
child.stdout?.on("data", (chunk: string) => rpc.feed(chunk));
|
|
252
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
253
|
+
this.#stderrTail = (this.#stderrTail + chunk).slice(-8192);
|
|
254
|
+
});
|
|
255
|
+
child.on("error", (err) => {
|
|
256
|
+
this.#opts.log("spawn-error", { message: redactText(err.message) });
|
|
257
|
+
this.#finish(redactText(err.message));
|
|
258
|
+
});
|
|
259
|
+
child.on("exit", (code, signal) => {
|
|
260
|
+
this.#opts.log("exit", { code: code ?? signal ?? "?" });
|
|
261
|
+
// The reason rides into pending-request rejections, so it must be
|
|
262
|
+
// redacted here (server stderr can carry auth material). When the
|
|
263
|
+
// server dies with no output the exit code/signal is the only clue,
|
|
264
|
+
// so it goes into the reason ahead of the stderr tail.
|
|
265
|
+
const where = signal ? `signal ${signal}` : code !== null ? `code ${code}` : "";
|
|
266
|
+
const tail = redactText(this.#stderrTail.trim());
|
|
267
|
+
const detail = [where, tail || "process gone"].filter(Boolean).join(": ");
|
|
268
|
+
this.#finish(detail, code, signal);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const result = (await this.request(
|
|
272
|
+
"initialize",
|
|
273
|
+
{
|
|
274
|
+
protocolVersion: 1,
|
|
275
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
|
|
276
|
+
},
|
|
277
|
+
INIT_TIMEOUT_MS,
|
|
278
|
+
)) as Record<string, unknown> | undefined;
|
|
279
|
+
const info = result?.agentInfo;
|
|
280
|
+
if (typeof info === "object" && info !== null) this.agentInfo = info as Record<string, unknown>;
|
|
281
|
+
this.#opts.log("initialized", { version: this.serverVersion() });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
serverVersion(): string | undefined {
|
|
285
|
+
const v = this.agentInfo?.version;
|
|
286
|
+
return typeof v === "string" ? v : undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async newSession(cwd: string): Promise<AcpSessionInfo> {
|
|
290
|
+
const result = (await this.guarded(
|
|
291
|
+
"session/new",
|
|
292
|
+
{ cwd, mcpServers: this.#mcpServers() },
|
|
293
|
+
SESSION_OP_TIMEOUT_MS,
|
|
294
|
+
)) as Record<string, unknown>;
|
|
295
|
+
const sessionId = result.sessionId;
|
|
296
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
297
|
+
throw new Error("ACP session/new returned no sessionId");
|
|
298
|
+
}
|
|
299
|
+
this.#updateSessionId = sessionId;
|
|
300
|
+
this.lastConfigOptions = result.configOptions;
|
|
301
|
+
return { sessionId };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async loadSession(sessionId: string, cwd: string): Promise<void> {
|
|
305
|
+
this.#updateSessionId = sessionId;
|
|
306
|
+
this.updateSuppression = true;
|
|
307
|
+
try {
|
|
308
|
+
// History replay arrives as notifications BEFORE this resolves; the
|
|
309
|
+
// suppression flag drops it (never live text).
|
|
310
|
+
const result = (await this.request(
|
|
311
|
+
"session/load",
|
|
312
|
+
{ sessionId, cwd, mcpServers: this.#mcpServers() },
|
|
313
|
+
SESSION_OP_TIMEOUT_MS,
|
|
314
|
+
)) as Record<string, unknown> | undefined;
|
|
315
|
+
this.lastConfigOptions = result?.configOptions;
|
|
316
|
+
} finally {
|
|
317
|
+
this.updateSuppression = false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async setConfigOption(sessionId: string, configId: string, value: string): Promise<void> {
|
|
322
|
+
const result = (await this.guarded(
|
|
323
|
+
"session/set_config_option",
|
|
324
|
+
{ sessionId, configId, value },
|
|
325
|
+
SESSION_OP_TIMEOUT_MS,
|
|
326
|
+
)) as Record<string, unknown> | undefined;
|
|
327
|
+
this.lastConfigOptions = result?.configOptions;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** No request timeout on purpose: the driver's overall/idle timers govern
|
|
331
|
+
* the turn and fire abortAll on breach. */
|
|
332
|
+
async prompt(
|
|
333
|
+
sessionId: string,
|
|
334
|
+
text: string,
|
|
335
|
+
images: Array<{ data: string; mimeType: string }> = [],
|
|
336
|
+
contextBlock?: { uri: string; text: string },
|
|
337
|
+
): Promise<{ stopReason: string; usage?: AgyUsage }> {
|
|
338
|
+
// Block order: images, then the embedded-context resource (pi-side
|
|
339
|
+
// digest), then the text question last (it refers to everything before
|
|
340
|
+
// it). Shapes per the ACP v1 content-block schema; embeddedContext is
|
|
341
|
+
// advertised in promptCapabilities (verified run 5).
|
|
342
|
+
const blocks: Array<Record<string, unknown>> = [
|
|
343
|
+
...images.map((i) => ({ type: "image", data: i.data, mimeType: i.mimeType })),
|
|
344
|
+
];
|
|
345
|
+
if (contextBlock) {
|
|
346
|
+
blocks.push({
|
|
347
|
+
type: "resource",
|
|
348
|
+
resource: {
|
|
349
|
+
uri: contextBlock.uri,
|
|
350
|
+
mimeType: "text/markdown",
|
|
351
|
+
text: contextBlock.text,
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
blocks.push({ type: "text", text });
|
|
356
|
+
const result = (await this.request("session/prompt", {
|
|
357
|
+
sessionId,
|
|
358
|
+
prompt: blocks,
|
|
359
|
+
})) as Record<string, unknown> | undefined;
|
|
360
|
+
const stopReason = result?.stopReason;
|
|
361
|
+
if (typeof stopReason !== "string") {
|
|
362
|
+
throw new Error("ACP session/prompt returned no stopReason");
|
|
363
|
+
}
|
|
364
|
+
// Exact PromptResponse.usage is distinct from context-window occupancy
|
|
365
|
+
// updates; never infer token counts from {used,size}.
|
|
366
|
+
const usage = parseAcpTurnUsage(result?.usage);
|
|
367
|
+
if (usage) {
|
|
368
|
+
this.#usageSeen = true;
|
|
369
|
+
this.#exactUsageSeen = true;
|
|
370
|
+
}
|
|
371
|
+
return { stopReason, ...(usage ? { usage } : {}) };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
cancel(sessionId: string): void {
|
|
375
|
+
if (!this.#rpc || !this.alive) throw new Error("ACP connection is not running");
|
|
376
|
+
this.#rpc.notify("session/cancel", { sessionId });
|
|
377
|
+
for (const cancel of [...this.#permissionPending]) cancel();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async closeSession(sessionId: string): Promise<void> {
|
|
381
|
+
await this.guarded("session/close", { sessionId }, SESSION_OP_TIMEOUT_MS);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Protocol-level permission answering: policy "auto" selects the first
|
|
385
|
+
* allow option synchronously; "deny" consults the parked human handler
|
|
386
|
+
* (#answerPermission) or fail-closes. See there for the full matrix. */
|
|
387
|
+
#onServerRequest(method: string, params: unknown): Promise<unknown> {
|
|
388
|
+
if (method === "session/request_permission") {
|
|
389
|
+
return this.#answerPermission(params as AcpPermissionRequest);
|
|
390
|
+
}
|
|
391
|
+
// fs/* and terminal/* are declined: our client capabilities are off and
|
|
392
|
+
// agy keeps executing its own tools (plan §8 capability posture).
|
|
393
|
+
this.#opts.log("unsupported-server-request", { method });
|
|
394
|
+
return Promise.reject(new Error(`client capability not enabled: ${method}`));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Answer session/request_permission. "auto" (skipPermissions turns)
|
|
398
|
+
* selects the first allow option synchronously. Otherwise a wired handler
|
|
399
|
+
* parks the request — the reply waits for the human — while every other
|
|
400
|
+
* path fail-closes synchronously: no handler, esc, throw, timeout,
|
|
401
|
+
* connection death. A chosen *_always option is remembered per connection
|
|
402
|
+
* for identical later requests (same toolCall kind+title). */
|
|
403
|
+
#answerPermission(params: AcpPermissionRequest): Promise<unknown> {
|
|
404
|
+
const options = Array.isArray(params?.options) ? params.options : [];
|
|
405
|
+
const allow = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"));
|
|
406
|
+
const denyId = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("reject"))?.optionId;
|
|
407
|
+
const policy = this.#opts.permissions?.() ?? "deny";
|
|
408
|
+
if (policy === "auto") {
|
|
409
|
+
const chosen = (allow ?? options[0])?.optionId;
|
|
410
|
+
this.#opts.log("permission", { optionId: chosen, policy });
|
|
411
|
+
return Promise.resolve({ outcome: { outcome: "selected", optionId: chosen } });
|
|
412
|
+
}
|
|
413
|
+
const handler = this.#opts.onPermissionRequest;
|
|
414
|
+
if (!handler) {
|
|
415
|
+
this.#opts.log("permission", { optionId: denyId, policy });
|
|
416
|
+
return Promise.resolve(
|
|
417
|
+
denyId !== undefined
|
|
418
|
+
? { outcome: { outcome: "selected", optionId: denyId } }
|
|
419
|
+
: { outcome: { outcome: "cancelled" } },
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
// Always-memory needs a stable identity: the title. Without one, every
|
|
423
|
+
// untitled call would collapse onto one key and inherit its answer.
|
|
424
|
+
const memKey = params?.toolCall?.title ? `${params.toolCall.kind ?? ""}|${params.toolCall.title}` : undefined;
|
|
425
|
+
const remembered = memKey !== undefined ? this.#permissionMemory.get(memKey) : undefined;
|
|
426
|
+
if (remembered !== undefined && options.some((o) => o.optionId === remembered)) {
|
|
427
|
+
this.#opts.log("permission", { optionId: remembered, policy: "memory" });
|
|
428
|
+
return Promise.resolve({ outcome: { outcome: "selected", optionId: remembered } });
|
|
429
|
+
}
|
|
430
|
+
const parkMs = this.#opts.permissionParkMs ?? PERMISSION_PARK_MS;
|
|
431
|
+
return new Promise((resolve) => {
|
|
432
|
+
let timer: NodeJS.Timeout | undefined;
|
|
433
|
+
// First settle wins: a late dialog answer after a timeout/deny must
|
|
434
|
+
// not re-log or record an always-memory from a dead request.
|
|
435
|
+
let settled = false;
|
|
436
|
+
const settle = (optionId: string | undefined, why: string): void => {
|
|
437
|
+
if (settled) return;
|
|
438
|
+
settled = true;
|
|
439
|
+
if (timer) clearTimeout(timer);
|
|
440
|
+
this.#permissionPending.delete(settleCancel);
|
|
441
|
+
this.#opts.log("permission", { optionId, policy: why });
|
|
442
|
+
// undefined optionId (no reject option on the table) cancels
|
|
443
|
+
// instead of selecting nothing.
|
|
444
|
+
resolve(
|
|
445
|
+
optionId !== undefined
|
|
446
|
+
? { outcome: { outcome: "selected", optionId } }
|
|
447
|
+
: { outcome: { outcome: "cancelled" } },
|
|
448
|
+
);
|
|
449
|
+
};
|
|
450
|
+
const settleDeny = (): void => settle(denyId, "dialog-deny");
|
|
451
|
+
const settleCancel = (): void => settle(undefined, "cancel");
|
|
452
|
+
timer = setTimeout(() => {
|
|
453
|
+
this.#opts.log("permission-timeout", { parkMs });
|
|
454
|
+
settleDeny();
|
|
455
|
+
}, parkMs);
|
|
456
|
+
this.#permissionPending.add(settleCancel);
|
|
457
|
+
Promise.resolve()
|
|
458
|
+
.then(() => handler(params))
|
|
459
|
+
.then((picked) => {
|
|
460
|
+
if (settled) return;
|
|
461
|
+
const chosen = picked != null ? options.find((o) => o.optionId === picked) : undefined;
|
|
462
|
+
if (!chosen?.optionId) {
|
|
463
|
+
settle(denyId, "dialog-deny");
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (chosen.kind === "allow_always" || chosen.kind === "reject_always") {
|
|
467
|
+
if (memKey !== undefined) this.#permissionMemory.set(memKey, chosen.optionId);
|
|
468
|
+
}
|
|
469
|
+
settle(chosen.optionId, "dialog");
|
|
470
|
+
})
|
|
471
|
+
.catch((err: unknown) => {
|
|
472
|
+
this.#opts.log("permission-error", { message: err instanceof Error ? err.message : String(err) });
|
|
473
|
+
settle(denyId, "dialog-error");
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
#onNotification(method: string, params: unknown): void {
|
|
479
|
+
if (method === "session/update") {
|
|
480
|
+
// Latched before the suppression gate: history-replay frames are the
|
|
481
|
+
// server's payloads too, and they are the cheapest signal source.
|
|
482
|
+
if (!this.#usageSeen && frameCarriesUsage(params)) this.#usageSeen = true;
|
|
483
|
+
if (this.#suppressUpdates) return; // load replay: history, not live text
|
|
484
|
+
const p = typeof params === "object" && params !== null ? (params as Record<string, unknown>) : {};
|
|
485
|
+
const sessionId = typeof p.sessionId === "string" ? p.sessionId : this.#updateSessionId;
|
|
486
|
+
this.#opts.onUpdate(sessionId, p.update);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (method === "auth_required") {
|
|
490
|
+
this.#opts.log("auth-required", params);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
this.#opts.log("notification", { method });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Wrap a request: -32000 family becomes AcpAuthError with the remediation
|
|
497
|
+
* the server provided. */
|
|
498
|
+
private async guarded(method: string, params: unknown, timeoutMs: number): Promise<unknown> {
|
|
499
|
+
try {
|
|
500
|
+
return await this.request(method, params, timeoutMs);
|
|
501
|
+
} catch (err) {
|
|
502
|
+
throw translateError(method, err);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {
|
|
507
|
+
if (!this.#rpc || !this.alive) throw new Error("ACP connection is not running");
|
|
508
|
+
try {
|
|
509
|
+
return await this.#rpc.request(method, params, timeoutMs);
|
|
510
|
+
} catch (err) {
|
|
511
|
+
throw translateError(method, err);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
#mcpServers(): AcpMcpServer[] {
|
|
516
|
+
try {
|
|
517
|
+
return this.#opts.mcpServers?.() ?? [];
|
|
518
|
+
} catch {
|
|
519
|
+
return [];
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
#write(frame: string): void {
|
|
524
|
+
const stdin = this.#child?.stdin;
|
|
525
|
+
if (!stdin || !stdin.writable || this.#exited) {
|
|
526
|
+
this.abortAll("connection closed");
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
stdin.write(frame + "\n");
|
|
531
|
+
} catch (err) {
|
|
532
|
+
this.#opts.log("write-failed", { message: err instanceof Error ? err.message : String(err) });
|
|
533
|
+
this.abortAll("connection closed");
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Gate D teardown: reject every pending request, then kill the process.
|
|
538
|
+
* Nothing is ever written to the dying transport; no promise survives. */
|
|
539
|
+
abortAll(reason: string): void {
|
|
540
|
+
this.#rpc?.abortAll(reason);
|
|
541
|
+
}
|
|
542
|
+
kill(): void {
|
|
543
|
+
if (this.#killed) return;
|
|
544
|
+
this.#killed = true;
|
|
545
|
+
this.abortAll("connection killed");
|
|
546
|
+
const child = this.#child;
|
|
547
|
+
if (!child || this.#exited) return;
|
|
548
|
+
this.#opts.log("kill", { pid: child.pid ?? "?" });
|
|
549
|
+
try {
|
|
550
|
+
if (child.pid && process.platform !== "win32") {
|
|
551
|
+
process.kill(-child.pid, "SIGTERM");
|
|
552
|
+
setTimeout(() => {
|
|
553
|
+
try {
|
|
554
|
+
if (!this.#exited && child.pid) process.kill(-child.pid, "SIGKILL");
|
|
555
|
+
} catch {
|
|
556
|
+
/* already gone */
|
|
557
|
+
}
|
|
558
|
+
}, 750);
|
|
559
|
+
} else {
|
|
560
|
+
child.kill("SIGTERM");
|
|
561
|
+
}
|
|
562
|
+
} catch {
|
|
563
|
+
/* already gone */
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
#finish(reason: string, code: number | null = null, signal: NodeJS.Signals | null = null): void {
|
|
568
|
+
if (this.#exited) return;
|
|
569
|
+
this.#exited = true;
|
|
570
|
+
// Pending permission dialogs must not outlive the connection: cancel them
|
|
571
|
+
// so their replies race the teardown instead of hanging the server.
|
|
572
|
+
const pendings = [...this.#permissionPending];
|
|
573
|
+
this.#permissionPending.clear();
|
|
574
|
+
for (const deny of pendings) deny();
|
|
575
|
+
this.abortAll(`connection exited: ${reason || "process gone"}`);
|
|
576
|
+
this.#releaseHome();
|
|
577
|
+
this.#opts.onExit({ code, signal, stderrTail: redactText(this.#stderrTail) });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
#releaseHome(): void {
|
|
581
|
+
if (this.#stagedHome) removeStagedAgyHome(this.#stagedHome);
|
|
582
|
+
this.#stagedHome = undefined;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** ACP's optional (unstable) per-turn PromptResponse.usage, not the
|
|
587
|
+
* session/usage_update context-window occupancy notification. */
|
|
588
|
+
export function parseAcpTurnUsage(value: unknown): AgyUsage | undefined {
|
|
589
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
590
|
+
const u = value as Record<string, unknown>;
|
|
591
|
+
const count = (v: unknown): number | undefined =>
|
|
592
|
+
typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : undefined;
|
|
593
|
+
const input = count(u.inputTokens);
|
|
594
|
+
const output = count(u.outputTokens);
|
|
595
|
+
const total = count(u.totalTokens);
|
|
596
|
+
const thought = count(u.thoughtTokens);
|
|
597
|
+
const cacheRead = count(u.cachedReadTokens);
|
|
598
|
+
if (input === undefined || output === undefined || total === undefined) return undefined;
|
|
599
|
+
return {
|
|
600
|
+
input_tokens: input,
|
|
601
|
+
output_tokens: output,
|
|
602
|
+
total_tokens: total,
|
|
603
|
+
...(thought === undefined ? {} : { thinking_tokens: thought }),
|
|
604
|
+
...(cacheRead === undefined ? {} : { cache_read_tokens: cacheRead }),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function translateError(method: string, err: unknown): Error {
|
|
609
|
+
if (err instanceof AcpAuthError) return err;
|
|
610
|
+
if (err instanceof JsonRpcResponseError) {
|
|
611
|
+
if (err.code === -32000) {
|
|
612
|
+
const detail = typeof err.data === "object" && err.data !== null ? (err.data as { message?: unknown }).message : undefined;
|
|
613
|
+
const detailText = typeof detail === "string" ? `: ${detail}` : `: ${err.message}`;
|
|
614
|
+
return new AcpAuthError(`ACP authentication required for ${method}${detailText}. Run agy in your terminal and complete Google sign-in, then restart pi.`);
|
|
615
|
+
}
|
|
616
|
+
// Typed protocol errors survive: callers inspect .code for parameter
|
|
617
|
+
// and capability failures. The method prefix is lost — describe()
|
|
618
|
+
// callers that need it log the method at the call site.
|
|
619
|
+
return err;
|
|
620
|
+
}
|
|
621
|
+
if (err instanceof Error) return err;
|
|
622
|
+
return new Error(`ACP ${method} failed: ${String(err)}`);
|
|
623
|
+
}
|