pi-opencode-cli-bridge 0.0.0-stage → 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/src/index.ts ADDED
@@ -0,0 +1,1865 @@
1
+ import { runOpenCodeProcess } from "./process.js";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import { registerApiProvider } from "@earendil-works/pi-ai/compat";
8
+ import {
9
+ calculateCost,
10
+ createAssistantMessageEventStream,
11
+ getCurrentSystemPrompt,
12
+ getCurrentTools,
13
+ normalizeContext,
14
+ type Api,
15
+ type AssistantMessage,
16
+ type AssistantMessageEventStream,
17
+ type Context,
18
+ type ImageContent,
19
+ type Message,
20
+ type Model,
21
+ type ModelThinkingLevel,
22
+ type SimpleStreamOptions,
23
+ type TextContent,
24
+ type ThinkingLevelMap,
25
+ type Tool,
26
+ type ToolCall,
27
+ } from "@earendil-works/pi-ai";
28
+
29
+ const PROVIDER_ID = "opencode-cli";
30
+ const API_ID = "opencode-cli-runner";
31
+ const AGENT_ID = "pi-model";
32
+ const DEFAULT_CONTEXT_WINDOW = 128_000;
33
+ const DEFAULT_MAX_TOKENS = 16_384;
34
+ const DISCOVERY_TIMEOUT_MS = 8_000;
35
+ const STATUS_TIMEOUT_MS = 10_000;
36
+ const SESSION_DELETE_TIMEOUT_MS = 10_000;
37
+ const STDERR_LIMIT = 20_000;
38
+ /**
39
+ * Upper bound for every model turn, applied even when Pi supplies no
40
+ * `timeoutMs`. A session ID reused from the wrong project directory hangs
41
+ * with empty stdout/stderr, so no turn may wait indefinitely (#73).
42
+ */
43
+ const DEFAULT_TURN_TIMEOUT_MS = 180_000;
44
+ /** Cap on tracked Pi sessions; the oldest entry is evicted with cleanup. */
45
+ const MAX_TRACKED_PI_SESSIONS = 10;
46
+
47
+ export type CliStatus = {
48
+ ok: boolean;
49
+ summary: string;
50
+ detail?: string;
51
+ };
52
+
53
+ const DEFAULT_FREE_MODELS = [
54
+ "opencode/mimo-v2.5-free",
55
+ "opencode/nemotron-3.5-lightning-free",
56
+ "opencode/ling-3.0-flash-fin-free",
57
+ "opencode/big-pickle",
58
+ ];
59
+ export type ModelCost = {
60
+ input: number;
61
+ output: number;
62
+ cacheRead: number;
63
+ cacheWrite: number;
64
+ };
65
+ const ZERO_COST: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
66
+ const THINKING_LEVELS = [
67
+ "off",
68
+ "minimal",
69
+ "low",
70
+ "medium",
71
+ "high",
72
+ "xhigh",
73
+ ] as const satisfies readonly ModelThinkingLevel[];
74
+
75
+ export type OpenCodeModelInfo = {
76
+ id: string;
77
+ name: string;
78
+ reasoning: boolean;
79
+ image: boolean;
80
+ contextWindow: number;
81
+ maxTokens: number;
82
+ cost: ModelCost;
83
+ /** True when `cost` was read from discovery metadata rather than defaulted. */
84
+ costFromMetadata?: boolean;
85
+ status?: string;
86
+ thinkingLevelMap?: ThinkingLevelMap;
87
+ };
88
+
89
+ export type ParsedToolCall = {
90
+ name: string;
91
+ arguments: Record<string, unknown>;
92
+ };
93
+
94
+ export type ToolCallParseResult =
95
+ | { ok: true; calls: ParsedToolCall[]; cleanedText?: string }
96
+ | {
97
+ ok: false;
98
+ rejection:
99
+ | { reason: "malformed_markers" }
100
+ | { reason: "invalid_payload" }
101
+ | { reason: "unavailable_tool"; toolName: string };
102
+ };
103
+
104
+ let registeredModels: OpenCodeModelInfo[] = [];
105
+ let lastDiscoveryTime: number | undefined;
106
+ let lastDiscoveryError: string | undefined;
107
+
108
+ function opencodeBin(): string {
109
+ const configured = process.env.OPENCODE_PI_BIN?.trim();
110
+ if (configured) return configured;
111
+ if (process.platform === "win32") {
112
+ // npm's `opencode.cmd` shim cannot be launched by spawn() without a shell.
113
+ // Resolve the native executable installed beside the active Node runtime.
114
+ const native = join(
115
+ dirname(process.execPath),
116
+ "node_modules",
117
+ "opencode-ai",
118
+ "bin",
119
+ "opencode.exe",
120
+ );
121
+ if (existsSync(native)) return native;
122
+ }
123
+ return "opencode";
124
+ }
125
+
126
+ function configuredModels(): string[] | undefined {
127
+ const raw = process.env.OPENCODE_PI_MODELS?.trim();
128
+ if (!raw) return undefined;
129
+ return raw
130
+ .split(/[\s,]+/)
131
+ .map((part) => part.trim())
132
+ .filter(Boolean)
133
+ .map((model) => (model.includes("/") ? model : `opencode/${model}`));
134
+ }
135
+
136
+ function modelDisplayName(model: string): string {
137
+ const [, id = model] = model.split(/\/(.*)/s);
138
+ return `OpenCode ${id}`;
139
+ }
140
+
141
+ function contextWindowFor(model: string): number {
142
+ if (model.includes("big-pickle")) return 200_000;
143
+ return DEFAULT_CONTEXT_WINDOW;
144
+ }
145
+
146
+ function maxTokensFor(model: string): number {
147
+ if (model.includes("big-pickle")) return 32_000;
148
+ return DEFAULT_MAX_TOKENS;
149
+ }
150
+
151
+ function looksFree(model: string): boolean {
152
+ return /(^opencode\/.*-free$)|(^opencode\/big-pickle$)/.test(model);
153
+ }
154
+
155
+ /** Read a non-negative finite cost figure; anything else means "unknown". */
156
+ function costFigure(value: unknown): number | undefined {
157
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
158
+ ? value
159
+ : undefined;
160
+ }
161
+
162
+ /**
163
+ * Read the real per-model cost from verbose discovery metadata (#78).
164
+ * Missing or malformed figures fall back to zero so a model is never
165
+ * reported with an invented non-zero cost.
166
+ */
167
+ export function parseModelCost(metadata: unknown): ModelCost {
168
+ const value = metadata as {
169
+ cost?: {
170
+ input?: unknown;
171
+ output?: unknown;
172
+ cache?: { read?: unknown; write?: unknown };
173
+ };
174
+ } | null;
175
+ const cost = value?.cost;
176
+ return {
177
+ input: costFigure(cost?.input) ?? 0,
178
+ output: costFigure(cost?.output) ?? 0,
179
+ cacheRead: costFigure(cost?.cache?.read) ?? 0,
180
+ cacheWrite: costFigure(cost?.cache?.write) ?? 0,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Free models are identified by zero input and output cost in the discovery
186
+ * metadata (#77). When a record carries no usable cost figures (older CLI
187
+ * output), fall back to the legacy `-free` name pattern so paid models are
188
+ * never misregistered as free.
189
+ */
190
+ export function isFreeModel(model: {
191
+ id: string;
192
+ cost: ModelCost;
193
+ costFromMetadata?: boolean;
194
+ }): boolean {
195
+ if (model.cost.input !== 0 || model.cost.output !== 0) return false;
196
+ if (model.costFromMetadata === true) return true;
197
+ return looksFree(model.id);
198
+ }
199
+
200
+ /**
201
+ * Discovery metadata carries a per-model status field (#79). Only `active`
202
+ * models are registered; a missing status is treated as active so older CLI
203
+ * output without the field keeps working.
204
+ */
205
+ export function isActiveModel(status: unknown): boolean {
206
+ if (status === undefined || status === null) return true;
207
+ return String(status).trim().toLowerCase() === "active";
208
+ }
209
+
210
+ function dedupe(values: string[]): string[] {
211
+ return [...new Set(values)];
212
+ }
213
+
214
+ function fallbackModel(id: string): OpenCodeModelInfo {
215
+ return {
216
+ id,
217
+ name: modelDisplayName(id),
218
+ reasoning: false,
219
+ image: false,
220
+ contextWindow: contextWindowFor(id),
221
+ maxTokens: maxTokensFor(id),
222
+ cost: ZERO_COST,
223
+ };
224
+ }
225
+
226
+ function positiveNumber(value: unknown, fallback: number): number {
227
+ return typeof value === "number" && Number.isFinite(value) && value > 0
228
+ ? value
229
+ : fallback;
230
+ }
231
+
232
+ function thinkingLevelMap(variants: unknown): ThinkingLevelMap {
233
+ const map: ThinkingLevelMap = {};
234
+ const names =
235
+ typeof variants === "object" && variants !== null && !Array.isArray(variants)
236
+ ? new Set(Object.keys(variants))
237
+ : new Set<string>();
238
+
239
+ for (const level of THINKING_LEVELS) {
240
+ if (level === "off") {
241
+ if (names.has("off")) map.off = "off";
242
+ else if (names.has("none")) map.off = "none";
243
+ continue;
244
+ }
245
+ map[level] = names.has(level) ? level : null;
246
+ }
247
+ if (!names.has("xhigh") && names.has("max")) map.xhigh = "max";
248
+ return map;
249
+ }
250
+
251
+ export function reasoningCliArgs(
252
+ requestedReasoning: ModelThinkingLevel | undefined,
253
+ map?: ThinkingLevelMap,
254
+ ): string[] {
255
+ if (!requestedReasoning || requestedReasoning === "off") return [];
256
+
257
+ const args = ["--thinking"];
258
+ const variant = map?.[requestedReasoning];
259
+ if (typeof variant === "string" && variant.trim()) {
260
+ args.push("--variant", variant);
261
+ }
262
+ return args;
263
+ }
264
+
265
+ function findJsonObjectEnd(text: string, start: number): number | undefined {
266
+ let depth = 0;
267
+ let inString = false;
268
+ let escaped = false;
269
+ for (let index = start; index < text.length; index += 1) {
270
+ const character = text[index];
271
+ if (inString) {
272
+ if (escaped) escaped = false;
273
+ else if (character === "\\") escaped = true;
274
+ else if (character === '"') inString = false;
275
+ continue;
276
+ }
277
+ if (character === '"') inString = true;
278
+ else if (character === "{") depth += 1;
279
+ else if (character === "}") {
280
+ depth -= 1;
281
+ if (depth === 0) return index + 1;
282
+ }
283
+ }
284
+ return undefined;
285
+ }
286
+
287
+ /** Parse the `model-id` + pretty-printed JSON pairs from OpenCode verbose discovery. */
288
+ export function parseVerboseModels(output: string): OpenCodeModelInfo[] {
289
+ const records = new Map<string, OpenCodeModelInfo>();
290
+ const idPattern = /^opencode\/[^\s]+\s*$/gm;
291
+ for (const match of output.matchAll(idPattern)) {
292
+ const id = match[0].trim();
293
+ const metadataStart = (match.index ?? 0) + match[0].length;
294
+ const objectStart = output.indexOf("{", metadataStart);
295
+ if (objectStart < 0) continue;
296
+
297
+ const nextId = output.slice(metadataStart).search(/^opencode\/[^\s]+\s*$/m);
298
+ if (nextId >= 0 && objectStart >= metadataStart + nextId) continue;
299
+ const objectEnd = findJsonObjectEnd(output, objectStart);
300
+ if (objectEnd === undefined) continue;
301
+
302
+ let metadata: unknown;
303
+ try {
304
+ metadata = JSON.parse(output.slice(objectStart, objectEnd));
305
+ } catch {
306
+ continue;
307
+ }
308
+ if (typeof metadata !== "object" || metadata === null) continue;
309
+
310
+ const value = metadata as {
311
+ name?: unknown;
312
+ status?: unknown;
313
+ cost?: {
314
+ input?: unknown;
315
+ output?: unknown;
316
+ cache?: { read?: unknown; write?: unknown };
317
+ };
318
+ limit?: { context?: unknown; output?: unknown };
319
+ capabilities?: { reasoning?: unknown; input?: { image?: unknown } };
320
+ variants?: unknown;
321
+ };
322
+ const reasoning = value.capabilities?.reasoning === true;
323
+ const cost = parseModelCost(value);
324
+ const costFromMetadata =
325
+ costFigure(value.cost?.input) !== undefined &&
326
+ costFigure(value.cost?.output) !== undefined;
327
+ const status =
328
+ typeof value.status === "string" && value.status.trim()
329
+ ? value.status.trim()
330
+ : undefined;
331
+ records.set(id, {
332
+ id,
333
+ name:
334
+ typeof value.name === "string" && value.name.trim()
335
+ ? value.name.trim()
336
+ : modelDisplayName(id),
337
+ reasoning,
338
+ image: value.capabilities?.input?.image === true,
339
+ contextWindow: positiveNumber(
340
+ value.limit?.context,
341
+ contextWindowFor(id),
342
+ ),
343
+ maxTokens: positiveNumber(value.limit?.output, maxTokensFor(id)),
344
+ cost,
345
+ ...(costFromMetadata ? { costFromMetadata: true as const } : {}),
346
+ ...(status !== undefined ? { status } : {}),
347
+ ...(reasoning
348
+ ? { thinkingLevelMap: thinkingLevelMap(value.variants) }
349
+ : {}),
350
+ });
351
+ }
352
+ return [...records.values()];
353
+ }
354
+
355
+ function runCapture(
356
+ args: string[],
357
+ input?: string,
358
+ timeoutMs = DISCOVERY_TIMEOUT_MS,
359
+ ): Promise<{ stdout: string; stderr: string; code: number | null }> {
360
+ return runOpenCodeProcess(opencodeBin(), args, {
361
+ input,
362
+ timeoutMs,
363
+ env: { ...process.env, OPENCODE_DISABLE_UPDATE_CHECK: "1" },
364
+ });
365
+ }
366
+
367
+ /** Build the install/setup instructions shown when the opencode CLI is unusable. */
368
+ export function setupGuidance(error: string): string {
369
+ return [
370
+ "opencode-pi could not use the local OpenCode CLI.",
371
+ `Reason: ${error}`,
372
+ "Install OpenCode (https://opencode.ai), ensure `opencode --version` works on PATH, then reload Pi.",
373
+ "Set OPENCODE_PI_BIN to override the executable path.",
374
+ ].join(" ");
375
+ }
376
+
377
+ /** Probe the OpenCode CLI with `opencode --version` to detect a missing/broken install. */
378
+ export async function checkCliStatus(): Promise<CliStatus> {
379
+ try {
380
+ const result = await runCapture(["--version"], undefined, STATUS_TIMEOUT_MS);
381
+ if (result.code !== 0) {
382
+ const detail =
383
+ result.stderr.trim() ||
384
+ result.stdout.trim() ||
385
+ `opencode --version exited with code ${result.code}`;
386
+ return { ok: false, summary: "OpenCode CLI is unusable", detail };
387
+ }
388
+ const version =
389
+ result.stdout.trim() || result.stderr.trim() || "OpenCode CLI is available";
390
+ return { ok: true, summary: version };
391
+ } catch (error) {
392
+ return {
393
+ ok: false,
394
+ summary: "OpenCode CLI is unavailable",
395
+ detail: error instanceof Error ? error.message : String(error),
396
+ };
397
+ }
398
+ }
399
+
400
+ export async function discoverModels(opts?: {
401
+ forceDiscovery?: boolean;
402
+ }): Promise<{
403
+ models: OpenCodeModelInfo[];
404
+ time: number;
405
+ error: string | undefined;
406
+ }> {
407
+ const configured = configuredModels();
408
+ // An explicit OPENCODE_PI_MODELS list already tells us exactly which
409
+ // models to register, so the fast (non-forced) path skips spawning
410
+ // opencode entirely rather than making explicitly configured users pay a
411
+ // discovery timeout on every startup when the binary is missing or slow.
412
+ // `forceDiscovery` (used by the user-initiated /opencode-pi update
413
+ // command) still runs discovery so configured models can be enriched with
414
+ // real capability metadata (reasoning/image/limits) on request.
415
+ if (configured?.length && !opts?.forceDiscovery) {
416
+ lastDiscoveryError = undefined;
417
+ return {
418
+ models: dedupe(configured).map(fallbackModel),
419
+ time: Date.now(),
420
+ error: undefined,
421
+ };
422
+ }
423
+
424
+ try {
425
+ const result = await runCapture(["models", "opencode", "--verbose"]);
426
+ if (result.code !== 0) {
427
+ throw new Error(
428
+ result.stderr.trim() ||
429
+ `opencode models exited with code ${result.code}`,
430
+ );
431
+ }
432
+
433
+ const metadata = parseVerboseModels(result.stdout);
434
+ if (metadata.length === 0) {
435
+ throw new Error("opencode verbose model discovery returned no metadata");
436
+ }
437
+ const byId = new Map(metadata.map((model) => [model.id, model]));
438
+ const selected = configured?.length
439
+ ? dedupe(configured).map((id) => byId.get(id) ?? fallbackModel(id))
440
+ : metadata.filter(
441
+ (model) => isActiveModel(model.status) && isFreeModel(model),
442
+ );
443
+ if (selected.length === 0) {
444
+ throw new Error("opencode verbose model discovery returned no free models");
445
+ }
446
+
447
+ lastDiscoveryError = undefined;
448
+ return { models: selected, time: Date.now(), error: undefined };
449
+ } catch (error) {
450
+ lastDiscoveryError = error instanceof Error ? error.message : String(error);
451
+ const fallbackIds = configured?.length
452
+ ? dedupe(configured)
453
+ : DEFAULT_FREE_MODELS;
454
+ return {
455
+ models: fallbackIds.map(fallbackModel),
456
+ time: Date.now(),
457
+ error: lastDiscoveryError,
458
+ };
459
+ }
460
+ }
461
+
462
+ async function refreshModels(
463
+ pi: ExtensionAPI,
464
+ ctx: { ui: { notify: (msg: string, level?: string) => void } },
465
+ ): Promise<void> {
466
+ const previousModels = new Set(registeredModels.map((model) => model.id));
467
+ const { models, time, error } = await discoverModels({
468
+ forceDiscovery: true,
469
+ });
470
+ registeredModels = models;
471
+ lastDiscoveryTime = time;
472
+
473
+ // Re-register the provider with the updated model list
474
+ const providerConfig: Parameters<ExtensionAPI["registerProvider"]>[1] = {
475
+ name: "OpenCode CLI",
476
+ baseUrl: "cli:opencode",
477
+ apiKey: "opencode-cli-no-api-key",
478
+ api: API_ID,
479
+ models: models.map(providerModel),
480
+ streamSimple: streamOpenCode,
481
+ };
482
+
483
+ try {
484
+ pi.registerProvider(PROVIDER_ID, providerConfig);
485
+ } catch {
486
+ // registerProvider may reject if already registered; the models array is already updated.
487
+ }
488
+ registerApiHandler();
489
+
490
+ const newModels = models.filter((model) => !previousModels.has(model.id));
491
+
492
+ let msg = `opencode-pi: refreshed ${models.length} model(s).`;
493
+ if (newModels.length > 0) {
494
+ msg += ` ${newModels.length} new: ${newModels
495
+ .slice(0, 5)
496
+ .map((model) => model.id)
497
+ .join(", ")}${newModels.length > 5 ? ", ..." : ""}`;
498
+ }
499
+ if (error) msg += ` Discovery issue: ${error}`;
500
+ ctx.ui.notify(msg, "info");
501
+ }
502
+
503
+ function emptyUsage(): AssistantMessage["usage"] {
504
+ return {
505
+ input: 0,
506
+ output: 0,
507
+ cacheRead: 0,
508
+ cacheWrite: 0,
509
+ totalTokens: 0,
510
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
511
+ };
512
+ }
513
+
514
+ function estimateTokens(text: string): number {
515
+ return Math.max(1, Math.ceil(text.length / 4));
516
+ }
517
+
518
+ function setEstimatedUsage(
519
+ model: Model<Api>,
520
+ output: AssistantMessage,
521
+ prompt: string,
522
+ text: string,
523
+ ) {
524
+ if (output.usage.totalTokens > 0) return;
525
+ output.usage.input = estimateTokens(prompt);
526
+ output.usage.output = estimateTokens(text);
527
+ output.usage.totalTokens = output.usage.input + output.usage.output;
528
+ calculateCost(model, output.usage);
529
+ }
530
+
531
+ function contentToText(
532
+ content: string | (TextContent | ImageContent)[],
533
+ ): string {
534
+ if (typeof content === "string") return content;
535
+ return content
536
+ .map((item) => {
537
+ if (item.type === "text") return item.text;
538
+ return `[image in conversation: ${item.mimeType}, ${item.data.length} base64 chars]`;
539
+ })
540
+ .join("\n");
541
+ }
542
+
543
+ export function imageContentsForModel(
544
+ messages: Message[],
545
+ supportsImages: boolean,
546
+ ): ImageContent[] {
547
+ if (!supportsImages) return [];
548
+
549
+ const images: ImageContent[] = [];
550
+ for (const message of messages) {
551
+ if (message.role !== "user" && message.role !== "toolResult") continue;
552
+ if (typeof message.content === "string") continue;
553
+ for (const content of message.content) {
554
+ if (content.type === "image") images.push(content);
555
+ }
556
+ }
557
+ return images;
558
+ }
559
+
560
+ function safeImageExtension(mimeType: string): string {
561
+ const extensions: Record<string, string> = {
562
+ "image/avif": ".avif",
563
+ "image/bmp": ".bmp",
564
+ "image/gif": ".gif",
565
+ "image/jpeg": ".jpg",
566
+ "image/png": ".png",
567
+ "image/webp": ".webp",
568
+ };
569
+ return extensions[mimeType.toLowerCase()] ?? ".bin";
570
+ }
571
+
572
+ async function writeImageFiles(
573
+ images: ImageContent[],
574
+ directory: string,
575
+ ): Promise<string[]> {
576
+ const imageDir = join(directory, "pi-images");
577
+ if (images.length > 0) await mkdir(imageDir, { recursive: true });
578
+ return Promise.all(
579
+ images.map(async (image, index) => {
580
+ if (!image.mimeType.toLowerCase().startsWith("image/")) {
581
+ throw new Error(`Unsupported image MIME type: ${image.mimeType}`);
582
+ }
583
+ const base64 = image.data.replace(/\s/g, "");
584
+ if (
585
+ !base64 ||
586
+ base64.length % 4 === 1 ||
587
+ !/^[A-Za-z0-9+/]*={0,2}$/.test(base64)
588
+ ) {
589
+ throw new Error(`Invalid base64 data for image ${index + 1}`);
590
+ }
591
+ const data = Buffer.from(base64, "base64");
592
+ if (data.length === 0) {
593
+ throw new Error(`Empty image data for image ${index + 1}`);
594
+ }
595
+ const path = join(
596
+ imageDir,
597
+ `image-${String(index + 1).padStart(3, "0")}${safeImageExtension(image.mimeType)}`,
598
+ );
599
+ await writeFile(path, data);
600
+ return path;
601
+ }),
602
+ );
603
+ }
604
+
605
+ function objectRecord(value: unknown): Record<string, unknown> | undefined {
606
+ return typeof value === "object" && value !== null && !Array.isArray(value)
607
+ ? value as Record<string, unknown>
608
+ : undefined;
609
+ }
610
+
611
+ function safeJson(value: unknown): string {
612
+ try {
613
+ return JSON.stringify(value, null, 2);
614
+ } catch {
615
+ return String(value);
616
+ }
617
+ }
618
+
619
+ function serializeMessage(message: Message): string {
620
+ if (message.role === "user" || message.role === "system") {
621
+ return `${message.role.toUpperCase()}:\n${contentToText(message.content)}`;
622
+ }
623
+
624
+ if (message.role === "toolResult") {
625
+ return [
626
+ `PI TOOL RESULT (${message.toolName}, id=${message.toolCallId}, isError=${message.isError}):`,
627
+ contentToText(message.content),
628
+ ].join("\n");
629
+ }
630
+
631
+ const parts = message.content.map(
632
+ (part: TextContent | ToolCall | { type: "thinking"; thinking: string }) => {
633
+ if (part.type === "text") return part.text;
634
+ if (part.type === "thinking")
635
+ return `<thinking>${part.thinking}</thinking>`;
636
+ return `<pi_tool_call>${safeJson({ id: part.id, name: part.name, arguments: part.arguments })}</pi_tool_call>`;
637
+ },
638
+ );
639
+ return `ASSISTANT:\n${parts.join("\n")}`;
640
+ }
641
+
642
+ function serializeTools(tools?: Tool[]): string {
643
+ if (!tools || tools.length === 0)
644
+ return "No Pi tools are available for this turn.";
645
+ return safeJson(
646
+ tools.map((tool) => ({
647
+ name: tool.name,
648
+ description: tool.description,
649
+ parameters: tool.parameters,
650
+ })),
651
+ );
652
+ }
653
+
654
+ function buildPrompt(context: Context): string {
655
+ const sections: string[] = [];
656
+ sections.push(`# Pi/OpenCode bridge instructions
657
+
658
+ You are being used as the model backend for Pi Coding Agent through the OpenCode CLI.
659
+ OpenCode's own tools are disabled. Do not try to use OpenCode tools.
660
+
661
+ If you need Pi to run a tool, your entire response must contain only one or more tool-call blocks separated by whitespace:
662
+ <pi_tool_call>{"name":"tool_name","arguments":{}}</pi_tool_call>
663
+
664
+ Rules for Pi tool calls:
665
+ - Use only exact tool names listed in the "Available Pi tools" section.
666
+ - The JSON inside every marker must be valid JSON with a string "name" and an object "arguments".
667
+ - Escape every quote inside a JSON string value as \\\", especially quotes inside shell commands.
668
+ - Markers are bridge control syntax. Never quote them, explain them, put them in prose, or wrap them in Markdown fences.
669
+ - Never output a marker as an example. A marker means you are requesting immediate execution.
670
+ - Do not put any text before or after tool-call markers. Mixed prose and markers will not execute.
671
+ - NEVER use XML-style tool-use syntax (e.g., <bash>, <read>, <glob>, <grep>, <edit>, <task>, <arg_key>, <arg_value>). These are your native tool-use format and are completely disabled.
672
+ - NEVER use Claude Code XML-style markup (e.g., <bash command="...">, <read path="...">, <arg_key>, <arg_value>). This is not a tool-call and will be treated as plain text.
673
+ - If you can answer without a tool, answer normally in plain text and do not emit any marker.
674
+ - After Pi returns tool results, match them to prior tool-call IDs in the transcript, then either answer or request another Pi tool call.`);
675
+
676
+ if (context.systemPrompt?.trim()) {
677
+ sections.push(`# Pi system prompt
678
+
679
+ ${context.systemPrompt}`);
680
+ }
681
+
682
+ sections.push(`# Available Pi tools
683
+
684
+ ${serializeTools(context.tools)}`);
685
+
686
+ if (context.messages.length > 0) {
687
+ sections.push(`# Conversation transcript
688
+
689
+ ${context.messages.map(serializeMessage).join("\n\n---\n\n")}`);
690
+ } else {
691
+ sections.push("# Conversation transcript\n\n(no prior messages)");
692
+ }
693
+
694
+ sections.push("Now produce the next assistant message for Pi.");
695
+ return sections.join("\n\n---\n\n");
696
+ }
697
+
698
+ const MARKER_OPEN = "<pi_tool_call>";
699
+
700
+ // Detects marker syntax anywhere in the response, not just at the start, so
701
+ // prose-then-marker and marker-then-prose responses are treated the same
702
+ // way: either both are rejected as malformed tool-call attempts, or neither
703
+ // is. A one-sided check let malformed leading prose silently leak raw
704
+ // marker text into the visible chat response while malformed trailing prose
705
+ // hard-failed the turn.
706
+ //
707
+ // Requires the opening marker specifically: a response can only be
708
+ // *attempting* a tool call if it emits the open tag. A lone closing marker
709
+ // (e.g. prose that merely quotes or discusses `</pi_tool_call>`) is not a
710
+ // plausible tool-call attempt and must not hard-fail the turn.
711
+ export function isToolCallMarkerResponse(text: string): boolean {
712
+ const trimmed = text.trim();
713
+ return trimmed.includes(MARKER_OPEN);
714
+ }
715
+
716
+ type MarkerClose = { index: number; length: number };
717
+
718
+ // Models occasionally vary whitespace, separators, plurality, or case in the
719
+ // closing tag. Keep the opening marker strict, but accept this bounded family
720
+ // of unmistakable Pi marker closers.
721
+ const MARKER_CLOSE_PATTERN = /^<\s*\/\s*pi[-_]tool[-_]calls?\s*>/i;
722
+
723
+ // A closing marker inside a JSON string (e.g. tool arguments that happen to
724
+ // contain the literal text "</pi_tool_call>") must not be treated as the
725
+ // real boundary, so this walks the JSON string-escaping state rather than
726
+ // matching the close tag with a plain regex.
727
+ function findMarkerClose(text: string, from: number): MarkerClose | undefined {
728
+ let inString = false;
729
+ let escaped = false;
730
+ for (let i = from; i < text.length; i++) {
731
+ const char = text[i];
732
+ if (inString) {
733
+ if (escaped) escaped = false;
734
+ else if (char === "\\") escaped = true;
735
+ else if (char === '"') inString = false;
736
+ continue;
737
+ }
738
+ if (char === '"') {
739
+ inString = true;
740
+ continue;
741
+ }
742
+ if (char !== "<") continue;
743
+ const match = text.slice(i).match(MARKER_CLOSE_PATTERN);
744
+ if (match) return { index: i, length: match[0].length };
745
+ }
746
+ return undefined;
747
+ }
748
+
749
+ // Some models emit a complete marker payload but omit only the closing tag.
750
+ // Recover that narrow case by locating one complete top-level JSON object or
751
+ // array and requiring whitespace-only content afterward. Payload validation
752
+ // and the current-turn tool allowlist are still applied by the caller.
753
+ function recoverUnclosedMarkerBody(
754
+ text: string,
755
+ bodyStart: number,
756
+ ): string | undefined {
757
+ let start = bodyStart;
758
+ while (/\s/.test(text[start] ?? "")) start++;
759
+ const first = text[start];
760
+ if (first !== "{" && first !== "[") return undefined;
761
+
762
+ const stack: string[] = [first];
763
+ let inString = false;
764
+ let escaped = false;
765
+ for (let i = start + 1; i < text.length; i++) {
766
+ const char = text[i];
767
+ if (inString) {
768
+ if (escaped) escaped = false;
769
+ else if (char === "\\") escaped = true;
770
+ else if (char === '"') inString = false;
771
+ continue;
772
+ }
773
+ if (char === '"') {
774
+ inString = true;
775
+ continue;
776
+ }
777
+ if (char === "{" || char === "[") {
778
+ stack.push(char);
779
+ continue;
780
+ }
781
+ if (char !== "}" && char !== "]") continue;
782
+
783
+ const expected = char === "}" ? "{" : "[";
784
+ if (stack.pop() !== expected) return undefined;
785
+ if (stack.length === 0) {
786
+ const end = i + 1;
787
+ return text.slice(end).trim() ? undefined : text.slice(bodyStart, end);
788
+ }
789
+ }
790
+ return undefined;
791
+ }
792
+
793
+ function normalizeQuotedToolCallResponse(text: string): string {
794
+ const trimmed = text.trim();
795
+ if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) return text;
796
+ try {
797
+ const decoded: unknown = JSON.parse(trimmed);
798
+ return typeof decoded === "string" && decoded.includes(MARKER_OPEN)
799
+ ? decoded
800
+ : text;
801
+ } catch {
802
+ return text;
803
+ }
804
+ }
805
+
806
+ /**
807
+ * Extract <pi_tool_call> marker bodies from the response text.
808
+ * Lenient: markers are extracted anywhere in the text, even with prose
809
+ * before or after them, matching how real models behave. A marker whose
810
+ * payload cannot be recovered (truncated JSON, unclosed with trailing
811
+ * content) is recorded as an invalid span instead of aborting extraction,
812
+ * so valid sibling markers still parse and the unrecoverable text can be
813
+ * stripped from the displayed response (#39).
814
+ */
815
+ type MarkerSpan = { start: number; end: number };
816
+
817
+ type ExtractedMarker = { body: string; start: number; end: number };
818
+
819
+ type MarkerExtraction = {
820
+ markers: ExtractedMarker[];
821
+ invalidSpans: MarkerSpan[];
822
+ };
823
+
824
+ function toolCallMarkers(text: string): MarkerExtraction | undefined {
825
+ const trimmed = text.trim();
826
+ const markers: ExtractedMarker[] = [];
827
+ const invalidSpans: MarkerSpan[] = [];
828
+ let cursor = 0;
829
+ while (cursor < trimmed.length) {
830
+ const openIndex = trimmed.indexOf(MARKER_OPEN, cursor);
831
+ if (openIndex === -1) break;
832
+
833
+ const bodyStart = openIndex + MARKER_OPEN.length;
834
+ const close = findMarkerClose(trimmed, bodyStart);
835
+ if (close) {
836
+ markers.push({
837
+ body: trimmed.slice(bodyStart, close.index),
838
+ start: openIndex,
839
+ end: close.index + close.length,
840
+ });
841
+ cursor = close.index + close.length;
842
+ continue;
843
+ }
844
+
845
+ const recovered = recoverUnclosedMarkerBody(trimmed, bodyStart);
846
+ if (recovered !== undefined) {
847
+ const end = bodyStart + recovered.length;
848
+ markers.push({ body: recovered, start: openIndex, end });
849
+ cursor = end;
850
+ continue;
851
+ }
852
+
853
+ // Unrecoverable marker: everything up to the next opening marker (or
854
+ // the end of the response) is inside the attempted marker, so strip it.
855
+ const nextOpen = trimmed.indexOf(MARKER_OPEN, bodyStart);
856
+ const spanEnd = nextOpen === -1 ? trimmed.length : nextOpen;
857
+ invalidSpans.push({ start: openIndex, end: spanEnd });
858
+ cursor = spanEnd;
859
+ }
860
+ if (markers.length === 0 && invalidSpans.length === 0) return undefined;
861
+ return { markers, invalidSpans };
862
+ }
863
+
864
+ function stripMarkerSpans(text: string, spans: MarkerSpan[]): string {
865
+ const sorted = [...spans].sort((a, b) => a.start - b.start);
866
+ let cleaned = "";
867
+ let cursor = 0;
868
+ for (const span of sorted) {
869
+ if (span.start < cursor) continue;
870
+ cleaned += text.slice(cursor, span.start);
871
+ cursor = Math.max(cursor, span.end);
872
+ }
873
+ cleaned += text.slice(cursor);
874
+ return cleaned;
875
+ }
876
+
877
+ export function parseToolCallResponse(
878
+ text: string,
879
+ allowedToolNames?: ReadonlySet<string>,
880
+ ): ToolCallParseResult {
881
+ const normalizedText = normalizeQuotedToolCallResponse(text);
882
+ if (!isToolCallMarkerResponse(normalizedText)) return { ok: true, calls: [] };
883
+
884
+ const extraction = toolCallMarkers(normalizedText);
885
+ if (!extraction) return { ok: true, calls: [] };
886
+
887
+ const parsed: ParsedToolCall[] = [];
888
+ const strippedSpans: MarkerSpan[] = [...extraction.invalidSpans];
889
+ let payloadRejection:
890
+ | { reason: "invalid_payload" }
891
+ | undefined;
892
+
893
+ for (const marker of extraction.markers) {
894
+ const calls = parseToolCallJson(marker.body);
895
+ if (calls.length === 0) {
896
+ // Skip the invalid body but keep any valid sibling calls; if no
897
+ // call survives, surface the original corrective diagnostic.
898
+ payloadRejection ??= { reason: "invalid_payload" };
899
+ strippedSpans.push({ start: marker.start, end: marker.end });
900
+ continue;
901
+ }
902
+ const unavailable = calls.find(
903
+ (call) => allowedToolNames && !allowedToolNames.has(call.name),
904
+ );
905
+ if (unavailable) {
906
+ return {
907
+ ok: false,
908
+ rejection: {
909
+ reason: "unavailable_tool",
910
+ toolName: unavailable.name,
911
+ },
912
+ };
913
+ }
914
+ parsed.push(...calls);
915
+ }
916
+
917
+ if (parsed.length === 0 && payloadRejection) {
918
+ return { ok: false, rejection: payloadRejection };
919
+ }
920
+
921
+ if (strippedSpans.length === 0) return { ok: true, calls: parsed };
922
+ return {
923
+ ok: true,
924
+ calls: parsed,
925
+ cleanedText: stripMarkerSpans(normalizedText, strippedSpans),
926
+ };
927
+ }
928
+
929
+ export function parseToolCalls(
930
+ text: string,
931
+ allowedToolNames?: ReadonlySet<string>,
932
+ ): ParsedToolCall[] {
933
+ const result = parseToolCallResponse(text, allowedToolNames);
934
+ return result.ok ? result.calls : [];
935
+ }
936
+
937
+ /**
938
+ * Detects if the model response contains XML-style tool-use markup
939
+ * (e.g., Claude Code's <bash>, <read>, <arg_key>, <arg_value> tags).
940
+ * Returns the detected tool name if found, or undefined if not.
941
+ */
942
+ function detectXmlToolUse(text: string): string | undefined {
943
+ // Match XML-style tool tags that look like tool invocations.
944
+ // These are NOT <pi_tool_call> markers — they are the model's native format.
945
+ // Claude Code uses <bash>, <read>, <edit>, etc. directly, but may also
946
+ // emit <arg_key>/<arg_value> pairs inside those tags.
947
+ const xmlToolPattern = /<(bash|read|edit|write|glob|grep|ls|webfetch|websearch|task|todowrite|question|skill|lsp|external_directory|doom_loop|agent|arg_key|arg_value)\b/i;
948
+ const match = text.match(xmlToolPattern);
949
+ return match ? match[1].toLowerCase() : undefined;
950
+ }
951
+
952
+ function toolCallRejectionMessage(
953
+ result: Extract<ToolCallParseResult, { ok: false }>,
954
+ allowedToolNames: ReadonlySet<string>,
955
+ ): string {
956
+ switch (result.rejection.reason) {
957
+ case "malformed_markers":
958
+ return "OpenCode returned malformed Pi tool-call markers. Ensure each marker contains valid JSON with a string \"name\" and object \"arguments\".";
959
+ case "invalid_payload":
960
+ return 'OpenCode returned an invalid Pi tool-call payload. Each marker must contain valid JSON with a non-empty string "name" and object "arguments".';
961
+ case "unavailable_tool": {
962
+ const available = [...allowedToolNames].map((name) =>
963
+ JSON.stringify(name),
964
+ );
965
+ return `OpenCode requested unavailable Pi tool ${JSON.stringify(result.rejection.toolName)}. Use only tools available in the current Pi turn: ${available.length > 0 ? available.join(", ") : "none"}.`;
966
+ }
967
+ }
968
+ }
969
+
970
+ function parseArguments(value: unknown): Record<string, unknown> | undefined {
971
+ let parsed = value;
972
+ if (typeof parsed === "string") {
973
+ try {
974
+ parsed = JSON.parse(parsed);
975
+ } catch {
976
+ return undefined;
977
+ }
978
+ }
979
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
980
+ return undefined;
981
+ }
982
+ return parsed as Record<string, unknown>;
983
+ }
984
+
985
+ function repairUnescapedJsonStringQuotes(raw: string): string | undefined {
986
+ const trimmed = raw.trim();
987
+ const matchingContainer =
988
+ (trimmed.startsWith("{") && trimmed.endsWith("}")) ||
989
+ (trimmed.startsWith("[") && trimmed.endsWith("]"));
990
+ if (!matchingContainer) return undefined;
991
+
992
+ let repaired = "";
993
+ let inString = false;
994
+ let escaped = false;
995
+ let changed = false;
996
+
997
+ for (let index = 0; index < trimmed.length; index++) {
998
+ const char = trimmed[index];
999
+ if (!inString) {
1000
+ repaired += char;
1001
+ if (char === '"') inString = true;
1002
+ continue;
1003
+ }
1004
+ if (escaped) {
1005
+ repaired += char;
1006
+ escaped = false;
1007
+ continue;
1008
+ }
1009
+ if (char === "\\") {
1010
+ repaired += char;
1011
+ escaped = true;
1012
+ continue;
1013
+ }
1014
+ if (char !== '"') {
1015
+ repaired += char;
1016
+ continue;
1017
+ }
1018
+
1019
+ let nextIndex = index + 1;
1020
+ while (/\s/.test(trimmed[nextIndex] ?? "")) nextIndex++;
1021
+ const next = trimmed[nextIndex];
1022
+ if (next === undefined || [",", ":", "}", "]"].includes(next)) {
1023
+ repaired += char;
1024
+ inString = false;
1025
+ } else {
1026
+ repaired += '\\"';
1027
+ changed = true;
1028
+ }
1029
+ }
1030
+
1031
+ return changed && !inString ? repaired : undefined;
1032
+ }
1033
+
1034
+ function parseToolCallJson(raw: string): ParsedToolCall[] {
1035
+ let value: unknown;
1036
+ try {
1037
+ value = JSON.parse(raw.trim());
1038
+ } catch {
1039
+ const repaired = repairUnescapedJsonStringQuotes(raw);
1040
+ if (!repaired) return [];
1041
+ try {
1042
+ value = JSON.parse(repaired);
1043
+ } catch {
1044
+ return [];
1045
+ }
1046
+ }
1047
+
1048
+ const container = value as { tool_calls?: unknown } | null;
1049
+ const candidates = Array.isArray(value)
1050
+ ? value
1051
+ : Array.isArray(container?.tool_calls)
1052
+ ? container.tool_calls
1053
+ : [value];
1054
+ if (candidates.length === 0) return [];
1055
+
1056
+ const calls: ParsedToolCall[] = [];
1057
+ for (const rawCandidate of candidates) {
1058
+ if (
1059
+ typeof rawCandidate !== "object" ||
1060
+ rawCandidate === null ||
1061
+ Array.isArray(rawCandidate)
1062
+ ) {
1063
+ return [];
1064
+ }
1065
+ const candidate = rawCandidate as {
1066
+ name?: unknown;
1067
+ tool?: unknown;
1068
+ arguments?: unknown;
1069
+ args?: unknown;
1070
+ input?: unknown;
1071
+ function?: { name?: unknown; arguments?: unknown };
1072
+ };
1073
+ const nameValue =
1074
+ typeof candidate.name === "string"
1075
+ ? candidate.name
1076
+ : typeof candidate.tool === "string"
1077
+ ? candidate.tool
1078
+ : candidate.function?.name;
1079
+
1080
+ let argumentsValue: unknown;
1081
+ if (Object.hasOwn(candidate, "arguments")) {
1082
+ argumentsValue = candidate.arguments;
1083
+ } else if (Object.hasOwn(candidate, "args")) {
1084
+ argumentsValue = candidate.args;
1085
+ } else if (Object.hasOwn(candidate, "input")) {
1086
+ argumentsValue = candidate.input;
1087
+ } else if (
1088
+ candidate.function &&
1089
+ typeof candidate.function === "object" &&
1090
+ Object.hasOwn(candidate.function, "arguments")
1091
+ ) {
1092
+ argumentsValue = candidate.function.arguments;
1093
+ } else {
1094
+ return [];
1095
+ }
1096
+
1097
+ const args = parseArguments(argumentsValue);
1098
+ if (
1099
+ typeof nameValue !== "string" ||
1100
+ !nameValue.trim() ||
1101
+ !args
1102
+ ) {
1103
+ return [];
1104
+ }
1105
+ calls.push({ name: nameValue, arguments: args });
1106
+ }
1107
+ return calls;
1108
+ }
1109
+
1110
+ /**
1111
+ * Effective per-turn timeout: honor Pi's `timeoutMs` when it is a positive
1112
+ * finite number, otherwise fall back to the unconditional internal bound so
1113
+ * no turn can hang indefinitely (#73).
1114
+ */
1115
+ export function resolveTurnTimeoutMs(timeoutMs: unknown): number {
1116
+ return typeof timeoutMs === "number" &&
1117
+ Number.isFinite(timeoutMs) &&
1118
+ timeoutMs > 0
1119
+ ? timeoutMs
1120
+ : DEFAULT_TURN_TIMEOUT_MS;
1121
+ }
1122
+
1123
+ const pendingSessionDeletions = new Set<Promise<void>>();
1124
+
1125
+ /** Best-effort, bounded deletion; shutdown also awaits one-shot deletions. */
1126
+ export function deleteOpenCodeSession(sessionId: string | undefined): Promise<void> {
1127
+ if (!sessionId) return Promise.resolve();
1128
+ const task = runCapture(["session", "delete", sessionId], undefined, SESSION_DELETE_TIMEOUT_MS)
1129
+ .then(() => undefined, () => undefined);
1130
+ pendingSessionDeletions.add(task);
1131
+ void task.finally(() => pendingSessionDeletions.delete(task));
1132
+ return task;
1133
+ }
1134
+
1135
+ export type PiSessionState = {
1136
+ /** Lazily created per-Pi-session OpenCode project directory (#74). */
1137
+ dirPromise?: Promise<string>;
1138
+ /** Recorded OpenCode session ID reused across turns (#75). */
1139
+ opencodeSessionId?: string;
1140
+ /** Model ID the recorded session was created with. */
1141
+ modelId?: string;
1142
+ /** Serialized tool list the recorded session was created with (#76). */
1143
+ toolsKey?: string;
1144
+ /** System prompt the recorded session was created with (#76). */
1145
+ systemPrompt?: string;
1146
+ /** Serialized transcript prefix already sent to the recorded session. */
1147
+ sentParts?: string[];
1148
+ };
1149
+
1150
+ const piSessions = new Map<string, PiSessionState>();
1151
+
1152
+ /** Number of tracked Pi sessions (exposed for tests). */
1153
+ export function trackedPiSessionCount(): number {
1154
+ return piSessions.size;
1155
+ }
1156
+
1157
+ function getPiSessionState(piSessionId: string): PiSessionState {
1158
+ const existing = piSessions.get(piSessionId);
1159
+ if (existing) {
1160
+ // Refresh recency so eviction drops the least-recently-used entry.
1161
+ piSessions.delete(piSessionId);
1162
+ piSessions.set(piSessionId, existing);
1163
+ return existing;
1164
+ }
1165
+ const state: PiSessionState = {};
1166
+ piSessions.set(piSessionId, state);
1167
+ while (piSessions.size > MAX_TRACKED_PI_SESSIONS) {
1168
+ const oldest = piSessions.keys().next();
1169
+ if (oldest.done) break;
1170
+ void cleanupPiSessionState(oldest.value as string);
1171
+ }
1172
+ return state;
1173
+ }
1174
+
1175
+ function ensurePiSessionDir(state: PiSessionState): Promise<string> {
1176
+ state.dirPromise ??= createTempAgentDir().catch((error) => {
1177
+ if (state.dirPromise) state.dirPromise = undefined;
1178
+ throw error;
1179
+ });
1180
+ return state.dirPromise;
1181
+ }
1182
+
1183
+ /**
1184
+ * Remove a Pi session's project directory and delete its OpenCode session
1185
+ * record — the explicit cleanup path for session teardown (#74). With no
1186
+ * argument, every tracked session is cleaned up.
1187
+ */
1188
+ export async function cleanupPiSessionState(
1189
+ piSessionId?: string,
1190
+ ): Promise<void> {
1191
+ const ids =
1192
+ piSessionId === undefined ? [...piSessions.keys()] : [piSessionId];
1193
+ await Promise.all(
1194
+ ids.map(async (id) => {
1195
+ const state = piSessions.get(id);
1196
+ if (!state) return;
1197
+ piSessions.delete(id);
1198
+ const deletion = deleteOpenCodeSession(state.opencodeSessionId);
1199
+ state.opencodeSessionId = undefined;
1200
+ if (state.dirPromise) {
1201
+ const dirPromise = state.dirPromise;
1202
+ state.dirPromise = undefined;
1203
+ const dir = await dirPromise.catch(() => undefined);
1204
+ if (dir) await rm(dir, { recursive: true, force: true }).catch(() => undefined);
1205
+ }
1206
+ await deletion;
1207
+ }),
1208
+ );
1209
+ if (piSessionId === undefined) await Promise.all([...pendingSessionDeletions]);
1210
+ }
1211
+
1212
+ /** Forget a broken session so the next turn starts fresh (#75). */
1213
+ function dropOpencodeSession(state: PiSessionState): void {
1214
+ deleteOpenCodeSession(state.opencodeSessionId);
1215
+ state.opencodeSessionId = undefined;
1216
+ state.modelId = undefined;
1217
+ state.toolsKey = undefined;
1218
+ state.systemPrompt = undefined;
1219
+ state.sentParts = undefined;
1220
+ }
1221
+
1222
+ function serializedTranscriptParts(messages: Message[]): string[] {
1223
+ return messages.map(serializeMessage);
1224
+ }
1225
+
1226
+ /**
1227
+ * Decide whether the next turn may continue the recorded OpenCode session
1228
+ * (#75, #76). Continuation requires the same model, the same serialized
1229
+ * tool list and system prompt, and a transcript that extends — never
1230
+ * rewrites — the previously sent prefix, with at least one new message.
1231
+ */
1232
+ function continuationDelta(
1233
+ state: PiSessionState,
1234
+ modelId: string,
1235
+ context: Context,
1236
+ parts: string[],
1237
+ ): string[] | undefined {
1238
+ if (!state.opencodeSessionId) return undefined;
1239
+ if (state.modelId !== modelId) return undefined;
1240
+ if ((state.toolsKey ?? "") !== serializeTools(context.tools)) return undefined;
1241
+ if ((state.systemPrompt ?? "") !== (context.systemPrompt ?? "")) return undefined;
1242
+ const sent = state.sentParts ?? [];
1243
+ if (sent.length >= parts.length) return undefined;
1244
+ for (let index = 0; index < sent.length; index += 1) {
1245
+ if (sent[index] !== parts[index]) return undefined;
1246
+ }
1247
+ return parts.slice(sent.length);
1248
+ }
1249
+
1250
+ function buildContinuationPrompt(deltaParts: string[]): string {
1251
+ return [
1252
+ "# Continued Pi/OpenCode bridge turn",
1253
+ "",
1254
+ "This message continues your existing OpenCode session: the Pi system prompt,",
1255
+ "tool list, and earlier transcript are already in context. Do not ask for them again.",
1256
+ "If you need Pi to run a tool, your entire response must contain only one or more",
1257
+ "tool-call blocks separated by whitespace:",
1258
+ '<pi_tool_call>{"name":"tool_name","arguments":{}}</pi_tool_call>',
1259
+ "Use only exact tool names from the earlier tool list. Never quote markers, explain",
1260
+ "them, or wrap them in Markdown fences. If you can answer without a tool, answer normally.",
1261
+ "",
1262
+ "# New transcript since your last response",
1263
+ "",
1264
+ deltaParts.join("\n\n---\n\n"),
1265
+ "",
1266
+ "Now produce the next assistant message for Pi.",
1267
+ ].join("\n");
1268
+ }
1269
+
1270
+ async function createTempAgentDir(): Promise<string> {
1271
+ const dir = await mkdtemp(join(tmpdir(), "opencode-pi-"));
1272
+ const configDir = join(dir, ".opencode");
1273
+ const agentsDir = join(configDir, "agents");
1274
+ const pluginsDir = join(configDir, "plugins");
1275
+ await mkdir(agentsDir, { recursive: true });
1276
+ await mkdir(pluginsDir, { recursive: true });
1277
+ await writeFile(
1278
+ join(agentsDir, `${AGENT_ID}.md`),
1279
+ `---
1280
+ description: Pi bridge agent. Pi tool calls are emitted as text markers.
1281
+ mode: primary
1282
+ permission:
1283
+ "*": ask
1284
+ ---
1285
+ 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.
1286
+ `,
1287
+ "utf8",
1288
+ );
1289
+ // Zen's free tier requires OpenCode's normal tool declarations to remain in
1290
+ // the request. Permission `deny` removes them before inference and is now
1291
+ // rejected as a third-party client. Keep declarations intact, but block
1292
+ // every native execution before side effects occur. The agent's `ask`
1293
+ // permission keeps declarations too, and `opencode run` auto-rejects asks,
1294
+ // so a plugin that fails to load still cannot run native tools.
1295
+ await writeFile(
1296
+ join(pluginsDir, "pi-native-tool-blocker.js"),
1297
+ `export const PiNativeToolBlocker = async () => ({
1298
+ "tool.execute.before": async (input) => {
1299
+ throw new Error("Native OpenCode tool blocked by opencode-pi: " + input.tool)
1300
+ },
1301
+ })
1302
+ `,
1303
+ "utf8",
1304
+ );
1305
+ return dir;
1306
+ }
1307
+
1308
+ export function streamOpenCode(
1309
+ model: Model<Api>,
1310
+ context: Context,
1311
+ options?: SimpleStreamOptions,
1312
+ ): AssistantMessageEventStream {
1313
+ const stream = createAssistantMessageEventStream();
1314
+
1315
+ (async () => {
1316
+ const output: AssistantMessage = {
1317
+ role: "assistant",
1318
+ content: [],
1319
+ api: model.api,
1320
+ provider: model.provider,
1321
+ model: model.id,
1322
+ usage: emptyUsage(),
1323
+ stopReason: "stop",
1324
+ timestamp: Date.now(),
1325
+ };
1326
+
1327
+ let tempDir: string | undefined;
1328
+ let accumulatedText = "";
1329
+ let accumulatedThinking = "";
1330
+ let stderr = "";
1331
+ let stdoutRemainder = "";
1332
+ let capturedSessionId: string | undefined;
1333
+ let opencodeToolUse: string | undefined;
1334
+ let opencodeError: string | undefined;
1335
+ let finishReason: "stop" | "length" = "stop";
1336
+ // Per-Pi-session reuse key supplied by Pi core (stable for the life of
1337
+ // the Pi session). Without it, every turn runs an isolated fresh
1338
+ // session in its own temporary directory.
1339
+ const piSessionId =
1340
+ typeof options?.sessionId === "string" && options.sessionId
1341
+ ? options.sessionId
1342
+ : undefined;
1343
+ const piState = piSessionId ? getPiSessionState(piSessionId) : undefined;
1344
+
1345
+ try {
1346
+ // pi 1.x carries the current prompt and tools in transcript system messages.
1347
+ const { messages } = normalizeContext(context);
1348
+ context = {
1349
+ messages: messages.filter((message) => message.role !== "system"),
1350
+ systemPrompt: getCurrentSystemPrompt(messages),
1351
+ tools: getCurrentTools(messages),
1352
+ };
1353
+ const transcriptParts = serializedTranscriptParts(context.messages);
1354
+ stream.push({ type: "start", partial: output });
1355
+ if (options?.signal?.aborted) throw new Error("Request was aborted");
1356
+
1357
+ // Continuation decision (#75, #76): reuse the recorded OpenCode
1358
+ // session only when the model, tools, system prompt, and transcript
1359
+ // prefix all still match. Any drift restarts fresh — and the stale
1360
+ // session record is deleted so it cannot leak.
1361
+ // A payload hook may rewrite the prompt, so its session cannot be continued as a transcript prefix.
1362
+ const payloadHook = options?.onPayload;
1363
+ let delta = piState && !payloadHook
1364
+ ? continuationDelta(piState, model.id, context, transcriptParts)
1365
+ : undefined;
1366
+ if (piState && piState.opencodeSessionId && delta === undefined) {
1367
+ dropOpencodeSession(piState);
1368
+ }
1369
+ const isContinuation = delta !== undefined;
1370
+ let prompt = isContinuation
1371
+ ? buildContinuationPrompt(delta)
1372
+ : buildPrompt(context);
1373
+ const sentMessages = isContinuation
1374
+ ? context.messages.slice(transcriptParts.length - delta.length)
1375
+ : context.messages;
1376
+
1377
+ const sessionDir = piState
1378
+ ? await ensurePiSessionDir(piState)
1379
+ : await createTempAgentDir().then((dir) => {
1380
+ tempDir = dir;
1381
+ return dir;
1382
+ });
1383
+ if (options?.signal?.aborted) throw new Error("Request was aborted");
1384
+ const images = imageContentsForModel(
1385
+ sentMessages,
1386
+ model.input.includes("image"),
1387
+ );
1388
+ const imagePaths = await writeImageFiles(images, sessionDir);
1389
+ if (options?.signal?.aborted) throw new Error("Request was aborted");
1390
+ const args = [
1391
+ "run",
1392
+ "-m",
1393
+ model.id,
1394
+ "--agent",
1395
+ AGENT_ID,
1396
+ "--format",
1397
+ "json",
1398
+ "--dir",
1399
+ sessionDir,
1400
+ ];
1401
+ if (isContinuation && piState?.opencodeSessionId) {
1402
+ args.push("--session", piState.opencodeSessionId);
1403
+ }
1404
+ const requestedReasoning = options?.reasoning as
1405
+ | ModelThinkingLevel
1406
+ | undefined;
1407
+ args.push(
1408
+ ...reasoningCliArgs(requestedReasoning, model.thinkingLevelMap),
1409
+ );
1410
+ for (const path of imagePaths) args.push("--file", path);
1411
+
1412
+ const handleLine = async (line: string) => {
1413
+ if (!line.trim()) return;
1414
+ let parsed: unknown;
1415
+ try {
1416
+ parsed = JSON.parse(line);
1417
+ } catch {
1418
+ // Plugins share OpenCode's stdout; keep stray log lines as diagnostics.
1419
+ stderr = (stderr + `\n${line}`).slice(-STDERR_LIMIT);
1420
+ return;
1421
+ }
1422
+ const event = objectRecord(parsed);
1423
+ const part = objectRecord(event?.part);
1424
+ if (!event || typeof event.type !== "string" ||
1425
+ (event.part !== undefined && !part)) {
1426
+ throw new Error("Invalid OpenCode event envelope");
1427
+ }
1428
+ await options?.onProviderStreamEvent?.(event, model);
1429
+
1430
+ // Every JSON event carries the OpenCode session ID (#71).
1431
+ if (typeof event.sessionID === "string" && event.sessionID) {
1432
+ capturedSessionId = event.sessionID;
1433
+ } else if (
1434
+ typeof part?.sessionID === "string" &&
1435
+ part.sessionID
1436
+ ) {
1437
+ capturedSessionId = part.sessionID;
1438
+ }
1439
+
1440
+ if (event.type === "text" || event.type === "reasoning") {
1441
+ if (typeof part?.text !== "string") throw new Error("Invalid OpenCode event text");
1442
+ if (event.type === "text") accumulatedText += part.text;
1443
+ else accumulatedThinking += part.text;
1444
+ return;
1445
+ }
1446
+
1447
+ if (event.type === "step_finish") {
1448
+ if (/length|max.?tokens/i.test(String(part?.reason ?? ""))) {
1449
+ finishReason = "length";
1450
+ }
1451
+ const tokens = objectRecord(part?.tokens);
1452
+ if (!tokens) return;
1453
+ const cache = objectRecord(tokens.cache);
1454
+ output.usage.input = Number(tokens.input ?? 0);
1455
+ output.usage.output =
1456
+ Number(tokens.output ?? 0) + Number(tokens.reasoning ?? 0);
1457
+ output.usage.cacheRead = Number(cache?.read ?? 0);
1458
+ output.usage.cacheWrite = Number(cache?.write ?? 0);
1459
+ output.usage.totalTokens = Number(
1460
+ tokens.total ??
1461
+ output.usage.input +
1462
+ output.usage.output +
1463
+ output.usage.cacheRead +
1464
+ output.usage.cacheWrite,
1465
+ );
1466
+ calculateCost(model, output.usage);
1467
+ return;
1468
+ }
1469
+
1470
+ if (event.type === "tool_use") {
1471
+ opencodeToolUse = part?.tool ? String(part.tool) : "unknown";
1472
+ return;
1473
+ }
1474
+
1475
+ if (event.type === "error") {
1476
+ const error = objectRecord(event.error);
1477
+ const message = objectRecord(error?.data)?.message ?? error?.message ?? part?.message;
1478
+ opencodeError =
1479
+ typeof message === "string" && message.trim()
1480
+ ? message.trim()
1481
+ : safeJson(event);
1482
+ stderr = (stderr + `\n${opencodeError}`).slice(-STDERR_LIMIT);
1483
+ }
1484
+ };
1485
+
1486
+ const result = await runOpenCodeProcess(opencodeBin(), args, {
1487
+ input: async () => {
1488
+ if (!payloadHook) return prompt;
1489
+ const replacement = await payloadHook({ prompt }, model);
1490
+ if (replacement === undefined) return prompt;
1491
+ const payload = objectRecord(replacement);
1492
+ if (typeof payload?.prompt !== "string") {
1493
+ throw new Error("OpenCode CLI payload must be an object with a string prompt");
1494
+ }
1495
+ return (prompt = payload.prompt);
1496
+ },
1497
+ // A CLI subprocess has no HTTP status; 0 marks the non-HTTP transport.
1498
+ onStart: () => options?.onResponse?.(
1499
+ { status: 0, headers: { "x-pi-transport": "opencode-cli" } },
1500
+ model,
1501
+ ),
1502
+ timeoutMs: resolveTurnTimeoutMs(options?.timeoutMs),
1503
+ signal: options?.signal,
1504
+ env: {
1505
+ ...process.env,
1506
+ ...options?.env,
1507
+ OPENCODE_DISABLE_UPDATE_CHECK: "1",
1508
+ OPENCODE_CONFIG_DIR: join(sessionDir, ".opencode"),
1509
+ },
1510
+ onStdout: async (chunk) => {
1511
+ stdoutRemainder += chunk;
1512
+ const lines = stdoutRemainder.split(/\r?\n/);
1513
+ stdoutRemainder = lines.pop() ?? "";
1514
+ for (const line of lines) await handleLine(line);
1515
+ },
1516
+ onEnd: () => handleLine(stdoutRemainder),
1517
+ });
1518
+ const { code } = result;
1519
+ stderr = (stderr + result.stderr).slice(-STDERR_LIMIT);
1520
+ if (code !== 0) {
1521
+ throw new Error(stderr.trim() || `opencode exited with code ${code}`);
1522
+ }
1523
+ if (opencodeError) throw new Error(opencodeError);
1524
+ if (opencodeToolUse) {
1525
+ throw new Error(
1526
+ `OpenCode attempted to use its disabled native tool (${JSON.stringify(opencodeToolUse)}). Retry the request; Pi tools must be requested with <pi_tool_call>{"name":"...","arguments":{}}</pi_tool_call> markers.`,
1527
+ );
1528
+ }
1529
+
1530
+ const allowedToolNames = new Set(
1531
+ context.tools?.map((tool) => tool.name) ?? [],
1532
+ );
1533
+ const toolCallResult = parseToolCallResponse(
1534
+ accumulatedText,
1535
+ allowedToolNames,
1536
+ );
1537
+ if (!toolCallResult.ok) {
1538
+ throw new Error(
1539
+ toolCallRejectionMessage(toolCallResult, allowedToolNames),
1540
+ );
1541
+ }
1542
+ const toolCalls = toolCallResult.calls;
1543
+ // Unrecoverable marker text is stripped so it never leaks into the
1544
+ // visible chat response; remaining prose still displays normally.
1545
+ const displayText = toolCallResult.cleanedText ?? accumulatedText;
1546
+ if (toolCalls.length === 0 && displayText.trim()) {
1547
+ // Check if the model tried to use XML-style tool-use instead of
1548
+ // <pi_tool_call> markers — a common mistake with Claude-based models.
1549
+ const xmlTool = detectXmlToolUse(displayText);
1550
+ if (xmlTool) {
1551
+ throw new Error(
1552
+ `OpenCode attempted to use its disabled native tool (${JSON.stringify(xmlTool)}). This bridge only accepts <pi_tool_call>{"name":"...","arguments":{}}</pi_tool_call> markers. Do not use XML-style tool-use syntax.`,
1553
+ );
1554
+ }
1555
+ }
1556
+ if (
1557
+ toolCalls.length === 0 &&
1558
+ !displayText.trim() &&
1559
+ !accumulatedThinking.trim()
1560
+ ) {
1561
+ const stderrMsg = stderr.trim();
1562
+ if (stderrMsg) {
1563
+ throw new Error(
1564
+ `OpenCode returned stderr: ${stderrMsg}`,
1565
+ );
1566
+ }
1567
+ // The model may have emitted text events with empty strings, or
1568
+ // may have produced reasoning-only output that was not captured
1569
+ // as assistant text. Provide a clearer diagnostic.
1570
+ const rawTextPreview = accumulatedText
1571
+ ? `raw text length=${accumulatedText.length} (trimmed to "${accumulatedText.slice(0, 80).replace(/\n/g, " ")}")`
1572
+ : "no text events received";
1573
+ const rawThinkingPreview = accumulatedThinking
1574
+ ? `thinking length=${accumulatedThinking.length}`
1575
+ : "no thinking output";
1576
+ throw new Error(
1577
+ `OpenCode returned empty assistant response (${rawTextPreview}; ${rawThinkingPreview}). This can happen with some free models when the prompt is too long, the model times out, or the model is overloaded. Retry the request or select another OpenCode model.`,
1578
+ );
1579
+ }
1580
+ setEstimatedUsage(
1581
+ model,
1582
+ output,
1583
+ prompt,
1584
+ accumulatedText + accumulatedThinking,
1585
+ );
1586
+ // The turn succeeded: record the session for reuse, or delete the
1587
+ // one-shot session record so it cannot accumulate (#71, #75).
1588
+ if (piState && !payloadHook) {
1589
+ piState.opencodeSessionId =
1590
+ capturedSessionId ?? piState.opencodeSessionId;
1591
+ piState.modelId = model.id;
1592
+ piState.toolsKey = serializeTools(context.tools);
1593
+ piState.systemPrompt = context.systemPrompt ?? "";
1594
+ piState.sentParts = transcriptParts;
1595
+ } else {
1596
+ deleteOpenCodeSession(capturedSessionId);
1597
+ }
1598
+
1599
+ if (accumulatedThinking) {
1600
+ const thinkingIndex = output.content.length;
1601
+ output.content.push({ type: "thinking", thinking: accumulatedThinking });
1602
+ stream.push({
1603
+ type: "thinking_start",
1604
+ contentIndex: thinkingIndex,
1605
+ partial: output,
1606
+ });
1607
+ stream.push({
1608
+ type: "thinking_delta",
1609
+ contentIndex: thinkingIndex,
1610
+ delta: accumulatedThinking,
1611
+ partial: output,
1612
+ });
1613
+ stream.push({
1614
+ type: "thinking_end",
1615
+ contentIndex: thinkingIndex,
1616
+ content: accumulatedThinking,
1617
+ partial: output,
1618
+ });
1619
+ }
1620
+
1621
+ if (toolCalls.length > 0) {
1622
+ output.stopReason = "toolUse";
1623
+ for (const call of toolCalls) {
1624
+ const toolCall: ToolCall = {
1625
+ type: "toolCall",
1626
+ id: `opencode_pi_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
1627
+ name: call.name,
1628
+ arguments: call.arguments as ToolCall["arguments"],
1629
+ };
1630
+ const contentIndex = output.content.length;
1631
+ output.content.push(toolCall);
1632
+ stream.push({
1633
+ type: "toolcall_start",
1634
+ contentIndex,
1635
+ partial: output,
1636
+ });
1637
+ stream.push({
1638
+ type: "toolcall_delta",
1639
+ contentIndex,
1640
+ delta: safeJson(toolCall.arguments),
1641
+ partial: output,
1642
+ });
1643
+ stream.push({
1644
+ type: "toolcall_end",
1645
+ contentIndex,
1646
+ toolCall,
1647
+ partial: output,
1648
+ });
1649
+ }
1650
+ stream.push({ type: "done", reason: "toolUse", message: output });
1651
+ stream.end();
1652
+ return;
1653
+ }
1654
+
1655
+ output.stopReason = finishReason;
1656
+ if (displayText) {
1657
+ const contentIndex = output.content.length;
1658
+ output.content.push({ type: "text", text: displayText });
1659
+ stream.push({ type: "text_start", contentIndex, partial: output });
1660
+ stream.push({
1661
+ type: "text_delta",
1662
+ contentIndex,
1663
+ delta: displayText,
1664
+ partial: output,
1665
+ });
1666
+ stream.push({
1667
+ type: "text_end",
1668
+ contentIndex,
1669
+ content: displayText,
1670
+ partial: output,
1671
+ });
1672
+ }
1673
+ stream.push({ type: "done", reason: finishReason, message: output });
1674
+ stream.end();
1675
+ } catch (error) {
1676
+ // Any error, timeout, or abort drops the session ID so the next turn
1677
+ // starts fresh with the full transcript; the failed session record is
1678
+ // deleted best-effort and never fails the turn (#71, #75).
1679
+ deleteOpenCodeSession(capturedSessionId);
1680
+ if (piState) dropOpencodeSession(piState);
1681
+ output.stopReason = options?.signal?.aborted ? "aborted" : "error";
1682
+ output.errorMessage =
1683
+ error instanceof Error ? error.message : String(error);
1684
+ // Ensure output.content has text so downstream tools (model-debugger,
1685
+ // Pi agent) see real content instead of flagging a "silent failure."
1686
+ if (output.content.length === 0) {
1687
+ output.content.push({ type: "text", text: `Error: ${output.errorMessage}` });
1688
+ }
1689
+ stream.push({ type: "error", reason: output.stopReason, error: output });
1690
+ stream.end();
1691
+ } finally {
1692
+ // Only one-shot turns own their directory. Per-Pi-session directories
1693
+ // persist for reuse and are removed at session teardown (#74).
1694
+ if (tempDir) {
1695
+ await rm(tempDir, { recursive: true, force: true }).catch(
1696
+ () => undefined,
1697
+ );
1698
+ }
1699
+ }
1700
+ })();
1701
+
1702
+ return stream;
1703
+ }
1704
+
1705
+ function providerModel(model: OpenCodeModelInfo) {
1706
+ return {
1707
+ id: model.id,
1708
+ name: `${model.name} (OpenCode CLI)`,
1709
+ reasoning: model.reasoning,
1710
+ ...(model.thinkingLevelMap
1711
+ ? { thinkingLevelMap: model.thinkingLevelMap }
1712
+ : {}),
1713
+ input: model.image
1714
+ ? (["text", "image"] as ("text" | "image")[])
1715
+ : (["text"] as ("text" | "image")[]),
1716
+ contextWindow: model.contextWindow,
1717
+ maxTokens: model.maxTokens,
1718
+ cost: model.cost,
1719
+ };
1720
+ }
1721
+
1722
+ function statusLines(): string[] {
1723
+ const lines = [
1724
+ `Provider: ${PROVIDER_ID}`,
1725
+ `OpenCode binary: ${opencodeBin()}`,
1726
+ `OpenCode installed: ${existsSync(opencodeBin()) || opencodeBin() === "opencode" ? "check PATH with /opencode-pi test" : "no"}`,
1727
+ `Registered models: ${registeredModels.length}`,
1728
+ `Last discovery: ${lastDiscoveryTime ? new Date(lastDiscoveryTime).toLocaleString() : "never"}`,
1729
+ ];
1730
+ if (lastDiscoveryError)
1731
+ lines.push(`Discovery fallback: ${lastDiscoveryError}`);
1732
+ lines.push("");
1733
+ for (const model of registeredModels) {
1734
+ const capabilities = [
1735
+ model.reasoning ? "reasoning" : undefined,
1736
+ model.image ? "image" : undefined,
1737
+ ].filter(Boolean);
1738
+ lines.push(
1739
+ ` - ${PROVIDER_ID}/${model.id}${capabilities.length > 0 ? ` (${capabilities.join(", ")})` : ""}`,
1740
+ );
1741
+ }
1742
+ lines.push("");
1743
+ lines.push(
1744
+ "OpenCode login is not required for the bundled free OpenCode models.",
1745
+ );
1746
+ lines.push(
1747
+ "OpenCode tools are disabled; Pi tool use is bridged with prompt-level tool-call markers.",
1748
+ );
1749
+ lines.push(
1750
+ "Run /opencode-pi update to refresh the model list from opencode.",
1751
+ );
1752
+ return lines;
1753
+ }
1754
+
1755
+ function registerApiHandler(): void {
1756
+ registerApiProvider(
1757
+ {
1758
+ api: API_ID,
1759
+ stream: (model, context, options) =>
1760
+ streamOpenCode(model, context, options),
1761
+ streamSimple: streamOpenCode,
1762
+ },
1763
+ PROVIDER_ID,
1764
+ );
1765
+ }
1766
+
1767
+ export default async function opencodePiExtension(pi: ExtensionAPI) {
1768
+ const { models, time } = await discoverModels();
1769
+ registeredModels = models;
1770
+ lastDiscoveryTime = time;
1771
+
1772
+ pi.registerProvider(PROVIDER_ID, {
1773
+ name: "OpenCode CLI",
1774
+ baseUrl: "cli:opencode",
1775
+ apiKey: "opencode-cli-no-api-key",
1776
+ api: API_ID,
1777
+ models: registeredModels.map(providerModel),
1778
+ streamSimple: streamOpenCode,
1779
+ });
1780
+ registerApiHandler();
1781
+
1782
+ // Tear down per-Pi-session OpenCode state — project directories and
1783
+ // session records — when the Pi session ends for any reason (#74).
1784
+ pi.on("session_shutdown", () => cleanupPiSessionState());
1785
+
1786
+ pi.on("session_start", async (_event: any, ctx: any) => {
1787
+ const cliStatus = await checkCliStatus();
1788
+ if (!cliStatus.ok) {
1789
+ const fastPathConfigured = Boolean(configuredModels()?.length);
1790
+ const fastPathNote = fastPathConfigured
1791
+ ? "OPENCODE_PI_MODELS skipped discovery, so this was not detected at startup. "
1792
+ : "";
1793
+ ctx.ui.notify(
1794
+ `opencode-pi: ${fastPathNote}${setupGuidance(cliStatus.detail ?? cliStatus.summary)}`,
1795
+ "warning",
1796
+ );
1797
+ return;
1798
+ }
1799
+ ctx.ui.notify(
1800
+ `opencode-pi: registered ${registeredModels.length} OpenCode CLI model(s). Use /model and pick ${PROVIDER_ID}.`,
1801
+ "info",
1802
+ );
1803
+ if (lastDiscoveryError) {
1804
+ ctx.ui.notify(
1805
+ `opencode-pi: model discovery used fallback (${lastDiscoveryError})`,
1806
+ "warning",
1807
+ );
1808
+ }
1809
+ });
1810
+
1811
+ pi.registerCommand("opencode-pi", {
1812
+ description: "OpenCode CLI bridge status and setup help",
1813
+ handler: async (args: string, ctx: any) => {
1814
+ const sub = args.trim().split(/\s+/).filter(Boolean)[0] ?? "status";
1815
+ if (sub === "status") {
1816
+ for (const line of statusLines()) ctx.ui.notify(line, "info");
1817
+ return;
1818
+ }
1819
+ if (sub === "models") {
1820
+ for (const model of registeredModels)
1821
+ ctx.ui.notify(`${PROVIDER_ID}/${model.id}`, "info");
1822
+ ctx.ui.notify(
1823
+ `Override with OPENCODE_PI_MODELS="opencode/model-a,opencode/model-b"`,
1824
+ "info",
1825
+ );
1826
+ return;
1827
+ }
1828
+ if (sub === "test") {
1829
+ ctx.ui.notify(
1830
+ `Run: pi -p --provider ${PROVIDER_ID} --model ${registeredModels[0]?.id ?? DEFAULT_FREE_MODELS[0]} "Reply with exactly OK"`,
1831
+ "info",
1832
+ );
1833
+ ctx.ui.notify(
1834
+ `OpenCode check: ${opencodeBin()} run -m ${registeredModels[0]?.id ?? DEFAULT_FREE_MODELS[0]} --format json "Reply OK"`,
1835
+ "info",
1836
+ );
1837
+ return;
1838
+ }
1839
+ if (sub === "update") {
1840
+ await refreshModels(pi, ctx);
1841
+ for (const line of statusLines()) ctx.ui.notify(line, "info");
1842
+ return;
1843
+ }
1844
+ if (sub === "help") {
1845
+ ctx.ui.notify(
1846
+ "Usage: /opencode-pi [status|models|test|update|help]",
1847
+ "info",
1848
+ );
1849
+ ctx.ui.notify(
1850
+ "Set OPENCODE_PI_BIN to override the opencode executable.",
1851
+ "info",
1852
+ );
1853
+ ctx.ui.notify(
1854
+ "Set OPENCODE_PI_MODELS to register a custom comma-separated model list.",
1855
+ "info",
1856
+ );
1857
+ return;
1858
+ }
1859
+ ctx.ui.notify(
1860
+ `Unknown /opencode-pi subcommand: ${sub}. Try /opencode-pi help`,
1861
+ "warning",
1862
+ );
1863
+ },
1864
+ });
1865
+ }