Add plannotator extension v0.19.10

This commit is contained in:
2026-05-07 11:38:14 +10:00
parent e914bc59c9
commit f37e4565ff
91 changed files with 35103 additions and 0 deletions

View File

@@ -0,0 +1,445 @@
// @generated — DO NOT EDIT. Source: packages/ai/providers/claude-agent-sdk.ts
/**
* Claude Agent SDK provider — the first concrete AIProvider implementation.
*
* Uses @anthropic-ai/claude-agent-sdk to create sessions that can:
* - Start fresh with Plannotator context as the system prompt
* - Fork from a parent Claude Code session (preserving full history)
* - Resume a previous Plannotator inline chat session
* - Stream text deltas back to the UI in real time
*
* Sessions are read-only by default (tools limited to Read, Glob, Grep)
* to keep inline chat safe and cost-bounded.
*/
import { buildSystemPrompt, buildForkPreamble, buildEffectivePrompt } from "../context.ts";
import { BaseSession } from "../base-session.ts";
import type {
AIProvider,
AIProviderCapabilities,
AISession,
AIMessage,
CreateSessionOptions,
ClaudeAgentSDKConfig,
} from "../types.ts";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PROVIDER_NAME = "claude-agent-sdk";
/** Default read-only tools for inline chat. */
const DEFAULT_ALLOWED_TOOLS = ["Read", "Glob", "Grep", "WebSearch"];
const DEFAULT_MAX_TURNS = 99;
const DEFAULT_MODEL = "claude-sonnet-4-6";
// ---------------------------------------------------------------------------
// SDK query options — typed to catch typos at compile time
// ---------------------------------------------------------------------------
interface ClaudeSDKQueryOptions {
model: string;
maxTurns: number;
allowedTools: string[];
cwd: string;
abortController: AbortController;
includePartialMessages: boolean;
persistSession: boolean;
maxBudgetUsd?: number;
systemPrompt?: string | { type: "preset"; preset: string; append?: string };
resume?: string;
forkSession?: boolean;
permissionMode?: ClaudeAgentSDKConfig['permissionMode'];
allowDangerouslySkipPermissions?: boolean;
pathToClaudeCodeExecutable?: string;
settingSources?: string[];
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export class ClaudeAgentSDKProvider implements AIProvider {
readonly name = PROVIDER_NAME;
readonly capabilities: AIProviderCapabilities = {
fork: true,
resume: true,
streaming: true,
tools: true,
};
readonly models = [
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', default: true },
{ id: 'claude-sonnet-4-6[1m]', label: 'Sonnet 4.6 (1M)' },
{ id: 'claude-opus-4-7', label: 'Opus 4.7' },
{ id: 'claude-opus-4-7[1m]', label: 'Opus 4.7 (1M)' },
{ id: 'claude-opus-4-6', label: 'Opus 4.6' },
{ id: 'claude-opus-4-6[1m]', label: 'Opus 4.6 (1M)' },
{ id: 'claude-haiku-4-5', label: 'Haiku 4.5' },
] as const;
private config: ClaudeAgentSDKConfig;
constructor(config: ClaudeAgentSDKConfig) {
this.config = config;
}
async createSession(options: CreateSessionOptions): Promise<AISession> {
return new ClaudeAgentSDKSession({
...this.baseConfig(options),
systemPrompt: buildSystemPrompt(options.context),
cwd: options.cwd ?? this.config.cwd ?? process.cwd(),
parentSessionId: null,
forkFromSession: null,
});
}
async forkSession(options: CreateSessionOptions): Promise<AISession> {
const parent = options.context.parent;
if (!parent) {
throw new Error(
"Cannot fork: no parent session provided in context. " +
"Use createSession() for standalone sessions."
);
}
return new ClaudeAgentSDKSession({
...this.baseConfig(options),
systemPrompt: null,
forkPreamble: buildForkPreamble(options.context),
cwd: parent.cwd,
parentSessionId: parent.sessionId,
forkFromSession: parent.sessionId,
});
}
async resumeSession(sessionId: string): Promise<AISession> {
return new ClaudeAgentSDKSession({
...this.baseConfig(),
systemPrompt: null,
cwd: this.config.cwd ?? process.cwd(),
parentSessionId: null,
forkFromSession: null,
resumeSessionId: sessionId,
});
}
dispose(): void {
// No persistent resources to clean up
}
private baseConfig(options?: CreateSessionOptions) {
return {
model: options?.model ?? this.config.model ?? DEFAULT_MODEL,
maxTurns: options?.maxTurns ?? DEFAULT_MAX_TURNS,
maxBudgetUsd: options?.maxBudgetUsd,
allowedTools: this.config.allowedTools ?? DEFAULT_ALLOWED_TOOLS,
permissionMode: this.config.permissionMode ?? "default",
claudeExecutablePath: this.config.claudeExecutablePath,
settingSources: this.config.settingSources ?? ['user', 'project'],
};
}
}
// ---------------------------------------------------------------------------
// SDK import cache — resolve once, reuse across all queries
// ---------------------------------------------------------------------------
// biome-ignore lint/suspicious/noExplicitAny: SDK types resolved at runtime via dynamic import
let sdkQueryFn: ((...args: any[]) => any) | null = null;
async function getSDKQuery() {
if (!sdkQueryFn) {
const sdk = await import("@anthropic-ai/claude-agent-sdk");
sdkQueryFn = sdk.query;
}
return sdkQueryFn!;
}
// ---------------------------------------------------------------------------
// Session
// ---------------------------------------------------------------------------
interface SessionConfig {
systemPrompt: string | null;
forkPreamble?: string;
model: string;
maxTurns: number;
maxBudgetUsd?: number;
allowedTools: string[];
permissionMode: ClaudeAgentSDKConfig['permissionMode'];
cwd: string;
parentSessionId: string | null;
forkFromSession: string | null;
resumeSessionId?: string;
claudeExecutablePath?: string;
settingSources?: string[];
}
class ClaudeAgentSDKSession extends BaseSession {
private config: SessionConfig;
/** Active Query object — needed to send control responses (permission decisions) */
private _activeQuery: { streamInput: (iter: AsyncIterable<unknown>) => Promise<void> } | null = null;
constructor(config: SessionConfig) {
super({
parentSessionId: config.parentSessionId,
initialId: config.resumeSessionId,
});
this.config = config;
}
async *query(prompt: string): AsyncIterable<AIMessage> {
const started = this.startQuery();
if (!started) { yield BaseSession.BUSY_ERROR; return; }
const { gen } = started;
try {
const queryFn = await getSDKQuery();
const queryPrompt = buildEffectivePrompt(
prompt,
this.config.forkPreamble ?? null,
this._firstQuerySent,
);
const options = this.buildQueryOptions();
const stream = queryFn({ prompt: queryPrompt, options }) as
AsyncIterable<Record<string, unknown>> & { streamInput: (iter: AsyncIterable<unknown>) => Promise<void> };
this._activeQuery = stream;
this._firstQuerySent = true;
for await (const message of stream) {
const mapped = mapSDKMessage(message);
// Capture the real session ID from the init message
if (
!this._resolvedId &&
"session_id" in message &&
typeof message.session_id === "string" &&
message.session_id
) {
this.resolveId(message.session_id);
}
for (const msg of mapped) {
yield msg;
}
}
} catch (err) {
yield {
type: "error",
error: err instanceof Error ? err.message : String(err),
code: "provider_error",
};
} finally {
this.endQuery(gen);
this._activeQuery = null;
}
}
abort(): void {
this._activeQuery = null;
super.abort();
}
respondToPermission(requestId: string, allow: boolean, message?: string): void {
if (!this._activeQuery || !this._activeQuery.streamInput) return;
const response = allow
? { type: 'control_response', response: { subtype: 'success', request_id: requestId, response: { behavior: 'allow' } } }
: { type: 'control_response', response: { subtype: 'success', request_id: requestId, response: { behavior: 'deny', message: message ?? 'User denied this action' } } };
this._activeQuery.streamInput(
(async function* () { yield response; })()
).catch(() => {});
}
// -------------------------------------------------------------------------
// Internal
// -------------------------------------------------------------------------
private buildQueryOptions(): ClaudeSDKQueryOptions {
const opts: ClaudeSDKQueryOptions = {
model: this.config.model,
maxTurns: this.config.maxTurns,
allowedTools: this.config.allowedTools,
cwd: this.config.cwd,
abortController: this._currentAbort!,
includePartialMessages: true,
persistSession: true,
...(this.config.claudeExecutablePath && {
pathToClaudeCodeExecutable: this.config.claudeExecutablePath,
}),
...(this.config.settingSources && {
settingSources: this.config.settingSources,
}),
};
if (this.config.maxBudgetUsd) {
opts.maxBudgetUsd = this.config.maxBudgetUsd;
}
// After the first query resolves a real session ID, all subsequent
// queries must resume that session to continue the conversation.
if (this._resolvedId) {
opts.resume = this._resolvedId;
return this.applyPermissionMode(opts);
}
// First query: use Claude Code's built-in prompt with our context appended
if (this.config.systemPrompt) {
opts.systemPrompt = {
type: "preset",
preset: "claude_code",
append: this.config.systemPrompt,
};
}
if (this.config.forkFromSession) {
opts.resume = this.config.forkFromSession;
opts.forkSession = true;
}
if (this.config.resumeSessionId) {
opts.resume = this.config.resumeSessionId;
}
return this.applyPermissionMode(opts);
}
private applyPermissionMode(opts: ClaudeSDKQueryOptions): ClaudeSDKQueryOptions {
if (this.config.permissionMode === "bypassPermissions") {
opts.permissionMode = "bypassPermissions";
opts.allowDangerouslySkipPermissions = true;
} else if (this.config.permissionMode === "plan") {
opts.permissionMode = "plan";
}
return opts;
}
}
// ---------------------------------------------------------------------------
// Message mapping
// ---------------------------------------------------------------------------
/**
* Map an SDK message to one or more AIMessages.
*
* An SDK assistant message can contain both text and tool_use content blocks
* in a single response. We emit each block as a separate AIMessage so no
* content is dropped.
*/
function mapSDKMessage(msg: Record<string, unknown>): AIMessage[] {
const type = msg.type as string;
switch (type) {
case "assistant": {
const message = msg.message as Record<string, unknown> | undefined;
if (!message) return [{ type: "unknown", raw: msg }];
const content = message.content as Array<Record<string, unknown>>;
if (!content) return [{ type: "unknown", raw: msg }];
const messages: AIMessage[] = [];
const textParts: string[] = [];
for (const block of content) {
if (block.type === "text" && typeof block.text === "string") {
textParts.push(block.text);
} else if (block.type === "tool_use") {
// Flush accumulated text before the tool_use block
if (textParts.length > 0) {
messages.push({ type: "text", text: textParts.join("") });
textParts.length = 0;
}
messages.push({
type: "tool_use",
toolName: block.name as string,
toolInput: block.input as Record<string, unknown>,
toolUseId: block.id as string,
});
}
}
// Flush any remaining text after the last block
if (textParts.length > 0) {
messages.push({ type: "text", text: textParts.join("") });
}
return messages.length > 0 ? messages : [{ type: "unknown", raw: msg }];
}
case "stream_event": {
const event = msg.event as Record<string, unknown> | undefined;
if (!event) return [{ type: "unknown", raw: msg }];
const eventType = event.type as string;
if (eventType === "content_block_delta") {
const delta = event.delta as Record<string, unknown>;
if (delta?.type === "text_delta" && typeof delta.text === "string") {
return [{ type: "text_delta", delta: delta.text }];
}
}
return [{ type: "unknown", raw: msg }];
}
case "user": {
// SDK wraps tool results in SDKUserMessage (type: "user")
if (msg.tool_use_result != null) {
return [{
type: "tool_result",
result: typeof msg.tool_use_result === "string"
? msg.tool_use_result
: JSON.stringify(msg.tool_use_result),
}];
}
return [{ type: "unknown", raw: msg }];
}
case "control_request": {
const request = msg.request as Record<string, unknown> | undefined;
if (request?.subtype === "can_use_tool") {
return [{
type: "permission_request",
requestId: msg.request_id as string,
toolName: request.tool_name as string,
toolInput: (request.input as Record<string, unknown>) ?? {},
title: request.title as string | undefined,
displayName: request.display_name as string | undefined,
description: request.description as string | undefined,
toolUseId: request.tool_use_id as string,
}];
}
return [{ type: "unknown", raw: msg }];
}
case "result": {
const sessionId = (msg.session_id as string) ?? "";
const subtype = msg.subtype as string;
return [{
type: "result",
sessionId,
success: subtype === "success",
result: (msg.result as string) ?? undefined,
costUsd: msg.total_cost_usd as number | undefined,
turns: msg.num_turns as number | undefined,
}];
}
default:
return [{ type: "unknown", raw: msg }];
}
}
// ---------------------------------------------------------------------------
// Factory registration
// ---------------------------------------------------------------------------
import { registerProviderFactory } from "../provider.ts";
registerProviderFactory(
PROVIDER_NAME,
async (config) => new ClaudeAgentSDKProvider(config as ClaudeAgentSDKConfig)
);

View File

@@ -0,0 +1,431 @@
// @generated — DO NOT EDIT. Source: packages/ai/providers/codex-sdk.ts
/**
* Codex SDK provider — bridges Plannotator's AI layer with OpenAI's Codex agent.
*
* Uses @openai/codex-sdk to create sessions that can:
* - Start fresh with Plannotator context as the system prompt
* - Fake-fork from a parent session (fresh thread + preamble, no real history)
* - Resume a previous thread by ID
* - Stream text deltas back to the UI in real time
*
* Sessions default to read-only sandbox mode for safety in inline chat.
*/
import { buildSystemPrompt, buildEffectivePrompt } from "../context.ts";
import { BaseSession } from "../base-session.ts";
import type {
AIProvider,
AIProviderCapabilities,
AISession,
AIMessage,
CreateSessionOptions,
CodexSDKConfig,
} from "../types.ts";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PROVIDER_NAME = "codex-sdk";
const DEFAULT_MODEL = "gpt-5.4";
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export class CodexSDKProvider implements AIProvider {
readonly name = PROVIDER_NAME;
readonly capabilities: AIProviderCapabilities = {
fork: false, // No real fork — faked with fresh thread + preamble
resume: true,
streaming: true,
tools: true,
};
readonly models = [
{ id: 'gpt-5.5', label: 'GPT-5.5' },
{ id: 'gpt-5.4', label: 'GPT-5.4', default: true },
{ id: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
{ id: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
{ id: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark' },
{ id: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
{ id: 'gpt-5.2', label: 'GPT-5.2' },
] as const;
private config: CodexSDKConfig;
constructor(config: CodexSDKConfig) {
this.config = config;
}
async createSession(options: CreateSessionOptions): Promise<AISession> {
return new CodexSDKSession({
...this.baseConfig(options),
systemPrompt: buildSystemPrompt(options.context),
cwd: options.cwd ?? this.config.cwd ?? process.cwd(),
parentSessionId: null,
});
}
async forkSession(_options: CreateSessionOptions): Promise<AISession> {
throw new Error(
"Codex does not support session forking. " +
"The endpoint layer should fall back to createSession()."
);
}
async resumeSession(sessionId: string): Promise<AISession> {
return new CodexSDKSession({
...this.baseConfig(),
systemPrompt: null,
cwd: this.config.cwd ?? process.cwd(),
parentSessionId: null,
resumeThreadId: sessionId,
});
}
dispose(): void {
// No persistent resources to clean up
}
private baseConfig(options?: CreateSessionOptions) {
return {
model: options?.model ?? this.config.model ?? DEFAULT_MODEL,
maxTurns: options?.maxTurns ?? 99,
sandboxMode: this.config.sandboxMode ?? "read-only" as const,
codexExecutablePath: this.config.codexExecutablePath,
reasoningEffort: options?.reasoningEffort,
};
}
}
// ---------------------------------------------------------------------------
// SDK import cache — resolve once, reuse across all sessions
// ---------------------------------------------------------------------------
// biome-ignore lint/suspicious/noExplicitAny: SDK type not available at compile time
let CodexClass: any = null;
async function getCodexClass() {
if (!CodexClass) {
// biome-ignore lint/suspicious/noExplicitAny: SDK exports vary between versions
const mod = await import("@openai/codex-sdk") as any;
CodexClass = mod.default ?? mod.Codex;
}
return CodexClass;
}
// ---------------------------------------------------------------------------
// Session
// ---------------------------------------------------------------------------
interface SessionConfig {
systemPrompt: string | null;
model: string;
maxTurns: number;
sandboxMode: "read-only" | "workspace-write" | "danger-full-access";
cwd: string;
parentSessionId: string | null;
resumeThreadId?: string;
codexExecutablePath?: string;
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
}
class CodexSDKSession extends BaseSession {
private config: SessionConfig;
// biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time
private _codexInstance: any = null;
// biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time
private _thread: any = null;
/** Tracks cumulative text length per item for delta extraction. */
private _itemTextOffsets = new Map<string, number>();
constructor(config: SessionConfig) {
super({
parentSessionId: config.parentSessionId,
initialId: config.resumeThreadId,
});
this.config = config;
// If resuming, treat the thread ID as already resolved
if (config.resumeThreadId) {
this._resolvedId = config.resumeThreadId;
}
}
async *query(prompt: string): AsyncIterable<AIMessage> {
const started = this.startQuery();
if (!started) { yield BaseSession.BUSY_ERROR; return; }
const { gen, signal } = started;
this._itemTextOffsets.clear();
try {
const Codex = await getCodexClass();
// Lazy-create the Codex instance
if (!this._codexInstance) {
this._codexInstance = new Codex({
...(this.config.codexExecutablePath && { codexPathOverride: this.config.codexExecutablePath }),
});
}
// Lazy-create or resume the thread
if (!this._thread) {
if (this.config.resumeThreadId) {
this._thread = this._codexInstance.resumeThread(this.config.resumeThreadId, {
model: this.config.model,
workingDirectory: this.config.cwd,
sandboxMode: this.config.sandboxMode,
...(this.config.reasoningEffort && { modelReasoningEffort: this.config.reasoningEffort }),
});
} else {
this._thread = this._codexInstance.startThread({
model: this.config.model,
workingDirectory: this.config.cwd,
sandboxMode: this.config.sandboxMode,
...(this.config.reasoningEffort && { modelReasoningEffort: this.config.reasoningEffort }),
});
}
}
const effectivePrompt = buildEffectivePrompt(
prompt,
this.config.systemPrompt,
this._firstQuerySent,
);
const streamed = await this._thread.runStreamed(effectivePrompt, {
signal,
});
this._firstQuerySent = true;
let turnFailed = false;
for await (const event of streamed.events) {
// ID resolution from thread.started
if (
!this._resolvedId &&
event.type === "thread.started" &&
typeof event.thread_id === "string"
) {
this.resolveId(event.thread_id);
}
if (event.type === "turn.failed") {
turnFailed = true;
}
const mapped = mapCodexEvent(event, this._itemTextOffsets);
for (const msg of mapped) {
yield msg;
}
}
// Emit synthetic result after stream ends
if (!turnFailed) {
yield {
type: "result",
sessionId: this.id,
success: true,
};
}
} catch (err) {
yield {
type: "error",
error: err instanceof Error ? err.message : String(err),
code: "provider_error",
};
} finally {
this.endQuery(gen);
}
}
}
// ---------------------------------------------------------------------------
// Event mapping
// ---------------------------------------------------------------------------
/**
* Map a Codex SDK ThreadEvent to one or more AIMessages.
*
* The itemTextOffsets map tracks cumulative text length per item ID
* so we can extract true deltas from the cumulative text in item.updated events.
*/
function mapCodexEvent(
event: Record<string, unknown>,
itemTextOffsets: Map<string, number>,
): AIMessage[] {
const eventType = event.type as string;
switch (eventType) {
case "thread.started":
case "turn.started":
return [];
case "turn.completed":
return [];
case "turn.failed": {
const error = event.error as Record<string, unknown> | undefined;
return [{
type: "error",
error: (error?.message as string) ?? "Turn failed",
code: "turn_failed",
}];
}
case "error":
return [{
type: "error",
error: (event.message as string) ?? "Unknown error",
code: "codex_error",
}];
case "item.started":
case "item.updated":
case "item.completed":
return mapCodexItem(event, itemTextOffsets);
default:
return [{ type: "unknown", raw: event }];
}
}
/**
* Map item-level events to AIMessages.
*/
function mapCodexItem(
event: Record<string, unknown>,
itemTextOffsets: Map<string, number>,
): AIMessage[] {
const item = event.item as Record<string, unknown>;
if (!item) return [{ type: "unknown", raw: event }];
const eventType = event.type as string;
const itemType = item.type as string;
const itemId = (item.id as string) ?? "";
const isStarted = eventType === "item.started";
const isCompleted = eventType === "item.completed";
switch (itemType) {
case "agent_message": {
const text = (item.text as string) ?? "";
if (isStarted) {
// Reset offset tracking for this item
itemTextOffsets.set(itemId, 0);
return [];
}
if (isCompleted) {
// Emit final complete text
itemTextOffsets.delete(itemId);
return text ? [{ type: "text", text }] : [];
}
// item.updated — extract delta from cumulative text
const prevOffset = itemTextOffsets.get(itemId) ?? 0;
if (text.length > prevOffset) {
const delta = text.slice(prevOffset);
itemTextOffsets.set(itemId, text.length);
return [{ type: "text_delta", delta }];
}
return [];
}
case "command_execution": {
const messages: AIMessage[] = [];
if (isStarted) {
messages.push({
type: "tool_use",
toolName: "Bash",
toolInput: { command: item.command as string },
toolUseId: itemId,
});
}
if (isCompleted) {
const output = (item.aggregated_output as string) ?? "";
const exitCode = item.exit_code as number | undefined;
messages.push({
type: "tool_result",
toolUseId: itemId,
result: exitCode != null ? `${output}\n[exit code: ${exitCode}]` : output,
});
}
return messages;
}
case "file_change": {
const changes = item.changes as Array<{ path: string; kind: string }> | undefined;
if (isStarted || isCompleted) {
return [{
type: "tool_use",
toolName: "FileChange",
toolInput: { changes: changes ?? [] },
toolUseId: itemId,
}];
}
return [];
}
case "mcp_tool_call": {
const messages: AIMessage[] = [];
if (isStarted) {
messages.push({
type: "tool_use",
toolName: `${item.server as string}/${item.tool as string}`,
toolInput: (item.arguments as Record<string, unknown>) ?? {},
toolUseId: itemId,
});
}
if (isCompleted) {
if (item.result != null) {
messages.push({
type: "tool_result",
toolUseId: itemId,
result: typeof item.result === "string" ? item.result : JSON.stringify(item.result),
});
}
if (item.error) {
const err = item.error as Record<string, unknown>;
messages.push({
type: "error",
error: (err.message as string) ?? "MCP tool call failed",
code: "mcp_error",
});
}
}
return messages;
}
case "error":
return [{
type: "error",
error: (item.message as string) ?? "Unknown error",
}];
case "reasoning":
case "web_search":
case "todo_list":
return [{ type: "unknown", raw: { eventType, item } }];
default:
return [{ type: "unknown", raw: { eventType, item } }];
}
}
// ---------------------------------------------------------------------------
// Exported for testing
// ---------------------------------------------------------------------------
export { mapCodexEvent, mapCodexItem };
// ---------------------------------------------------------------------------
// Factory registration
// ---------------------------------------------------------------------------
import { registerProviderFactory } from "../provider.ts";
registerProviderFactory(
PROVIDER_NAME,
async (config) => new CodexSDKProvider(config as CodexSDKConfig)
);

View File

@@ -0,0 +1,547 @@
// @generated — DO NOT EDIT. Source: packages/ai/providers/opencode-sdk.ts
/**
* OpenCode provider — bridges Plannotator's AI layer with OpenCode's agent server.
*
* Uses @opencode-ai/sdk to connect to an existing `opencode serve` first and
* only spawns a new server when nothing is reachable. One server is shared
* across all sessions. The user must have the `opencode` CLI installed and
* authenticated.
*/
import type { OpencodeClient } from "@opencode-ai/sdk";
import { BaseSession } from "../base-session.ts";
import { buildSystemPrompt } from "../context.ts";
import type {
AIMessage,
AIProvider,
AIProviderCapabilities,
AISession,
CreateSessionOptions,
OpenCodeConfig,
} from "../types.ts";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PROVIDER_NAME = "opencode-sdk";
// ---------------------------------------------------------------------------
// SDK import cache — resolve once, reuse across all sessions
// ---------------------------------------------------------------------------
// biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time
let sdk: any = null;
async function getSDK() {
if (!sdk) {
sdk = await import("@opencode-ai/sdk");
}
return sdk;
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export class OpenCodeProvider implements AIProvider {
readonly name = PROVIDER_NAME;
readonly capabilities: AIProviderCapabilities = {
fork: true,
resume: true,
streaming: true,
tools: true,
};
models?: Array<{ id: string; label: string; default?: boolean }>;
private config: OpenCodeConfig;
// biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time
private server: { url: string; close: () => void } | null = null;
private client: OpencodeClient | null = null;
private startPromise: Promise<void> | null = null;
private lastAttachError: string | null = null;
constructor(config: OpenCodeConfig) {
this.config = config;
}
/** Attach to an existing OpenCode server or spawn one if needed. */
async ensureServer(): Promise<void> {
if (this.client) return;
this.startPromise ??= this.doStart().catch((err) => {
this.startPromise = null;
throw err;
});
return this.startPromise;
}
private async doStart(): Promise<void> {
this.lastAttachError = null;
const { createOpencodeServer, createOpencodeClient } = await getSDK();
const attachedClient = await this.tryAttachExistingServer(createOpencodeClient);
if (attachedClient) {
this.client = attachedClient;
return;
}
try {
this.server = await createOpencodeServer({
hostname: this.config.hostname ?? "127.0.0.1",
...(this.config.port != null && { port: this.config.port }),
timeout: 15_000,
});
} catch (err) {
const spawnMessage = err instanceof Error ? err.message : String(err);
if (this.lastAttachError) {
throw new Error(`${this.lastAttachError}\nFallback startup also failed: ${spawnMessage}`);
}
throw err;
}
this.client = createOpencodeClient({
baseUrl: this.server!.url,
directory: this.config.cwd ?? process.cwd(),
});
}
private async tryAttachExistingServer(
createOpencodeClient: (config?: { baseUrl?: string; directory?: string }) => OpencodeClient,
): Promise<OpencodeClient | null> {
const cwd = this.config.cwd ?? process.cwd();
const baseUrl = `http://${this.config.hostname ?? "127.0.0.1"}:${this.config.port ?? 4096}`;
const client = createOpencodeClient({
baseUrl,
directory: cwd,
});
try {
await client.config.get({
throwOnError: true,
signal: AbortSignal.timeout(1_000),
});
return client;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.lastAttachError = `Failed to attach to existing OpenCode server at ${baseUrl}: ${message}`;
return null;
}
}
private getClient(): OpencodeClient {
if (!this.client) {
throw new Error("OpenCode client is not initialized.");
}
return this.client;
}
async createSession(options: CreateSessionOptions): Promise<AISession> {
await this.ensureServer();
const client = this.getClient();
const result = await client.session.create({
query: { directory: options.cwd ?? this.config.cwd ?? process.cwd() },
});
const sessionData = result.data;
if (!sessionData) {
throw new Error("OpenCode did not return session data.");
}
const session = new OpenCodeSession({
sessionId: sessionData.id,
systemPrompt: buildSystemPrompt(options.context),
client,
model: options.model,
parentSessionId: null,
});
return session;
}
async forkSession(options: CreateSessionOptions): Promise<AISession> {
await this.ensureServer();
const client = this.getClient();
const parentId = options.context.parent?.sessionId;
if (!parentId) {
throw new Error("Fork requires a parent session ID.");
}
const result = await client.session.fork({
path: { id: parentId },
});
const sessionData = result.data;
if (!sessionData) {
throw new Error("OpenCode did not return forked session data.");
}
return new OpenCodeSession({
sessionId: sessionData.id,
systemPrompt: buildSystemPrompt(options.context),
client,
model: options.model,
parentSessionId: parentId,
});
}
async resumeSession(sessionId: string): Promise<AISession> {
await this.ensureServer();
const client = this.getClient();
// Verify session exists
await client.session.get({ path: { id: sessionId } });
return new OpenCodeSession({
sessionId,
systemPrompt: null,
client,
model: undefined,
parentSessionId: null,
});
}
dispose(): void {
if (this.server) {
this.server.close();
this.server = null;
}
this.client = null;
this.startPromise = null;
}
/** Fetch available models from OpenCode. Call before registering the provider. */
async fetchModels(): Promise<void> {
try {
await this.ensureServer();
const client = this.getClient();
const result = await client.provider.list({
query: { directory: this.config.cwd ?? process.cwd() },
});
const data = result.data;
if (!data) {
return;
}
const connected = new Set(data.connected ?? []);
const allProviders = data.all ?? [];
const models: Array<{ id: string; label: string; default?: boolean }> = [];
for (const provider of allProviders) {
if (!connected.has(provider.id)) continue;
for (const model of Object.values(provider.models)) {
models.push({
id: `${provider.id}/${model.id}`,
label: model.name ?? model.id,
});
}
}
if (models.length > 0) {
// Mark first model as default
models[0].default = true;
this.models = models;
}
} catch {
// OpenCode not configured or no models available
}
}
}
// ---------------------------------------------------------------------------
// Session
// ---------------------------------------------------------------------------
interface SessionConfig {
sessionId: string;
systemPrompt: string | null;
// biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time
client: any;
/** Model in "providerID/modelID" format. */
model?: string;
parentSessionId: string | null;
}
class OpenCodeSession extends BaseSession {
private config: SessionConfig;
constructor(config: SessionConfig) {
super({
parentSessionId: config.parentSessionId,
initialId: config.sessionId,
});
this.config = config;
this._resolvedId = config.sessionId;
}
async *query(prompt: string): AsyncIterable<AIMessage> {
const started = this.startQuery();
if (!started) {
yield BaseSession.BUSY_ERROR;
return;
}
const { gen } = started;
try {
// Build model param if specified
let modelParam: { providerID: string; modelID: string } | undefined;
if (this.config.model) {
const [providerID, ...rest] = this.config.model.split("/");
const modelID = rest.join("/");
if (providerID && modelID) {
modelParam = { providerID, modelID };
}
}
// Subscribe to SSE events
const { stream } = await this.config.client.event.subscribe();
try {
// Send prompt asynchronously
try {
await this.config.client.session.promptAsync({
path: { id: this.config.sessionId },
body: {
...(!this._firstQuerySent &&
this.config.systemPrompt && {
system: this.config.systemPrompt,
}),
...(modelParam && { model: modelParam }),
parts: [{ type: "text", text: prompt }],
},
});
} catch (err) {
yield {
type: "error",
error: `OpenCode rejected prompt: ${err instanceof Error ? err.message : String(err)}`,
code: "opencode_prompt_rejected",
};
return;
}
this._firstQuerySent = true;
// Drain SSE events filtered by session ID
for await (const event of stream) {
const eventType = event.type as string;
const props = event.properties as Record<string, unknown> | undefined;
if (!props) continue;
// Filter: only events for our session
const eventSessionId =
(props.sessionID as string) ??
((props.info as Record<string, unknown>)?.sessionID as string) ??
((props.part as Record<string, unknown>)?.sessionID as string);
if (eventSessionId && eventSessionId !== this.config.sessionId) continue;
const mapped = mapOpenCodeEvent(eventType, props, this.id);
for (const msg of mapped) {
yield msg;
if (msg.type === "result" || (msg.type === "error" && isTerminalEvent(eventType))) {
return;
}
}
}
} finally {
stream.return?.();
}
} catch (err) {
yield {
type: "error",
error: err instanceof Error ? err.message : String(err),
code: "provider_error",
};
} finally {
this.endQuery(gen);
}
}
abort(): void {
this.config.client.session
.abort({ path: { id: this.config.sessionId } })
.catch(() => {});
super.abort();
}
respondToPermission(
requestId: string,
allow: boolean,
_message?: string,
): void {
this.config.client
.postSessionIdPermissionsPermissionId({
path: { id: this.config.sessionId, permissionID: requestId },
body: { response: allow ? "once" : "reject" },
})
.catch(() => {});
}
}
// ---------------------------------------------------------------------------
// Event mapping
// ---------------------------------------------------------------------------
/** Returns true for events that should terminate the query when mapped to an error. */
function isTerminalEvent(eventType: string): boolean {
return eventType === "session.error" || eventType === "session.status";
}
/**
* Map an OpenCode SSE event to AIMessage[].
*
* Key events:
* message.part.delta → text_delta (streaming text)
* message.part.updated → tool_use / tool_result (tool lifecycle)
* permission.updated → permission_request
* session.status → result (when idle)
* message.updated → error (when message has error)
*/
export function mapOpenCodeEvent(
eventType: string,
props: Record<string, unknown>,
sessionId: string,
): AIMessage[] {
switch (eventType) {
case "message.part.delta": {
const field = props.field as string;
const delta = props.delta as string;
if (field === "text" && delta) {
return [{ type: "text_delta", delta }];
}
return [];
}
case "message.part.updated": {
const part = props.part as Record<string, unknown>;
if (!part) return [];
const partType = part.type as string;
if (partType === "tool") {
const state = part.state as Record<string, unknown>;
if (!state) return [];
const status = state.status as string;
const callID = (part.callID as string) ?? (part.id as string);
const toolName = part.tool as string;
switch (status) {
case "running":
return [
{
type: "tool_use",
toolName: toolName ?? "unknown",
toolInput: (state.input as Record<string, unknown>) ?? {},
toolUseId: callID,
},
];
case "completed": {
const output = (state.output as string) ?? "";
return [
{
type: "tool_result",
toolUseId: callID,
result: output,
},
];
}
case "error": {
const error = (state.error as string) ?? "Tool execution failed";
return [
{
type: "tool_result",
toolUseId: callID,
result: `[Error] ${error}`,
},
];
}
default:
return [];
}
}
return [];
}
case "permission.updated": {
const id = props.id as string;
const permType = props.type as string;
const title = props.title as string;
const callID = props.callID as string;
const metadata = (props.metadata as Record<string, unknown>) ?? {};
return [
{
type: "permission_request",
requestId: id,
toolName: permType ?? "unknown",
toolInput: metadata,
title: title ?? permType,
toolUseId: callID ?? id,
},
];
}
case "session.status": {
const status = props.status as Record<string, unknown>;
if (status?.type === "idle") {
return [
{
type: "result",
sessionId,
success: true,
},
];
}
return [];
}
case "session.error": {
const error = props.error as Record<string, unknown>;
const message =
(error?.message as string) ?? (props.message as string) ?? "Session error";
return [
{
type: "error",
error: message,
code: "opencode_session_error",
},
];
}
case "message.updated": {
const info = props.info as Record<string, unknown>;
if (!info) return [];
const msgError = info.error as Record<string, unknown>;
if (msgError) {
const errorData = msgError.data as Record<string, unknown>;
const message =
(errorData?.message as string) ??
(msgError.name as string) ??
"Message error";
return [
{
type: "error",
error: message,
code: "opencode_message_error",
},
];
}
return [];
}
default:
return [];
}
}
// ---------------------------------------------------------------------------
// Factory registration
// ---------------------------------------------------------------------------
import { registerProviderFactory } from "../provider.ts";
registerProviderFactory(
PROVIDER_NAME,
async (config) => new OpenCodeProvider(config as OpenCodeConfig),
);

View File

@@ -0,0 +1,111 @@
// @generated — DO NOT EDIT. Source: packages/ai/providers/pi-events.ts
/**
* Pi event mapping — shared between Bun and Node.js Pi providers.
*
* Pure function, no runtime-specific dependencies.
*/
import type { AIMessage } from "../types.ts";
/**
* Map a Pi AgentEvent (received as JSONL) to AIMessage[].
*
* Pi event hierarchy:
* agent_start > turn_start > message_start > message_update* > message_end
* > tool_execution_start > tool_execution_end > turn_end > agent_end
*
* We extract:
* - text_delta from message_update.assistantMessageEvent
* - tool_use from toolcall_end
* - tool_result from tool_execution_end
* - result from agent_end
*/
export function mapPiEvent(
event: Record<string, unknown>,
sessionId: string,
): AIMessage[] {
const eventType = event.type as string;
switch (eventType) {
case "message_update": {
const ame = event.assistantMessageEvent as
| Record<string, unknown>
| undefined;
if (!ame) return [];
const subType = ame.type as string;
switch (subType) {
case "text_delta":
return [{ type: "text_delta", delta: ame.delta as string }];
case "toolcall_end": {
const tc = ame.toolCall as Record<string, unknown>;
if (!tc) return [];
return [
{
type: "tool_use",
toolName: tc.name as string,
toolInput: (tc.arguments as Record<string, unknown>) ?? {},
toolUseId: tc.id as string,
},
];
}
case "error": {
const partial = ame.error as Record<string, unknown> | undefined;
const errorMessage =
(partial?.errorMessage as string) ?? "Stream error";
return [
{ type: "error", error: errorMessage, code: "pi_stream_error" },
];
}
default:
return [];
}
}
case "tool_execution_end": {
const result = event.result;
const isError = event.isError as boolean;
const resultStr =
result == null
? ""
: typeof result === "string"
? result
: JSON.stringify(result);
return [
{
type: "tool_result",
toolUseId: event.toolCallId as string,
result: isError
? `[Error] ${resultStr || "Tool execution failed"}`
: resultStr,
},
];
}
case "agent_end":
return [
{
type: "result",
sessionId,
success: true,
},
];
case "process_exited":
return [
{
type: "error",
error: "Pi process exited unexpectedly.",
code: "pi_process_exit",
},
];
default:
return [];
}
}

View File

@@ -0,0 +1,377 @@
// @generated — DO NOT EDIT. Source: packages/ai/providers/pi-sdk-node.ts
/**
* Pi SDK provider — Node.js variant.
*
* Identical to pi-sdk.ts except PiProcess uses child_process.spawn()
* instead of Bun.spawn(). Everything else (PiSDKProvider, PiSDKSession,
* mapPiEvent) is re-exported from the Bun version unchanged.
*
* Used by the Pi extension which runs under jiti (Node.js).
*/
import { spawn, type ChildProcess } from "node:child_process";
import { BaseSession } from "../base-session.ts";
import { buildEffectivePrompt, buildSystemPrompt } from "../context.ts";
import type {
AIMessage,
AIProvider,
AIProviderCapabilities,
CreateSessionOptions,
PiSDKConfig,
} from "../types.ts";
import { registerProviderFactory } from "../provider.ts";
// Re-export mapPiEvent from shared (runtime-agnostic)
export { mapPiEvent } from "./pi-events.ts";
const PROVIDER_NAME = "pi-sdk";
// ---------------------------------------------------------------------------
// JSONL subprocess wrapper (Node.js)
// ---------------------------------------------------------------------------
type EventListener = (event: Record<string, unknown>) => void;
class PiProcessNode {
private proc: ChildProcess | null = null;
private listeners: EventListener[] = [];
private pendingRequests = new Map<
string,
{
resolve: (data: Record<string, unknown>) => void;
reject: (err: Error) => void;
}
>();
private nextId = 0;
private buffer = "";
private _alive = false;
async spawn(piPath: string, cwd: string): Promise<void> {
this.proc = spawn(piPath, ["--mode", "rpc"], {
cwd,
stdio: ["pipe", "pipe", "pipe"],
});
this._alive = true;
this.readStream();
this.proc.on("exit", () => {
this._alive = false;
for (const [, pending] of this.pendingRequests) {
pending.reject(new Error("Pi process exited unexpectedly"));
}
this.pendingRequests.clear();
for (const listener of this.listeners) {
listener({ type: "process_exited" });
}
});
}
private readStream(): void {
if (!this.proc?.stdout) return;
this.proc.stdout.on("data", (chunk: Buffer) => {
this.buffer += chunk.toString();
const lines = this.buffer.split("\n");
this.buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.replace(/\r$/, "");
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed);
this.routeMessage(parsed);
} catch {
// Ignore malformed lines
}
}
});
}
private routeMessage(msg: Record<string, unknown>): void {
if (msg.type === "response" && typeof msg.id === "string") {
const pending = this.pendingRequests.get(msg.id);
if (pending) {
this.pendingRequests.delete(msg.id);
if (msg.success === false) {
pending.reject(new Error((msg.error as string) ?? "RPC error"));
} else {
pending.resolve((msg.data as Record<string, unknown>) ?? {});
}
return;
}
}
for (const listener of this.listeners) {
listener(msg);
}
}
send(command: Record<string, unknown>): void {
if (!this.proc?.stdin || this.proc.stdin.destroyed) return;
this.proc.stdin.write(`${JSON.stringify(command)}\n`);
}
sendAndWait(
command: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const id = `req_${++this.nextId}`;
return new Promise((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject });
this.send({ ...command, id });
});
}
onEvent(listener: EventListener): () => void {
this.listeners.push(listener);
return () => {
const idx = this.listeners.indexOf(listener);
if (idx >= 0) this.listeners.splice(idx, 1);
};
}
get alive(): boolean {
return this._alive;
}
kill(): void {
this._alive = false;
if (this.proc) {
this.proc.kill();
this.proc = null;
}
this.listeners.length = 0;
for (const [, pending] of this.pendingRequests) {
pending.reject(new Error("Process killed"));
}
this.pendingRequests.clear();
}
}
// ---------------------------------------------------------------------------
// Provider (identical to pi-sdk.ts, using PiProcessNode)
// ---------------------------------------------------------------------------
export class PiSDKNodeProvider implements AIProvider {
readonly name = PROVIDER_NAME;
readonly capabilities: AIProviderCapabilities = {
fork: false,
resume: false,
streaming: true,
tools: true,
};
models?: Array<{ id: string; label: string; default?: boolean }>;
private config: PiSDKConfig;
private sessions = new Map<string, PiSDKNodeSession>();
constructor(config: PiSDKConfig) {
this.config = config;
}
async createSession(options: CreateSessionOptions): Promise<PiSDKNodeSession> {
const session = new PiSDKNodeSession({
systemPrompt: buildSystemPrompt(options.context),
cwd: options.cwd ?? this.config.cwd ?? process.cwd(),
parentSessionId: null,
piExecutablePath: this.config.piExecutablePath ?? "pi",
model: options.model ?? this.config.model,
});
this.sessions.set(session.id, session);
return session;
}
async forkSession(): Promise<never> {
throw new Error(
"Pi does not support session forking. " +
"The endpoint layer should fall back to createSession().",
);
}
async resumeSession(): Promise<never> {
throw new Error("Pi does not support session resuming.");
}
dispose(): void {
for (const session of this.sessions.values()) {
session.killProcess();
}
this.sessions.clear();
}
async fetchModels(): Promise<void> {
const piPath = this.config.piExecutablePath ?? "pi";
let proc: PiProcessNode | undefined;
try {
proc = new PiProcessNode();
await proc.spawn(piPath, this.config.cwd ?? process.cwd());
const data = await Promise.race([
proc.sendAndWait({ type: "get_available_models" }),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), 10_000),
),
]);
const rawModels = (
data as { models?: Array<{ provider: string; id: string; name?: string }> }
).models;
if (rawModels && rawModels.length > 0) {
this.models = rawModels.map((m, i) => ({
id: `${m.provider}/${m.id}`,
label: m.name ?? m.id,
...(i === 0 && { default: true }),
}));
}
} catch {
// Pi not configured or no models available
} finally {
proc?.kill();
}
}
}
// ---------------------------------------------------------------------------
// Session (identical to pi-sdk.ts, using PiProcessNode)
// ---------------------------------------------------------------------------
interface SessionConfig {
systemPrompt: string;
cwd: string;
parentSessionId: string | null;
piExecutablePath: string;
model?: string;
}
class PiSDKNodeSession extends BaseSession {
private config: SessionConfig;
private process: PiProcessNode | null = null;
constructor(config: SessionConfig) {
super({ parentSessionId: config.parentSessionId });
this.config = config;
}
async *query(prompt: string): AsyncIterable<AIMessage> {
const { mapPiEvent } = await import("./pi-events.ts");
const started = this.startQuery();
if (!started) {
yield BaseSession.BUSY_ERROR;
return;
}
const { gen } = started;
try {
if (!this.process || !this.process.alive) {
this.process = new PiProcessNode();
await this.process.spawn(this.config.piExecutablePath, this.config.cwd);
if (this.config.model) {
const [provider, ...rest] = this.config.model.split("/");
const modelId = rest.join("/");
if (provider && modelId) {
try {
await this.process.sendAndWait({ type: "set_model", provider, modelId });
} catch { /* Continue with Pi's default model */ }
}
}
try {
const state = await this.process.sendAndWait({ type: "get_state" });
if (typeof state.sessionId === "string") {
this.resolveId(state.sessionId);
}
} catch { /* Continue with placeholder ID */ }
if (!this.process.alive) {
yield {
type: "error",
error: "Pi process exited during startup. Check that Pi is configured correctly (API keys, models).",
code: "pi_startup_error",
};
return;
}
}
const effectivePrompt = buildEffectivePrompt(
prompt,
this.config.systemPrompt,
this._firstQuerySent,
);
const queue: AIMessage[] = [];
let resolve: (() => void) | null = null;
let done = false;
const push = (msg: AIMessage) => { queue.push(msg); resolve?.(); };
const finish = () => { done = true; resolve?.(); };
const unsubscribe = this.process.onEvent((event) => {
const mapped = mapPiEvent(event, this.id);
for (const msg of mapped) {
push(msg);
if (
msg.type === "result" ||
(msg.type === "error" && (event.type === "agent_end" || event.type === "process_exited"))
) {
finish();
}
}
});
try {
await this.process.sendAndWait({ type: "prompt", message: effectivePrompt });
} catch (err) {
unsubscribe();
yield {
type: "error",
error: `Pi rejected prompt: ${err instanceof Error ? err.message : String(err)}`,
code: "pi_prompt_rejected",
};
return;
}
this._firstQuerySent = true;
try {
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise<void>((r) => { resolve = r; });
resolve = null;
}
}
} finally {
unsubscribe();
}
} catch (err) {
yield {
type: "error",
error: err instanceof Error ? err.message : String(err),
code: "provider_error",
};
} finally {
this.endQuery(gen);
}
}
abort(): void {
if (this.process?.alive) {
this.process.send({ type: "abort" });
}
super.abort();
}
killProcess(): void {
this.process?.kill();
this.process = null;
}
}
// ---------------------------------------------------------------------------
// Factory registration
// ---------------------------------------------------------------------------
registerProviderFactory(
PROVIDER_NAME,
async (config) => new PiSDKNodeProvider(config as PiSDKConfig),
);

View File

@@ -0,0 +1,442 @@
// @generated — DO NOT EDIT. Source: packages/ai/providers/pi-sdk.ts
/**
* Pi SDK provider — bridges Plannotator's AI layer with Pi's coding agent.
*
* Spawns `pi --mode rpc` as a subprocess and communicates via JSONL over
* stdio. No Pi SDK is imported — this is a thin protocol adapter.
*
* One subprocess per session. The user must have the `pi` CLI installed.
*/
import { BaseSession } from "../base-session.ts";
import { buildEffectivePrompt, buildSystemPrompt } from "../context.ts";
import type {
AIMessage,
AIProvider,
AIProviderCapabilities,
CreateSessionOptions,
PiSDKConfig,
} from "../types.ts";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PROVIDER_NAME = "pi-sdk";
// ---------------------------------------------------------------------------
// JSONL subprocess wrapper
// ---------------------------------------------------------------------------
type EventListener = (event: Record<string, unknown>) => void;
class PiProcess {
private proc: ReturnType<typeof Bun.spawn> | null = null;
private listeners: EventListener[] = [];
private pendingRequests = new Map<
string,
{
resolve: (data: Record<string, unknown>) => void;
reject: (err: Error) => void;
}
>();
private nextId = 0;
private buffer = "";
private _alive = false;
async spawn(piPath: string, cwd: string): Promise<void> {
this.proc = Bun.spawn([piPath, "--mode", "rpc"], {
cwd,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
this._alive = true;
this.readStream();
this.proc.exited.then(() => {
this._alive = false;
for (const [, pending] of this.pendingRequests) {
pending.reject(new Error("Pi process exited unexpectedly"));
}
this.pendingRequests.clear();
// Signal active query listeners so the drain loop exits with an error
for (const listener of this.listeners) {
listener({ type: "process_exited" });
}
});
}
private async readStream(): Promise<void> {
if (!this.proc?.stdout || typeof this.proc.stdout === "number") return;
const reader = (this.proc.stdout as ReadableStream<Uint8Array>).getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
this.buffer += decoder.decode(value, { stream: true });
const lines = this.buffer.split("\n");
this.buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.replace(/\r$/, "");
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed);
this.routeMessage(parsed);
} catch {
// Ignore malformed lines
}
}
}
} catch {
// Stream closed
}
}
private routeMessage(msg: Record<string, unknown>): void {
// Response to a command we sent
if (msg.type === "response" && typeof msg.id === "string") {
const pending = this.pendingRequests.get(msg.id);
if (pending) {
this.pendingRequests.delete(msg.id);
if (msg.success === false) {
pending.reject(new Error((msg.error as string) ?? "RPC error"));
} else {
pending.resolve((msg.data as Record<string, unknown>) ?? {});
}
return;
}
}
// Agent event — forward to listeners
for (const listener of this.listeners) {
listener(msg);
}
}
/** Send a command without waiting for a response. */
send(command: Record<string, unknown>): void {
if (!this.proc?.stdin || typeof this.proc.stdin === "number") return;
// Bun.spawn stdin is a FileSink with .write(), not a WritableStream
const sink = this.proc.stdin as { write(data: string): void; flush(): void };
sink.write(`${JSON.stringify(command)}\n`);
sink.flush();
}
/** Send a command and wait for the correlated response. */
sendAndWait(
command: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const id = `req_${++this.nextId}`;
return new Promise((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject });
this.send({ ...command, id });
});
}
/** Register a listener for agent events (non-response messages). */
onEvent(listener: EventListener): () => void {
this.listeners.push(listener);
return () => {
const idx = this.listeners.indexOf(listener);
if (idx >= 0) this.listeners.splice(idx, 1);
};
}
get alive(): boolean {
return this._alive;
}
kill(): void {
this._alive = false;
if (this.proc) {
this.proc.kill();
this.proc = null;
}
this.listeners.length = 0;
for (const [, pending] of this.pendingRequests) {
pending.reject(new Error("Process killed"));
}
this.pendingRequests.clear();
}
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export class PiSDKProvider implements AIProvider {
readonly name = PROVIDER_NAME;
readonly capabilities: AIProviderCapabilities = {
fork: false,
resume: false,
streaming: true,
tools: true,
};
models?: Array<{ id: string; label: string; default?: boolean }>;
private config: PiSDKConfig;
private sessions = new Map<string, PiSDKSession>();
constructor(config: PiSDKConfig) {
this.config = config;
}
async createSession(options: CreateSessionOptions): Promise<PiSDKSession> {
const session = new PiSDKSession({
systemPrompt: buildSystemPrompt(options.context),
cwd: options.cwd ?? this.config.cwd ?? process.cwd(),
parentSessionId: null,
piExecutablePath: this.config.piExecutablePath ?? "pi",
model: options.model ?? this.config.model,
});
this.sessions.set(session.id, session);
return session;
}
async forkSession(): Promise<never> {
throw new Error(
"Pi does not support session forking. " +
"The endpoint layer should fall back to createSession().",
);
}
async resumeSession(): Promise<never> {
throw new Error("Pi does not support session resuming.");
}
dispose(): void {
for (const session of this.sessions.values()) {
session.killProcess();
}
this.sessions.clear();
}
/** Fetch available models from Pi. Call before registering the provider. */
async fetchModels(): Promise<void> {
const piPath = this.config.piExecutablePath ?? "pi";
let proc: PiProcess | undefined;
try {
proc = new PiProcess();
await proc.spawn(piPath, this.config.cwd ?? process.cwd());
const data = await Promise.race([
proc.sendAndWait({ type: "get_available_models" }),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), 10_000),
),
]);
const rawModels = (
data as {
models?: Array<{ provider: string; id: string; name?: string }>;
}
).models;
if (rawModels && rawModels.length > 0) {
this.models = rawModels.map((m, i) => ({
id: `${m.provider}/${m.id}`,
label: m.name ?? m.id,
...(i === 0 && { default: true }),
}));
}
} catch {
// Pi not configured or no models available
} finally {
proc?.kill();
}
}
}
// ---------------------------------------------------------------------------
// Session
// ---------------------------------------------------------------------------
interface SessionConfig {
systemPrompt: string;
cwd: string;
parentSessionId: string | null;
piExecutablePath: string;
/** Model in "provider/modelId" format, e.g. "anthropic/claude-haiku-4-5". */
model?: string;
}
class PiSDKSession extends BaseSession {
private config: SessionConfig;
private process: PiProcess | null = null;
constructor(config: SessionConfig) {
super({ parentSessionId: config.parentSessionId });
this.config = config;
}
async *query(prompt: string): AsyncIterable<AIMessage> {
const started = this.startQuery();
if (!started) {
yield BaseSession.BUSY_ERROR;
return;
}
const { gen } = started;
try {
// Lazy-spawn subprocess
if (!this.process || !this.process.alive) {
this.process = new PiProcess();
await this.process.spawn(this.config.piExecutablePath, this.config.cwd);
// Set model if specified (format: "provider/modelId")
if (this.config.model) {
const [provider, ...rest] = this.config.model.split("/");
const modelId = rest.join("/");
if (provider && modelId) {
try {
await this.process.sendAndWait({
type: "set_model",
provider,
modelId,
});
} catch {
// Continue with Pi's default model
}
}
}
// Get session ID
try {
const state = await this.process.sendAndWait({ type: "get_state" });
if (typeof state.sessionId === "string") {
this.resolveId(state.sessionId);
}
} catch {
// Continue with placeholder ID
}
// If subprocess died during startup, surface the error immediately
if (!this.process.alive) {
yield {
type: "error",
error:
"Pi process exited during startup. Check that Pi is configured correctly (API keys, models).",
code: "pi_startup_error",
};
return;
}
}
// Build effective prompt (prepend system prompt on first query)
const effectivePrompt = buildEffectivePrompt(
prompt,
this.config.systemPrompt,
this._firstQuerySent,
);
// Set up async queue to bridge callback events → async iterable
const queue: AIMessage[] = [];
let resolve: (() => void) | null = null;
let done = false;
const push = (msg: AIMessage) => {
queue.push(msg);
resolve?.();
};
const finish = () => {
done = true;
resolve?.();
};
const unsubscribe = this.process.onEvent((event) => {
const mapped = mapPiEvent(event, this.id);
for (const msg of mapped) {
push(msg);
if (
msg.type === "result" ||
(msg.type === "error" &&
(event.type === "agent_end" || event.type === "process_exited"))
) {
finish();
}
}
});
// Send prompt — use sendAndWait to catch RPC-level rejections
// (e.g. expired credentials, invalid session)
try {
await this.process.sendAndWait({
type: "prompt",
message: effectivePrompt,
});
} catch (err) {
unsubscribe();
yield {
type: "error",
error: `Pi rejected prompt: ${err instanceof Error ? err.message : String(err)}`,
code: "pi_prompt_rejected",
};
return;
}
this._firstQuerySent = true;
// Drain queue
try {
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise<void>((r) => {
resolve = r;
});
resolve = null;
}
}
} finally {
unsubscribe();
}
} catch (err) {
yield {
type: "error",
error: err instanceof Error ? err.message : String(err),
code: "provider_error",
};
} finally {
this.endQuery(gen);
}
}
abort(): void {
if (this.process?.alive) {
this.process.send({ type: "abort" });
}
super.abort();
}
/** Kill the subprocess. Called by the provider on dispose. */
killProcess(): void {
this.process?.kill();
this.process = null;
}
}
// ---------------------------------------------------------------------------
// Event mapping — shared with pi-sdk-node.ts
// ---------------------------------------------------------------------------
import { mapPiEvent } from "./pi-events.ts";
export { mapPiEvent } from "./pi-events.ts";
// ---------------------------------------------------------------------------
// Factory registration
// ---------------------------------------------------------------------------
import { registerProviderFactory } from "../provider.ts";
registerProviderFactory(
PROVIDER_NAME,
async (config) => new PiSDKProvider(config as PiSDKConfig),
);