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 +21 -0
- package/README.md +138 -0
- package/dist/agy-runner.d.ts +14 -0
- package/dist/agy-runner.js +50 -0
- package/dist/conversation-tracker.d.ts +3 -0
- package/dist/conversation-tracker.js +38 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +10 -0
- package/dist/prompt-mapper.d.ts +2 -0
- package/dist/prompt-mapper.js +42 -0
- package/dist/provider.d.ts +11 -0
- package/dist/provider.js +213 -0
- package/dist/session-store.d.ts +19 -0
- package/dist/session-store.js +108 -0
- package/package.json +26 -0
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,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
|
+
}
|
package/dist/plugin.d.ts
ADDED
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,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;
|
package/dist/provider.js
ADDED
|
@@ -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
|
+
}
|