/** * `/agent` — activate a configured Pi agent as a persistent mode in the * current session. * * This package is a mode on the current agent session: it switches model, * thinking level, and active tools, appends the agent prompt to the base * prompt, and restores the pre-activation baseline on `/agent none` or * `/agent off`. It does not spawn children and provides no delegated * subagents. */ import type { CustomEntry, ExtensionAPI, ExtensionCommandContext, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import type { ModelProfilesConfig } from "@pi-kaush/pi-model-profiles"; import { discoverAgents, formatAgentDisplayName, shouldConfirmProjectAgent, type AgentConfig, } from "./agent-discovery.ts"; import { parseAgentModelSpec, type ThinkingLevel } from "./model-spec.ts"; import { buildAgentPickerItems, NONE_VALUE, showAgentPicker, } from "./agent-picker.ts"; interface ModelReference { provider: string; id: string; } export { isModuleUnavailable, parseAgentModelSpec }; export type { AgentModelSpec } from "./model-spec.ts"; // Durable session state is namespaced so it cannot collide with other // packages. Legacy Aikado sessions wrote `active-agent-state`; restore reads // both types and prefers the newest recognized entry on the active branch so // sessions activated by either implementation resume correctly. const PI_AGENT_MODE_STATE_TYPE = "pi-agent-mode/state"; const LEGACY_ACTIVE_AGENT_STATE_TYPE = "active-agent-state"; const ACTIVE_AGENT_STATE_TYPES = [ LEGACY_ACTIVE_AGENT_STATE_TYPE, PI_AGENT_MODE_STATE_TYPE, ]; interface AgentBaseline { model?: ModelReference; thinkingLevel: ThinkingLevel; tools: string[]; } interface ActiveAgent { agent: AgentConfig; baseline: AgentBaseline; } interface ActiveAgentState { active: boolean; name?: string; baseline?: AgentBaseline; } function isPlainObject(value: unknown): value is Record { if (typeof value !== "object" || value === null) return false; const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; } function isActiveAgentState(value: unknown): value is ActiveAgentState { if (!isPlainObject(value) || typeof value.active !== "boolean") return false; if ( value.active && (typeof value.name !== "string" || value.name.length === 0) ) return false; if (value.baseline !== undefined) { if (!isPlainObject(value.baseline)) return false; if (typeof value.baseline.thinkingLevel !== "string") return false; if ( !Array.isArray(value.baseline.tools) || !value.baseline.tools.every((tool) => typeof tool === "string") ) return false; if (value.baseline.model !== undefined) { if ( !isPlainObject(value.baseline.model) || typeof value.baseline.model.provider !== "string" || typeof value.baseline.model.id !== "string" ) return false; } } return true; } function isModuleUnavailable(error: unknown): boolean { const code = (error as { code?: string } | undefined)?.code; return ( code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND" || (error instanceof Error && error.message.includes("Cannot find module")) ); } async function pickAgent( ctx: ExtensionCommandContext, agents: AgentConfig[], activeName: string | undefined, ): Promise { const items = buildAgentPickerItems(agents, activeName); if (ctx.mode === "tui") return showAgentPicker(ctx, items); const baseLabels = items.map((item) => `${item.label} — ${item.description}`); const labelCounts = new Map(); for (const label of baseLabels) { labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1); } const labels = baseLabels.map((label, index) => labelCounts.get(label)! > 1 ? `${label} [${items[index]!.value}]` : label, ); const choice = await ctx.ui.select("Activate agent:", labels); if (choice === undefined) return null; const firstMatch = labels.indexOf(choice); if (firstMatch !== -1 && labels.lastIndexOf(choice) !== firstMatch) { ctx.ui.notify( "That selection matches more than one agent; rename one of the duplicates.", "warning", ); return null; } return firstMatch === -1 ? null : (items[firstMatch]?.value ?? null); } export function registerAgentMode(pi: ExtensionAPI) { let activeAgent: ActiveAgent | undefined; function getBaseline(ctx: ExtensionContext): AgentBaseline { return { ...(ctx.model ? { model: { provider: ctx.model.provider, id: ctx.model.id } } : {}), thinkingLevel: pi.getThinkingLevel(), tools: pi.getActiveTools(), }; } function updateAgentStatus(ctx: ExtensionContext) { if (!activeAgent) { ctx.ui.setStatus("active-agent", undefined); return; } ctx.ui.setStatus( "active-agent", ctx.ui.theme.bold( ctx.ui.theme.fg("warning", formatAgentDisplayName(activeAgent.agent)), ), ); } function persistActiveAgent() { const state: ActiveAgentState = activeAgent ? { active: true, name: activeAgent.agent.name, baseline: activeAgent.baseline, } : { active: false }; pi.appendEntry(PI_AGENT_MODE_STATE_TYPE, state); } async function refreshModelCatalog(ctx: ExtensionContext): Promise { try { // Activation may happen after models.json changes in a long-lived session. // Pi 0.80 accepts no options; newer Pi can suppress remote catalog fetches. // The cycling scope is independent of the models an agent may select. await ( ctx.modelRegistry.refresh as (options: { allowNetwork: boolean; }) => void | Promise )({ allowNetwork: false }); return true; } catch (error) { ctx.ui.notify( `Could not refresh model catalog: ${error instanceof Error ? error.message : error}`, "error", ); return false; } } function findModel(modelSpec: string, ctx: ExtensionContext) { const spec = modelSpec.trim(); const separator = spec.indexOf("/"); if (separator > 0 && separator < spec.length - 1) { return ctx.modelRegistry.find( spec.slice(0, separator), spec.slice(separator + 1), ); } const matches = ctx.modelRegistry .getAll() .filter((model) => model.id === spec || model.name === spec); return ( matches.find((model) => model.provider === ctx.model?.provider) ?? matches[0] ); } async function restoreModel( model: ModelReference | undefined, ctx: ExtensionContext, ): Promise { if (!model) return true; if (ctx.model?.provider === model.provider && ctx.model.id === model.id) return true; if (!(await refreshModelCatalog(ctx))) return false; const resolved = ctx.modelRegistry.find(model.provider, model.id); if (!resolved) { ctx.ui.notify( `Cannot restore model ${model.provider}/${model.id}: it is no longer available.`, "warning", ); return false; } if (await pi.setModel(resolved)) return true; ctx.ui.notify( `Cannot restore model ${model.provider}/${model.id}: authentication is unavailable.`, "warning", ); return false; } function applyAgentTools( agent: AgentConfig, baseline: AgentBaseline, ctx: ExtensionContext, ) { const requested = agent.tools?.length ? agent.tools : baseline.tools; const available = new Set(pi.getAllTools().map((tool) => tool.name)); const valid = requested.filter((tool) => available.has(tool)); const missing = requested.filter((tool) => !available.has(tool)); if (missing.length > 0) { ctx.ui.notify( `Agent ${formatAgentDisplayName(agent)}: unavailable tools ignored: ${missing.join(", ")}`, "warning", ); } pi.setActiveTools(valid); } /** * Walk the agent's profile candidates in order and switch to the first * model that exists and is authenticated. Fallback happens only during * activation; running sessions never switch models mid-request. */ async function applyProfileModels( agent: AgentConfig, ctx: ExtensionContext, ): Promise<{ thinkingLevel?: ThinkingLevel } | false> { let profiles: ModelProfilesConfig; try { // Runtime-only import: a missing @pi-kaush/pi-model-profiles degrades // just profile support instead of failing the whole extension load. const profilesModule = await import("@pi-kaush/pi-model-profiles"); profiles = profilesModule.loadModelProfiles( profilesModule.resolveProfilesPath(getAgentDir()), ); } catch (error) { if (isModuleUnavailable(error)) { ctx.ui.notify( `Agent ${formatAgentDisplayName(agent)}: profile support unavailable; install it with pi install npm:@pi-kaush/pi-model-profiles.`, "warning", ); } else { ctx.ui.notify( `Agent ${formatAgentDisplayName(agent)}: ${error instanceof Error ? error.message : error}`, "error", ); } return false; } const profileName = agent.profile!.trim(); const profile = profiles.profiles[profileName]; if (!profile) { ctx.ui.notify( `Agent ${formatAgentDisplayName(agent)}: unknown profile "${profileName}". Available: ${Object.keys(profiles.profiles).join(", ")}.`, "error", ); return false; } for (const candidate of profile.candidates) { const model = findModel(candidate.model, ctx); if (!model) continue; if (await pi.setModel(model)) { return { ...(candidate.thinkingLevel !== undefined ? { thinkingLevel: candidate.thinkingLevel } : {}), }; } } ctx.ui.notify( `Agent ${formatAgentDisplayName(agent)}: profile "${profileName}" has no available candidate models. Configured: ${profile.candidates.map((candidate) => candidate.model).join(", ")}.`, "error", ); return false; } async function activateAgent( agent: AgentConfig, ctx: ExtensionContext, baseline = activeAgent?.baseline ?? getBaseline(ctx), options: { persist: boolean } = { persist: true }, ): Promise { let thinkingLevel = baseline.thinkingLevel; if (agent.profile && agent.model) { ctx.ui.notify( `Agent ${formatAgentDisplayName(agent)}: declare either "profile" or "model" in frontmatter, not both.`, "error", ); return false; } if ((agent.profile || agent.model) && !(await refreshModelCatalog(ctx))) return false; if (agent.profile) { const applied = await applyProfileModels(agent, ctx); if (!applied) return false; thinkingLevel = applied.thinkingLevel ?? baseline.thinkingLevel; } else if (agent.model) { const modelSpec = parseAgentModelSpec(agent.model); const model = findModel(modelSpec.model, ctx); if (!model) { ctx.ui.notify( `Agent ${formatAgentDisplayName(agent)}: configured model "${agent.model}" was not found.`, "error", ); return false; } if (!(await pi.setModel(model))) { ctx.ui.notify( `Agent ${formatAgentDisplayName(agent)}: authentication is unavailable for ${agent.model}.`, "error", ); return false; } thinkingLevel = modelSpec.thinkingLevel ?? baseline.thinkingLevel; } else if (!(await restoreModel(baseline.model, ctx))) { ctx.ui.notify( `Agent ${formatAgentDisplayName(agent)}: could not restore the baseline model; activation aborted.`, "error", ); return false; } pi.setThinkingLevel(thinkingLevel); applyAgentTools(agent, baseline, ctx); activeAgent = { agent, baseline }; updateAgentStatus(ctx); if (options.persist) persistActiveAgent(); return true; } async function clearActiveAgent(ctx: ExtensionContext) { if (!activeAgent) { ctx.ui.notify("No active agent.", "info"); return; } const modelRestored = await restoreModel(activeAgent.baseline.model, ctx); pi.setThinkingLevel(activeAgent.baseline.thinkingLevel); pi.setActiveTools(activeAgent.baseline.tools); activeAgent = undefined; updateAgentStatus(ctx); persistActiveAgent(); ctx.ui.notify( modelRestored ? "Active agent cleared; previous model and tools restored." : "Active agent cleared, but the previous model could not be restored.", modelRestored ? "info" : "warning", ); } async function confirmProjectAgent( agent: AgentConfig, ctx: ExtensionContext, ): Promise { if ( agent.source !== "project" || !ctx.hasUI || !shouldConfirmProjectAgent(agent) ) return true; return ctx.ui.confirm( "Activate project-local agent?", `Agent: ${formatAgentDisplayName(agent)}\nSource: ${agent.filePath}\n\nProject agents are repo-controlled. Only continue for trusted projects.`, ); } pi.registerCommand("agent", { description: "Activate a named agent in this session; use /agent none to restore defaults", handler: async (args, ctx) => { const requested = args.trim(); if (requested === "none" || requested === "off") { await clearActiveAgent(ctx); return; } const agents = discoverAgents(ctx.cwd, "both").agents; if (agents.length === 0) { ctx.ui.notify("No agents found.", "warning"); return; } let agent: AgentConfig | undefined; if (requested) { agent = agents.find((candidate) => candidate.name === requested); if (!agent) { ctx.ui.notify( `Unknown agent "${requested}". Available: ${agents.map((candidate) => formatAgentDisplayName(candidate)).join(", ")}`, "warning", ); return; } } else { if (!ctx.hasUI) { ctx.ui.notify( `Usage: /agent . Available: ${agents.map((agent) => formatAgentDisplayName(agent)).join(", ")}`, "info", ); return; } const picked = await pickAgent(ctx, agents, activeAgent?.agent.name); if (!picked) return; if (picked === NONE_VALUE) { await clearActiveAgent(ctx); return; } agent = agents.find((candidate) => candidate.name === picked); } if (!agent || !(await confirmProjectAgent(agent, ctx))) return; if (await activateAgent(agent, ctx)) { ctx.ui.notify(`Active agent: ${formatAgentDisplayName(agent)}`, "info"); } }, }); pi.on("before_agent_start", (event) => { if (!activeAgent) return; return { systemPrompt: `${event.systemPrompt}\n\n## Active agent: ${formatAgentDisplayName(activeAgent.agent)}\nThe following agent definition is active for this session. Follow it in addition to Pi's base instructions and loaded project context.\n\n${activeAgent.agent.systemPrompt}`, }; }); pi.on("session_start", async (_event, ctx) => { activeAgent = undefined; const stateEntry = ctx.sessionManager .getBranch() .filter( (entry): entry is CustomEntry => entry.type === "custom" && ACTIVE_AGENT_STATE_TYPES.includes(entry.customType) && isActiveAgentState(entry.data), ) .pop(); const state = stateEntry?.data; if (!state?.active || !state.name) { updateAgentStatus(ctx); return; } const agent = discoverAgents(ctx.cwd, "both").agents.find( (candidate) => candidate.name === state.name, ); if (!agent) { ctx.ui.notify( `Active agent "${state.name}" is no longer available.`, "warning", ); updateAgentStatus(ctx); return; } await activateAgent(agent, ctx, state.baseline ?? getBaseline(ctx), { persist: false, }); }); pi.on("session_shutdown", (_event, ctx) => { ctx.ui.setStatus("active-agent", undefined); }); } // Pi loads the package as an extension through this default factory. export default async function (pi: ExtensionAPI) { registerAgentMode(pi); }