Add plannotator extension v0.19.10
This commit is contained in:
95
extensions/plannotator/generated/ai/base-session.ts
Normal file
95
extensions/plannotator/generated/ai/base-session.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// @generated — DO NOT EDIT. Source: packages/ai/base-session.ts
|
||||
/**
|
||||
* Shared session base class — extracts the common lifecycle, abort, and
|
||||
* ID-resolution logic that every AIProvider session needs.
|
||||
*
|
||||
* Concrete providers extend this and implement query().
|
||||
*/
|
||||
|
||||
import type { AIMessage, AISession } from "./types.ts";
|
||||
|
||||
export abstract class BaseSession implements AISession {
|
||||
readonly parentSessionId: string | null;
|
||||
onIdResolved?: (oldId: string, newId: string) => void;
|
||||
|
||||
protected _placeholderId: string;
|
||||
protected _resolvedId: string | null = null;
|
||||
protected _isActive = false;
|
||||
protected _currentAbort: AbortController | null = null;
|
||||
protected _queryGen = 0;
|
||||
protected _firstQuerySent = false;
|
||||
|
||||
constructor(opts: { parentSessionId: string | null; initialId?: string }) {
|
||||
this.parentSessionId = opts.parentSessionId;
|
||||
this._placeholderId = opts.initialId ?? crypto.randomUUID();
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this._resolvedId ?? this._placeholderId;
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this._isActive;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query lifecycle helpers — call from concrete query() implementations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Error message returned when a query is already active. */
|
||||
static readonly BUSY_ERROR: AIMessage = {
|
||||
type: "error",
|
||||
error:
|
||||
"A query is already in progress. Abort the current query before sending a new one.",
|
||||
code: "session_busy",
|
||||
};
|
||||
|
||||
/**
|
||||
* Call at the start of query(). Returns the generation number and abort
|
||||
* signal, or null if the session is busy.
|
||||
*/
|
||||
protected startQuery(): { gen: number; signal: AbortSignal } | null {
|
||||
if (this._isActive) return null;
|
||||
|
||||
const gen = ++this._queryGen;
|
||||
this._isActive = true;
|
||||
this._currentAbort = new AbortController();
|
||||
return { gen, signal: this._currentAbort.signal };
|
||||
}
|
||||
|
||||
/**
|
||||
* Call in the finally block of query(). Only clears state if the
|
||||
* generation matches (prevents a stale finally from clobbering a newer query).
|
||||
*/
|
||||
protected endQuery(gen: number): void {
|
||||
if (this._queryGen === gen) {
|
||||
this._isActive = false;
|
||||
this._currentAbort = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call when the provider resolves the real session ID from the backend.
|
||||
* Fires the onIdResolved callback so the SessionManager can remap its key.
|
||||
*/
|
||||
protected resolveId(newId: string): void {
|
||||
if (this._resolvedId) return; // Already resolved
|
||||
const oldId = this._placeholderId;
|
||||
this._resolvedId = newId;
|
||||
this.onIdResolved?.(oldId, newId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the current in-flight query. Subclasses should call super.abort()
|
||||
* after any provider-specific cleanup.
|
||||
*/
|
||||
abort(): void {
|
||||
if (this._currentAbort) {
|
||||
this._currentAbort.abort();
|
||||
this._isActive = false;
|
||||
this._currentAbort = null;
|
||||
}
|
||||
}
|
||||
|
||||
abstract query(prompt: string): AsyncIterable<AIMessage>;
|
||||
}
|
||||
212
extensions/plannotator/generated/ai/context.ts
Normal file
212
extensions/plannotator/generated/ai/context.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
// @generated — DO NOT EDIT. Source: packages/ai/context.ts
|
||||
/**
|
||||
* Context builders — translate Plannotator review state into system prompts
|
||||
* that give the AI session the right background for answering questions.
|
||||
*
|
||||
* These are provider-agnostic: any AIProvider implementation can use them
|
||||
* to build the system prompt it needs.
|
||||
*/
|
||||
|
||||
import type { AIContext } from "./types.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a system prompt from the given context.
|
||||
*
|
||||
* The prompt tells the AI:
|
||||
* - What role it plays (plan reviewer, code reviewer, etc.)
|
||||
* - The content it should reference (plan markdown, diff patch, file)
|
||||
* - Any annotations the user has already made
|
||||
* - That it's operating inside Plannotator (not a general coding session)
|
||||
*/
|
||||
export function buildSystemPrompt(ctx: AIContext): string {
|
||||
switch (ctx.mode) {
|
||||
case "plan-review":
|
||||
return buildPlanReviewPrompt(ctx);
|
||||
case "code-review":
|
||||
return buildCodeReviewPrompt(ctx);
|
||||
case "annotate":
|
||||
return buildAnnotatePrompt(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a compact context summary suitable for injecting into a fork prompt.
|
||||
*
|
||||
* When forking from a parent session, we don't need a full system prompt
|
||||
* (the parent's history already provides context). Instead, we inject a
|
||||
* short "you are now in Plannotator" preamble with the relevant content.
|
||||
*/
|
||||
export function buildForkPreamble(ctx: AIContext): string {
|
||||
const lines: string[] = [
|
||||
"The user is now reviewing your work in Plannotator and has a question.",
|
||||
"Answer concisely based on the conversation history and the context below.",
|
||||
"",
|
||||
];
|
||||
|
||||
switch (ctx.mode) {
|
||||
case "plan-review": {
|
||||
lines.push("## Current Plan Under Review");
|
||||
lines.push("");
|
||||
lines.push(truncate(ctx.plan.plan, MAX_PLAN_CHARS));
|
||||
if (ctx.plan.annotations) {
|
||||
lines.push("");
|
||||
lines.push("## User Annotations So Far");
|
||||
lines.push(ctx.plan.annotations);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "code-review": {
|
||||
if (ctx.review.filePath) {
|
||||
lines.push(`## Reviewing: ${ctx.review.filePath}`);
|
||||
}
|
||||
if (ctx.review.selectedCode) {
|
||||
lines.push("");
|
||||
lines.push("### Selected Code");
|
||||
lines.push("```");
|
||||
lines.push(ctx.review.selectedCode);
|
||||
lines.push("```");
|
||||
}
|
||||
if (ctx.review.lineRange) {
|
||||
const { start, end, side } = ctx.review.lineRange;
|
||||
lines.push(`Lines ${start}-${end} (${side} side)`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("## Diff Patch");
|
||||
lines.push("```diff");
|
||||
lines.push(truncate(ctx.review.patch, MAX_DIFF_CHARS));
|
||||
lines.push("```");
|
||||
if (ctx.review.annotations) {
|
||||
lines.push("");
|
||||
lines.push("## User Annotations So Far");
|
||||
lines.push(ctx.review.annotations);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "annotate": {
|
||||
lines.push(`## Annotating: ${ctx.annotate.filePath}`);
|
||||
lines.push("");
|
||||
lines.push(truncate(ctx.annotate.content, MAX_PLAN_CHARS));
|
||||
if (ctx.annotate.annotations) {
|
||||
lines.push("");
|
||||
lines.push("## User Annotations So Far");
|
||||
lines.push(ctx.annotate.annotations);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the effective prompt for a query, prepending a preamble on the first
|
||||
* message. Used by providers that inject context via the prompt itself (Codex,
|
||||
* Pi) rather than a separate system-prompt channel (Claude).
|
||||
*/
|
||||
export function buildEffectivePrompt(
|
||||
userPrompt: string,
|
||||
preamble: string | null,
|
||||
firstQuerySent: boolean,
|
||||
): string {
|
||||
if (!firstQuerySent && preamble) {
|
||||
return `${preamble}\n\n---\n\nUser question: ${userPrompt}`;
|
||||
}
|
||||
return userPrompt;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_PLAN_CHARS = 60_000;
|
||||
const MAX_DIFF_CHARS = 40_000;
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
return `${text.slice(0, max)}\n\n... [truncated for context window]`;
|
||||
}
|
||||
|
||||
function buildPlanReviewPrompt(
|
||||
ctx: Extract<AIContext, { mode: "plan-review" }>
|
||||
): string {
|
||||
const sections: string[] = [
|
||||
"The user is reviewing an implementation plan in Plannotator.",
|
||||
"",
|
||||
"## Plan Under Review",
|
||||
"",
|
||||
truncate(ctx.plan.plan, MAX_PLAN_CHARS),
|
||||
];
|
||||
|
||||
if (ctx.plan.previousPlan) {
|
||||
sections.push("");
|
||||
sections.push("## Previous Plan Version (for reference)");
|
||||
sections.push(truncate(ctx.plan.previousPlan, MAX_PLAN_CHARS / 2));
|
||||
}
|
||||
|
||||
if (ctx.plan.annotations) {
|
||||
sections.push("");
|
||||
sections.push("## User Annotations");
|
||||
sections.push(ctx.plan.annotations);
|
||||
}
|
||||
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
function buildCodeReviewPrompt(
|
||||
ctx: Extract<AIContext, { mode: "code-review" }>
|
||||
): string {
|
||||
const sections: string[] = [
|
||||
"The user is reviewing a code diff in Plannotator.",
|
||||
];
|
||||
|
||||
if (ctx.review.filePath) {
|
||||
sections.push("");
|
||||
sections.push(`## Currently Viewing: ${ctx.review.filePath}`);
|
||||
}
|
||||
|
||||
if (ctx.review.selectedCode) {
|
||||
sections.push("");
|
||||
sections.push("## Selected Code");
|
||||
sections.push("```");
|
||||
sections.push(ctx.review.selectedCode);
|
||||
sections.push("```");
|
||||
}
|
||||
|
||||
sections.push("");
|
||||
sections.push("## Diff");
|
||||
sections.push("```diff");
|
||||
sections.push(truncate(ctx.review.patch, MAX_DIFF_CHARS));
|
||||
sections.push("```");
|
||||
|
||||
if (ctx.review.annotations) {
|
||||
sections.push("");
|
||||
sections.push("## User Annotations");
|
||||
sections.push(ctx.review.annotations);
|
||||
}
|
||||
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
function buildAnnotatePrompt(
|
||||
ctx: Extract<AIContext, { mode: "annotate" }>
|
||||
): string {
|
||||
const sections: string[] = [
|
||||
"The user is annotating a markdown document in Plannotator.",
|
||||
"",
|
||||
`## Document: ${ctx.annotate.filePath}`,
|
||||
"",
|
||||
truncate(ctx.annotate.content, MAX_PLAN_CHARS),
|
||||
];
|
||||
|
||||
if (ctx.annotate.annotations) {
|
||||
sections.push("");
|
||||
sections.push("## User Annotations");
|
||||
sections.push(ctx.annotate.annotations);
|
||||
}
|
||||
|
||||
return sections.join("\n");
|
||||
}
|
||||
309
extensions/plannotator/generated/ai/endpoints.ts
Normal file
309
extensions/plannotator/generated/ai/endpoints.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
// @generated — DO NOT EDIT. Source: packages/ai/endpoints.ts
|
||||
/**
|
||||
* HTTP endpoint handlers for AI features.
|
||||
*
|
||||
* These handlers are provider-agnostic — they work with whatever AIProvider
|
||||
* is registered in the provided ProviderRegistry. They're designed to be
|
||||
* mounted into any Plannotator server (plan review, code review, annotate).
|
||||
*
|
||||
* Endpoints:
|
||||
* POST /api/ai/session — Create or fork an AI session
|
||||
* POST /api/ai/query — Send a message and stream the response
|
||||
* POST /api/ai/abort — Abort the current query
|
||||
* GET /api/ai/sessions — List active sessions
|
||||
* GET /api/ai/capabilities — Check if AI features are available
|
||||
*/
|
||||
|
||||
import type { AIContext, AIMessage, CreateSessionOptions } from "./types.ts";
|
||||
import type { ProviderRegistry } from "./provider.ts";
|
||||
import type { SessionManager } from "./session-manager.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for request/response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
/** The context mode and content for the session. */
|
||||
context: AIContext;
|
||||
/** Instance ID of the provider to use (optional — uses default if omitted). */
|
||||
providerId?: string;
|
||||
/** Optional model override. */
|
||||
model?: string;
|
||||
/** Max agentic turns. */
|
||||
maxTurns?: number;
|
||||
/** Max budget in USD. */
|
||||
maxBudgetUsd?: number;
|
||||
/** Reasoning effort (Codex only). */
|
||||
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
}
|
||||
|
||||
export interface QueryRequest {
|
||||
/** The session ID to query. */
|
||||
sessionId: string;
|
||||
/** The user's prompt/question. */
|
||||
prompt: string;
|
||||
/** Optional context update (e.g., new annotations since session was created). */
|
||||
contextUpdate?: string;
|
||||
}
|
||||
|
||||
export interface AbortRequest {
|
||||
/** The session ID to abort. */
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AIEndpointDeps {
|
||||
/** Provider registry (one per server or shared). */
|
||||
registry: ProviderRegistry;
|
||||
/** Session manager instance (one per server). */
|
||||
sessionManager: SessionManager;
|
||||
/** Resolve the current working directory for new AI sessions. */
|
||||
getCwd?: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the route handler map for AI endpoints.
|
||||
*
|
||||
* Usage in a Bun server:
|
||||
* ```ts
|
||||
* const aiHandlers = createAIEndpoints({ registry, sessionManager });
|
||||
*
|
||||
* // In your request handler:
|
||||
* if (url.pathname.startsWith('/api/ai/')) {
|
||||
* const handler = aiHandlers[url.pathname];
|
||||
* if (handler) return handler(req);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function createAIEndpoints(deps: AIEndpointDeps) {
|
||||
const { registry, sessionManager, getCwd } = deps;
|
||||
|
||||
return {
|
||||
"/api/ai/capabilities": async (_req: Request) => {
|
||||
const defaultEntry = registry.getDefault();
|
||||
const providerDetails = registry.list().map(id => {
|
||||
const p = registry.get(id)!;
|
||||
return {
|
||||
id,
|
||||
name: p.name,
|
||||
capabilities: p.capabilities,
|
||||
models: p.models ?? [],
|
||||
};
|
||||
});
|
||||
return Response.json({
|
||||
available: !!defaultEntry,
|
||||
providers: providerDetails,
|
||||
defaultProvider: defaultEntry?.id ?? null,
|
||||
});
|
||||
},
|
||||
|
||||
"/api/ai/session": async (req: Request) => {
|
||||
if (req.method !== "POST") {
|
||||
return new Response("Method not allowed", { status: 405 });
|
||||
}
|
||||
|
||||
const body = (await req.json()) as CreateSessionRequest;
|
||||
const { context, providerId, model, maxTurns, maxBudgetUsd, reasoningEffort } = body;
|
||||
|
||||
if (!context?.mode) {
|
||||
return Response.json(
|
||||
{ error: "Missing context.mode" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve provider: by ID, or default
|
||||
const provider = providerId
|
||||
? registry.get(providerId)
|
||||
: registry.getDefault()?.provider;
|
||||
|
||||
if (!provider) {
|
||||
return Response.json(
|
||||
{ error: providerId ? `Provider "${providerId}" not found` : "No AI provider available" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const options: CreateSessionOptions = {
|
||||
context,
|
||||
cwd: getCwd?.(),
|
||||
model,
|
||||
maxTurns,
|
||||
maxBudgetUsd,
|
||||
reasoningEffort,
|
||||
};
|
||||
|
||||
// Fork if parent session is provided AND provider supports it.
|
||||
// Providers that can't fork (e.g. Codex) fall back to a fresh
|
||||
// session with the full system prompt — no fake history.
|
||||
const shouldFork = context.parent && provider.capabilities.fork;
|
||||
const session = shouldFork
|
||||
? await provider.forkSession(options)
|
||||
: await provider.createSession(options);
|
||||
|
||||
const entry = sessionManager.track(session, context.mode);
|
||||
|
||||
return Response.json({
|
||||
sessionId: session.id,
|
||||
parentSessionId: session.parentSessionId,
|
||||
mode: context.mode,
|
||||
createdAt: entry.createdAt,
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to create session",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
"/api/ai/query": async (req: Request) => {
|
||||
if (req.method !== "POST") {
|
||||
return new Response("Method not allowed", { status: 405 });
|
||||
}
|
||||
|
||||
const body = (await req.json()) as QueryRequest;
|
||||
const { sessionId, prompt, contextUpdate } = body;
|
||||
|
||||
if (!sessionId || !prompt) {
|
||||
return Response.json(
|
||||
{ error: "Missing sessionId or prompt" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const entry = sessionManager.get(sessionId);
|
||||
if (!entry) {
|
||||
return Response.json(
|
||||
{ error: "Session not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
sessionManager.touch(sessionId);
|
||||
|
||||
// If context update provided, prepend it to the prompt
|
||||
const effectivePrompt = contextUpdate
|
||||
? `[Context update: the user has made changes since this conversation started]\n${contextUpdate}\n\n${prompt}`
|
||||
: prompt;
|
||||
|
||||
// Set label from first query if not already set
|
||||
if (!entry.label) {
|
||||
entry.label = prompt.slice(0, 80);
|
||||
}
|
||||
|
||||
// Stream the response using Server-Sent Events (SSE)
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
for await (const message of entry.session.query(effectivePrompt)) {
|
||||
const data = JSON.stringify(message);
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${data}\n\n`)
|
||||
);
|
||||
}
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
} catch (err) {
|
||||
const errorMsg: AIMessage = {
|
||||
type: "error",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
code: "stream_error",
|
||||
};
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(errorMsg)}\n\n`)
|
||||
);
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
"/api/ai/abort": async (req: Request) => {
|
||||
if (req.method !== "POST") {
|
||||
return new Response("Method not allowed", { status: 405 });
|
||||
}
|
||||
|
||||
const body = (await req.json()) as AbortRequest;
|
||||
const entry = sessionManager.get(body.sessionId);
|
||||
if (!entry) {
|
||||
return Response.json(
|
||||
{ error: "Session not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
entry.session.abort();
|
||||
return Response.json({ ok: true });
|
||||
},
|
||||
|
||||
"/api/ai/permission": async (req: Request) => {
|
||||
if (req.method !== "POST") {
|
||||
return new Response("Method not allowed", { status: 405 });
|
||||
}
|
||||
|
||||
const body = (await req.json()) as {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
allow: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (!body.sessionId || !body.requestId) {
|
||||
return Response.json(
|
||||
{ error: "Missing sessionId or requestId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const entry = sessionManager.get(body.sessionId);
|
||||
if (!entry) {
|
||||
return Response.json(
|
||||
{ error: "Session not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
entry.session.respondToPermission?.(
|
||||
body.requestId,
|
||||
body.allow,
|
||||
body.message
|
||||
);
|
||||
|
||||
return Response.json({ ok: true });
|
||||
},
|
||||
|
||||
"/api/ai/sessions": async (_req: Request) => {
|
||||
const entries = sessionManager.list();
|
||||
return Response.json(
|
||||
entries.map((e) => ({
|
||||
sessionId: e.session.id,
|
||||
mode: e.mode,
|
||||
parentSessionId: e.parentSessionId,
|
||||
createdAt: e.createdAt,
|
||||
lastActiveAt: e.lastActiveAt,
|
||||
isActive: e.session.isActive,
|
||||
label: e.label,
|
||||
}))
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
export type AIEndpoints = ReturnType<typeof createAIEndpoints>;
|
||||
106
extensions/plannotator/generated/ai/index.ts
Normal file
106
extensions/plannotator/generated/ai/index.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// @generated — DO NOT EDIT. Source: packages/ai/index.ts
|
||||
/**
|
||||
* @plannotator/ai — AI provider layer for Plannotator.
|
||||
*
|
||||
* This package provides the backbone for AI-powered features (inline chat,
|
||||
* plan Q&A, code review assistance) across all Plannotator surfaces.
|
||||
*
|
||||
* Architecture:
|
||||
*
|
||||
* ┌─────────────────┐ ┌──────────────┐
|
||||
* │ Plan Review UI │────▶│ │
|
||||
* ├─────────────────┤ │ AI Endpoints │──▶ SSE stream
|
||||
* │ Code Review UI │────▶│ (HTTP) │
|
||||
* ├─────────────────┤ │ │
|
||||
* │ Annotate UI │────▶└──────┬───────┘
|
||||
* └─────────────────┘ │
|
||||
* ▼
|
||||
* ┌────────────────┐
|
||||
* │ Session Manager │
|
||||
* └────────┬───────┘
|
||||
* │
|
||||
* ┌────────▼───────┐
|
||||
* │ AIProvider │ (abstract)
|
||||
* └────────┬───────┘
|
||||
* │
|
||||
* ┌─────────────┼──────────────┐
|
||||
* ▼ ▼ ▼
|
||||
* ┌──────────────┐ ┌──────────┐ ┌───────────┐
|
||||
* │ Claude Agent │ │ OpenCode │ │ Future │
|
||||
* │ SDK Provider │ │ Provider │ │ Providers │
|
||||
* └──────────────┘ └──────────┘ └───────────┘
|
||||
*
|
||||
* Quick start:
|
||||
*
|
||||
* ```ts
|
||||
* import "@plannotator/ai/providers/claude-agent-sdk";
|
||||
* import { ProviderRegistry, createProvider, createAIEndpoints, SessionManager } from "@plannotator/ai";
|
||||
*
|
||||
* // 1. Create a registry and provider
|
||||
* const registry = new ProviderRegistry();
|
||||
* const provider = await createProvider({ type: "claude-agent-sdk", cwd: process.cwd() });
|
||||
* registry.register(provider);
|
||||
*
|
||||
* // 2. Create endpoints and session manager
|
||||
* const sessionManager = new SessionManager();
|
||||
* const aiEndpoints = createAIEndpoints({ registry, sessionManager });
|
||||
*
|
||||
* // 3. Mount endpoints in your Bun server
|
||||
* // aiEndpoints["/api/ai/query"](request) → SSE Response
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
AIProvider,
|
||||
AIProviderCapabilities,
|
||||
AIProviderConfig,
|
||||
AISession,
|
||||
AIMessage,
|
||||
AITextMessage,
|
||||
AITextDeltaMessage,
|
||||
AIToolUseMessage,
|
||||
AIToolResultMessage,
|
||||
AIErrorMessage,
|
||||
AIResultMessage,
|
||||
AIPermissionRequestMessage,
|
||||
AIUnknownMessage,
|
||||
AIContext,
|
||||
AIContextMode,
|
||||
PlanContext,
|
||||
CodeReviewContext,
|
||||
AnnotateContext,
|
||||
ParentSession,
|
||||
CreateSessionOptions,
|
||||
ClaudeAgentSDKConfig,
|
||||
CodexSDKConfig,
|
||||
PiSDKConfig,
|
||||
OpenCodeConfig,
|
||||
} from "./types.ts";
|
||||
|
||||
// Provider registry
|
||||
export {
|
||||
ProviderRegistry,
|
||||
registerProviderFactory,
|
||||
createProvider,
|
||||
} from "./provider.ts";
|
||||
|
||||
// Context builders
|
||||
export { buildSystemPrompt, buildForkPreamble, buildEffectivePrompt } from "./context.ts";
|
||||
|
||||
// Base session
|
||||
export { BaseSession } from "./base-session.ts";
|
||||
|
||||
// Session manager
|
||||
export { SessionManager } from "./session-manager.ts";
|
||||
export type { SessionEntry, SessionManagerOptions } from "./session-manager.ts";
|
||||
|
||||
// HTTP endpoints
|
||||
export { createAIEndpoints } from "./endpoints.ts";
|
||||
export type {
|
||||
AIEndpoints,
|
||||
AIEndpointDeps,
|
||||
CreateSessionRequest,
|
||||
QueryRequest,
|
||||
AbortRequest,
|
||||
} from "./endpoints.ts";
|
||||
104
extensions/plannotator/generated/ai/provider.ts
Normal file
104
extensions/plannotator/generated/ai/provider.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// @generated — DO NOT EDIT. Source: packages/ai/provider.ts
|
||||
/**
|
||||
* Provider registry — manages AI provider instances.
|
||||
*
|
||||
* Supports multiple instances of the same provider type (e.g., two Claude
|
||||
* Agent SDK providers with different configs) keyed by instance ID.
|
||||
*
|
||||
* Each server (plan review, code review, annotate) should create its own
|
||||
* ProviderRegistry or share one — no module-level global state.
|
||||
*/
|
||||
|
||||
import type { AIProvider, AIProviderConfig } from "./types.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory registry (global — factories are stateless type→constructor maps)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ProviderFactory = (config: AIProviderConfig) => Promise<AIProvider>;
|
||||
const factories = new Map<string, ProviderFactory>();
|
||||
|
||||
/** Register a factory function for a provider type. */
|
||||
export function registerProviderFactory(
|
||||
type: string,
|
||||
factory: ProviderFactory
|
||||
): void {
|
||||
factories.set(type, factory);
|
||||
}
|
||||
|
||||
/** Create a provider from config using a registered factory. Does NOT auto-register. */
|
||||
export async function createProvider(
|
||||
config: AIProviderConfig
|
||||
): Promise<AIProvider> {
|
||||
const factory = factories.get(config.type);
|
||||
if (!factory) {
|
||||
throw new Error(
|
||||
`No AI provider factory registered for type "${config.type}". ` +
|
||||
`Available: ${[...factories.keys()].join(", ") || "(none)"}`
|
||||
);
|
||||
}
|
||||
return factory(config);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ProviderRegistry {
|
||||
private instances = new Map<string, AIProvider>();
|
||||
|
||||
/**
|
||||
* Register a provider instance under an ID.
|
||||
* If no instanceId is provided, uses `provider.name`.
|
||||
* Returns the instanceId used.
|
||||
*/
|
||||
register(provider: AIProvider, instanceId?: string): string {
|
||||
const id = instanceId ?? provider.name;
|
||||
this.instances.set(id, provider);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Get a provider by instance ID. */
|
||||
get(instanceId: string): AIProvider | undefined {
|
||||
return this.instances.get(instanceId);
|
||||
}
|
||||
|
||||
/** Get the first registered provider (convenience for single-provider setups). */
|
||||
getDefault(): { id: string; provider: AIProvider } | undefined {
|
||||
const first = this.instances.entries().next();
|
||||
if (first.done) return undefined;
|
||||
return { id: first.value[0], provider: first.value[1] };
|
||||
}
|
||||
|
||||
/** Get all instances of a given provider type (by provider.name). */
|
||||
getByType(typeName: string): AIProvider[] {
|
||||
return [...this.instances.values()].filter((p) => p.name === typeName);
|
||||
}
|
||||
|
||||
/** List all instance IDs. */
|
||||
list(): string[] {
|
||||
return [...this.instances.keys()];
|
||||
}
|
||||
|
||||
/** Dispose and remove a single instance. No-op if not found. */
|
||||
dispose(instanceId: string): void {
|
||||
const provider = this.instances.get(instanceId);
|
||||
if (provider) {
|
||||
provider.dispose();
|
||||
this.instances.delete(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispose all providers and clear the registry. */
|
||||
disposeAll(): void {
|
||||
for (const provider of this.instances.values()) {
|
||||
provider.dispose();
|
||||
}
|
||||
this.instances.clear();
|
||||
}
|
||||
|
||||
/** Number of registered instances. */
|
||||
get size(): number {
|
||||
return this.instances.size;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
431
extensions/plannotator/generated/ai/providers/codex-sdk.ts
Normal file
431
extensions/plannotator/generated/ai/providers/codex-sdk.ts
Normal 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)
|
||||
);
|
||||
547
extensions/plannotator/generated/ai/providers/opencode-sdk.ts
Normal file
547
extensions/plannotator/generated/ai/providers/opencode-sdk.ts
Normal 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),
|
||||
);
|
||||
111
extensions/plannotator/generated/ai/providers/pi-events.ts
Normal file
111
extensions/plannotator/generated/ai/providers/pi-events.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
377
extensions/plannotator/generated/ai/providers/pi-sdk-node.ts
Normal file
377
extensions/plannotator/generated/ai/providers/pi-sdk-node.ts
Normal 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),
|
||||
);
|
||||
442
extensions/plannotator/generated/ai/providers/pi-sdk.ts
Normal file
442
extensions/plannotator/generated/ai/providers/pi-sdk.ts
Normal 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),
|
||||
);
|
||||
196
extensions/plannotator/generated/ai/session-manager.ts
Normal file
196
extensions/plannotator/generated/ai/session-manager.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
// @generated — DO NOT EDIT. Source: packages/ai/session-manager.ts
|
||||
/**
|
||||
* Session manager — tracks active and historical AI sessions.
|
||||
*
|
||||
* Each Plannotator server instance (plan review, code review, annotate)
|
||||
* gets its own SessionManager. It tracks:
|
||||
*
|
||||
* - Active sessions (currently streaming or idle but resumable)
|
||||
* - The lineage from forked sessions back to their parent
|
||||
* - Metadata for UI display (timestamps, mode, status)
|
||||
*
|
||||
* This is an in-memory store scoped to the server's lifetime. Sessions
|
||||
* are not persisted to disk by the manager (the underlying provider
|
||||
* handles its own persistence via the agent SDK).
|
||||
*/
|
||||
|
||||
import type { AISession, AIContextMode } from "./types.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SessionEntry {
|
||||
/** The live session handle (if still active). */
|
||||
session: AISession;
|
||||
/** What mode this session was created for. */
|
||||
mode: AIContextMode;
|
||||
/** The parent session ID this was forked from (null if standalone). */
|
||||
parentSessionId: string | null;
|
||||
/** When this session was created. */
|
||||
createdAt: number;
|
||||
/** When the last query was sent. */
|
||||
lastActiveAt: number;
|
||||
/** Short description for UI display (e.g., the user's first question). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface SessionManagerOptions {
|
||||
/**
|
||||
* Maximum number of sessions to keep in the manager.
|
||||
* Oldest idle sessions are evicted when the limit is reached.
|
||||
* Default: 20.
|
||||
*/
|
||||
maxSessions?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class SessionManager {
|
||||
private sessions = new Map<string, SessionEntry>();
|
||||
private aliases = new Map<string, string>();
|
||||
private maxSessions: number;
|
||||
|
||||
constructor(options: SessionManagerOptions = {}) {
|
||||
this.maxSessions = options.maxSessions ?? 20;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a newly created session.
|
||||
*
|
||||
* If the session supports ID resolution (e.g., the real SDK session ID
|
||||
* arrives after the first query), call `remapId()` to update the key.
|
||||
*/
|
||||
track(session: AISession, mode: AIContextMode, label?: string): SessionEntry {
|
||||
this.evictIfNeeded();
|
||||
|
||||
const entry: SessionEntry = {
|
||||
session,
|
||||
mode,
|
||||
parentSessionId: session.parentSessionId,
|
||||
createdAt: Date.now(),
|
||||
lastActiveAt: Date.now(),
|
||||
label,
|
||||
};
|
||||
this.sessions.set(session.id, entry);
|
||||
|
||||
// Wire up ID remapping so providers can resolve the real session ID later
|
||||
session.onIdResolved = (oldId, newId) => this.remapId(oldId, newId);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remap a session from one ID to another.
|
||||
* Used when the real session ID is resolved after initial tracking.
|
||||
*/
|
||||
remapId(oldId: string, newId: string): void {
|
||||
const entry = this.sessions.get(oldId);
|
||||
if (entry) {
|
||||
this.sessions.delete(oldId);
|
||||
this.sessions.set(newId, entry);
|
||||
// Keep the old ID as an alias so clients using the original ID still work
|
||||
this.aliases.set(oldId, newId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve an alias to the canonical ID, or return the ID as-is. */
|
||||
private resolve(sessionId: string): string {
|
||||
return this.aliases.get(sessionId) ?? sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a tracked session by ID (or alias).
|
||||
*/
|
||||
get(sessionId: string): SessionEntry | undefined {
|
||||
return this.sessions.get(this.resolve(sessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a session as recently active (updates lastActiveAt).
|
||||
*/
|
||||
touch(sessionId: string): void {
|
||||
const entry = this.sessions.get(this.resolve(sessionId));
|
||||
if (entry) {
|
||||
entry.lastActiveAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a session from tracking.
|
||||
* Does NOT abort the session — call session.abort() first if needed.
|
||||
*/
|
||||
remove(sessionId: string): void {
|
||||
const canonical = this.resolve(sessionId);
|
||||
this.sessions.delete(canonical);
|
||||
// Clean up any aliases pointing to this session
|
||||
for (const [alias, target] of this.aliases) {
|
||||
if (target === canonical) this.aliases.delete(alias);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tracked sessions, newest first.
|
||||
*/
|
||||
list(): SessionEntry[] {
|
||||
return [...this.sessions.values()].sort(
|
||||
(a, b) => b.lastActiveAt - a.lastActiveAt
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List sessions forked from a specific parent.
|
||||
*/
|
||||
forksOf(parentSessionId: string): SessionEntry[] {
|
||||
return this.list().filter(
|
||||
(e) => e.parentSessionId === parentSessionId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of tracked sessions.
|
||||
*/
|
||||
get size(): number {
|
||||
return this.sessions.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort all active sessions and clear tracking.
|
||||
*/
|
||||
disposeAll(): void {
|
||||
for (const entry of this.sessions.values()) {
|
||||
if (entry.session.isActive) {
|
||||
entry.session.abort();
|
||||
}
|
||||
}
|
||||
this.sessions.clear();
|
||||
this.aliases.clear();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private evictIfNeeded(): void {
|
||||
if (this.sessions.size < this.maxSessions) return;
|
||||
|
||||
// Find the oldest idle session to evict
|
||||
let oldest: { id: string; at: number } | null = null;
|
||||
for (const [id, entry] of this.sessions) {
|
||||
if (entry.session.isActive) continue; // don't evict active sessions
|
||||
if (!oldest || entry.lastActiveAt < oldest.at) {
|
||||
oldest = { id, at: entry.lastActiveAt };
|
||||
}
|
||||
}
|
||||
|
||||
if (oldest) {
|
||||
this.sessions.delete(oldest.id);
|
||||
// Clean up aliases pointing to the evicted session
|
||||
for (const [alias, target] of this.aliases) {
|
||||
if (target === oldest.id) this.aliases.delete(alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
370
extensions/plannotator/generated/ai/types.ts
Normal file
370
extensions/plannotator/generated/ai/types.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
// @generated — DO NOT EDIT. Source: packages/ai/types.ts
|
||||
/**
|
||||
* Core types for the Plannotator AI provider layer.
|
||||
*
|
||||
* This module defines the abstract interfaces that any agent runtime
|
||||
* (Claude Agent SDK, OpenCode, future providers) must implement to
|
||||
* power AI features inside Plannotator's plan review and code review UIs.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context — what the AI session knows about
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The surface the user is interacting with when they invoke AI. */
|
||||
export type AIContextMode = "plan-review" | "code-review" | "annotate";
|
||||
|
||||
/**
|
||||
* Describes the parent agent session that originally produced the plan or diff.
|
||||
* Used to fork conversations with full history.
|
||||
*/
|
||||
export interface ParentSession {
|
||||
/** Session ID from the host agent (e.g. Claude Code session UUID). */
|
||||
sessionId: string;
|
||||
/** Working directory the parent session was running in. */
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of plan-review-specific context.
|
||||
* Passed when AIContextMode is "plan-review".
|
||||
*/
|
||||
export interface PlanContext {
|
||||
/** The full plan markdown as submitted by the agent. */
|
||||
plan: string;
|
||||
/** Previous plan version (if this is a resubmission). */
|
||||
previousPlan?: string;
|
||||
/** The version number in the plan's history. */
|
||||
version?: number;
|
||||
/** Annotations the user has made so far (serialised for the prompt). */
|
||||
annotations?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of code-review-specific context.
|
||||
* Passed when AIContextMode is "code-review".
|
||||
*/
|
||||
export interface CodeReviewContext {
|
||||
/** The unified diff patch. */
|
||||
patch: string;
|
||||
/** The specific file being discussed (if scoped). */
|
||||
filePath?: string;
|
||||
/** The line range being discussed (if scoped). */
|
||||
lineRange?: { start: number; end: number; side: "old" | "new" };
|
||||
/** The code snippet being discussed (if scoped). */
|
||||
selectedCode?: string;
|
||||
/** Summary of annotations the user has made. */
|
||||
annotations?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of annotate-mode context.
|
||||
* Passed when AIContextMode is "annotate".
|
||||
*/
|
||||
export interface AnnotateContext {
|
||||
/** The markdown file content being annotated. */
|
||||
content: string;
|
||||
/** Path to the file on disk. */
|
||||
filePath: string;
|
||||
/** Summary of annotations the user has made. */
|
||||
annotations?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of mode-specific contexts, discriminated by `mode`.
|
||||
*/
|
||||
export type AIContext =
|
||||
| { mode: "plan-review"; plan: PlanContext; parent?: ParentSession }
|
||||
| { mode: "code-review"; review: CodeReviewContext; parent?: ParentSession }
|
||||
| { mode: "annotate"; annotate: AnnotateContext; parent?: ParentSession };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Messages — what streams back from the AI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AITextMessage {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface AITextDeltaMessage {
|
||||
type: "text_delta";
|
||||
delta: string;
|
||||
}
|
||||
|
||||
export interface AIToolUseMessage {
|
||||
type: "tool_use";
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
toolUseId: string;
|
||||
}
|
||||
|
||||
export interface AIToolResultMessage {
|
||||
type: "tool_result";
|
||||
toolUseId?: string;
|
||||
result: string;
|
||||
}
|
||||
|
||||
export interface AIErrorMessage {
|
||||
type: "error";
|
||||
error: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface AIResultMessage {
|
||||
type: "result";
|
||||
sessionId: string;
|
||||
success: boolean;
|
||||
/** The final text result (if success). */
|
||||
result?: string;
|
||||
/** Total cost in USD (if available). */
|
||||
costUsd?: number;
|
||||
/** Number of agentic turns used. */
|
||||
turns?: number;
|
||||
}
|
||||
|
||||
export interface AIPermissionRequestMessage {
|
||||
type: "permission_request";
|
||||
requestId: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
title?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
toolUseId: string;
|
||||
}
|
||||
|
||||
export interface AIUnknownMessage {
|
||||
type: "unknown";
|
||||
/** The raw message from the provider, for debugging/transparency. */
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type AIMessage =
|
||||
| AITextMessage
|
||||
| AITextDeltaMessage
|
||||
| AIToolUseMessage
|
||||
| AIToolResultMessage
|
||||
| AIErrorMessage
|
||||
| AIResultMessage
|
||||
| AIPermissionRequestMessage
|
||||
| AIUnknownMessage;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session — a live conversation with the AI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AISession {
|
||||
/** Unique identifier for this session. */
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* The parent session this was forked from, if any.
|
||||
* Null for fresh sessions.
|
||||
*/
|
||||
readonly parentSessionId: string | null;
|
||||
|
||||
/**
|
||||
* Send a prompt and stream back messages.
|
||||
* The returned async iterable yields messages as they arrive.
|
||||
*/
|
||||
query(prompt: string): AsyncIterable<AIMessage>;
|
||||
|
||||
/**
|
||||
* Abort the current in-flight query.
|
||||
* Safe to call if no query is running (no-op).
|
||||
*/
|
||||
abort(): void;
|
||||
|
||||
/** Whether a query is currently in progress. */
|
||||
readonly isActive: boolean;
|
||||
|
||||
/**
|
||||
* Respond to a permission request from the provider.
|
||||
* Called when the user approves or denies a tool use in the UI.
|
||||
*/
|
||||
respondToPermission?(requestId: string, allow: boolean, message?: string): void;
|
||||
|
||||
/**
|
||||
* Callback invoked when the real session ID is resolved from the provider.
|
||||
* Set by the SessionManager to remap its internal tracking key.
|
||||
*/
|
||||
onIdResolved?: (oldId: string, newId: string) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider — the pluggable backend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AIProviderCapabilities {
|
||||
/** Whether the provider supports forking from a parent session. */
|
||||
fork: boolean;
|
||||
/** Whether the provider supports resuming a prior session by ID. */
|
||||
resume: boolean;
|
||||
/** Whether the provider streams partial text deltas. */
|
||||
streaming: boolean;
|
||||
/** Whether the provider can execute tools (read files, search, etc.). */
|
||||
tools: boolean;
|
||||
}
|
||||
|
||||
export interface CreateSessionOptions {
|
||||
/** The context (plan, diff, file) to seed the session with. */
|
||||
context: AIContext;
|
||||
/**
|
||||
* Working directory override for the agent session.
|
||||
* Falls back to the provider's configured cwd if omitted.
|
||||
*/
|
||||
cwd?: string;
|
||||
/**
|
||||
* Model override. Provider-specific string.
|
||||
* Falls back to provider default if omitted.
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
* Maximum agentic turns for the session.
|
||||
* Keeps inline chat cost-bounded.
|
||||
*/
|
||||
maxTurns?: number;
|
||||
/**
|
||||
* Maximum budget in USD for this session.
|
||||
*/
|
||||
maxBudgetUsd?: number;
|
||||
/**
|
||||
* Reasoning effort level (Codex only).
|
||||
* Controls how much thinking the model does before responding.
|
||||
*/
|
||||
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
}
|
||||
|
||||
/**
|
||||
* An AI provider implements the bridge between Plannotator and a specific
|
||||
* agent runtime. The provider is responsible for:
|
||||
*
|
||||
* 1. Creating new AI sessions seeded with review context
|
||||
* 2. Forking from parent agent sessions to maintain conversation history
|
||||
* 3. Streaming responses back as AIMessage events
|
||||
*
|
||||
* Providers are registered by name and selected at runtime based on the
|
||||
* host environment (Claude Code → "claude-agent-sdk", OpenCode → "opencode-sdk").
|
||||
*/
|
||||
export interface AIProvider {
|
||||
/** Unique name for this provider (e.g. "claude-agent-sdk"). */
|
||||
readonly name: string;
|
||||
|
||||
/** What this provider can do. */
|
||||
readonly capabilities: AIProviderCapabilities;
|
||||
|
||||
/** Available models for this provider. */
|
||||
readonly models?: ReadonlyArray<{ id: string; label: string; default?: boolean }>;
|
||||
|
||||
/**
|
||||
* Create a fresh session (no parent history).
|
||||
* Context is injected via the system prompt.
|
||||
*/
|
||||
createSession(options: CreateSessionOptions): Promise<AISession>;
|
||||
|
||||
/**
|
||||
* Fork from a parent agent session.
|
||||
*
|
||||
* The new session inherits the parent's full conversation history
|
||||
* (files read, analysis performed, decisions made) and additionally
|
||||
* receives the Plannotator review context. This enables the user to
|
||||
* ask contextual questions like "why did you change this function?"
|
||||
* without the AI losing insight.
|
||||
*
|
||||
* Providers that don't support real forking MUST throw. The endpoint
|
||||
* layer checks `capabilities.fork` before calling this, so it should
|
||||
* only be reached by providers that genuinely support history inheritance.
|
||||
*/
|
||||
forkSession(options: CreateSessionOptions): Promise<AISession>;
|
||||
|
||||
/**
|
||||
* Resume a previously created Plannotator AI session by its ID.
|
||||
* Used when the user returns to a conversation they started earlier.
|
||||
*
|
||||
* If the provider doesn't support resuming, this should throw.
|
||||
*/
|
||||
resumeSession(sessionId: string): Promise<AISession>;
|
||||
|
||||
/**
|
||||
* Clean up any resources held by the provider.
|
||||
* Called when the server shuts down.
|
||||
*/
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Configuration passed to a provider factory.
|
||||
* Each provider type may extend this with its own fields.
|
||||
*/
|
||||
export interface AIProviderConfig {
|
||||
/** Provider type identifier (matches AIProvider.name). */
|
||||
type: string;
|
||||
/** Working directory for the agent. */
|
||||
cwd?: string;
|
||||
/** Default model to use. */
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeAgentSDKConfig extends AIProviderConfig {
|
||||
type: "claude-agent-sdk";
|
||||
/**
|
||||
* Tools the AI session is allowed to use.
|
||||
* Defaults to read-only tools for safety in inline chat.
|
||||
*/
|
||||
allowedTools?: string[];
|
||||
/**
|
||||
* Permission mode for the session.
|
||||
* Defaults to "default" (inherits user's existing permission rules).
|
||||
*/
|
||||
permissionMode?: "default" | "plan" | "bypassPermissions";
|
||||
/**
|
||||
* Explicit path to the claude CLI binary.
|
||||
* Required when running inside a compiled binary where PATH resolution
|
||||
* doesn't work the same way (e.g., bun build --compile).
|
||||
*/
|
||||
claudeExecutablePath?: string;
|
||||
/**
|
||||
* Setting sources to load permission rules from.
|
||||
* Loads user's existing Claude Code permission rules so inline chat
|
||||
* inherits what they've already approved.
|
||||
*/
|
||||
settingSources?: string[];
|
||||
}
|
||||
|
||||
export interface CodexSDKConfig extends AIProviderConfig {
|
||||
type: "codex-sdk";
|
||||
/**
|
||||
* Sandbox mode controls what the Codex agent can do.
|
||||
* Defaults to "read-only" for safety in inline chat.
|
||||
*/
|
||||
sandboxMode?: "read-only" | "workspace-write" | "danger-full-access";
|
||||
/**
|
||||
* Explicit path to the codex CLI binary.
|
||||
* Required when running inside a compiled binary where PATH resolution
|
||||
* doesn't work the same way (e.g., bun build --compile).
|
||||
*/
|
||||
codexExecutablePath?: string;
|
||||
}
|
||||
|
||||
export interface PiSDKConfig extends AIProviderConfig {
|
||||
type: "pi-sdk";
|
||||
/**
|
||||
* Explicit path to the pi CLI binary.
|
||||
* Required when running inside a compiled binary where PATH resolution
|
||||
* doesn't work the same way (e.g., bun build --compile).
|
||||
*/
|
||||
piExecutablePath?: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeConfig extends AIProviderConfig {
|
||||
type: "opencode-sdk";
|
||||
/** Hostname for the OpenCode server. Default: "127.0.0.1". */
|
||||
hostname?: string;
|
||||
/** Port for the OpenCode server. Default: 4096. */
|
||||
port?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user