@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.
- package/CHANGELOG.md +125 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/docs/ARCHITECTURE.md +48 -0
- package/docs/DEVELOPMENT.md +64 -0
- package/docs/PI-BRIDGE-GAPS.md +186 -0
- package/docs/PI-INVOKETOOL-PATCH.md +227 -0
- package/extensions/index.ts +474 -0
- package/package.json +69 -0
- package/src/ask-tool.ts +579 -0
- package/src/config.ts +119 -0
- package/src/diff-render.ts +190 -0
- package/src/discovery.ts +199 -0
- package/src/mcp-server.ts +443 -0
- package/src/models.ts +261 -0
- package/src/patcher.ts +571 -0
- package/src/poller.ts +202 -0
- package/src/protobuf.ts +184 -0
- package/src/provider.ts +502 -0
- package/src/runner.ts +386 -0
- package/src/sessions.ts +159 -0
package/src/runner.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// Core agy turn runner: spawn `agy -p`, poll its conversation DB for new steps,
|
|
2
|
+
// decode each, and emit structured events via a callback. Shared by the
|
|
3
|
+
// standalone CLI (scripts/run-agy.ts) and the pi provider (src/provider.ts).
|
|
4
|
+
//
|
|
5
|
+
// Streaming contract:
|
|
6
|
+
// - agy writes steps to SQLite incrementally as it works.
|
|
7
|
+
// - We poll the DB every POLL_INTERVAL_MS (250), decode new rows, emit.
|
|
8
|
+
// - After agy exits, TRAILING_POLLS x 100ms catch late flushes.
|
|
9
|
+
// - agy does NOT print the conversation id; we bind it by snapshot/diff
|
|
10
|
+
// (see discovery.ts).
|
|
11
|
+
//
|
|
12
|
+
// agy runs its OWN closed tool loop (read/write/edit/exec against --add-dir).
|
|
13
|
+
// We cannot bridge those tools to pi. Tool steps surface as "tool" events so
|
|
14
|
+
// the UI can show "agy: editing foo.ts" - the edit itself already landed.
|
|
15
|
+
|
|
16
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
17
|
+
import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
|
|
18
|
+
import { ConversationPoller, type Step } from "./poller.js";
|
|
19
|
+
import {
|
|
20
|
+
CONVERSATIONS_DIR,
|
|
21
|
+
conversationDbPath,
|
|
22
|
+
newConversationId,
|
|
23
|
+
snapshotConversations,
|
|
24
|
+
} from "./discovery.js";
|
|
25
|
+
import { extractAgentText, extractToolCall, extractTitle } from "./protobuf.js";
|
|
26
|
+
|
|
27
|
+
// --- tuning -----------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export const POLL_INTERVAL_MS = 250;
|
|
30
|
+
export const TRAILING_POLLS = 3;
|
|
31
|
+
export const TRAILING_POLL_MS = 100;
|
|
32
|
+
export const DEFAULT_TIMEOUT_MIN = 10;
|
|
33
|
+
const GRACE_AFTER_TIMEOUT_MS = 5000;
|
|
34
|
+
// Cap on poll-tick retries to bind the conversation id when the snapshot is
|
|
35
|
+
// ambiguous (a concurrent agy also created a .db). Each retry can run a full
|
|
36
|
+
// /proc FD scan, so we stop after this many ticks (~15s at 250ms) and let the
|
|
37
|
+
// turn fail safe rather than scan indefinitely. Generous enough to cover a
|
|
38
|
+
// slow agy startup (OAuth refresh + cold init).
|
|
39
|
+
const MAX_BIND_ATTEMPTS = 60;
|
|
40
|
+
|
|
41
|
+
// agy conversation ids are UUID DB-stems. First char must be alphanumeric so a
|
|
42
|
+
// leading-dash value can't misbind on agy's arg parser as the token after
|
|
43
|
+
// --conversation (flag injection). Hyphens allowed in the body (real UUIDs).
|
|
44
|
+
const CONV_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/;
|
|
45
|
+
|
|
46
|
+
// --- events -----------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export type AgyEvent =
|
|
49
|
+
| { kind: "text"; text: string }
|
|
50
|
+
| { kind: "thinking"; text: string }
|
|
51
|
+
| { kind: "tool"; name: string; inputJson: string }
|
|
52
|
+
| { kind: "title"; title: string };
|
|
53
|
+
|
|
54
|
+
// step_type values observed in real DBs. Tool steps share the same payload
|
|
55
|
+
// layout (field 5 -> toolCall), so we decode them uniformly. Unknown types
|
|
56
|
+
// are skipped (return null) - forward-compatible with future agy additions.
|
|
57
|
+
const TOOL_STEP_TYPES = new Set([5, 7, 8, 9, 17, 21, 33, 101, 132, 138]);
|
|
58
|
+
|
|
59
|
+
/** Map a raw step row to a decoded event, or null when nothing to emit.
|
|
60
|
+
* Pure: no I/O, no side effects. Exported for testing. */
|
|
61
|
+
export function decodeStep(step: Step): AgyEvent | null {
|
|
62
|
+
if (step.stepType === 15) {
|
|
63
|
+
const t = extractAgentText(step.payload);
|
|
64
|
+
return t ? { kind: "text", text: t.text } : null;
|
|
65
|
+
}
|
|
66
|
+
if (step.stepType === 14) {
|
|
67
|
+
// Thinking steps reuse the agentText layout (field 20.1) in observed DBs.
|
|
68
|
+
const t = extractAgentText(step.payload);
|
|
69
|
+
return t ? { kind: "thinking", text: t.text } : null;
|
|
70
|
+
}
|
|
71
|
+
if (step.stepType === 23) {
|
|
72
|
+
const title = extractTitle(step.payload);
|
|
73
|
+
return title ? { kind: "title", title } : null;
|
|
74
|
+
}
|
|
75
|
+
if (TOOL_STEP_TYPES.has(step.stepType)) {
|
|
76
|
+
const tc = extractToolCall(step.payload);
|
|
77
|
+
if (tc?.name) return { kind: "tool", name: tc.name, inputJson: tc.inputJson };
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- options + result -------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
export interface AgyRunOptions {
|
|
86
|
+
/** Workspace root agy operates in (passed as --add-dir). */
|
|
87
|
+
cwd: string;
|
|
88
|
+
/** Exact agy model string, e.g. "Gemini 3.6 Flash (Medium)". Caller
|
|
89
|
+
* resolves aliases; the runner passes this through verbatim. */
|
|
90
|
+
model?: string;
|
|
91
|
+
/** agy execution mode. accept-edits = agy applies edits; plan = review-only. */
|
|
92
|
+
mode?: "accept-edits" | "plan";
|
|
93
|
+
/** Pass --dangerously-skip-permissions so commands don't hang on an
|
|
94
|
+
* unanswerable y/n prompt in non-interactive `-p` mode. Default true.
|
|
95
|
+
* Required for accept-edits to function; harmless under plan mode. */
|
|
96
|
+
skipPermissions?: boolean;
|
|
97
|
+
/** The prompt. Required. */
|
|
98
|
+
prompt: string;
|
|
99
|
+
/** Existing conversation id to resume. When set, agy reuses it (no snapshot). */
|
|
100
|
+
conversationId?: string | null;
|
|
101
|
+
/** Highest step idx already streamed in a prior turn (resume only). The
|
|
102
|
+
* poller starts reading AFTER this idx so resumed turns don't replay
|
|
103
|
+
* history. Default -1 (read everything). */
|
|
104
|
+
baseStepIdx?: number;
|
|
105
|
+
/** Hard cap on the run, in minutes. */
|
|
106
|
+
timeoutMin?: number;
|
|
107
|
+
/** Optional AbortSignal for cancellation. */
|
|
108
|
+
signal?: AbortSignal;
|
|
109
|
+
/** Conversations dir override (testing / isolation). */
|
|
110
|
+
conversationsDir?: string;
|
|
111
|
+
/** agy binary path. Defaults to AGY_BIN env or "agy". */
|
|
112
|
+
binary?: string;
|
|
113
|
+
/** Extra args appended (split from AGY_EXTRA_ARGS by the caller). */
|
|
114
|
+
extraArgs?: string[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface AgyRunResult {
|
|
118
|
+
exitCode: number;
|
|
119
|
+
conversationId: string | null;
|
|
120
|
+
lastIdx: number;
|
|
121
|
+
aborted: boolean;
|
|
122
|
+
timedOut: boolean;
|
|
123
|
+
stderr: string;
|
|
124
|
+
durationMs: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- spawn helpers ----------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
function resolveBinary(explicit?: string): string {
|
|
130
|
+
return explicit || process.env.AGY_BIN || "agy";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function extraArgsFromEnv(): string[] {
|
|
134
|
+
const raw = process.env.AGY_EXTRA_ARGS;
|
|
135
|
+
return raw ? raw.split(/\s+/).filter((s) => s.length > 0) : [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
139
|
+
|
|
140
|
+
// --- the turn ---------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/** Spawn agy and stream decoded events until it exits. Calls `onEvent` for
|
|
143
|
+
* every decoded step in order. Returns the run outcome (exit code, discovered
|
|
144
|
+
* conversation id, last step idx seen). Never throws on agy failure -
|
|
145
|
+
* surfaces non-zero exit / timeout / abort in the result. */
|
|
146
|
+
export async function runAgyTurn(
|
|
147
|
+
opts: AgyRunOptions,
|
|
148
|
+
onEvent: (event: AgyEvent) => void,
|
|
149
|
+
): Promise<AgyRunResult> {
|
|
150
|
+
const start = Date.now();
|
|
151
|
+
const dir = opts.conversationsDir || CONVERSATIONS_DIR;
|
|
152
|
+
const mode = opts.mode ?? "accept-edits";
|
|
153
|
+
const timeoutMin = opts.timeoutMin ?? DEFAULT_TIMEOUT_MIN;
|
|
154
|
+
const binary = resolveBinary(opts.binary);
|
|
155
|
+
const extra = [...extraArgsFromEnv(), ...(opts.extraArgs ?? [])];
|
|
156
|
+
|
|
157
|
+
const rawConvId = opts.conversationId ?? null;
|
|
158
|
+
const isContinuation =
|
|
159
|
+
typeof rawConvId === "string" && rawConvId.length > 0 && CONV_ID_RE.test(rawConvId);
|
|
160
|
+
const snapshot = isContinuation ? null : snapshotConversations(dir);
|
|
161
|
+
let lastIdx = opts.baseStepIdx ?? -1;
|
|
162
|
+
|
|
163
|
+
// Build argv. --add-dir first so agy binds the workspace before anything.
|
|
164
|
+
const args = ["--add-dir", opts.cwd, ...extra];
|
|
165
|
+
// When the MCP tool bridge is running, add its config dir so this agy
|
|
166
|
+
// discovers pi's tools (memory, codegraph, search, ...). agy reads
|
|
167
|
+
// .agents/mcp_config.json from --add-dir dirs. AskAntigravity does NOT pass
|
|
168
|
+
// this dir, so its agy stays plain (no recursion).
|
|
169
|
+
if (bridgeMcpConfigExists()) args.push("--add-dir", bridgeMcpConfigDir());
|
|
170
|
+
if (opts.model) args.push("--model", opts.model);
|
|
171
|
+
args.push("--mode", mode);
|
|
172
|
+
// Without this, any run_command triggers an interactive permission prompt
|
|
173
|
+
// that hangs forever in non-interactive print mode (no TTY to answer y/n).
|
|
174
|
+
// accept-edits only auto-approves file writes, NOT commands. See PLAN.md.
|
|
175
|
+
if (opts.skipPermissions !== false) args.push("--dangerously-skip-permissions");
|
|
176
|
+
if (isContinuation && rawConvId) args.push("--conversation", rawConvId);
|
|
177
|
+
args.push("--print-timeout", `${timeoutMin}m`);
|
|
178
|
+
args.push("-p", opts.prompt);
|
|
179
|
+
|
|
180
|
+
let stderr = "";
|
|
181
|
+
let boundId = isContinuation ? rawConvId : null;
|
|
182
|
+
// Counts bind attempts so the /proc FD scan can't run on every tick forever
|
|
183
|
+
// when the snapshot stays ambiguous (see MAX_BIND_ATTEMPTS).
|
|
184
|
+
let bindAttempts = 0;
|
|
185
|
+
|
|
186
|
+
// One poller per turn, lazily opened once we know the conversation id.
|
|
187
|
+
let poller: ConversationPoller | null = null;
|
|
188
|
+
// agy extends the step it is currently writing in place (same idx, growing
|
|
189
|
+
// text). poll() returns only idx > lastIdx, so we re-read the last
|
|
190
|
+
// text/thinking step each tick and emit the grown suffix as a delta.
|
|
191
|
+
// flushStreamStep also runs BEFORE the loop so a step boundary landing
|
|
192
|
+
// inside this tick doesn't drop the outgoing step's final in-place tail
|
|
193
|
+
// (the loop may switch streamIdx to a new idx before we'd re-read).
|
|
194
|
+
let streamIdx = -1;
|
|
195
|
+
let streamKind: "text" | "thinking" | null = null;
|
|
196
|
+
let streamEmitted = 0;
|
|
197
|
+
const flushStreamStep = (): void => {
|
|
198
|
+
if (streamIdx < 0 || !poller || !streamKind) return;
|
|
199
|
+
const s = poller.readStepAt(streamIdx);
|
|
200
|
+
if (!s) return;
|
|
201
|
+
try {
|
|
202
|
+
const t = extractAgentText(s.payload);
|
|
203
|
+
if (t && t.text.length > streamEmitted) {
|
|
204
|
+
onEvent({ kind: streamKind, text: t.text.slice(streamEmitted) });
|
|
205
|
+
streamEmitted = t.text.length;
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
/* torn re-read; next poll retries */
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const pollOnce = (): boolean => {
|
|
212
|
+
// Bind the conversation id on a fresh run (agy doesn't print it).
|
|
213
|
+
// pid lets newConversationId disambiguate when a concurrent agy also
|
|
214
|
+
// drops a new .db in the dir during our turn (see discovery.ts).
|
|
215
|
+
if (!boundId && snapshot && bindAttempts < MAX_BIND_ATTEMPTS) {
|
|
216
|
+
boundId = newConversationId(dir, snapshot, {
|
|
217
|
+
pid: proc?.pid,
|
|
218
|
+
// Only the genuinely-ambiguous case (>1 new DB, unresolved) counts
|
|
219
|
+
// against the cap. The ordinary "agy hasn't written its DB yet" wait
|
|
220
|
+
// must keep polling for the full turn timeout, not burn a budget
|
|
221
|
+
// meant to bound the expensive /proc FD-scan retries.
|
|
222
|
+
onAmbiguous: () => { bindAttempts++; },
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (!boundId) return false;
|
|
226
|
+
|
|
227
|
+
if (!poller) {
|
|
228
|
+
poller = new ConversationPoller(conversationDbPath(boundId, dir), lastIdx);
|
|
229
|
+
}
|
|
230
|
+
if (!poller.isOpen && !poller.tryOpen()) return false;
|
|
231
|
+
|
|
232
|
+
// Coalesce: one data_version check per tick gates BOTH the in-place
|
|
233
|
+
// re-read (flushStreamStep -> readStepAt) and the new-row read. While
|
|
234
|
+
// agy is thinking and hasn't committed, hasChanged() is false and we
|
|
235
|
+
// skip both SELECTs, so no row read fires on an idle tick.
|
|
236
|
+
if (!poller.hasChanged()) return false;
|
|
237
|
+
|
|
238
|
+
// Catch the currently-tracked step's final in-place growth BEFORE the
|
|
239
|
+
// loop may switch tracking to a new step.
|
|
240
|
+
flushStreamStep();
|
|
241
|
+
const steps = poller.readNewSteps();
|
|
242
|
+
for (const step of steps) {
|
|
243
|
+
// A torn read (agy mid-write) can throw RangeError out of the protobuf
|
|
244
|
+
// walker. Drop that step rather than aborting the whole turn - the
|
|
245
|
+
// row settles on the next poll. (agy-acp database.ts pattern, lifted
|
|
246
|
+
// to the decode layer where the throw actually originates.)
|
|
247
|
+
try {
|
|
248
|
+
const event = decodeStep(step);
|
|
249
|
+
if (event) {
|
|
250
|
+
onEvent(event);
|
|
251
|
+
if (event.kind === "text" || event.kind === "thinking") {
|
|
252
|
+
streamIdx = step.idx;
|
|
253
|
+
streamKind = event.kind;
|
|
254
|
+
streamEmitted = event.text.length;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
/* drop undecodable step; lastIdx still advances past it */
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
flushStreamStep();
|
|
262
|
+
lastIdx = poller.lastIdx;
|
|
263
|
+
return steps.length > 0;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const result: AgyRunResult = {
|
|
267
|
+
exitCode: 0,
|
|
268
|
+
conversationId: null,
|
|
269
|
+
lastIdx: -1,
|
|
270
|
+
aborted: false,
|
|
271
|
+
timedOut: false,
|
|
272
|
+
stderr: "",
|
|
273
|
+
durationMs: 0,
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
let proc: ChildProcess | null = null;
|
|
277
|
+
let settled = false;
|
|
278
|
+
let timedOut = false;
|
|
279
|
+
let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
280
|
+
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
|
281
|
+
|
|
282
|
+
const killTree = () => {
|
|
283
|
+
try {
|
|
284
|
+
if (proc?.pid) process.kill(-proc.pid, "SIGTERM");
|
|
285
|
+
} catch {
|
|
286
|
+
/* process group already gone */
|
|
287
|
+
}
|
|
288
|
+
if (!sigkillTimer) {
|
|
289
|
+
sigkillTimer = setTimeout(() => {
|
|
290
|
+
try {
|
|
291
|
+
if (proc?.pid) process.kill(-proc.pid, "SIGKILL");
|
|
292
|
+
} catch {
|
|
293
|
+
/* give up */
|
|
294
|
+
}
|
|
295
|
+
}, GRACE_AFTER_TIMEOUT_MS);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const onAbort = () => killTree();
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
await new Promise<void>((resolveP, rejectP) => {
|
|
303
|
+
// detached: true so we can signal the whole process group. agy
|
|
304
|
+
// spawns its own exec subprocesses in -p mode; a direct kill would
|
|
305
|
+
// orphan those grandchildren.
|
|
306
|
+
proc = spawn(binary, args, {
|
|
307
|
+
cwd: opts.cwd,
|
|
308
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
309
|
+
shell: false,
|
|
310
|
+
detached: true,
|
|
311
|
+
});
|
|
312
|
+
proc.stderr?.setEncoding("utf8");
|
|
313
|
+
proc.stderr?.on("data", (d: string) => (stderr += d));
|
|
314
|
+
|
|
315
|
+
// Drive the DB poll concurrently with the running process. THIS is the
|
|
316
|
+
// streaming: without it, no event reaches the caller until agy exits,
|
|
317
|
+
// defeating the whole point of the provider. Cleared in cleanup().
|
|
318
|
+
const pollTimer = setInterval(pollOnce, POLL_INTERVAL_MS);
|
|
319
|
+
pollTimer.unref?.();
|
|
320
|
+
|
|
321
|
+
const cleanup = () => {
|
|
322
|
+
clearInterval(pollTimer);
|
|
323
|
+
if (watchdog) clearTimeout(watchdog);
|
|
324
|
+
if (sigkillTimer) clearTimeout(sigkillTimer);
|
|
325
|
+
if (opts.signal) opts.signal.removeEventListener("abort", onAbort);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
// Enforce the timeout ourselves (agy's --print-timeout is advisory).
|
|
329
|
+
watchdog = setTimeout(() => {
|
|
330
|
+
timedOut = true;
|
|
331
|
+
killTree();
|
|
332
|
+
}, timeoutMin * 60_000);
|
|
333
|
+
|
|
334
|
+
if (opts.signal) {
|
|
335
|
+
if (opts.signal.aborted) killTree();
|
|
336
|
+
else opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const finish = (code: number | null) => {
|
|
340
|
+
if (settled) return;
|
|
341
|
+
settled = true;
|
|
342
|
+
cleanup();
|
|
343
|
+
resolveP();
|
|
344
|
+
void code; // exit code read off proc below
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
proc.on("error", (err) => {
|
|
348
|
+
cleanup();
|
|
349
|
+
rejectP(err);
|
|
350
|
+
});
|
|
351
|
+
proc.on("close", finish);
|
|
352
|
+
proc.on("exit", finish);
|
|
353
|
+
});
|
|
354
|
+
} catch (err) {
|
|
355
|
+
// spawn ENOENT etc. - surface as a non-zero result, don't throw.
|
|
356
|
+
stderr += err instanceof Error ? err.message : String(err);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Capture abort state before the trailing-poll loop. On cancel we skip the
|
|
360
|
+
// trailing polls (3 x 100ms) so the stream finalizes promptly with whatever
|
|
361
|
+
// was already streamed, instead of stalling ~300ms after agy was killed.
|
|
362
|
+
const wasAborted = !!opts.signal?.aborted;
|
|
363
|
+
|
|
364
|
+
// Trailing polls: agy may flush a final step moments after exit. The
|
|
365
|
+
// agy-acp pattern (3 x 100ms) catches these without adding noticeable latency.
|
|
366
|
+
if (!wasAborted) {
|
|
367
|
+
for (let i = 0; i < TRAILING_POLLS; i++) {
|
|
368
|
+
pollOnce();
|
|
369
|
+
await sleep(TRAILING_POLL_MS);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// CFA note: poller/proc are assigned inside closures that TS can't track
|
|
374
|
+
// through the await, so they narrow to `null` here. Casts break the
|
|
375
|
+
// narrowing without lying about the runtime type.
|
|
376
|
+
(poller as ConversationPoller | null)?.close();
|
|
377
|
+
|
|
378
|
+
result.exitCode = (proc as ChildProcess | null)?.exitCode ?? (stderr ? 1 : 0);
|
|
379
|
+
result.aborted = wasAborted;
|
|
380
|
+
result.timedOut = timedOut;
|
|
381
|
+
result.conversationId = boundId;
|
|
382
|
+
result.lastIdx = lastIdx;
|
|
383
|
+
result.stderr = stderr;
|
|
384
|
+
result.durationMs = Date.now() - start;
|
|
385
|
+
return result;
|
|
386
|
+
}
|
package/src/sessions.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Persisted map: pi session key -> agy conversation id + last streamed step.
|
|
2
|
+
//
|
|
3
|
+
// agy holds its own conversation history in the DB keyed by conversation id.
|
|
4
|
+
// To resume a multi-turn pi conversation, we thread the same agy id across
|
|
5
|
+
// streamSimple calls and skip steps we already streamed last turn.
|
|
6
|
+
//
|
|
7
|
+
// The key is options.sessionId when pi provides it, else the cwd (single-
|
|
8
|
+
// conversation-per-process fallback). Stored at
|
|
9
|
+
// ~/.pi/agent/antigravity-bridge/sessions.json with atomic rename.
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
const STORE_PATH = path.join(
|
|
16
|
+
os.homedir(),
|
|
17
|
+
".pi",
|
|
18
|
+
"agent",
|
|
19
|
+
"antigravity-bridge",
|
|
20
|
+
"sessions.json",
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export interface AgySession {
|
|
24
|
+
conversationId: string;
|
|
25
|
+
lastStepIdx: number;
|
|
26
|
+
/** pi context.messages length captured at the start of the last agy turn.
|
|
27
|
+
* Used by the provider to compute a delta digest of pi-side context agy
|
|
28
|
+
* was not spawned for (compaction summaries, other-provider turns). 0 on a
|
|
29
|
+
* fresh or pre-watermark session. Optional for backward compat with older
|
|
30
|
+
* persisted sessions that predate the watermark. */
|
|
31
|
+
lastMessageCount?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type StoreMap = Record<string, AgySession>;
|
|
35
|
+
|
|
36
|
+
/** Narrow untrusted parsed JSON into a clean StoreMap, validating each entry's
|
|
37
|
+
* shape instead of trusting the whole structure (the cast previously let a
|
|
38
|
+
* hand-edited or corrupt file plant wrong-typed fields into the cache).
|
|
39
|
+
* Drops any entry whose `conversationId` isn't a string; falls back to -1 when
|
|
40
|
+
* `lastStepIdx` is missing or not a finite number. Returns {} for non-object
|
|
41
|
+
* input. */
|
|
42
|
+
function narrowStoreMap(parsed: unknown): StoreMap {
|
|
43
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
44
|
+
const clean: StoreMap = {};
|
|
45
|
+
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
46
|
+
if (!value || typeof value !== "object") continue;
|
|
47
|
+
const conversationId = (value as { conversationId?: unknown }).conversationId;
|
|
48
|
+
if (typeof conversationId !== "string") continue;
|
|
49
|
+
const idx = (value as { lastStepIdx?: unknown }).lastStepIdx;
|
|
50
|
+
const mc = (value as { lastMessageCount?: unknown }).lastMessageCount;
|
|
51
|
+
clean[key] = {
|
|
52
|
+
conversationId,
|
|
53
|
+
lastStepIdx: typeof idx === "number" && Number.isFinite(idx) ? idx : -1,
|
|
54
|
+
lastMessageCount: typeof mc === "number" && Number.isFinite(mc) ? mc : 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return clean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Atomic-write JSON session store. Writes are serialized through a promise
|
|
61
|
+
* chain so two concurrent turns can't interleave renames.
|
|
62
|
+
*
|
|
63
|
+
* Multi-process safety: persist() re-reads the file fresh and overlays only
|
|
64
|
+
* the keys this instance has dirtied since load, so two pi processes editing
|
|
65
|
+
* different sessions never clobber each other. (Whole-file overwrite from a
|
|
66
|
+
* stale in-memory snapshot was the previous behavior - last writer won.) */
|
|
67
|
+
export class SessionStore {
|
|
68
|
+
private readonly path: string;
|
|
69
|
+
private cache: StoreMap = {};
|
|
70
|
+
private readonly dirty = new Set<string>();
|
|
71
|
+
private writeChain: Promise<void> = Promise.resolve();
|
|
72
|
+
|
|
73
|
+
constructor(storePath: string = STORE_PATH) {
|
|
74
|
+
this.path = storePath;
|
|
75
|
+
this.load();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private load(): void {
|
|
79
|
+
try {
|
|
80
|
+
const raw = fs.readFileSync(this.path, "utf8");
|
|
81
|
+
this.cache = narrowStoreMap(JSON.parse(raw));
|
|
82
|
+
} catch {
|
|
83
|
+
// missing or corrupt - start empty. The first set() will create it.
|
|
84
|
+
this.cache = {};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get(key: string): AgySession | null {
|
|
89
|
+
const s = this.cache[key];
|
|
90
|
+
if (!s || typeof s.conversationId !== "string") return null;
|
|
91
|
+
return {
|
|
92
|
+
conversationId: s.conversationId,
|
|
93
|
+
lastStepIdx: s.lastStepIdx ?? -1,
|
|
94
|
+
lastMessageCount: s.lastMessageCount ?? 0,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Update one session and persist atomically (temp file + rename). The
|
|
99
|
+
* write is queued; callers don't need to await it. */
|
|
100
|
+
set(key: string, session: AgySession): void {
|
|
101
|
+
this.cache[key] = session;
|
|
102
|
+
this.dirty.add(key);
|
|
103
|
+
// Serialize through a chain so concurrent sets don't interleave renames.
|
|
104
|
+
this.writeChain = this.writeChain
|
|
105
|
+
.then(() => this.persist())
|
|
106
|
+
.catch(() => {
|
|
107
|
+
/* swallow; next set() retries */
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async persist(): Promise<void> {
|
|
112
|
+
const dir = path.dirname(this.path);
|
|
113
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
114
|
+
|
|
115
|
+
// Re-read the current file so we merge against the latest on-disk state,
|
|
116
|
+
// not our possibly-stale in-memory snapshot. Overlay only OUR dirty keys
|
|
117
|
+
// so a concurrent process's writes to other keys survive.
|
|
118
|
+
let disk: StoreMap = {};
|
|
119
|
+
try {
|
|
120
|
+
const raw = await fs.promises.readFile(this.path, "utf8");
|
|
121
|
+
disk = narrowStoreMap(JSON.parse(raw));
|
|
122
|
+
} catch {
|
|
123
|
+
/* missing or corrupt - start from empty */
|
|
124
|
+
}
|
|
125
|
+
const merged: StoreMap = { ...disk };
|
|
126
|
+
for (const key of this.dirty) {
|
|
127
|
+
merged[key] = this.cache[key];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
131
|
+
await fs.promises.writeFile(tmp, JSON.stringify(merged, null, 2) + "\n", {
|
|
132
|
+
mode: 0o600,
|
|
133
|
+
});
|
|
134
|
+
await fs.promises.rename(tmp, this.path);
|
|
135
|
+
this.dirty.clear();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Wipe all session bindings (forces fresh agy conversations on every
|
|
139
|
+
* active session). Used by the /agy clear command. */
|
|
140
|
+
clear(): void {
|
|
141
|
+
this.cache = {};
|
|
142
|
+
this.dirty.clear();
|
|
143
|
+
this.writeChain = this.writeChain
|
|
144
|
+
.then(async () => {
|
|
145
|
+
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
146
|
+
await fs.promises.mkdir(path.dirname(this.path), { recursive: true });
|
|
147
|
+
await fs.promises.writeFile(tmp, "{}\n", { mode: 0o600 });
|
|
148
|
+
await fs.promises.rename(tmp, this.path);
|
|
149
|
+
})
|
|
150
|
+
.catch(() => {
|
|
151
|
+
/* swallow; user can retry */
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Count persisted session bindings (for status display). */
|
|
156
|
+
get size(): number {
|
|
157
|
+
return Object.keys(this.cache).length;
|
|
158
|
+
}
|
|
159
|
+
}
|