pi-opencode-cli-bridge 0.2.13 → 0.2.15

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,182 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { Context } from "@earendil-works/pi-ai";
5
+ import { cliVersions, opencodeBin, runCapture } from "./cli.js";
6
+ import { serializeTools } from "./prompt.js";
7
+
8
+ export const AGENT_ID = "pi-model";
9
+ const SESSION_DELETE_TIMEOUT_MS = 10_000;
10
+
11
+ /** Cap on tracked Pi sessions; the oldest entry is evicted with cleanup. */
12
+ const MAX_TRACKED_PI_SESSIONS = 10;
13
+ const pendingSessionDeletions = new Set<Promise<void>>();
14
+
15
+ /** Best-effort, bounded deletion; shutdown also awaits one-shot deletions. */
16
+ export function deleteOpenCodeSession(sessionId: string | undefined): Promise<void> {
17
+ if (!sessionId) return Promise.resolve();
18
+ const args = ["session", "delete", sessionId];
19
+ if (cliVersions.get(opencodeBin())?.startsWith("2.")) args.push("--standalone");
20
+ const task = runCapture(args, undefined, SESSION_DELETE_TIMEOUT_MS)
21
+ .then(() => undefined, () => undefined);
22
+ pendingSessionDeletions.add(task);
23
+ void task.finally(() => pendingSessionDeletions.delete(task));
24
+ return task;
25
+ }
26
+
27
+ type PiSessionState = {
28
+ /** Lazily created per-Pi-session OpenCode project directory (#74). */
29
+ dirPromise?: Promise<string>;
30
+ /** Recorded OpenCode session ID reused across turns (#75). */
31
+ opencodeSessionId?: string;
32
+ /** Model ID the recorded session was created with. */
33
+ modelId?: string;
34
+ /** Serialized tool list the recorded session was created with (#76). */
35
+ toolsKey?: string;
36
+ /** System prompt the recorded session was created with (#76). */
37
+ systemPrompt?: string;
38
+ /** Serialized transcript prefix already sent to the recorded session. */
39
+ sentParts?: string[];
40
+ };
41
+
42
+ const piSessions = new Map<string, PiSessionState>();
43
+
44
+ /** Number of tracked Pi sessions (exposed for tests). */
45
+ export function trackedPiSessionCount(): number {
46
+ return piSessions.size;
47
+ }
48
+
49
+ export function getPiSessionState(piSessionId: string): PiSessionState {
50
+ const existing = piSessions.get(piSessionId);
51
+ if (existing) {
52
+ // Refresh recency so eviction drops the least-recently-used entry.
53
+ piSessions.delete(piSessionId);
54
+ piSessions.set(piSessionId, existing);
55
+ return existing;
56
+ }
57
+ const state: PiSessionState = {};
58
+ piSessions.set(piSessionId, state);
59
+ while (piSessions.size > MAX_TRACKED_PI_SESSIONS) {
60
+ const oldest = piSessions.keys().next();
61
+ if (oldest.done) break;
62
+ void cleanupPiSessionState(oldest.value as string);
63
+ }
64
+ return state;
65
+ }
66
+
67
+ export function ensurePiSessionDir(state: PiSessionState): Promise<string> {
68
+ state.dirPromise ??= createTempAgentDir().catch((error) => {
69
+ if (state.dirPromise) state.dirPromise = undefined;
70
+ throw error;
71
+ });
72
+ return state.dirPromise;
73
+ }
74
+
75
+ /**
76
+ * Remove a Pi session's project directory and delete its OpenCode session
77
+ * record — the explicit cleanup path for session teardown (#74). With no
78
+ * argument, every tracked session is cleaned up.
79
+ */
80
+ export async function cleanupPiSessionState(
81
+ piSessionId?: string,
82
+ ): Promise<void> {
83
+ const ids =
84
+ piSessionId === undefined ? [...piSessions.keys()] : [piSessionId];
85
+ await Promise.all(
86
+ ids.map(async (id) => {
87
+ const state = piSessions.get(id);
88
+ if (!state) return;
89
+ piSessions.delete(id);
90
+ const deletion = deleteOpenCodeSession(state.opencodeSessionId);
91
+ state.opencodeSessionId = undefined;
92
+ if (state.dirPromise) {
93
+ const dirPromise = state.dirPromise;
94
+ state.dirPromise = undefined;
95
+ const dir = await dirPromise.catch(() => undefined);
96
+ if (dir) await rm(dir, { recursive: true, force: true }).catch(() => undefined);
97
+ }
98
+ await deletion;
99
+ }),
100
+ );
101
+ if (piSessionId === undefined) await Promise.all([...pendingSessionDeletions]);
102
+ }
103
+
104
+ /** Forget a broken session so the next turn starts fresh (#75). */
105
+ export function dropOpencodeSession(state: PiSessionState): void {
106
+ deleteOpenCodeSession(state.opencodeSessionId);
107
+ state.opencodeSessionId = undefined;
108
+ state.modelId = undefined;
109
+ state.toolsKey = undefined;
110
+ state.systemPrompt = undefined;
111
+ state.sentParts = undefined;
112
+ }
113
+
114
+ /**
115
+ * Decide whether the next turn may continue the recorded OpenCode session
116
+ * (#75, #76). Continuation requires the same model, the same serialized
117
+ * tool list and system prompt, and a transcript that extends — never
118
+ * rewrites — the previously sent prefix, with at least one new message.
119
+ */
120
+ export function continuationDelta(
121
+ state: PiSessionState,
122
+ modelId: string,
123
+ context: Context,
124
+ parts: string[],
125
+ ): string[] | undefined {
126
+ if (!state.opencodeSessionId) return undefined;
127
+ if (state.modelId !== modelId) return undefined;
128
+ if ((state.toolsKey ?? "") !== serializeTools(context.tools)) return undefined;
129
+ if ((state.systemPrompt ?? "") !== (context.systemPrompt ?? "")) return undefined;
130
+ const sent = state.sentParts ?? [];
131
+ if (sent.length >= parts.length) return undefined;
132
+ for (let index = 0; index < sent.length; index += 1) {
133
+ if (sent[index] !== parts[index]) return undefined;
134
+ }
135
+ return parts.slice(sent.length);
136
+ }
137
+
138
+ export async function createTempAgentDir(): Promise<string> {
139
+ const dir = await mkdtemp(join(tmpdir(), "opencode-pi-"));
140
+ const configDir = join(dir, ".opencode");
141
+ const agentsDir = join(configDir, "agents");
142
+ const pluginsDir = join(configDir, "plugins");
143
+ await mkdir(agentsDir, { recursive: true });
144
+ await mkdir(pluginsDir, { recursive: true });
145
+ // `opencode run` streams an OpenCode compaction summary as reply text, where
146
+ // its quoted markers break the turn. Pi owns compaction, so overflow must
147
+ // surface as an error instead.
148
+ await writeFile(
149
+ join(configDir, "opencode.json"),
150
+ JSON.stringify({ compaction: { auto: false } }),
151
+ "utf8",
152
+ );
153
+ await writeFile(
154
+ join(agentsDir, `${AGENT_ID}.md`),
155
+ `---
156
+ description: Pi bridge agent. Pi tool calls are emitted as text markers.
157
+ mode: primary
158
+ permission:
159
+ "*": ask
160
+ ---
161
+ You are the OpenCode side of a Pi Coding Agent bridge. Do not use OpenCode native tools. Reply in plain text, or emit <pi_tool_call>{"name":"...","arguments":{...}}</pi_tool_call> exactly when the prompt asks you to request a Pi tool.
162
+ `,
163
+ "utf8",
164
+ );
165
+ // Zen's free tier requires OpenCode's normal tool declarations to remain in
166
+ // the request. Permission `deny` removes them before inference and is now
167
+ // rejected as a third-party client. Keep declarations intact, but block
168
+ // every native execution before side effects occur. The agent's `ask`
169
+ // permission keeps declarations too, and `opencode run` auto-rejects asks.
170
+ // V2 relies on that permission guard; the legacy plugin only guards V1.
171
+ await writeFile(
172
+ join(pluginsDir, "pi-native-tool-blocker.js"),
173
+ `export const PiNativeToolBlocker = async () => ({
174
+ "tool.execute.before": async (input) => {
175
+ throw new Error("Native OpenCode tool " + input.tool + " is disabled in the Pi bridge. Request the equivalent Pi tool instead: reply with <pi_tool_call>{\\"name\\":\\"tool_name\\",\\"arguments\\":{}}</pi_tool_call> using a tool from the Pi tool list.")
176
+ },
177
+ })
178
+ `,
179
+ "utf8",
180
+ );
181
+ return dir;
182
+ }