import { rm } from "node:fs/promises"; import { arch, availableParallelism, cpus, platform } from "node:os"; import type { EventBus, ExtensionCommandContext, ExtensionContext, ToolDefinition, } from "@earendil-works/pi-coding-agent"; import { type TSchema, Type } from "typebox"; import type { AgentExecuteTool } from "./bridges/agent-bridge.ts"; import type { EvalSchemaToolInfo } from "./bridges/schema-bridge.ts"; import { type CompletionRequest, type CompletionResult, createCompletionHandler, } from "./completion/handler.ts"; import { defaultCodemodeSettings } from "./config/settings.ts"; import { openCodemodeMenu } from "./config/codemode-menu.ts"; import { EvalNotifier } from "./extension/eval-notifier.ts"; import { promotionBlock } from "./extension/promotion.ts"; import { EVAL_CELLS_STATUS_KEY } from "./extension/eval-status.ts"; import { EvalStatusTicker } from "./extension/eval-status-ticker.ts"; import { createExecuteTool, createRuntime, enabledLanguagesFrom, type SessionRuntime, } from "./extension/runtime-factory.ts"; import type { CodemodeSessionManager, CreateCodemodeSessionManagerOptions, } from "./extension/session-manager.ts"; import { SessionManagerProxy } from "./extension/session-manager-proxy.ts"; import { PeerToolClient } from "./host/peer/client.ts"; import { ToolExecutionShim } from "./host/tool-execution.ts"; import { EvalDetachedCellManager, type EvalDetachedCellStatusEntry, } from "./tool/detached-cell-manager.ts"; import { createEvalTool, refreshEvalTool } from "./tool/eval-tool.ts"; import { evalEmphasisStyle } from "./prompt/eval-prompt.ts"; import { RESERVED_TOOL_NAMES } from "./bridge/reserved.ts"; import { EMPTY_TOOL_SNAPSHOT } from "./tool/tool-snapshot.ts"; import { renderEvalCall, renderEvalResult } from "./tool/render.ts"; import { createWaitTool } from "./tool/wait-tool.ts"; const SESSION_LIFECYCLE_EVENTS = [ "session_start", "session_shutdown", "session_before_switch", "session_before_fork", ] as const; type SessionLifecycleEvent = (typeof SESSION_LIFECYCLE_EVENTS)[number]; type CodemodeEvent = SessionLifecycleEvent | "model_select" | "before_agent_start"; export type CodemodeRegisteredTool = | ReturnType | HintStubTool; export interface CodemodeExtensionAPI { executeTool: AgentExecuteTool; getActiveTools: () => string[]; getAllTools: () => readonly EvalSchemaToolInfo[]; on: ( event: CodemodeEvent, handler: (event: unknown, ctx: ExtensionContext) => unknown ) => void; registerCommand: ( name: string, options: { description?: string; handler: (args: string, ctx: ExtensionCommandContext) => Promise } ) => void; registerRemovedToolHint: (name: string, hint: string) => void; registerTool: (tool: CodemodeRegisteredTool) => void; sendUserMessage: ( content: string, options?: { deliverAs?: "steer" | "followUp" } ) => void; } /** * The pi 0.83.0 ExtensionAPI surface the port consumes (api-gaps G1/G2): the * default export adapts this to the internal CodemodeExtensionAPI. */ export interface CodemodeHostAPI { /** Shared extension event bus (pi.events); carries the cooperative tool peer protocol. */ readonly events: EventBus; // pi-codemode intentionally exposes no setActiveTools by contract; any // future dynamic activation must dedupe locally before forwarding (see the // tool-listing-collisions contract test). getActiveTools: () => string[]; getAllTools: () => readonly EvalSchemaToolInfo[]; // Generic so pi's overloaded ExtensionAPI.on (per-event handler types) remains // structurally assignable under strictFunctionTypes: a fixed CodemodeEvent // signature would be contravariant against pi's literal event overloads. on: ( event: E, handler: (event: unknown, ctx: ExtensionContext) => unknown ) => void; registerCommand: ( name: string, options: { description?: string; handler: (args: string, ctx: ExtensionCommandContext) => Promise } ) => void; registerTool: ( tool: ToolDefinition ) => void; sendUserMessage: ( content: string, options?: { deliverAs?: "steer" | "followUp" } ) => void; } export interface CodemodeOptions { readonly complete?: ( request: CompletionRequest, ctx: ExtensionContext ) => Promise; readonly createSessionManager?: ( options: CreateCodemodeSessionManagerOptions ) => CodemodeSessionManager | Promise; /** Injectable clock for detached-cell elapsed labels; defaults to Date.now. */ readonly now?: () => number; } type HintStubTool = ToolDefinition & { readonly name: "exec"; }; function createHintStub(name: "exec", hint: string): HintStubTool { return { name, label: `Removed tool: ${name}`, description: hint, parameters: Type.Object({}, { additionalProperties: false }), prepareArguments: () => ({}), execute: async () => { throw new RangeError(hint); }, }; } export default function piCodemode( host: CodemodeHostAPI, options: CodemodeOptions = {} ): void { let activeContext: ExtensionContext | undefined; const peerTools = new PeerToolClient({ events: host.events }); const shim = new ToolExecutionShim({ listTools: () => host.getAllTools(), getActiveTools: () => host.getActiveTools(), getContext: () => activeContext, // Approval gate for cell tool calls: default policy (read allow, write // and execute ask) with the default name-based risk classification. approvals: {}, peerTools, }); // Reserved bridge names and pi-codemode's own tools are never probed: they // are either intercepted by the kernel bridge or host-only. Cooperative // peers only ever receive names no host-owned implementation can serve. const probePeerTools = async (): Promise => { const probeNames = host .getActiveTools() .filter( (name) => !RESERVED_TOOL_NAMES.has(name) && name !== "eval" && name !== "wait" && name !== "exec" ); await peerTools.probe(probeNames); }; const pi: CodemodeExtensionAPI = { registerTool: (tool) => { // host.registerTool is generic: dispatch the union so each branch calls it // with a concrete ToolDefinition (the same dispatch the shim needs). if (tool.name === "eval") { host.registerTool(tool); shim.registerTool(tool); } else { host.registerTool(tool); shim.registerTool(tool); } }, registerRemovedToolHint: (name, hint) => { if (name !== "exec") { return; } const stub = createHintStub(name, hint); host.registerTool(stub); shim.registerTool(stub); }, on: (event, handler) => { switch (event) { case "session_start": host.on("session_start", handler); return; case "session_shutdown": host.on("session_shutdown", handler); return; case "session_before_switch": host.on("session_before_switch", handler); return; case "session_before_fork": host.on("session_before_fork", handler); return; case "model_select": host.on("model_select", handler); return; case "before_agent_start": host.on("before_agent_start", handler); return; default: assertNever(event); } }, registerCommand: (name, options) => host.registerCommand(name, options), executeTool: (toolName, params, executeOptions) => shim.executeTool(toolName, params, executeOptions), getActiveTools: () => host.getActiveTools(), getAllTools: () => host.getAllTools(), sendUserMessage: (content, notifyOptions) => host.sendUserMessage(content, notifyOptions), }; const manager = new SessionManagerProxy(); const complete = options.complete ?? ((request, ctx) => createCompletionHandler()(ctx)(request)); const renderers = { renderCall: renderEvalCall, renderResult: renderEvalResult, }; let activeRuntime: SessionRuntime | undefined; let activeModelId: string | undefined; let activeCells: EvalDetachedCellManager | undefined; const notifier = new EvalNotifier({ sendUserMessage: (content, notifyOptions) => pi.sendUserMessage(content, notifyOptions), getContext: () => activeContext, getMode: () => "wake", }); const statusTicker = new EvalStatusTicker({ ...(options.now === undefined ? {} : { now: options.now }), render: (status) => { const ctx = activeContext; if (ctx?.ui?.setStatus === undefined) { return; } const theme = ctx.ui.theme; ctx.ui.setStatus( EVAL_CELLS_STATUS_KEY, status === undefined || ctx.mode !== "tui" || theme === undefined ? status : theme.bg("selectedBg", theme.fg("text", status)) ); }, }); const showDetachedCells = ( entries: readonly EvalDetachedCellStatusEntry[] ): void => { statusTicker.sync(entries); }; const baselineCellManager = new EvalDetachedCellManager({ notifier, onStatusChange: showDetachedCells, ...(options.now === undefined ? {} : { now: options.now }), }); const inactiveEvalOptions = { enabledLanguages: { py: true, js: true, rb: false, ts: false }, kernelManager: manager, cellTimeoutSeconds: defaultCodemodeSettings.cellTimeoutSeconds, executeTool: createExecuteTool(pi), listTools: () => EMPTY_TOOL_SNAPSHOT.tools, toolSnapshot: EMPTY_TOOL_SNAPSHOT, complete, settings: defaultCodemodeSettings, cellManager: baselineCellManager, executionTracker: manager, renderers, hostLine: hostLine(), waitTool: true, isActive: () => activeRuntime !== undefined, } as const; const evalOptions = { ...inactiveEvalOptions }; const evalTool = createEvalTool(evalOptions); const registerEvalForRuntime = ( runtime: SessionRuntime, modelId: string | undefined, cellManager: EvalDetachedCellManager, register = true ): void => { Object.assign(evalOptions, { enabledLanguages: runtime.enabledLanguages, cellTimeoutSeconds: runtime.settings.cellTimeoutSeconds, executeTool: runtime.executeTool, listTools: () => runtime.toolSnapshot.tools, toolSnapshot: runtime.toolSnapshot, settings: runtime.settings, artifactsDir: runtime.artifactsDir, cellManager, spawns: runtime.spawns, spawnDefaultAgent: runtime.settings.taskTools.task, sessionStore: runtime.sessionStore, ...(modelId === undefined ? {} : { modelId }), }); refreshEvalTool(evalTool, evalOptions); if (register) pi.registerTool(evalTool); }; const deactivateEval = (): void => { Object.assign(evalOptions, inactiveEvalOptions); refreshEvalTool(evalTool, evalOptions); evalTool.description = `No active codemode session. Start a session before using eval.\n\n${evalTool.description}`; evalTool.promptSnippet = "Eval is unavailable: no active codemode session."; evalTool.promptGuidelines = []; pi.registerTool(evalTool); }; const dropRuntime = async (): Promise => { peerTools.clear(); const runtime = activeRuntime; const cells = activeCells; activeRuntime = undefined; activeModelId = undefined; activeCells = undefined; statusTicker.stop(); await cells?.dispose(); activeContext = undefined; await manager.dispose(); if (runtime?.artifactsTemp === true) { await rm(runtime.artifactsDir, { recursive: true, force: true }).catch( (error: unknown) => { console.error( `failed to remove temp artifacts dir ${runtime.artifactsDir}:`, error ); } ); } if (runtime !== undefined || cells !== undefined) { deactivateEval(); } }; pi.registerTool(evalTool); host.registerTool(createWaitTool(() => activeCells)); // pi 0.83.0 has no registerRemovedToolHint (api-gaps G2): the hint is delivered // by error-stub tools (see TECHNICAL.md, Host bridges) so a model calling exec still gets the // migration guidance. pi.registerRemovedToolHint( "exec", 'exec was removed; use eval({ language: "js", code }) instead. Long eval cells detach on timeout and notify when complete.' ); pi.registerCommand("codemode", { description: "View and edit the effective codemode configuration (settings TUI)", handler: async (_args, ctx) => { await openCodemodeMenu(ctx, ctx.cwd); }, }); // Runtime promotion: when eval/wait are active, append a bounded guidance // block to the system prompt so models reach for the code-mode tools at the // right moments. Toggleable via the promotion.enabled setting (default // true; surfaced in the settings schema and the /codemode menu). pi.on("before_agent_start", (event, ctx) => { const runtime = activeRuntime; const promotionEnabled = runtime?.settings.promotion.enabled ?? defaultCodemodeSettings.promotion.enabled; if (!promotionEnabled) { return; } const selectedTools = typeof event === "object" && event !== null && "systemPromptOptions" in event && typeof (event as { systemPromptOptions?: { selectedTools?: unknown } }).systemPromptOptions ?.selectedTools === "object" && (event as { systemPromptOptions?: { selectedTools?: unknown[] } }).systemPromptOptions ?.selectedTools !== null ? ((event as { systemPromptOptions?: { selectedTools: string[] } }).systemPromptOptions ?.selectedTools as string[]) : []; const active = new Set(selectedTools); if (!active.has("eval") && !active.has("wait")) { return; } const systemPrompt = typeof event === "object" && event !== null && "systemPrompt" in event ? String((event as { systemPrompt?: unknown }).systemPrompt ?? "") : ""; return { systemPrompt: `${systemPrompt}\n\n${promotionBlock(runtime?.toolSnapshot.version)}`, }; }); pi.on("session_start", async (event, ctx) => { const previousCells = activeCells; activeCells = undefined; await previousCells?.dispose(); const generation = manager.beginReplacement(); const runtime = await createRuntime(pi, ctx, event, complete, options); const replaced = await manager.replace(generation, runtime.manager); if (!replaced) { return; } notifier.reset(); activeContext = ctx; const cellManager = new EvalDetachedCellManager({ artifactsDir: runtime.artifactsDir, notifier, onStatusChange: showDetachedCells, ...(options.now === undefined ? {} : { now: options.now }), }); activeCells = cellManager; activeRuntime = runtime; activeModelId = ctx.model?.id; registerEvalForRuntime(runtime, activeModelId, cellManager); probePeerTools().catch((error: unknown) => { console.error("peer tool probe failed:", error); }); }); pi.on("session_shutdown", async () => dropRuntime()); pi.on("session_before_switch", async () => dropRuntime()); pi.on("session_before_fork", async () => dropRuntime()); pi.on("model_select", async (event, ctx) => { activeContext = ctx; const runtime = activeRuntime; if (runtime === undefined) { return; } const modelId = modelIdFrom(event); if (modelId === undefined || evalEmphasisStyle(modelId) === evalEmphasisStyle(activeModelId)) { return; } activeModelId = modelId; const cellManager = activeCells; if (cellManager === undefined) { return; } registerEvalForRuntime(runtime, modelId, cellManager); }); } function assertNever(value: never): never { throw new TypeError(`Unhandled codemode event: ${String(value)}`); } function hostLine(): string { const cpu = cpus()[0]?.model?.trim(); return [ `${platform()} ${arch()}`, cpu, `${availableParallelism()} cores`, ] .filter((part): part is string => !!part) .join(" \u00b7 "); } function modelIdFrom(event: unknown): string | undefined { if (typeof event !== "object" || event === null || !("model" in event)) { return; } const model = event.model; if (typeof model !== "object" || model === null || !("id" in model)) { return; } return typeof model.id === "string" ? model.id : undefined; } export { enabledLanguagesFrom };