Add plannotator extension v0.19.10
This commit is contained in:
@@ -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)
|
||||
);
|
||||
Reference in New Issue
Block a user