opencode-agy-bridge 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 raultov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # opencode-agy-bridge
2
+
3
+ OpenCode plugin + provider that routes LLM prompts to `agy` (Google Antigravity CLI).
4
+
5
+ ## How it works
6
+
7
+ ```
8
+ opencode TUI
9
+ └─ /model → select agy/antigravity
10
+ └─ you type a prompt
11
+ └─ provider spawns: agy --add-dir <cwd> [--conversation <id>] -p -
12
+ └─ agy → Google Antigravity backend → Gemini
13
+ └─ stdout (buffered, full response)
14
+ └─ provider extracts delta vs previous turn
15
+ └─ text-delta + finish → opencode renders the response
16
+ ```
17
+
18
+ ## Prerequisites
19
+
20
+ 1. **`agy` installed and authenticated** — run `agy` standalone at least once to complete OAuth.
21
+ 2. **Node.js ≥ 18** or **Bun ≥ 1.0**.
22
+ 3. **OpenCode** `>= 1.15.x` (uses Vercel AI SDK v3).
23
+
24
+ ## Installation (local development)
25
+
26
+ ```bash
27
+ git clone <this-repo>
28
+ cd opencode-agy-bridge
29
+
30
+ # Using Bun (recommended)
31
+ bun install
32
+ bun run build
33
+ bun test # verify 42 tests pass
34
+
35
+ # Or using pnpm
36
+ pnpm install
37
+ pnpm run build
38
+ pnpm test
39
+
40
+ # Or using npm
41
+ npm install
42
+ npm run build
43
+ npm test
44
+ ```
45
+
46
+ ## Features
47
+
48
+ - **Robust Delta Extraction:** Automatically normalizes `\r\n` (CRLF) and `\n` (LF) line endings, tolerates trailing whitespace/newline differences, and implements suffix-based alignment to support seamless recovery during context window truncation.
49
+
50
+ ## Configuration
51
+
52
+ Add to your `~/.config/opencode/opencode.json`:
53
+
54
+ ```jsonc
55
+ {
56
+ "plugin": [
57
+ // ...your existing plugins...
58
+ "/home/USER/workspace/opencode-agy-bridge/dist/plugin.js"
59
+ ],
60
+ "provider": {
61
+ // ...your existing providers...
62
+ "agy": {
63
+ "npm": "/home/USER/workspace/opencode-agy-bridge",
64
+ "name": "Google Antigravity (via agy CLI)",
65
+ "options": {
66
+ "binary": "agy",
67
+ "timeoutMs": 300000
68
+ },
69
+ "models": {
70
+ "antigravity": {
71
+ "name": "Antigravity (server-selected Gemini)"
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ ```
78
+
79
+ Then restart OpenCode and run `/model` → select `agy/antigravity`.
80
+
81
+ ## Known limitations
82
+
83
+ | Limitation | Detail |
84
+ |---|---|
85
+ | **No real streaming** | `agy --print` buffers the full response and emits it on completion. Tokens appear in one batch, not one-by-one. PTY allocation (`script -q`) was tested and does not destrabilize the buffering — agy holds output until the response is complete regardless of whether stdout is a TTY. The provider therefore emits a single `text-delta` per turn instead of faking progressive chunks. |
86
+ | **Single cosmetic model** | `agy` does not accept `--model`. The model is chosen server-side by Antigravity. Declaring extra models in config has no effect. |
87
+ | **Requires authenticated `agy`** | You must run `agy` standalone at least once to authenticate via OAuth. |
88
+ | **No tool-call passthrough** | `agy` CLI does not return structured tool calls to the caller. Tool use happens inside agy's own process. |
89
+ | **Per-turn subprocess** | Each prompt spawns a fresh `agy` process. Context is preserved via `--conversation <id>`. |
90
+ | **Images/file parts omitted** | OpenCode messages with image/file content parts are skipped with a warning — `agy` CLI does not support them. |
91
+ | **Conversation binding heuristic** | The bridge infers `conversation_id` by diffing `~/.gemini/antigravity-cli/conversations/*.pb` before/after each turn. If multiple `.pb` files appear simultaneously, binding is refused and each turn runs in single-turn mode. |
92
+
93
+ ## Project structure
94
+
95
+ ```
96
+ src/
97
+ ├── agy-runner.ts # spawn agy, capture stdout/stderr
98
+ ├── conversation-tracker.ts # snapshot .pb files, infer conversation_id
99
+ ├── session-store.ts # persist session→conversation_id mapping
100
+ ├── prompt-mapper.ts # Vercel AI SDK prompt → plain text
101
+ ├── provider.ts # LanguageModelV2 implementation (core)
102
+ └── plugin.ts # OpenCode plugin entrypoint (hooks)
103
+ ```
104
+
105
+ ## Development
106
+
107
+ Using **Bun**:
108
+ ```bash
109
+ bun run build
110
+ bun test
111
+ ```
112
+
113
+ Using **pnpm**:
114
+ ```bash
115
+ pnpm run build
116
+ pnpm test
117
+ ```
118
+
119
+ Using **npm**:
120
+ ```bash
121
+ npm run build
122
+ npm test
123
+ ```
124
+
125
+ ## CI/CD (GitHub Actions)
126
+
127
+ The project includes two GitHub Actions workflows:
128
+
129
+ - **CI (`ci.yml`):** Runs on push and pull requests to `main` or `master` to compile the project and execute all unit tests using Bun.
130
+ - **Release (`release.yml`):** Runs when a new GitHub Release is created. It automatically installs dependencies, builds, tests, and publishes the package to the public npm registry.
131
+
132
+ Note that both `npm` and `pnpm` share the same public registry (`registry.npmjs.org`), so a single publish step makes the package installable by both package managers.
133
+
134
+ ### Setup
135
+
136
+ To enable automated releases:
137
+ 1. Generate an Access Token with publish permissions on [npmjs.com](https://www.npmjs.com/).
138
+ 2. Add the token as a repository secret named `NPM_TOKEN` in your GitHub repository settings under **Settings** → **Secrets and variables** → **Actions**.
@@ -0,0 +1,14 @@
1
+ export interface RunAgyInput {
2
+ prompt: string;
3
+ cwd: string;
4
+ conversationId?: string;
5
+ binary?: string;
6
+ extraArgs?: string[];
7
+ timeoutMs?: number;
8
+ }
9
+ export interface RunAgyResult {
10
+ stdout: string;
11
+ stderr: string;
12
+ exitCode: number;
13
+ }
14
+ export declare function runAgy(input: RunAgyInput): Promise<RunAgyResult>;
@@ -0,0 +1,50 @@
1
+ import { spawn } from "node:child_process";
2
+ export async function runAgy(input) {
3
+ const binary = input.binary ?? "agy";
4
+ const timeoutMs = input.timeoutMs ?? 300_000;
5
+ const extraArgs = input.extraArgs ?? [];
6
+ const args = [
7
+ "--add-dir",
8
+ input.cwd,
9
+ ...extraArgs,
10
+ ];
11
+ if (input.conversationId) {
12
+ args.push("--conversation", input.conversationId);
13
+ }
14
+ args.push("-p", "-");
15
+ return new Promise((resolve, reject) => {
16
+ const child = spawn(binary, args, {
17
+ cwd: input.cwd,
18
+ stdio: ["pipe", "pipe", "pipe"],
19
+ });
20
+ const stdoutChunks = [];
21
+ const stderrChunks = [];
22
+ child.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
23
+ child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
24
+ child.stdin.write(input.prompt);
25
+ child.stdin.end();
26
+ const timer = setTimeout(() => {
27
+ child.kill("SIGTERM");
28
+ reject(new Error("agy timed out"));
29
+ }, timeoutMs);
30
+ child.on("close", (code) => {
31
+ clearTimeout(timer);
32
+ const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
33
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8");
34
+ const exitCode = code ?? 1;
35
+ if (stderr.trim()) {
36
+ console.error("[agy-bridge] agy stderr:", stderr.trimEnd());
37
+ }
38
+ if (exitCode !== 0 && !stdout.trim()) {
39
+ const msg = stderr.trim() || `agy exited with status ${exitCode}`;
40
+ reject(new Error(msg));
41
+ return;
42
+ }
43
+ resolve({ stdout, stderr, exitCode });
44
+ });
45
+ child.on("error", (err) => {
46
+ clearTimeout(timer);
47
+ reject(new Error(`failed to spawn agy: ${err.message}`));
48
+ });
49
+ });
50
+ }
@@ -0,0 +1,3 @@
1
+ export declare function defaultConversationsDir(): string;
2
+ export declare function snapshot(dir: string): Promise<Set<string>>;
3
+ export declare function findNewConversation(before: Set<string>, dir: string): Promise<string | null>;
@@ -0,0 +1,38 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ export function defaultConversationsDir() {
5
+ return join(homedir(), ".gemini", "antigravity-cli", "conversations");
6
+ }
7
+ export async function snapshot(dir) {
8
+ try {
9
+ const entries = await readdir(dir);
10
+ const stems = new Set();
11
+ for (const entry of entries) {
12
+ if (entry.endsWith(".pb")) {
13
+ stems.add(entry.slice(0, -3));
14
+ }
15
+ }
16
+ return stems;
17
+ }
18
+ catch {
19
+ return new Set();
20
+ }
21
+ }
22
+ export async function findNewConversation(before, dir) {
23
+ const after = await snapshot(dir);
24
+ const created = [];
25
+ for (const stem of after) {
26
+ if (!before.has(stem)) {
27
+ created.push(stem);
28
+ }
29
+ }
30
+ if (created.length === 0) {
31
+ return null;
32
+ }
33
+ if (created.length > 1) {
34
+ console.error("[agy-bridge] WARN: multiple new agy conversation files appeared; refusing to bind");
35
+ return null;
36
+ }
37
+ return created[0];
38
+ }
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ declare const plugin: Plugin;
3
+ export default plugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,10 @@
1
+ const plugin = async () => ({
2
+ "chat.headers": async (incoming, output) => {
3
+ // Only inject for our own provider
4
+ if (incoming.model.providerID !== "agy")
5
+ return;
6
+ // Pass the stable OpenCode session ID so agy can reuse conversations
7
+ output.headers["x-agy-session-id"] = incoming.sessionID;
8
+ },
9
+ });
10
+ export default plugin;
@@ -0,0 +1,2 @@
1
+ import type { LanguageModelV2Prompt } from "@ai-sdk/provider";
2
+ export declare function flattenPrompt(prompt: LanguageModelV2Prompt): string;
@@ -0,0 +1,42 @@
1
+ export function flattenPrompt(prompt) {
2
+ const nonSystem = prompt.filter((msg) => msg.role !== "system");
3
+ if (nonSystem.length === 0)
4
+ return "";
5
+ if (nonSystem.length === 1) {
6
+ return extractText(nonSystem[0]).trim();
7
+ }
8
+ const parts = [];
9
+ const history = nonSystem.slice(0, -1);
10
+ const current = nonSystem[nonSystem.length - 1];
11
+ parts.push("[Contexto previo de la conversación]");
12
+ for (const msg of history) {
13
+ const text = extractText(msg);
14
+ if (text.trim()) {
15
+ const label = msg.role === "user" ? "User" : "Assistant";
16
+ parts.push(`${label}: ${text}`);
17
+ }
18
+ }
19
+ parts.push("[Fin del contexto]");
20
+ const currentText = extractText(current).trim();
21
+ if (currentText) {
22
+ parts.push("");
23
+ parts.push("Petición actual:");
24
+ parts.push(currentText);
25
+ }
26
+ return parts.join("\n");
27
+ }
28
+ function extractText(msg) {
29
+ if (typeof msg.content === "string") {
30
+ return msg.content;
31
+ }
32
+ if (Array.isArray(msg.content)) {
33
+ const texts = [];
34
+ for (const part of msg.content) {
35
+ if (part.type === "text") {
36
+ texts.push(part.text);
37
+ }
38
+ }
39
+ return texts.join("\n");
40
+ }
41
+ return "";
42
+ }
@@ -0,0 +1,11 @@
1
+ import type { ProviderV2 } from "@ai-sdk/provider";
2
+ export interface AgyProviderOptions {
3
+ binary?: string;
4
+ conversationsDir?: string;
5
+ stateFile?: string;
6
+ extraArgs?: string[];
7
+ timeoutMs?: number;
8
+ }
9
+ export declare function extractDelta(prevOutput: string, fullText: string, conversationBound: boolean): string;
10
+ export declare function createAgyProvider(opts?: AgyProviderOptions): ProviderV2;
11
+ export default function defaultFactory(opts?: AgyProviderOptions): ProviderV2;
@@ -0,0 +1,213 @@
1
+ import { runAgy } from "./agy-runner";
2
+ import { snapshot, findNewConversation, defaultConversationsDir } from "./conversation-tracker";
3
+ import { SessionStore } from "./session-store";
4
+ import { flattenPrompt } from "./prompt-mapper";
5
+ import { randomUUID } from "node:crypto";
6
+ const prevOutputs = new Map();
7
+ export function extractDelta(prevOutput, fullText, conversationBound) {
8
+ if (!conversationBound || !prevOutput) {
9
+ return fullText;
10
+ }
11
+ const normalize = (str) => str.replace(/\r\n/g, "\n");
12
+ const normPrev = normalize(prevOutput);
13
+ const normFull = normalize(fullText);
14
+ if (normFull.startsWith(normPrev)) {
15
+ return normFull.slice(normPrev.length).replace(/^\n+/, "");
16
+ }
17
+ const normPrevTrimmed = normPrev.trimEnd();
18
+ if (normFull.startsWith(normPrevTrimmed)) {
19
+ return normFull.slice(normPrevTrimmed.length).replace(/^\s+/, "");
20
+ }
21
+ const idx = normFull.indexOf(normPrevTrimmed);
22
+ if (idx !== -1) {
23
+ return normFull.slice(idx + normPrevTrimmed.length).replace(/^\s+/, "");
24
+ }
25
+ const lines = normPrevTrimmed.split("\n").filter((l) => l.trim());
26
+ if (lines.length > 0) {
27
+ const lastLine = lines[lines.length - 1].trim();
28
+ if (lastLine.length >= 10) {
29
+ const lastLineIdx = normFull.indexOf(lastLine);
30
+ if (lastLineIdx !== -1) {
31
+ return normFull.slice(lastLineIdx + lastLine.length).replace(/^\s+/, "");
32
+ }
33
+ }
34
+ }
35
+ const tailLength = 150;
36
+ const tail = normPrevTrimmed.length > tailLength
37
+ ? normPrevTrimmed.slice(-tailLength)
38
+ : normPrevTrimmed;
39
+ if (tail.length >= 20) {
40
+ const tailIdx = normFull.lastIndexOf(tail);
41
+ if (tailIdx !== -1) {
42
+ return normFull.slice(tailIdx + tail.length).replace(/^\s+/, "");
43
+ }
44
+ }
45
+ console.error("[agy-bridge] WARN: agy stdout was not append-only; sending full output and resetting delta baseline");
46
+ return fullText;
47
+ }
48
+ function buildLanguageModel(modelId, opts) {
49
+ const store = new SessionStore(opts.stateFile);
50
+ const conversationsDir = opts.conversationsDir ?? defaultConversationsDir();
51
+ const doGenerate = async (callOpts) => {
52
+ // Use the stable OpenCode session ID injected via plugin chat.headers hook.
53
+ // Falls back to providerMetadata or a random UUID for standalone/testing.
54
+ const sessionId = callOpts.headers?.["x-agy-session-id"] ??
55
+ callOpts.providerOptions?.agy
56
+ ?.sessionId ??
57
+ randomUUID();
58
+ const entry = await store.getEntry(sessionId);
59
+ let conversationId = entry?.conversationId ?? null;
60
+ const processedMessages = entry?.processedMessages ?? 0;
61
+ // On first turn (no conversation yet), acquire a global lock before
62
+ // spawning agy so we can safely diff *.pb files without races from
63
+ // another concurrent OpenCode instance.
64
+ let releaseBindingLock = null;
65
+ if (!conversationId) {
66
+ releaseBindingLock = await SessionStore.acquireBindingLock();
67
+ }
68
+ let before = null;
69
+ try {
70
+ before = conversationId ? null : await snapshot(conversationsDir);
71
+ // Only send new messages when conversation is already bound.
72
+ // agy preserves context internally via --conversation, so sending
73
+ // the full history each turn confuses it and causes hallucination.
74
+ const newMessages = conversationId
75
+ ? callOpts.prompt.slice(processedMessages)
76
+ : callOpts.prompt;
77
+ const prompt = flattenPrompt(newMessages);
78
+ const result = await runAgy({
79
+ prompt,
80
+ cwd: process.cwd(),
81
+ conversationId: conversationId ?? undefined,
82
+ binary: opts.binary,
83
+ extraArgs: opts.extraArgs,
84
+ timeoutMs: opts.timeoutMs,
85
+ });
86
+ if (!conversationId && before) {
87
+ const newId = await findNewConversation(before, conversationsDir);
88
+ if (newId) {
89
+ conversationId = newId;
90
+ }
91
+ }
92
+ // Restore prevOutput from persisted store (survives restarts).
93
+ // In-memory cache takes priority (faster, has latest turn data).
94
+ let prevOutput = prevOutputs.get(sessionId) ?? "";
95
+ if (!prevOutput && entry?.prevOutput) {
96
+ prevOutput = entry.prevOutput;
97
+ prevOutputs.set(sessionId, prevOutput);
98
+ }
99
+ const delta = extractDelta(prevOutput, result.stdout, !!conversationId);
100
+ if (conversationId) {
101
+ prevOutputs.set(sessionId, result.stdout);
102
+ }
103
+ else {
104
+ prevOutputs.delete(sessionId);
105
+ }
106
+ // Persist state so it survives process restarts.
107
+ await store.set(sessionId, conversationId, conversationId ? callOpts.prompt.length : 0, conversationId ? result.stdout : "");
108
+ return {
109
+ content: [{ type: "text", text: delta }],
110
+ finishReason: "stop",
111
+ usage: {
112
+ inputTokens: 0,
113
+ outputTokens: 0,
114
+ totalTokens: 0,
115
+ },
116
+ providerMetadata: {
117
+ agy: {
118
+ sessionId,
119
+ conversationId: conversationId ?? null,
120
+ },
121
+ },
122
+ response: {
123
+ id: randomUUID(),
124
+ timestamp: new Date(),
125
+ modelId,
126
+ },
127
+ warnings: [],
128
+ };
129
+ }
130
+ finally {
131
+ if (releaseBindingLock) {
132
+ await releaseBindingLock();
133
+ }
134
+ }
135
+ };
136
+ const doStream = async (callOpts) => {
137
+ const generatePromise = doGenerate(callOpts);
138
+ let aborted = false;
139
+ callOpts.abortSignal?.addEventListener("abort", () => {
140
+ aborted = true;
141
+ });
142
+ const stream = new ReadableStream({
143
+ async start(controller) {
144
+ try {
145
+ controller.enqueue({
146
+ type: "stream-start",
147
+ warnings: [],
148
+ });
149
+ const result = await generatePromise;
150
+ if (aborted) {
151
+ controller.close();
152
+ return;
153
+ }
154
+ const textContent = result.content.find((c) => c.type === "text");
155
+ const text = textContent && "text" in textContent ? textContent.text : "";
156
+ if (text) {
157
+ controller.enqueue({
158
+ type: "text-start",
159
+ id: "agy-1",
160
+ });
161
+ controller.enqueue({
162
+ type: "text-delta",
163
+ id: "agy-1",
164
+ delta: text,
165
+ });
166
+ controller.enqueue({
167
+ type: "text-end",
168
+ id: "agy-1",
169
+ });
170
+ }
171
+ controller.enqueue({
172
+ type: "finish",
173
+ finishReason: result.finishReason,
174
+ usage: result.usage,
175
+ });
176
+ controller.close();
177
+ }
178
+ catch (err) {
179
+ controller.enqueue({ type: "error", error: String(err) });
180
+ controller.close();
181
+ }
182
+ },
183
+ cancel() {
184
+ // agy is one-shot; no real cancellation possible here
185
+ },
186
+ });
187
+ return { stream };
188
+ };
189
+ return {
190
+ specificationVersion: "v2",
191
+ provider: "agy",
192
+ modelId,
193
+ supportedUrls: {},
194
+ doGenerate,
195
+ doStream,
196
+ };
197
+ }
198
+ export function createAgyProvider(opts) {
199
+ const resolvedOpts = opts ?? {};
200
+ const languageModel = (modelId) => buildLanguageModel(modelId, resolvedOpts);
201
+ return {
202
+ languageModel,
203
+ textEmbeddingModel() {
204
+ throw new Error("agy bridge does not support text embeddings");
205
+ },
206
+ imageModel() {
207
+ throw new Error("agy bridge does not support image generation");
208
+ },
209
+ };
210
+ }
211
+ export default function defaultFactory(opts) {
212
+ return createAgyProvider(opts);
213
+ }
@@ -0,0 +1,19 @@
1
+ interface StoreEntry {
2
+ conversationId: string | null;
3
+ processedMessages: number;
4
+ prevOutput: string;
5
+ }
6
+ export declare class SessionStore {
7
+ private stateFile;
8
+ constructor(stateFile?: string);
9
+ /**
10
+ * Acquires a global lock for the bind-while-running phase.
11
+ * Prevents concurrent agy instances from creating ambiguous .pb files.
12
+ */
13
+ static acquireBindingLock(): Promise<() => Promise<void>>;
14
+ getEntry(sessionId: string): Promise<StoreEntry | null>;
15
+ set(sessionId: string, conversationId: string | null, processedMessages?: number, prevOutput?: string): Promise<void>;
16
+ private loadStore;
17
+ private loadStoreUnlocked;
18
+ }
19
+ export {};
@@ -0,0 +1,108 @@
1
+ import { readFile, writeFile, rename, mkdir, open, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ function defaultStateFile() {
5
+ return join(homedir(), ".opencode-agy-bridge", "sessions.json");
6
+ }
7
+ function defaultBindingLockPath() {
8
+ return join(homedir(), ".opencode-agy-bridge", "binding.lock");
9
+ }
10
+ async function acquireLock(lockPath) {
11
+ const lockDir = dirname(lockPath);
12
+ await mkdir(lockDir, { recursive: true });
13
+ const startTime = Date.now();
14
+ const staleTimeoutMs = 30_000;
15
+ let backoff = 1;
16
+ const maxBackoff = 500;
17
+ while (true) {
18
+ try {
19
+ const fh = await open(lockPath, "wx");
20
+ await fh.close();
21
+ return () => releaseLock(lockPath);
22
+ }
23
+ catch {
24
+ if (Date.now() - startTime > staleTimeoutMs) {
25
+ try {
26
+ const stats = await stat(lockPath);
27
+ if (Date.now() - stats.mtimeMs > staleTimeoutMs) {
28
+ await releaseLock(lockPath);
29
+ continue;
30
+ }
31
+ }
32
+ catch {
33
+ continue;
34
+ }
35
+ }
36
+ await new Promise((r) => setTimeout(r, backoff));
37
+ backoff = Math.min(backoff * 2, maxBackoff);
38
+ }
39
+ }
40
+ }
41
+ async function releaseLock(lockPath) {
42
+ try {
43
+ const { unlink } = await import("node:fs/promises");
44
+ await unlink(lockPath);
45
+ }
46
+ catch {
47
+ // best effort
48
+ }
49
+ }
50
+ export class SessionStore {
51
+ stateFile;
52
+ constructor(stateFile) {
53
+ this.stateFile = stateFile ?? defaultStateFile();
54
+ }
55
+ /**
56
+ * Acquires a global lock for the bind-while-running phase.
57
+ * Prevents concurrent agy instances from creating ambiguous .pb files.
58
+ */
59
+ static acquireBindingLock() {
60
+ return acquireLock(defaultBindingLockPath());
61
+ }
62
+ async getEntry(sessionId) {
63
+ const store = await this.loadStore();
64
+ return store.sessions[sessionId] ?? null;
65
+ }
66
+ async set(sessionId, conversationId, processedMessages = 0, prevOutput = "") {
67
+ const stateDir = dirname(this.stateFile);
68
+ await mkdir(stateDir, { recursive: true });
69
+ const lockPath = this.stateFile + ".lock";
70
+ const release = await acquireLock(lockPath);
71
+ try {
72
+ const store = await this.loadStoreUnlocked();
73
+ store.sessions[sessionId] = {
74
+ conversationId,
75
+ processedMessages,
76
+ prevOutput,
77
+ };
78
+ const tmpPath = this.stateFile + ".tmp";
79
+ await writeFile(tmpPath, JSON.stringify(store, null, 2), "utf-8");
80
+ await rename(tmpPath, this.stateFile);
81
+ }
82
+ finally {
83
+ await release();
84
+ }
85
+ }
86
+ async loadStore() {
87
+ const stateDir = dirname(this.stateFile);
88
+ await mkdir(stateDir, { recursive: true });
89
+ const lockPath = this.stateFile + ".lock";
90
+ const release = await acquireLock(lockPath);
91
+ try {
92
+ return await this.loadStoreUnlocked();
93
+ }
94
+ finally {
95
+ await release();
96
+ }
97
+ }
98
+ async loadStoreUnlocked() {
99
+ try {
100
+ const raw = await readFile(this.stateFile, "utf-8");
101
+ const parsed = JSON.parse(raw);
102
+ return { sessions: parsed.sessions ?? {} };
103
+ }
104
+ catch {
105
+ return { sessions: {} };
106
+ }
107
+ }
108
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "opencode-agy-bridge",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "OpenCode plugin + provider that routes LLM prompts to agy (Google Antigravity CLI)",
7
+ "exports": {
8
+ ".": "./dist/provider.js",
9
+ "./plugin": "./dist/plugin.js"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "test": "bun test"
17
+ },
18
+ "dependencies": {
19
+ "@ai-sdk/provider": "^3.0.0"
20
+ },
21
+ "devDependencies": {
22
+ "@opencode-ai/plugin": "^1.15.12",
23
+ "@types/bun": "latest",
24
+ "typescript": "^5.8.0"
25
+ }
26
+ }