@estebanforge/pi-antigravity-bridge 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,190 @@
1
+ // G8: surface agy's file edits as a diff in pi's thinking stream.
2
+ //
3
+ // agy edits with its OWN native `write_to_file` (full-file content), inside a
4
+ // closed tool loop that lands changes on disk. pi's native colored diff viewer
5
+ // is unreachable for agy turns (it is part of the tool-call lifecycle, and the
6
+ // provider emits no toolCall blocks by design), so we compute a line-numbered
7
+ // diff ourselves and stream it as text through the thinking channel, reusing
8
+ // pi's own `generateDiffString` for an identical format.
9
+ //
10
+ // OLD content comes from git (the committed version), resolved PER FILE so
11
+ // nested repos / submodules / multi-repo workspaces each diff against their
12
+ // own HEAD. A turn-scoped cache makes multi-edit-same-file turns diff
13
+ // incrementally instead of cumulatively. Off-repo / untracked / binary files
14
+ // degrade to a one-line summary. See docs/PI-BRIDGE-GAPS.md (G8).
15
+
16
+ import { execFileSync } from "node:child_process";
17
+ import path from "node:path";
18
+ import { generateDiffString } from "@earendil-works/pi-coding-agent";
19
+
20
+ /** Maximum diff lines emitted for one edit before truncation. */
21
+ export const DEFAULT_MAX_DIFF_LINES = 100;
22
+
23
+ /** Injectable git operations so the pure logic is unit-testable without a
24
+ * real repo. `toplevel` returns null when the dir is not inside a git work
25
+ * tree; `showHead` returns null when the path is untracked / absent from HEAD. */
26
+ export interface GitOps {
27
+ toplevel(fileDir: string): string | null;
28
+ showHead(toplevel: string, relPath: string): string | null;
29
+ }
30
+
31
+ /** Default git ops via synchronous git CLI calls. Cheap; one process per call. */
32
+ export function createExecGitOps(): GitOps {
33
+ const run = (args: string[], cwd: string): string | null => {
34
+ try {
35
+ return execFileSync("git", args, {
36
+ cwd,
37
+ encoding: "utf-8",
38
+ stdio: ["ignore", "pipe", "ignore"],
39
+ });
40
+ } catch {
41
+ return null;
42
+ }
43
+ };
44
+ return {
45
+ toplevel: (fileDir) => {
46
+ const t = run(["rev-parse", "--show-toplevel"], fileDir);
47
+ return t ? t.trim() || null : null;
48
+ },
49
+ showHead: (toplevel, relPath) => run(["show", `HEAD:${relPath}`], toplevel),
50
+ };
51
+ }
52
+
53
+ export type EditDiffKind = "diff" | "summary" | "binary" | "none";
54
+
55
+ export interface EditDiffOutcome {
56
+ kind: EditDiffKind;
57
+ /** Text to append after the edit label in the thinking stream. Empty for
58
+ * `none` (nothing to show). */
59
+ text: string;
60
+ }
61
+
62
+ /** A parsed agy edit-tool invocation: a file path plus its full new content.
63
+ * Detected generically by key name so future agy edit-tool variants work
64
+ * without hardcoding tool names. */
65
+ export interface ParsedEdit {
66
+ /** File path as agy wrote it (absolute or cwd-relative). */
67
+ file: string;
68
+ content: string;
69
+ description?: string;
70
+ }
71
+
72
+ /** True if the string looks like binary (contains a NUL byte). Mirrors git's
73
+ * own heuristic; avoids feeding binary into the line differ. */
74
+ function isBinary(s: string): boolean {
75
+ return s.includes("\u0000");
76
+ }
77
+
78
+ /** Parse an agy tool-call inputJson into an edit if it carries both a
79
+ * file-path-like and a content-like string field. Returns null for non-edits
80
+ * (reads, greps, malformed JSON, missing fields). */
81
+ export function parseEditToolInput(inputJson: string): ParsedEdit | null {
82
+ if (!inputJson) return null;
83
+ let obj: Record<string, unknown>;
84
+ try {
85
+ obj = JSON.parse(inputJson);
86
+ } catch {
87
+ return null;
88
+ }
89
+ if (!obj || typeof obj !== "object") return null;
90
+ let file = "";
91
+ let content = "";
92
+ let description: string | undefined;
93
+ for (const [k, v] of Object.entries(obj)) {
94
+ if (typeof v !== "string") continue;
95
+ if (!file && /(^|_)?(file|path)$/i.test(k)) file = v;
96
+ else if (!content && /content|code/i.test(k)) content = v;
97
+ else if (!description && /description|toolaction|toolsummary/i.test(k)) description = v;
98
+ }
99
+ if (!file || !content) return null;
100
+ return { file, content, description };
101
+ }
102
+
103
+ /** Turn-scoped diff context. Holds the OLD-content cache (so the 2nd edit to
104
+ * the same file in a turn diffs against the 1st edit's result, not HEAD again)
105
+ * and a toplevel cache (one `rev-parse` per edited directory per turn).
106
+ * Create one fresh per turn so concurrent turns never share state. */
107
+ export class TurnDiffContext {
108
+ private readonly oldCache = new Map<string, string>();
109
+ private readonly toplevelCache = new Map<string, string | null>();
110
+ private readonly seenFiles = new Set<string>();
111
+
112
+ constructor(
113
+ private readonly git: GitOps,
114
+ private readonly maxDiffLines: number = DEFAULT_MAX_DIFF_LINES,
115
+ ) {}
116
+
117
+ /** Compute the diff (or summary) for one edit. `absFile` must be absolute. */
118
+ diffEdit(absFile: string, newContent: string): EditDiffOutcome {
119
+ // Binary short-circuit before any git work.
120
+ if (isBinary(newContent)) {
121
+ this.oldCache.set(absFile, newContent);
122
+ return { kind: "binary", text: "(binary file; diff skipped)" };
123
+ }
124
+
125
+ const dir = path.dirname(absFile);
126
+ let toplevel = this.toplevelCache.get(dir);
127
+ if (toplevel === undefined) {
128
+ toplevel = this.git.toplevel(dir);
129
+ this.toplevelCache.set(dir, toplevel);
130
+ }
131
+
132
+ // Not inside any git repo: no OLD baseline available without a pre-turn
133
+ // snapshot (approach C, deferred). Degrade to a one-line summary.
134
+ if (!toplevel) {
135
+ this.oldCache.set(absFile, newContent);
136
+ return {
137
+ kind: "summary",
138
+ text: `(${countLines(newContent)} lines; not in a git repo, no diff)`,
139
+ };
140
+ }
141
+
142
+ const relPath = path.relative(toplevel, absFile);
143
+ // File lives outside its resolved repo (e.g. agy edited something under a
144
+ // sibling tree). Treat like off-repo: summary, no diff.
145
+ if (relPath.startsWith("..")) {
146
+ this.oldCache.set(absFile, newContent);
147
+ return {
148
+ kind: "summary",
149
+ text: `(${countLines(newContent)} lines; outside the git repo, no diff)`,
150
+ };
151
+ }
152
+
153
+ // OLD baseline: the prior edit's result if we've seen this file this
154
+ // turn, else the committed version (untracked/new file -> empty string).
155
+ let oldContent: string;
156
+ if (this.seenFiles.has(absFile) && this.oldCache.has(absFile)) {
157
+ oldContent = this.oldCache.get(absFile) ?? "";
158
+ } else {
159
+ const committed = this.git.showHead(toplevel, relPath);
160
+ oldContent = committed ?? "";
161
+ }
162
+ this.seenFiles.add(absFile);
163
+ this.oldCache.set(absFile, newContent);
164
+
165
+ if (isBinary(oldContent)) {
166
+ return { kind: "binary", text: "(was binary; diff skipped)" };
167
+ }
168
+ if (oldContent === newContent) {
169
+ return { kind: "none", text: "" };
170
+ }
171
+
172
+ const { diff } = generateDiffString(oldContent, newContent);
173
+ return { kind: "diff", text: capLines(diff, this.maxDiffLines) };
174
+ }
175
+ }
176
+
177
+ function countLines(s: string): number {
178
+ if (s.length === 0) return 0;
179
+ return s.split("\n").length;
180
+ }
181
+
182
+ /** Truncate a diff to at most `max` lines, keeping the head and noting how many
183
+ * were dropped. A diff's changes are interspersed, so head-first is fine. */
184
+ function capLines(diff: string, max: number): string {
185
+ if (max <= 0) return diff;
186
+ const lines = diff.split("\n");
187
+ if (lines.length <= max) return diff;
188
+ const dropped = lines.length - max;
189
+ return `${lines.slice(0, max).join("\n")}\n[... ${dropped} more diff lines]`;
190
+ }
@@ -0,0 +1,199 @@
1
+ // Discover the agy conversation id that `agy -p` creates but never prints.
2
+ //
3
+ // agy's print mode writes its steps to a fresh SQLite DB at
4
+ // ~/.gemini/antigravity-cli/conversations/<uuid>.db. It does NOT echo the id.
5
+ // The only reliable bind is to snapshot the *.db stems in that dir before
6
+ // spawn, then diff after: exactly one new file = ours. If several appear we
7
+ // refuse to bind (can't tell which is ours) - same approach as antigravity-acp
8
+ // (scan.ts), agy-acp (scan.ts), and pi-ask-antigravity.
9
+ //
10
+ // CONCURRENCY: when another agy (or subagent) starts in parallel, more than
11
+ // one new .db can land in the dir during our turn. mtime filtering does NOT
12
+ // disambiguate two *active* concurrent runs (both DBs are >= turn-start), and
13
+ // prompt-content matching is fragile (the user-message step_type 98 payload
14
+ // is undocumented and deeply nested; a field-number shift would silently
15
+ // misbind). The authoritative signal is which candidate .db OUR spawned agy
16
+ // process tree holds open right now - resolved via /proc/<pid>/fd on Linux.
17
+ // When that signal is unavailable (no pid, non-Linux, process already exited,
18
+ // or the scan itself is ambiguous) we fail safe to null, preserving the
19
+ // original "refuse to bind" behavior rather than guessing.
20
+
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+
25
+ /** Default conversations dir. Override with AGY_CONVERSATIONS_DIR. */
26
+ export const CONVERSATIONS_DIR =
27
+ process.env.AGY_CONVERSATIONS_DIR ||
28
+ path.join(os.homedir(), ".gemini", "antigravity-cli", "conversations");
29
+
30
+ /** Snapshot the set of conversation ids (*.db stems) currently on disk.
31
+ * Empty set (not throw) when the dir is missing - agy will create it. */
32
+ export function snapshotConversations(dir: string = CONVERSATIONS_DIR): Set<string> {
33
+ const out = new Set<string>();
34
+ let entries: string[];
35
+ try {
36
+ entries = fs.readdirSync(dir);
37
+ } catch {
38
+ return out;
39
+ }
40
+ for (const f of entries) {
41
+ if (f.endsWith(".db")) out.add(f.slice(0, -3));
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** Resolve which of the `candidates` DB ids is held open by the process tree
47
+ * rooted at `rootPid`. Used to disambiguate concurrent agy runs.
48
+ *
49
+ * Returns the single matching id, or null when none / several are open, when
50
+ * /proc is unavailable, or when reading fails. Injectable so tests can stub
51
+ * it without touching the filesystem. */
52
+ export type OpenDbResolver = (
53
+ rootPid: number,
54
+ dir: string,
55
+ candidates: Set<string>,
56
+ ) => string | null;
57
+
58
+ /** Read pid + ppid from /proc/<pid>/stat. Returns null when the entry is gone
59
+ * (process exited) or unparseable. comm may contain spaces and parens, so we
60
+ * split on the LAST ')' rather than naively tokenizing. */
61
+ function readProcStat(pid: number): { pid: number; ppid: number } | null {
62
+ let raw: string;
63
+ try {
64
+ raw = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
65
+ } catch {
66
+ return null;
67
+ }
68
+ const closeParen = raw.lastIndexOf(")");
69
+ if (closeParen < 0) return null;
70
+ // After "pid (comm)" come: state ppid pgrp session ...
71
+ const fields = raw.slice(closeParen + 2).trim().split(/\s+/);
72
+ const ppid = Number(fields[1]);
73
+ if (!Number.isFinite(ppid)) return null;
74
+ return { pid, ppid };
75
+ }
76
+
77
+ /** Collect rootPid and every descendant pid by walking /proc/<pid>/stat
78
+ * parent links. Single pass over /proc building a pid->ppid map, then
79
+ * iterated to closure. */
80
+ function collectDescendants(rootPid: number): Set<number> {
81
+ const out = new Set<number>([rootPid]);
82
+ let entries: string[];
83
+ try {
84
+ entries = fs.readdirSync("/proc");
85
+ } catch {
86
+ return out;
87
+ }
88
+ const ppidOf = new Map<number, number>();
89
+ for (const e of entries) {
90
+ if (!/^\d+$/.test(e)) continue;
91
+ const s = readProcStat(Number(e));
92
+ if (s) ppidOf.set(s.pid, s.ppid);
93
+ }
94
+ let changed = true;
95
+ while (changed) {
96
+ changed = false;
97
+ for (const [pid, ppid] of ppidOf) {
98
+ if (out.has(pid)) continue;
99
+ if (out.has(ppid)) {
100
+ out.add(pid);
101
+ changed = true;
102
+ }
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** Linux /proc implementation of OpenDbResolver. Scans every FD symlink in
109
+ * the root process's tree and returns the one candidate .db that is open.
110
+ * Non-authoritative when zero or >1 candidates are open - caller fails safe. */
111
+ export const procTreeOpenDbResolver: OpenDbResolver = (rootPid, dir, candidates) => {
112
+ if (candidates.size <= 1) return null; // nothing to disambiguate
113
+ if (process.platform !== "linux") return null; // /proc/<pid>/fd is Linux-only
114
+ const dirResolved = safeRealpath(dir);
115
+ const tree = collectDescendants(rootPid);
116
+ const found = new Set<string>();
117
+ for (const pid of tree) {
118
+ let fds: string[];
119
+ try {
120
+ fds = fs.readdirSync(`/proc/${pid}/fd`);
121
+ } catch {
122
+ continue; // process gone or fd dir unreadable
123
+ }
124
+ for (const fd of fds) {
125
+ let target: string;
126
+ try {
127
+ target = fs.readlinkSync(`/proc/${pid}/fd/${fd}`);
128
+ } catch {
129
+ continue;
130
+ }
131
+ // agy also holds .db-wal / .db-shm open; only the base .db matters.
132
+ const base = path.basename(target);
133
+ if (!base.endsWith(".db")) continue;
134
+ if (dirResolved && safeRealpath(path.dirname(target)) !== dirResolved) continue;
135
+ const id = base.slice(0, -3);
136
+ if (candidates.has(id)) found.add(id);
137
+ }
138
+ }
139
+ if (found.size === 1) return [...found][0] ?? null;
140
+ return null;
141
+ };
142
+
143
+ /** fs.realpathSync that swallows ENOENT (dir may not be canonicalized yet). */
144
+ function safeRealpath(p: string): string | null {
145
+ try {
146
+ return fs.realpathSync(p);
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ /** Options for {@link newConversationId}. */
153
+ export interface BindOptions {
154
+ /** Pid of the spawned agy (the process we want to bind to). When set and
155
+ * multiple candidate DBs exist, the process-tree FD resolver tries to
156
+ * pick ours. Omit to keep the legacy fail-safe behavior. */
157
+ pid?: number;
158
+ /** Override resolver (tests). Defaults to the Linux /proc scanner. */
159
+ resolveOpenDb?: OpenDbResolver;
160
+ /** Invoked when the snapshot is ambiguous (more than one new DB since
161
+ * `before`) and the resolver could not authoritatively pick ours. Lets a
162
+ * caller bound its retry budget to the genuinely-ambiguous case only, so
163
+ * the ordinary "agy hasn't created its DB yet" wait is not counted
164
+ * against it. Not invoked when there is nothing new yet, or when exactly
165
+ * one new DB binds, or when the resolver succeeds. */
166
+ onAmbiguous?: () => void;
167
+ }
168
+
169
+ /** Find the conversation id created since `before`. Returns null when none
170
+ * appeared, or when several appeared and we cannot authoritatively tie one
171
+ * to our process. Pass `opts.pid` to enable concurrent-run disambiguation. */
172
+ export function newConversationId(
173
+ dir: string,
174
+ before: Set<string>,
175
+ opts: BindOptions = {},
176
+ ): string | null {
177
+ const after = snapshotConversations(dir);
178
+ const created = [...after].filter((id) => !before.has(id));
179
+ if (created.length === 0) return null;
180
+ if (created.length === 1) return created[0] ?? null;
181
+ // Ambiguous: more than one new DB since the snapshot. Try to authoritatively
182
+ // identify ours via the spawned process's open files. Fail safe to null
183
+ // (refuse to bind) when the resolver can't pick exactly one.
184
+ if (opts.pid !== undefined) {
185
+ const resolve = opts.resolveOpenDb ?? procTreeOpenDbResolver;
186
+ const hit = resolve(opts.pid, dir, new Set(created));
187
+ if (hit && created.includes(hit)) return hit;
188
+ }
189
+ opts.onAmbiguous?.();
190
+ return null;
191
+ }
192
+
193
+ /** Path to the DB file for a given conversation id. */
194
+ export function conversationDbPath(
195
+ id: string,
196
+ dir: string = CONVERSATIONS_DIR,
197
+ ): string {
198
+ return path.join(dir, `${id}.db`);
199
+ }