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