/** * Agent Switcher — cambio de agentes estilo opencode para pi. * * Permite alternar entre agentes (p. ej. `plan` y `build`) con `/agent`, * `/agents` o los atajos de teclado de cada agente. Cada agente puede llevar su * de thinking, allowlist de herramientas, rol de solo lectura y system * prompt, definidos en archivos Markdown con frontmatter: * * ~/.pi/agent/agents/*.md (agentes de usuario) * /.pi/agents/*.md (agentes de proyecto, sobrescriben) * * Ver README.md para el esquema completo y defs.ts para la lista de claves. * * Semántica (equivalente al cambio de agentes de opencode): * - El agente activo gobierna: su modelo/thinking se aplican al activarse y * se reafirman en cada `before_agent_start`. * - Si el agente no define `model`, se conserva el modelo de la sesión. * - Un cambio manual de modelo con `/model` queda registrado como override * de sesión para el agente activo y se restaura al volver a él. * - La selección persiste en la sesión y se restaura al reanudarla. */ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Model } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ToolCallEvent, } from "@earendil-works/pi-coding-agent"; import { findAgent, loadAgentDefinitions, type AgentDefinition } from "./defs.ts"; import { isReadOnlySafeCommand } from "./policy.ts"; const ENTRY_TYPE = "agent-switcher"; const DEFAULT_AGENT = "build"; interface OverrideRecord { /** Valor elegido durante la sesión (modelo o thinking). */ value: string; /** Valor del archivo en el momento de elegirlo. Si el archivo cambia, el override deja de valer. */ base?: string; /** Solo los overrides explícitos (`/agent model`) se aplican; descarta registros de versiones previas. */ explicit?: boolean; } interface PersistedState { active?: string; /** Overrides de modelo por agente. Solo se aplican los `explicit: true`. */ models?: Record; /** Overrides de thinking por agente. Solo se aplican los `explicit: true`. */ thinking?: Record; } export default function agentSwitcher(pi: ExtensionAPI) { /** Definiciones cargadas (se recargan en cada session_start). */ let defs: AgentDefinition[] = []; /** Nombre del agente activo. */ let activeName = DEFAULT_AGENT; /** Herramientas activas antes de la primera restricción (para restaurar). */ let toolsBaseline: string[] | undefined; /** Overrides de sesión por agente (solo cambios explícitos del usuario). */ const modelOverrides = new Map(); const thinkingOverrides = new Map(); /** Cola de aplicación: serializa cambios de modelo/thinking/herramientas para * que pulsaciones rápidas de F5/F6 no se pisen (el último cambio gana). */ let applyChain: Promise = Promise.resolve(); /** Último error de modelo notificado (para no repetirlo en cada run). */ let lastError = ""; pi.registerFlag("agent", { description: "Agente activo al arrancar (agent-switcher)", type: "string", }); function current(): AgentDefinition | undefined { if (defs.length === 0) defs = loadAgentDefinitions(process.cwd()); return findAgent(defs, activeName); } /** El override vale solo mientras el archivo del agente no cambie. */ function effectiveOverride( override: OverrideRecord | undefined, base: string | undefined, ): string | undefined { if (!override || override.base !== base) return undefined; return override.value; } function effectiveRef(agent: AgentDefinition): string | undefined { return effectiveOverride(modelOverrides.get(agent.name), agent.model) ?? agent.model; } function effectiveThinking(agent: AgentDefinition): string | undefined { return effectiveOverride(thinkingOverrides.get(agent.name), agent.thinking) ?? agent.thinking; } function modelRef(model: Model | undefined): string | undefined { return model ? `${model.provider}/${model.id}` : undefined; } function notify(ctx: ExtensionContext, text: string, kind: "info" | "warning" | "error" = "info"): void { if (ctx.hasUI) ctx.ui.notify(text, kind); } /** Resuelve "provider/id" o un "id" único contra el registro de modelos. */ function resolveModel( ctx: ExtensionContext, ref: string, ): { model?: Model; error?: string } { let all: Model[]; try { all = ctx.modelRegistry.getAll(); } catch { return { error: `no se pudo leer el registro de modelos` }; } const slash = ref.indexOf("/"); if (slash > 0) { const provider = ref.slice(0, slash); const id = ref.slice(slash + 1); const model = all.find((m) => m.provider === provider && m.id === id); return model ? { model } : { error: `modelo "${ref}" no encontrado` }; } const matches = all.filter((m) => m.id === ref); if (matches.length === 1) return { model: matches[0] }; if (matches.length > 1) { const providers = [...new Set(matches.map((m) => m.provider))].join(", "); return { error: `"${ref}" es ambiguo entre: ${providers}. Usa "provider/id"` }; } return { error: `modelo "${ref}" no encontrado` }; } /** Restaura o restringe las herramientas según el agente. */ function applyTools(agent: AgentDefinition): string[] { const registered = new Set(pi.getAllTools().map((t) => t.name)); if (agent.tools && agent.tools.length > 0) { if (toolsBaseline === undefined) toolsBaseline = pi.getActiveTools(); const list = agent.tools.filter((n) => registered.has(n)); if (list.length > 0) pi.setActiveTools(list); return agent.tools.filter((n) => !registered.has(n)); } if (toolsBaseline !== undefined) { pi.setActiveTools(toolsBaseline); toolsBaseline = undefined; } return []; } /** Encola una operación de aplicación para serializarla con el resto. */ function enqueue(fn: () => Promise): Promise { const run = applyChain.then(fn, fn); applyChain = run.then( () => undefined, () => undefined, ); return run; } /** * Aplica modelo, thinking y herramientas del agente de forma idempotente. * Las aplicaciones se serializan: si se pulsa F5 y F6 en ráfaga, cada una * termina por orden y el último cambio gana (antes la segunda se saltaba la * aplicación y los mensajes se cruzaban con el agente anterior). * `announce` controla las notificaciones (solo en cambios explícitos). */ async function ensureApplied( agent: AgentDefinition, ctx: ExtensionContext, announce: boolean, ): Promise { return enqueue(() => applyAgentPolicy(agent, ctx, announce)); } async function applyAgentPolicy( agent: AgentDefinition, ctx: ExtensionContext, announce: boolean, ): Promise { let appliedRef: string | undefined; { // Modelo: override de sesión > modelo del agente > modelo actual. const ref = effectiveRef(agent); if (ref) { const { model, error } = resolveModel(ctx, ref); if (!model) { if (announce || error !== lastError) { notify(ctx, `agent-switcher (${agent.name}): ${error}`, "warning"); lastError = error ?? ""; } } else { lastError = ""; appliedRef = modelRef(model); const differs = ctx.model?.provider !== model.provider || ctx.model?.id !== model.id; if (differs) { try { const ok = await pi.setModel(model); if (!ok && announce) { notify( ctx, `agent-switcher: sin autenticación para ${model.provider}/${model.id}`, "warning", ); } } catch { if (announce) { notify(ctx, `agent-switcher: no se pudo cambiar a ${model.provider}/${model.id}`, "warning"); } } } } } // Thinking: override de sesión > thinking del agente. const thinking = effectiveThinking(agent); if (thinking) pi.setThinkingLevel(thinking as ThinkingLevel); // Herramientas. const unknown = applyTools(agent); if (unknown.length > 0 && announce) { notify(ctx, `agent-switcher: herramientas no registradas ignoradas: ${unknown.join(", ")}`, "warning"); } if (announce && agent.name === activeName) { const parts = [`Agente activo: ${agent.name}`]; const shown = appliedRef ?? modelRef(ctx.model); if (shown) parts.push(`modelo ${shown}`); if (agent.tools?.length) parts.push(`tools ${agent.tools.join(", ")}`); if (agent.acceptanceRole === "read-only") parts.push("solo lectura"); notify(ctx, parts.join(" · "), "info"); } } } function persist(): void { const state: PersistedState = { active: activeName, models: Object.fromEntries(modelOverrides), thinking: Object.fromEntries(thinkingOverrides), }; pi.appendEntry(ENTRY_TYPE, state); } function defaultAgentName(): string { if (findAgent(defs, DEFAULT_AGENT)) return DEFAULT_AGENT; return (defs.find((d) => d.advertise) ?? defs[0])?.name ?? DEFAULT_AGENT; } /** Cambia el agente activo y aplica su política. */ async function switchTo( agent: AgentDefinition, ctx: ExtensionContext, announce = true, ): Promise { const wasActive = agent.name === activeName; activeName = agent.name; await ensureApplied(agent, ctx, announce); persist(); if (announce && wasActive) { notify(ctx, `agent-switcher: "${agent.name}" ya estaba activo`, "info"); } } function agentLines(ctx: ExtensionContext): string[] { return defs.map((d) => { const marker = d.name === activeName ? "●" : "○"; const override = effectiveOverride(modelOverrides.get(d.name), d.model); let ref = override ?? d.model ?? "—"; if (d.name === activeName && ref === "—") ref = modelRef(ctx.model) ?? "—"; if (override) ref += " (sesión)"; const flags = [ d.acceptanceRole === "read-only" ? "solo lectura" : "", d.advertise ? "" : "oculto", ].filter(Boolean); const head = `${marker} ${d.name}${d.aliases.length ? ` (${d.aliases.join(", ")})` : ""} — ${ref}${ flags.length ? ` · ${flags.join(" · ")}` : "" }`; return d.description ? `${head}\n ${d.description}` : head; }); } async function commandAgent(args: string, ctx: ExtensionCommandContext): Promise { if (defs.length === 0) defs = loadAgentDefinitions(ctx.cwd); const trimmed = args.trim(); // /agent → selector (o listado si no hay UI). if (!trimmed) { const visible = defs.filter((d) => d.advertise); if (!ctx.hasUI || visible.length === 0) { notify(ctx, `Agentes:\n${agentLines(ctx).join("\n")}`, "info"); return; } const labels = visible.map( (d) => `${d.name === activeName ? "●" : "○"} ${d.name}${ d.aliases.length ? ` (${d.aliases.join(", ")})` : "" } — ${d.description}${effectiveRef(d) ? ` [${effectiveRef(d)}]` : ""}`, ); const chosen = await ctx.ui.select("Cambiar de agente", labels); if (!chosen) return; const idx = labels.indexOf(chosen); const def = visible[idx]; if (def) await switchTo(def, ctx); return; } const [sub, ...restWords] = trimmed.split(/\s+/); const rest = restWords.join(" "); const subLower = sub.toLowerCase(); if (subLower === "list" || subLower === "ls") { notify(ctx, `Agentes:\n${agentLines(ctx).join("\n")}`, "info"); return; } if (subLower === "help") { notify( ctx, [ "Uso:", " /agent selector de agentes", " /agent cambiar de agente", " /agent list listar agentes y su estado", " /agent model modelo efectivo del agente activo", " /agent model fijar modelo del agente (esta sesión)", " /agent model reset quitar el override y usar el modelo del archivo", " /agents alias de /agent list", `Atajos de teclado: ver "shortcut:" en el frontmatter de cada agente (F5/F6 por defecto).`, ].join("\n"), "info", ); return; } if (subLower === "model") { const agent = current(); if (!agent) { notify(ctx, "agent-switcher: no hay ningún agente activo", "warning"); return; } if (!rest) { const override = effectiveOverride(modelOverrides.get(agent.name), agent.model); notify( ctx, override ? `${agent.name} → ${override} (override de sesión; /agent model reset para volver a ${agent.model ?? "el modelo de la sesión"})` : agent.model ? `${agent.name} → ${agent.model} (del archivo)` : `${agent.name} no define modelo; usa el de la sesión (${modelRef(ctx.model) ?? "ninguno"})`, "info", ); return; } if (["reset", "clear", "borrar", "-"].includes(rest.toLowerCase())) { modelOverrides.delete(agent.name); await ensureApplied(agent, ctx, true); persist(); notify( ctx, `Override de ${agent.name} eliminado → ${agent.model ?? "modelo de la sesión"}`, "info", ); return; } const { model, error } = resolveModel(ctx, rest); if (!model) { notify(ctx, `agent-switcher: ${error}`, "warning"); return; } modelOverrides.set(agent.name, { value: `${model.provider}/${model.id}`, base: agent.model, explicit: true, }); let ok = false; try { ok = await enqueue(() => pi.setModel(model)); } catch { ok = false; } if (!ok) { modelOverrides.delete(agent.name); notify( ctx, `agent-switcher: no se pudo cambiar a ${model.provider}/${model.id} (¿sin autenticación?)`, "warning", ); return; } persist(); notify(ctx, `Modelo de ${agent.name} (sesión) → ${model.provider}/${model.id}`, "info"); return; } const target = findAgent(defs, subLower); if (!target) { notify( ctx, `agent-switcher: agente desconocido "${sub}". Disponibles: ${defs .map((d) => d.name) .join(", ")}\n/agent list para más detalle, /agent para el selector.`, "warning", ); return; } await switchTo(target, ctx); } pi.registerCommand("agent", { description: "Cambiar de agente (estilo opencode): /agent [nombre|model |list|help]", getArgumentCompletions: (prefix: string) => { const options = ["list", "model", "help"]; for (const d of defs) { options.push(d.name, ...d.aliases); } const p = prefix.toLowerCase(); const seen = new Set(); return options .filter((v) => v.startsWith(p) && !seen.has(v)) .map((v) => { seen.add(v); const d = findAgent(defs, v); return { value: v, label: v, description: d?.description }; }); }, handler: async (args, ctx) => { await commandAgent(args, ctx); }, }); pi.registerCommand("agents", { description: "Listar agentes disponibles y su estado", handler: async (_args, ctx) => { await commandAgent("list", ctx); }, }); // Atajos directos por agente (frontmatter `shortcut:`, p. ej. f5, f6). // Se registran al cargar la extensión para que pi los recoja al construir // los keybindings; tras editar un .md usa /reload para recargarlos. { if (defs.length === 0) defs = loadAgentDefinitions(process.cwd()); const claimed = new Set(); for (const agent of defs) { for (const key of agent.shortcuts) { if (claimed.has(key)) continue; claimed.add(key); try { pi.registerShortcut(key as Parameters[0], { description: `Cambiar al agente ${agent.name}`, handler: async (ctx) => { const target = findAgent(defs, agent.name); if (target) await switchTo(target, ctx); }, }); } catch { // Atajo inválido o en conflicto: no debe romper la carga. } } } } // Nota de diseño: los cambios de modelo de /model, Ctrl+P o de otras // extensiones NO se convierten en overrides (un evento tardío se atribuía // al agente equivocado e intercambiaba los modelos). Solo `/agent model` // crea overrides; el modelo del agente se reafirma en cada run. // Defensa en profundidad: la allowlist de herramientas y el modo // solo lectura también se aplican a nivel de llamada. pi.on("tool_call", async (event: ToolCallEvent) => { const agent = current(); if (!agent) return; if (agent.tools && agent.tools.length > 0 && !agent.tools.includes(event.toolName)) { return { block: true, reason: `Agente "${agent.name}": la herramienta "${event.toolName}" no está en su lista permitida (${agent.tools.join(", ")}).`, }; } if (agent.acceptanceRole === "read-only") { if (event.toolName === "edit" || event.toolName === "write") { return { block: true, reason: `Agente "${agent.name}": modo solo lectura; "${event.toolName}" está bloqueado. Cambia de agente con /agent para implementar.`, }; } if (event.toolName === "bash" || event.toolName === "powershell") { const input = event.input as { command?: unknown }; const command = typeof input?.command === "string" ? input.command : ""; if (!isReadOnlySafeCommand(command)) { return { block: true, reason: `Agente "${agent.name}": modo solo lectura; el comando no está en la lista de solo lectura.\nComando: ${command}\nUsa /agent para cambiar a un agente con permisos de escritura.`, }; } } } return undefined; }); // Aplicar la política del agente activo en cada run: prompt, modelo, // thinking y herramientas (idempotente). pi.on("before_agent_start", async (event, ctx) => { const agent = current(); if (!agent) return; const opts = event.systemPromptOptions; if (agent.systemPrompt) { if (agent.systemPromptMode === "replace") { opts.customPrompt = agent.systemPrompt; } else if (!opts.appendSystemPrompt.includes(agent.systemPrompt)) { opts.appendSystemPrompt = opts.appendSystemPrompt ? `${opts.appendSystemPrompt}\n\n${agent.systemPrompt}` : agent.systemPrompt; } } if (!agent.inheritProjectContext) opts.contextFiles = []; if (!agent.inheritSkills) opts.skills = []; if (agent.tools?.length) { const registered = new Set(pi.getAllTools().map((t) => t.name)); opts.selectedTools = agent.tools.filter((n) => registered.has(n)); } await ensureApplied(agent, ctx, false); }); // Cargar definiciones y restaurar el agente de la sesión. pi.on("session_start", async (event, ctx) => { defs = loadAgentDefinitions(ctx.cwd); modelOverrides.clear(); thinkingOverrides.clear(); // Restaurar antes de soltar el baseline: si la sesión/recarga arranca con // un agente de solo lectura activo, soltarlo sin restaurar perdería la // lista base y build ya no podría recuperar sus herramientas. if (toolsBaseline !== undefined) { pi.setActiveTools(toolsBaseline); toolsBaseline = undefined; } lastError = ""; const entries = ctx.sessionManager.getEntries() as Array<{ type?: string; customType?: string; data?: PersistedState; }>; const last = [...entries] .reverse() .find((e) => e.type === "custom" && e.customType === ENTRY_TYPE && e.data); // Solo se aplican overrides explícitos ({value, base, explicit:true}); // los strings legacy y los registros de versiones previas (autograbados) // se ignoran: pueden estar contaminados y sombrear el modelo del archivo. if (last?.data?.models) { for (const [name, o] of Object.entries(last.data.models)) { if (o && typeof o === "object" && o.explicit === true && typeof o.value === "string") { modelOverrides.set(name, o); } } } if (last?.data?.thinking) { for (const [name, o] of Object.entries(last.data.thinking)) { if (o && typeof o === "object" && o.explicit === true && typeof o.value === "string") { thinkingOverrides.set(name, o); } } } // Prioridad: flag --agent > agente persistido > agente por defecto. const flagAgent = pi.getFlag("agent"); const persisted = last?.data?.active; if (typeof flagAgent === "string" && findAgent(defs, flagAgent)) { activeName = findAgent(defs, flagAgent)!.name; } else if (persisted && findAgent(defs, persisted)) { activeName = findAgent(defs, persisted)!.name; } else { activeName = defaultAgentName(); } const agent = current(); if (agent) await ensureApplied(agent, ctx, event.reason === "startup" || event.reason === "new"); persist(); }); }