@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +902 -0
  2. package/LICENSE +21 -0
  3. package/README.md +235 -3
  4. package/extensions/index.ts +1993 -0
  5. package/package.json +71 -6
  6. package/src/acp/connection.ts +623 -0
  7. package/src/acp/driver.ts +953 -0
  8. package/src/acp/events.ts +331 -0
  9. package/src/acp/home.ts +238 -0
  10. package/src/acp/jsonrpc.ts +232 -0
  11. package/src/acp/setup.ts +107 -0
  12. package/src/acp/usage-estimate.ts +61 -0
  13. package/src/agents.ts +64 -0
  14. package/src/agy-install.ts +85 -0
  15. package/src/agy-paths.ts +30 -0
  16. package/src/agy-setup.ts +72 -0
  17. package/src/agy-version.ts +283 -0
  18. package/src/approval-detect.ts +146 -0
  19. package/src/approval-gate.ts +208 -0
  20. package/src/approval-hook.ts +270 -0
  21. package/src/artifacts-ui.ts +88 -0
  22. package/src/artifacts.ts +144 -0
  23. package/src/ask-tool.ts +1222 -0
  24. package/src/background-watch.ts +225 -0
  25. package/src/bridge-catalog.ts +27 -0
  26. package/src/config.ts +485 -0
  27. package/src/daily-log.ts +196 -0
  28. package/src/dialog-lock.ts +48 -0
  29. package/src/diff-render.ts +205 -0
  30. package/src/driver-types.ts +172 -0
  31. package/src/driver.ts +907 -0
  32. package/src/engine-picker.ts +160 -0
  33. package/src/frame-guard.ts +38 -0
  34. package/src/mcp-registration.ts +382 -0
  35. package/src/mcp-server.ts +620 -0
  36. package/src/models.ts +407 -0
  37. package/src/native-tools.ts +104 -0
  38. package/src/parked-turn.ts +125 -0
  39. package/src/patch-cleanup.ts +519 -0
  40. package/src/poll-fold.ts +55 -0
  41. package/src/prompt-images.ts +92 -0
  42. package/src/provider.ts +2066 -0
  43. package/src/queued-messages.ts +152 -0
  44. package/src/redact.ts +27 -0
  45. package/src/sessions.ts +185 -0
  46. package/src/skills.ts +192 -0
  47. package/src/stream-events.ts +122 -0
  48. package/src/stream-sanitizer.ts +180 -0
  49. package/src/subagent-roster.ts +245 -0
  50. package/src/sysprompt-agent.ts +163 -0
  51. package/src/tasks-ui.ts +90 -0
  52. package/src/tasks.ts +174 -0
  53. package/src/usage.ts +174 -0
  54. package/src/web-tools.ts +320 -0
@@ -0,0 +1,232 @@
1
+ // Newline-delimited JSON-RPC 2.0 session over a stdio transport.
2
+ //
3
+ // Transport-agnostic by design: the caller supplies a `send` sink (writes one
4
+ // serialized frame + newline to the child's stdin) and feeds incoming bytes to
5
+ // `feed()`. Everything ACP-specific lives in connection.ts.
6
+ //
7
+ // Semantics verified live against agy 1.5.3 ACP:
8
+ // - requests are correlated by numeric id; responses reject the pending
9
+ // promise on JSON-RPC `error` results;
10
+ // - server-to-client REQUESTS (session/request_permission, fs/*, terminal/*)
11
+ // arrive as messages with both `method` and `id` and are answered through
12
+ // the handler registered via setRequestHandler;
13
+ // - notifications (session/update, auth_required) carry no id;
14
+ // - malformed lines are counted and dropped, never fatal (the parseAgyLine
15
+ // lesson: a chatty banner must not kill the reader loop);
16
+ // - `abortAll()` rejects every pending request (Gate D teardown: no promise
17
+ // survives a killed connection).
18
+
19
+ import { isKnownNoiseLine, MAX_FRAME_BYTES, stripGluedNoise } from "../frame-guard.js";
20
+
21
+ export interface JsonRpcErrorShape {
22
+ code: number;
23
+ message: string;
24
+ data?: unknown;
25
+ }
26
+
27
+ export interface JsonRpcIncoming {
28
+ id?: number | string | null;
29
+ method?: string;
30
+ params?: unknown;
31
+ result?: unknown;
32
+ error?: JsonRpcErrorShape;
33
+ }
34
+
35
+ export type SendFn = (frame: string) => void;
36
+
37
+ export interface JsonRpcSessionOptions {
38
+ send: SendFn;
39
+ /** Server-to-client request (has method + id). Return the result value, or
40
+ * throw to answer with a JSON-RPC error. */
41
+ onRequest?: (method: string, params: unknown) => Promise<unknown>;
42
+ /** Server-to-client notification (method, no id). */
43
+ onNotification?: (method: string, params: unknown) => void;
44
+ /** Unparseable line (logged, never fatal). */
45
+ onParseError?: (line: string) => void;
46
+ /** Frame-cap breach: the peer flooded stdout with no newline. The session
47
+ * aborts itself; the transport must kill the process. */
48
+ onOverflow?: (detail: string) => void;
49
+ /** Default timeout for requests without an explicit one (ms). 0 = none. */
50
+ defaultTimeoutMs?: number;
51
+ }
52
+
53
+ interface Pending {
54
+ resolve: (value: unknown) => void;
55
+ reject: (err: Error) => void;
56
+ timer?: ReturnType<typeof setTimeout>;
57
+ method: string;
58
+ }
59
+
60
+ export class JsonRpcSession {
61
+ #send: SendFn;
62
+ #opts: JsonRpcSessionOptions;
63
+ #pending = new Map<number, Pending>();
64
+ #nextId = 1;
65
+ #aborted = false;
66
+ #overflowed = false;
67
+ #lineBuf = "";
68
+ parseErrors = 0;
69
+
70
+ constructor(opts: JsonRpcSessionOptions) {
71
+ this.#opts = opts;
72
+ this.#send = opts.send;
73
+ }
74
+
75
+ /** Feed raw transport bytes. Stdio chunks are NOT newline-aligned: bytes
76
+ * are buffered and only complete lines are parsed (a frame split across a
77
+ * 64KB pipe boundary must never be dropped). The buffer is capped: a peer
78
+ * flooding stdout with no newline breaches the cap and the session dies
79
+ * (onOverflow + abortAll), it never grows without bound. */
80
+ feed(chunk: string): void {
81
+ if (this.#overflowed) return;
82
+ this.#lineBuf += chunk;
83
+ if (this.#lineBuf.length > MAX_FRAME_BYTES) {
84
+ const detail = `${this.#lineBuf.length} bytes buffered with no newline (cap ${MAX_FRAME_BYTES})`;
85
+ this.#overflow(detail);
86
+ return;
87
+ }
88
+ const lines = this.#lineBuf.split("\n");
89
+ this.#lineBuf = lines.pop() ?? "";
90
+ for (const raw of lines) {
91
+ const line = raw.trim();
92
+ if (!line) continue;
93
+ // Known foreign noise (e.g. the Chromium launcher behind a browser
94
+ // login) is dropped before parsing, not counted as a parse error.
95
+ if (isKnownNoiseLine(line)) continue;
96
+ let msg: JsonRpcIncoming;
97
+ try {
98
+ msg = JSON.parse(line) as JsonRpcIncoming;
99
+ } catch {
100
+ // A noise FRAGMENT without its newline glued itself onto the next
101
+ // real frame; strip everything up to the marker and re-parse.
102
+ const repaired = stripGluedNoise(line);
103
+ if (repaired) {
104
+ try {
105
+ msg = JSON.parse(repaired) as JsonRpcIncoming;
106
+ } catch {
107
+ this.parseErrors += 1;
108
+ this.#opts.onParseError?.(line.slice(0, 200));
109
+ continue;
110
+ }
111
+ } else {
112
+ // Malformed lines are counted and dropped, never fatal (the
113
+ // parseAgyLine lesson: a chatty banner must not kill the reader
114
+ // loop).
115
+ this.parseErrors += 1;
116
+ this.#opts.onParseError?.(line.slice(0, 200));
117
+ continue;
118
+ }
119
+ }
120
+ this.#handleMessage(msg);
121
+ }
122
+ }
123
+
124
+ #overflow(detail: string): void {
125
+ this.#overflowed = true;
126
+ this.#lineBuf = "";
127
+ // Aborting first gives pending requests the typed overflow reason; the
128
+ // transport's own teardown afterwards is a no-op on the empty map.
129
+ this.abortAll(`stdout frame overflow: ${detail}`);
130
+ this.#opts.onOverflow?.(detail);
131
+ }
132
+
133
+ #handleMessage(msg: JsonRpcIncoming): void {
134
+ // Response to one of our requests.
135
+ if (msg.method === undefined && msg.id !== undefined && msg.id !== null) {
136
+ const id = Number(msg.id);
137
+ const pending = this.#pending.get(id);
138
+ if (!pending) return; // late response to an aborted request: drop
139
+ this.#pending.delete(id);
140
+ if (pending.timer) clearTimeout(pending.timer);
141
+ if (msg.error) {
142
+ pending.reject(new JsonRpcResponseError(msg.error));
143
+ } else {
144
+ pending.resolve(msg.result);
145
+ }
146
+ return;
147
+ }
148
+ if (msg.method === undefined) return;
149
+ // Server-to-client request: must be answered with the same id. The
150
+ // handler is always invoked through Promise.resolve() so a synchronous
151
+ // throw can never escape into the reader loop.
152
+ if (msg.id !== undefined && msg.id !== null) {
153
+ const id = msg.id;
154
+ const handler = this.#opts.onRequest;
155
+ if (!handler) {
156
+ this.#sendError(id, -32601, `client does not support method: ${msg.method}`);
157
+ return;
158
+ }
159
+ void Promise.resolve()
160
+ .then(() => handler(msg.method as string, msg.params))
161
+ .then((result) => {
162
+ if (this.#aborted) return;
163
+ this.#send(JSON.stringify({ jsonrpc: "2.0", id, result: result ?? {} }));
164
+ })
165
+ .catch((err: unknown) => {
166
+ if (this.#aborted) return;
167
+ const message = err instanceof Error ? err.message : String(err);
168
+ this.#sendError(id, -32000, message);
169
+ });
170
+ return;
171
+ }
172
+ // Notification.
173
+ this.#opts.onNotification?.(msg.method, msg.params);
174
+ }
175
+
176
+ #sendError(id: number | string, code: number, message: string): void {
177
+ if (this.#aborted) return;
178
+ this.#send(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }));
179
+ }
180
+
181
+ /** Send a request. Resolves with the result value; rejects on error
182
+ * responses (JsonRpcResponseError), timeouts, or abortAll(). */
183
+ request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {
184
+ if (this.#aborted) return Promise.reject(new Error("connection aborted"));
185
+ const id = this.#nextId++;
186
+ const effective = timeoutMs ?? this.#opts.defaultTimeoutMs ?? 0;
187
+ return new Promise<unknown>((resolve, reject) => {
188
+ const pending: Pending = { resolve, reject, method };
189
+ if (effective > 0) {
190
+ pending.timer = setTimeout(() => {
191
+ this.#pending.delete(id);
192
+ reject(new Error(`ACP request timed out after ${effective}ms: ${method}`));
193
+ }, effective);
194
+ }
195
+ this.#pending.set(id, pending);
196
+ this.#send(JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} }));
197
+ });
198
+ }
199
+
200
+ notify(method: string, params?: unknown): void {
201
+ if (this.#aborted) return;
202
+ this.#send(JSON.stringify({ jsonrpc: "2.0", method, params: params ?? {} }));
203
+ }
204
+
205
+ /** Reject every pending request (Gate D teardown). The transport is going
206
+ * away; nothing will ever be written again. */
207
+ abortAll(reason: string): void {
208
+ this.#aborted = true;
209
+ for (const [id, pending] of this.#pending) {
210
+ if (pending.timer) clearTimeout(pending.timer);
211
+ pending.reject(new Error(`${reason} (request: ${pending.method})`));
212
+ this.#pending.delete(id);
213
+ }
214
+ }
215
+
216
+ get pendingCount(): number {
217
+ return this.#pending.size;
218
+ }
219
+ }
220
+
221
+ /** A JSON-RPC `error` result, preserving code + data (the -32602 `loc` paths
222
+ * and the -32601 method name are correction oracles; see the reference doc). */
223
+ export class JsonRpcResponseError extends Error {
224
+ readonly code: number;
225
+ readonly data: unknown;
226
+ constructor(error: JsonRpcErrorShape) {
227
+ super(error.message);
228
+ this.name = "JsonRpcResponseError";
229
+ this.code = error.code;
230
+ this.data = error.data;
231
+ }
232
+ }
@@ -0,0 +1,107 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export interface AcpCleanupResult {
6
+ removedBuilds: string[];
7
+ rootRemoved: boolean;
8
+ }
9
+
10
+ export function defaultManagedAcpRoot(): string {
11
+ return path.join(os.homedir(), ".local", "opt", "agy-acp");
12
+ }
13
+
14
+ function regularFile(file: string): boolean {
15
+ try {
16
+ return fs.lstatSync(file).isFile();
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ function realDirectory(file: string): boolean {
23
+ try {
24
+ const stat = fs.lstatSync(file);
25
+ return stat.isDirectory() && !stat.isSymbolicLink();
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ function enoent(err: unknown): boolean {
32
+ return (err as NodeJS.ErrnoException).code === "ENOENT";
33
+ }
34
+
35
+ /** Remove only artifacts carrying the old installer's layout markers. */
36
+ export function cleanupManagedAcpInstall(root: string = defaultManagedAcpRoot()): AcpCleanupResult {
37
+ let rootStat: fs.Stats;
38
+ try {
39
+ rootStat = fs.lstatSync(root);
40
+ } catch (err) {
41
+ if (enoent(err)) return { removedBuilds: [], rootRemoved: false };
42
+ throw err;
43
+ }
44
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return { removedBuilds: [], rootRemoved: false };
45
+
46
+ const entries = fs.readdirSync(root, { withFileTypes: true });
47
+ const interrupted = new Set<string>();
48
+ for (const entry of entries) {
49
+ const match = /^\.download-(.+)\.zip$/.exec(entry.name);
50
+ if (!match || !regularFile(path.join(root, entry.name))) continue;
51
+ interrupted.add(match[1]!);
52
+ try {
53
+ fs.unlinkSync(path.join(root, entry.name));
54
+ } catch (err) {
55
+ if (!enoent(err)) throw err;
56
+ }
57
+ }
58
+
59
+ const removedBuilds: string[] = [];
60
+ for (const entry of entries) {
61
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
62
+ const dir = path.join(root, entry.name);
63
+ if (!regularFile(path.join(dir, "agy_acp_server.par"))) {
64
+ if (interrupted.has(entry.name)) {
65
+ try {
66
+ if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
67
+ } catch (err) {
68
+ if (!enoent(err)) throw err;
69
+ }
70
+ }
71
+ continue;
72
+ }
73
+ const marked =
74
+ regularFile(path.join(dir, "zip.sha256")) ||
75
+ realDirectory(path.join(dir, "localharness_external")) ||
76
+ interrupted.has(entry.name);
77
+ if (!marked) continue;
78
+ try {
79
+ fs.rmSync(dir, { recursive: true, force: true });
80
+ removedBuilds.push(entry.name);
81
+ } catch (err) {
82
+ if (!enoent(err)) throw err;
83
+ }
84
+ }
85
+
86
+ const current = path.join(root, "current");
87
+ try {
88
+ if (fs.lstatSync(current).isSymbolicLink()) {
89
+ const target = path.resolve(root, fs.readlinkSync(current));
90
+ if (path.dirname(target) === root && removedBuilds.includes(path.basename(target))) fs.unlinkSync(current);
91
+ }
92
+ } catch (err) {
93
+ if (!enoent(err)) throw err;
94
+ }
95
+
96
+ let rootRemoved = false;
97
+ try {
98
+ if (fs.readdirSync(root).length === 0) {
99
+ fs.rmdirSync(root);
100
+ rootRemoved = true;
101
+ }
102
+ } catch (err) {
103
+ if (enoent(err)) rootRemoved = true;
104
+ else throw err;
105
+ }
106
+ return { removedBuilds, rootRemoved };
107
+ }
@@ -0,0 +1,61 @@
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
+ /** Cached estimate for repeated live updates during the same turn. */
26
+ inputTokens?: number;
27
+ /** ACP embeddedContext resource text (G1 digest): also reaches the model. */
28
+ contextText?: string;
29
+ /** Per-delta token sums (mode "estimate"). Summing per delta, like
30
+ * pi-token-speed's recordDelta, avoids word-merge artifacts when a
31
+ * chunk boundary splits a word. */
32
+ textTokens: number;
33
+ thoughtTokens: number;
34
+ /** Streamed delta counts (mode "direct": 1 token per delta). */
35
+ textDeltas: number;
36
+ thoughtDeltas: number;
37
+ }
38
+
39
+ /** Synthesize usage for a finished turn, or undefined when nothing is worth
40
+ * reporting (mode off, or a turn with no prompt and no output). */
41
+ export function synthesizeUsage(input: UsageEstimateInput): AgyUsage | undefined {
42
+ if (input.mode === "off") return undefined;
43
+ const inputTokens = input.inputTokens ?? estimateTokens(
44
+ input.contextText ? `${input.prompt}\n${input.contextText}` : input.prompt,
45
+ );
46
+ const thoughtTokens =
47
+ input.mode === "direct" ? input.thoughtDeltas : input.thoughtTokens;
48
+ const textTokens =
49
+ input.mode === "direct" ? input.textDeltas : input.textTokens;
50
+ // Thinking folds INTO output (OpenAI convention: reasoning tokens bill as
51
+ // output). toPiUsage drops thinking_tokens, and thinking time is inside
52
+ // elapsed wall time, so folding keeps the tokens/time ratio honest.
53
+ const outputTokens = textTokens + thoughtTokens;
54
+ if (inputTokens === 0 && outputTokens === 0) return undefined;
55
+ return {
56
+ input_tokens: inputTokens,
57
+ output_tokens: outputTokens,
58
+ thinking_tokens: thoughtTokens,
59
+ total_tokens: inputTokens + outputTokens,
60
+ };
61
+ }
package/src/agents.ts ADDED
@@ -0,0 +1,64 @@
1
+ // agy custom-agent roster (stream-json engine only).
2
+ //
3
+ // `agy agent` (alias `agents`) lists the user's custom agents one per line.
4
+ // Live-verified against agy 1.2.10: bare names, no banner and no columns
5
+ // when agents exist; help text on bad flags; empty output when none. The
6
+ // parser still tolerates a name-plus-description column layout and banner
7
+ // lines so a future agy that annotates the listing keeps parsing.
8
+ //
9
+ // A picked name rides the turn request as `--agent` (same flag our web
10
+ // tools already pass for their throwaway agents). ACP has no agent slot in
11
+ // per-turn agent selection, so the bridge refuses it at the command level.
12
+
13
+ import { spawnAgyRaw } from "./models.js";
14
+
15
+ /** Agent names are agy agent-dir names: filesystem-safe tokens. */
16
+ export const AGENT_NAME_MAX = 128;
17
+ const AGENT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
18
+
19
+ /** Parse raw `agy agent` output into agent names. Tolerant: skips empty and
20
+ * banner/help lines, takes the first whitespace-separated token per line
21
+ * (a future description column survives), rejects names that are not
22
+ * filesystem-safe tokens, dedupes preserving order. */
23
+ export function parseAgyAgentsRaw(text: string): string[] {
24
+ const seen = new Set<string>();
25
+ const agents: string[] = [];
26
+ for (const line of text.split("\n")) {
27
+ const trimmed = line.trim();
28
+ if (!trimmed) continue;
29
+ // Help output and older banner formats are noise, not agent names
30
+ // (checked on the whole line: "List available agents" has no single
31
+ // banner token).
32
+ if (/^(usage:|flags:|-h\b|list available|available agents)/i.test(trimmed)) continue;
33
+ const token = trimmed.split(/\s+/)[0] ?? "";
34
+ if (!AGENT_NAME_RE.test(token) || seen.has(token)) continue;
35
+ seen.add(token);
36
+ agents.push(token);
37
+ }
38
+ return agents;
39
+ }
40
+
41
+ /** True when `name` is a valid agent-dir token (what `--agent` accepts). */
42
+ export function isValidAgyAgentName(name: string): boolean {
43
+ return AGENT_NAME_RE.test(name);
44
+ }
45
+
46
+ /** Normalize a configured agent value: trim, cap, strip control characters;
47
+ * empty result means "no agent configured". Kept permissive beyond the
48
+ * token regex so a user typo reaches agy's own validation (a visible
49
+ * turn error) instead of silently reverting to the default agent. */
50
+ export function normalizeAgyAgentName(value: string | undefined): string | undefined {
51
+ if (value === undefined) return undefined;
52
+ const cleaned = value
53
+ .trim()
54
+ .replace(/[\x00-\x1f\x7f]/g, "")
55
+ .slice(0, AGENT_NAME_MAX);
56
+ return cleaned === "" ? undefined : cleaned;
57
+ }
58
+
59
+ /** List the custom agents `agy agent` reports. "" stdout (missing binary,
60
+ * failure, cap) parses to [], so callers can treat "no agents" and "could
61
+ * not ask" the same way the command layer renders it. */
62
+ export async function listAgyAgents(binary: string = "agy"): Promise<string[]> {
63
+ return parseAgyAgentsRaw(await spawnAgyRaw(binary, ["agent"]));
64
+ }
@@ -0,0 +1,85 @@
1
+ import { spawn } from "node:child_process";
2
+ import { isAgyInstalled } from "./engine-picker.js";
3
+
4
+ export interface AgyInstallCommand {
5
+ command: string;
6
+ args: string[];
7
+ }
8
+
9
+ export function agyInstallCommand(platform: NodeJS.Platform = process.platform): AgyInstallCommand {
10
+ if (platform === "win32") {
11
+ return {
12
+ command: "powershell.exe",
13
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "irm https://antigravity.google/cli/install.ps1 | iex"],
14
+ };
15
+ }
16
+ return {
17
+ command: "bash",
18
+ args: ["-c", "set -o pipefail; curl -fsSL https://antigravity.google/cli/install.sh | bash"],
19
+ };
20
+ }
21
+
22
+ export function agyAuthenticationMessage(): string {
23
+ return "Run `agy` in your terminal and complete the Google sign-in. Restart pi afterwards.";
24
+ }
25
+
26
+ type InstallRunner = (command: AgyInstallCommand, timeoutMs: number) => Promise<{ code: number | null; output: string }>;
27
+
28
+ const defaultRunner: InstallRunner = ({ command, args }, timeoutMs) =>
29
+ new Promise((resolve, reject) => {
30
+ const child = spawn(command, args, {
31
+ stdio: ["ignore", "pipe", "pipe"],
32
+ detached: process.platform !== "win32",
33
+ windowsHide: true,
34
+ });
35
+ let output = "";
36
+ let settled = false;
37
+ const append = (chunk: Buffer) => {
38
+ output = (output + chunk.toString("utf8")).slice(-64_000);
39
+ };
40
+ const fail = (err: Error) => {
41
+ if (settled) return;
42
+ settled = true;
43
+ clearTimeout(watchdog);
44
+ reject(err);
45
+ };
46
+ child.stdout?.on("data", append);
47
+ child.stderr?.on("data", append);
48
+ child.on("error", fail);
49
+ child.on("close", (code) => {
50
+ if (settled) return;
51
+ settled = true;
52
+ clearTimeout(watchdog);
53
+ resolve({ code, output: output.trim() });
54
+ });
55
+ const watchdog = setTimeout(() => {
56
+ try {
57
+ if (child.pid && process.platform === "win32") {
58
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
59
+ killer.on("error", () => {});
60
+ killer.unref();
61
+ } else if (child.pid) process.kill(-child.pid, "SIGKILL");
62
+ else child.kill("SIGKILL");
63
+ } catch {
64
+ // The process may have exited between the deadline and kill.
65
+ }
66
+ fail(new Error("Antigravity CLI installer exceeded 10 minutes"));
67
+ }, timeoutMs);
68
+ });
69
+
70
+ export async function installAgyCli(opts: {
71
+ platform?: NodeJS.Platform;
72
+ run?: InstallRunner;
73
+ verify?: () => boolean | Promise<boolean>;
74
+ timeoutMs?: number;
75
+ } = {}): Promise<{ ok: true; output: string } | { ok: false; error: string }> {
76
+ try {
77
+ const result = await (opts.run ?? defaultRunner)(agyInstallCommand(opts.platform), opts.timeoutMs ?? 10 * 60_000);
78
+ if (result.code !== 0) return { ok: false, error: result.output || `installer exited with code ${result.code ?? "unknown"}` };
79
+ const verify = opts.verify ?? (() => isAgyInstalled(process.env.AGY_BIN || "agy"));
80
+ if (!(await verify())) return { ok: false, error: "installer finished, but agy is not available in this pi process; open a new terminal, run `agy --version`, then restart pi" };
81
+ return { ok: true, output: result.output };
82
+ } catch (err) {
83
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
84
+ }
85
+ }
@@ -0,0 +1,30 @@
1
+ // agy per-engine state dirs ("brain" trees).
2
+ //
3
+ // Both engines keep per-conversation state in the native CLI tree:
4
+ // ~/.gemini/antigravity-cli/brain/<conversationId>/
5
+ // The ACP tree mirrors the CLI layout (.system_generated/tasks/,
6
+ // .system_generated/steps/), so downstream scans (tasks, artifacts) work
7
+ // unchanged once the right root is picked. A third tree exists —
8
+ // ~/.gemini/antigravity-cli/conversations/<uuid>.db — but it serves
9
+ // one-shot print-mode calls (AskAntigravity), NOT these features; do not
10
+ // conflate the two. Conversation ids are the raw ids agy reports, no
11
+ // hashing; the ids our session store persists match these dir names.
12
+
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import type { Engine } from "./config.js";
16
+
17
+ /** Brain root for one engine. `home` injectable for tests. */
18
+ export function agyBrainRoot(engine: Engine, home: string = os.homedir()): string {
19
+ return path.join(home, ".gemini", "antigravity-cli", "brain");
20
+ }
21
+
22
+ /** Per-conversation state dir. `conversationId` is the raw id agy reports
23
+ * (and what the session store persists). */
24
+ export function agyConversationDir(
25
+ engine: Engine,
26
+ conversationId: string,
27
+ home: string = os.homedir(),
28
+ ): string {
29
+ return path.join(agyBrainRoot(engine, home), conversationId);
30
+ }
@@ -0,0 +1,72 @@
1
+ import { agyAuthenticationMessage, agyInstallCommand, installAgyCli } from "./agy-install.js";
2
+
3
+ export interface AgySetupUi {
4
+ confirm: (title: string, body: string) => Promise<boolean>;
5
+ notify: (message: string, level: "info" | "warning") => void;
6
+ setStatus: (id: string, text?: string) => void;
7
+ }
8
+
9
+ export function createAgySetupOffer(opts: {
10
+ ui: AgySetupUi;
11
+ binaryOverride?: string;
12
+ install?: typeof installAgyCli;
13
+ missingMessage: () => string;
14
+ log: (event: string, data: unknown, level: "warn" | "info") => void;
15
+ }): () => Promise<void> {
16
+ let offered = false;
17
+ let inFlight: Promise<void> | null = null;
18
+ const install = opts.install ?? installAgyCli;
19
+ const notify = (message: string, level: "info" | "warning") => {
20
+ try {
21
+ opts.ui.notify(message, level);
22
+ } catch (err) {
23
+ opts.log("agy-install", { error: String(err) }, "warn");
24
+ }
25
+ };
26
+
27
+ return () => {
28
+ if (inFlight) return inFlight;
29
+ if (offered) return Promise.resolve();
30
+ offered = true;
31
+ inFlight = (async () => {
32
+ try {
33
+ if (opts.binaryOverride) {
34
+ notify(
35
+ `AGY_BIN points to ${opts.binaryOverride}, which is missing or unsupported. Fix or unset AGY_BIN. The official installer cannot change this override.`,
36
+ "warning",
37
+ );
38
+ return;
39
+ }
40
+ const spec = agyInstallCommand();
41
+ const command = [spec.command, ...spec.args].join(" ");
42
+ const approved = await opts.ui.confirm(
43
+ "Antigravity CLI required",
44
+ `Both engines require the agy CLI. Run Google's official installer now?\n\n${command}`,
45
+ );
46
+ if (!approved) {
47
+ notify(opts.missingMessage(), "warning");
48
+ return;
49
+ }
50
+ opts.ui.setStatus("agy-install", "Installing Antigravity CLI…");
51
+ const result = await install();
52
+ if (!result.ok) {
53
+ opts.log("agy-install", { error: result.error }, "warn");
54
+ notify(`Antigravity CLI installation failed: ${result.error}\n${opts.missingMessage()}`, "warning");
55
+ return;
56
+ }
57
+ notify(`Antigravity CLI installed. ${agyAuthenticationMessage()}`, "info");
58
+ } catch (err) {
59
+ opts.log("agy-install", { error: String(err) }, "warn");
60
+ notify(`Antigravity CLI setup failed: ${String(err)}\n${opts.missingMessage()}`, "warning");
61
+ } finally {
62
+ try {
63
+ opts.ui.setStatus("agy-install", undefined);
64
+ } catch {
65
+ // UI teardown must not reject a shared setup promise.
66
+ }
67
+ inFlight = null;
68
+ }
69
+ })();
70
+ return inFlight;
71
+ };
72
+ }