From e4b86fc6eb7f19aa52612d14d9e0f92cceedc089 Mon Sep 17 00:00:00 2001 From: Toni Ramiro Date: Mon, 28 Sep 2026 19:22:36 +0200 Subject: [PATCH] =?UTF-8?q?Publica=20agent-switcher:=20extensi=C3=B3n=20de?= =?UTF-8?q?=20pi=20para=20cambio=20de=20agentes=20estilo=20opencode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + LICENSE | 21 + README.md | 133 +++++++ extensions/agent-switcher/defs.ts | 269 +++++++++++++ extensions/agent-switcher/index.ts | 582 ++++++++++++++++++++++++++++ extensions/agent-switcher/policy.ts | 123 ++++++ package.json | 25 ++ 7 files changed, 1156 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 extensions/agent-switcher/defs.ts create mode 100644 extensions/agent-switcher/index.ts create mode 100644 extensions/agent-switcher/policy.ts create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c45938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..05739c0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Toni Ramiro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..df12f37 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# Agent Switcher + +Cambio de agentes estilo **opencode** para pi: alterna entre agentes (p. ej. +`planner` y `build`) y cada agente aplica **su propio modelo**, nivel de +thinking, allowlist de herramientas, rol de solo lectura y system prompt. + +## Instalación + +- Instalación permanente: + `pi install https://git.sargatxet.cloud/sargatxet/pi-agent-switcher` +- Una sola sesión: + `pi -e https://git.sargatxet.cloud/sargatxet/pi-agent-switcher` +- Manual (fallback): + `git clone https://git.sargatxet.cloud/sargatxet/pi-agent-switcher.git ~/.pi/agent/extensions/agent-switcher` + o probar sin instalar: + `pi --extension /extensions/agent-switcher/index.ts` + +## Comandos + +| Comando / atajo | Acción | +|---|---| +| `/agent` | Selector de agentes (lista si no hay TUI) | +| `/agent ` | Cambiar al agente `` (también alias) | +| `/agents` | Listar agentes con su estado y modelo | +| `/agent model` | Modelo efectivo del agente activo | +| `/agent model ` | Fijar el modelo del agente activo **para esta sesión** | +| `/agent model reset` | Quitar el override y volver al `model:` del archivo | +| `F5` / `F6` | Cambiar al agente planner/build (configurable con `shortcut:`) | +| `pi --agent planner` | Arrancar con un agente concreto | + +El agente activo se confirma con el aviso `Agente activo: … · modelo …` al +cambiar, y con `/agent list`. La línea de estado del footer se eliminó por +resultar confusa. + +## Definir agentes + +Cada agente es un Markdown con frontmatter en: + +- `~/.pi/agent/agents/*.md` — agentes de usuario +- `/.pi/agents/*.md` — agentes de proyecto (sobrescriben al mismo nombre) + +Ejemplo (`~/.pi/agent/agents/build.md`): + +```markdown +--- +name: build +description: Implementation agent running on the fast model +aliases: constructor, implement +advertise: true +model: provider/model-id +thinking: medium +systemPromptMode: append +inheritProjectContext: true +inheritSkills: true +--- + +You are `build`: the implementation agent. ... +``` + +### Claves del frontmatter + +| Clave | Defecto | Significado | +|---|---|---| +| `name` | nombre del archivo | Identificador único para `/agent ` | +| `description` | — | Texto mostrado en el selector | +| `aliases` | — | Nombres alternativos, separados por comas | +| `advertise` | `true` | `false` = oculto en el selector y en el ciclo | +| `shortcut` | — | Atajos directos al agente, separados por comas (p. ej. `f5`). Conflicto con atajos nativos: pi lo descarta con aviso | +| `model` | — | `provider/id` o un `id` único. **Sin valor = conserva el modelo actual** | +| `thinking` | — | `off\|minimal\|low\|medium\|high\|xhigh\|max` | +| `systemPromptMode` | `append` | `replace` = el cuerpo sustituye el prefijo del prompt; `append` = se añade | +| `inheritProjectContext` | `true` | `false` = el agente no ve `AGENTS.md`/archivos de contexto | +| `inheritSkills` | `true` | `false` = el agente no ve las skills | +| `tools` | — | Allowlist de herramientas activas (separadas por comas o espacios) | +| `acceptanceRole` | — | `read-only` = bloquea `edit`/`write` y restringe `bash` a comandos de solo lectura | +| `defaultContext` | — | Aceptado e ignorado (fresco no aplica al cambio inline) | + +El cuerpo del archivo (tras el frontmatter) es el system prompt del agente. + +## Semántica (equivalente a opencode) + +- **El agente activo gobierna**: su `model` y `thinking` se aplican al + activarse y se reafirman en cada `before_agent_start`; su `tools` y su + prompt también se aplican cada run (todo idempotente). +- **Agente sin `model`** = no toca el modelo: conserva el de la sesión + (útil para un `build` que simplemente hereda el modelo por defecto de pi). +- **Overrides de sesión**: solo `/agent model ` crea un override para el + agente activo y se restaura al volver a él. Los cambios con `/model` o + `Ctrl+P` aplican de inmediato, pero **el modelo del agente se reafirma en + cada prompt** (los eventos de modelo ya no se autograban: así los cambios + de otras extensiones no pueden intercambiar los modelos de los agentes). + El override lleva memoria del valor del archivo que tenía al crearse: + **si editas `model:` en el `.md`, el archivo manda** y el override se + descarta. `/agent model reset` lo borra a mano; `/agent list` + marca los agentes con modelo «(sesión)». Para fijarlo de forma permanente, + edita el `model:` del archivo del agente. +- **Persistencia**: el agente activo y los overrides se guardan en la + sesión y se restauran al reanudarla. +- **Solo lectura**: además de desactivar las herramientas de escritura, + hay una segunda barrera en `tool_call` que bloquea `edit`/`write` y + cualquier comando bash que no esté en la lista de solo lectura + (`policy.ts`). + +## Notas + +- Los agentes se recargan en cada inicio de sesión; tras editar un `.md` + durante la sesión usa `/reload` (también recarga los atajos nuevos). + +## Los atajos de teclado no funcionan + +1. **¿La sesión cargó la extensión?** Ejecuta `/hotkeys`: debe aparecer + `Cambiar al agente …`. Si no aparece, ejecuta `/reload` (o reinicia pi): + las sesiones abiertas antes de instalar el plugin no lo cargan solas. +2. **¿El terminal entrega la combinación?** La configuración por defecto usa + **teclas de función** (`f5`, `f6`) precisamente porque llegan en todos los + terminales. Si cambias a combinaciones como `ctrl+alt+x`, estas solo llegan + a pi si el terminal usa el protocolo kitty/CSI-u (kitty, Ghostty, WezTerm + con `enable_kitty_keyboard`, VS Code ≥ 1.109.5) o si envía `ESC + carácter + de control`; fallan en terminales clásicas, dentro de tmux y con teclado + español donde **AltGr = Ctrl+Alt**. +3. En tmux, habilita `set -s extended-keys on` para que lleguen las teclas + modificadas (ver la doc de pi sobre tmux). +4. Cambia las teclas editando `shortcut:` en el `.md` del agente y + ejecutando `/reload`. Cualquier combinación que aparezca en `/hotkeys` + es válida. +- Si otra extensión también llama a `setModel` en `before_agent_start` + (p. ej. un cambio de modelo automático por fases), las dos competirán + en cada run: mantén solo una estrategia activa. +- Con **pi-subagents** instalado, `subagents_enable` se fuerza siempre en + las herramientas activas. En agentes con `tools:`/`read-only` esa + llamada se bloquea en `tool_call` (delegar permitiría escribir ficheros); + el resto de su comportamiento no se ve afectado. +- Para probar sin instalar nada: `pi --extension /extensions/agent-switcher/index.ts`. diff --git a/extensions/agent-switcher/defs.ts b/extensions/agent-switcher/defs.ts new file mode 100644 index 0000000..879eca4 --- /dev/null +++ b/extensions/agent-switcher/defs.ts @@ -0,0 +1,269 @@ +/** + * Definiciones de agente al estilo opencode. + * + * Cada agente es un archivo Markdown en `/agents/*.md` (o en + * `/.pi/agents/*.md` para definiciones de proyecto) con frontmatter: + * + * --- + * name: planner + * description: Planning specialist ... + * aliases: planificador + * advertise: true + * shortcut: f5 + * model: provider/model-id + * thinking: high + * systemPromptMode: replace + * inheritProjectContext: true + * inheritSkills: false + * tools: read, grep, find, ls, bash + * acceptanceRole: read-only + * --- + * + * + * Claves soportadas: + * name Identificador único (también el nombre del archivo). + * description Texto mostrado en el selector (/agent). + * aliases Nombres alternativos separados por comas. + * advertise false = oculto en el selector (sigue accesible por nombre). + * shortcut Atajos de teclado directos al agente, separados por + * comas (p. ej. f5). Se recomiendan teclas de función: + * llegan en todos los terminales (ver README). + * model "provider/id" o "id" (debe resolverse de forma única). + * thinking off|minimal|low|medium|high|xhigh|max + * systemPromptMode replace | append (cómo se usa el cuerpo del archivo) + * inheritProjectContext true/false (por defecto true) + * inheritSkills true/false (por defecto true) + * tools Lista de herramientas activas separadas por comas. + * acceptanceRole read-only = solo lectura (bash restringido, sin escritura). + * defaultContext Aceptado e ignorado (fresco/subagente no aplica al + * cambio inline; ver README). + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +export type ThinkingLevelName = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; + +const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); + +export interface AgentDefinition { + /** Nombre único; se usa para cambiar de agente. */ + name: string; + description: string; + aliases: string[]; + /** false = no aparece en el selector (sigue accesible por nombre o alias). */ + advertise: boolean; + /** Atajos de teclado directos al agente (frontmatter `shortcut:`, separados por comas). */ + shortcuts: string[]; + /** Modelo propio del agente: "provider/id" o un "id" único. Sin valor = modelo actual. */ + model?: string; + /** Nivel de thinking propio del agente. */ + thinking?: ThinkingLevelName; + /** replace = el cuerpo sustituye el system prompt; append = se añade. */ + systemPromptMode: "append" | "replace"; + /** Cuerpo del archivo Markdown (system prompt del agente). */ + systemPrompt: string; + inheritProjectContext: boolean; + inheritSkills: boolean; + /** Allowlist de herramientas activas; sin clave = sin restricción. */ + tools?: string[]; + /** read-only = solo lectura (bloquea escritura y restringe bash). */ + acceptanceRole?: "read-only" | "full"; + /** Origen del archivo (para depuración). */ + sourcePath?: string; +} + +/** Directorio de configuración del agente (equivalente a getAgentDir()). */ +export function agentDir(): string { + return process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent"); +} + +/** Divide el frontmatter YAML simple ("key: value") del cuerpo. */ +function splitFrontmatter(text: string): { fields: Map; body: string } { + const fields = new Map(); + const normalized = text.replace(/^\uFEFF/, ""); + const match = normalized.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) return { fields, body: normalized.trim() }; + + for (const line of match[1].split(/\r?\n/)) { + const idx = line.indexOf(":"); + if (idx <= 0) continue; + const key = line.slice(0, idx).trim().toLowerCase(); + let value = line.slice(idx + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"') && value.length >= 2) || + (value.startsWith("'") && value.endsWith("'") && value.length >= 2) + ) { + value = value.slice(1, -1); + } + if (key) fields.set(key, value); + } + return { fields, body: match[2].trim() }; +} + +function parseBoolean(raw: string | undefined, fallback: boolean): boolean { + if (raw === undefined) return fallback; + const v = raw.trim().toLowerCase(); + if (v === "true" || v === "yes" || v === "on" || v === "1") return true; + if (v === "false" || v === "no" || v === "off" || v === "0") return false; + return fallback; +} + +function parseList(raw: string | undefined): string[] { + if (!raw) return []; + return raw + .split(/[,;]/) + .map((s) => s.trim()) + .filter(Boolean); +} + +function parseTools(raw: string | undefined): string[] | undefined { + if (raw === undefined) return undefined; + const list = raw + .split(/[\s,]+/) + .map((s) => s.trim()) + .filter(Boolean); + return list.length > 0 ? list : undefined; +} + +/** Construye una definición a partir de un archivo Markdown con frontmatter. */ +export function parseAgentFile(text: string, sourcePath: string): AgentDefinition | undefined { + const { fields, body } = splitFrontmatter(text); + const fallbackName = path.basename(sourcePath, path.extname(sourcePath)).toLowerCase(); + const name = (fields.get("name") ?? fallbackName).trim().toLowerCase(); + if (!name) return undefined; + + const thinkingRaw = fields.get("thinking")?.trim().toLowerCase(); + const thinking = + thinkingRaw && THINKING_LEVELS.has(thinkingRaw) ? (thinkingRaw as ThinkingLevelName) : undefined; + + const modeRaw = fields.get("systempromptmode")?.trim().toLowerCase(); + const systemPromptMode: "append" | "replace" = modeRaw === "replace" ? "replace" : "append"; + + const roleRaw = fields.get("acceptancerole")?.trim().toLowerCase(); + const acceptanceRole: "read-only" | "full" | undefined = + roleRaw === "read-only" || roleRaw === "readonly" ? "read-only" : roleRaw === "full" ? "full" : undefined; + + const model = fields.get("model")?.trim() || undefined; + + return { + name, + description: fields.get("description")?.trim() ?? "", + aliases: parseList(fields.get("aliases")).map((a) => a.toLowerCase()), + advertise: parseBoolean(fields.get("advertise"), true), + shortcuts: (fields.get("shortcut") ?? "") + .split(/[,\s]+/) + .map((s) => s.trim().toLowerCase()) + .filter(Boolean), + model, + thinking, + systemPromptMode, + systemPrompt: body, + inheritProjectContext: parseBoolean(fields.get("inheritprojectcontext"), true), + inheritSkills: parseBoolean(fields.get("inheritskills"), true), + tools: parseTools(fields.get("tools")), + acceptanceRole, + sourcePath, + }; +} + +function readDefsFromDir(dir: string): AgentDefinition[] { + let entries: string[]; + try { + entries = fs.readdirSync(dir); + } catch { + return []; + } + const defs: AgentDefinition[] = []; + for (const entry of entries.sort()) { + if (!entry.toLowerCase().endsWith(".md")) continue; + const filePath = path.join(dir, entry); + try { + const def = parseAgentFile(fs.readFileSync(filePath, "utf8"), filePath); + if (def) defs.push(def); + } catch { + // Archivo ilegible: se ignora para no romper la carga. + } + } + return defs; +} + +/** + * Carga las definiciones: `/agents` (o $PI_AGENTS_DIR) y + * `/.pi/agents`. Un proyecto puede sobrescribir un agente de usuario + * repitiendo su `name`. Si no hay archivos, se usan los agentes integrados. + */ +export function loadAgentDefinitions(cwd: string): AgentDefinition[] { + const byName = new Map(); + + const userDir = process.env.PI_AGENTS_DIR ?? path.join(agentDir(), "agents"); + const projectDir = path.join(cwd, ".pi", "agents"); + + const dirs = projectDir === userDir ? [userDir] : [userDir, projectDir]; + for (const dir of dirs) { + for (const def of readDefsFromDir(dir)) { + byName.set(def.name, def); + } + } + + if (byName.size === 0) { + for (const def of BUILTIN_AGENTS) byName.set(def.name, def); + } + return [...byName.values()]; +} + +/** Resuelve nombre o alias a la definición. */ +export function findAgent(defs: AgentDefinition[], nameOrAlias: string): AgentDefinition | undefined { + const needle = nameOrAlias.trim().toLowerCase(); + if (!needle) return undefined; + return defs.find((d) => d.name === needle || d.aliases.includes(needle)); +} + +/** + * Agentes integrados: se usan solo si no existe ningún archivo en agents/. + * `build` es el agente por defecto (comportamiento normal de pi). + */ +const BUILTIN_AGENTS: AgentDefinition[] = [ + { + name: "build", + description: "Implementación: ejecuta planes y edita código (agente por defecto)", + aliases: ["implement", "primary"], + advertise: true, + shortcuts: [], + systemPromptMode: "append", + systemPrompt: "", + inheritProjectContext: true, + inheritSkills: true, + acceptanceRole: "full", + }, + { + name: "plan", + description: "Planificación de solo lectura: analiza y propone pasos sin tocar archivos", + aliases: ["planner"], + advertise: true, + shortcuts: [], + systemPromptMode: "replace", + systemPrompt: [ + "You are `plan`: the planning agent.", + "", + "Your only job is to turn a goal into a concrete, verifiable implementation plan.", + "You never edit or create files. Use only read-only tools to explore the repository.", + "", + "Output format (always):", + "", + "Plan:", + "1. : what to change, where, and why", + "2. ...", + "", + "Files: the files each step touches.", + "Verification: how to confirm each step (commands, tests, type checks).", + "Risks: edge cases, likely breakages, dependencies between steps.", + "Open decisions: anything the user must decide before execution.", + ].join("\n"), + inheritProjectContext: true, + inheritSkills: false, + tools: ["read", "grep", "find", "ls", "bash"], + acceptanceRole: "read-only", + }, +]; diff --git a/extensions/agent-switcher/index.ts b/extensions/agent-switcher/index.ts new file mode 100644 index 0000000..5bf507f --- /dev/null +++ b/extensions/agent-switcher/index.ts @@ -0,0 +1,582 @@ +/** + * 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(); + }); +} diff --git a/extensions/agent-switcher/policy.ts b/extensions/agent-switcher/policy.ts new file mode 100644 index 0000000..510765a --- /dev/null +++ b/extensions/agent-switcher/policy.ts @@ -0,0 +1,123 @@ +/** + * Política de solo lectura para agentes con `acceptanceRole: read-only`. + * + * Lista de comandos bash considerados seguros (solo lectura). Cualquier + * comando destructivo se bloquea aunque contenga un comando seguro como + * subcadena (p. ej. `cat fichero && rm -rf /`). + */ + +const DESTRUCTIVE_PATTERNS: RegExp[] = [ + /\brm\b/i, + /\brmdir\b/i, + /\bmv\b/i, + /\bcp\b/i, + /\bmkdir\b/i, + /\btouch\b/i, + /\bchmod\b/i, + /\bchown\b/i, + /\bchgrp\b/i, + /\bln\b/i, + /\btee\b/i, + /\btruncate\b/i, + /\bdd\b/i, + /\bshred\b/i, + /(^|[^<])\s*>\s*[^&]/, + /\s>>\s/, + /\bnpm\s+(install|uninstall|update|ci|link|publish|patch)/i, + /\byarn\s+(add|remove|install|publish)/i, + /\bpnpm\s+(add|remove|install|publish)/i, + /\bbun\s+(add|remove|install|pm)/i, + /\bpip\s+(install|uninstall)/i, + /\buv\s+(pip\s+)?(install|sync|add)/i, + /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i, + /\bbrew\s+(install|uninstall|upgrade|update)/i, + /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone|restore|apply)/i, + /\bsudo\b/i, + /\bsu\b/i, + /\bkill\b/i, + /\bpkill\b/i, + /\bkillall\b/i, + /\breboot\b/i, + /\bshutdown\b/i, + /\bsystemctl\s+(start|stop|restart|enable|disable)/i, + /\bservice\s+\S+\s+(start|stop|restart)/i, + /\b(vim?|nano|emacs|code|subl|helix)\b/i, +]; + +const SAFE_PATTERNS: RegExp[] = [ + /^\s*cat\b/, + /^\s*head\b/, + /^\s*tail\b/, + /^\s*less\b/, + /^\s*more\b/, + /^\s*grep\b/, + /^\s*find\b/, + /^\s*fd\b/, + /^\s*rg\b/, + /^\s*ls\b/, + /^\s*pwd\b/, + /^\s*echo\b/, + /^\s*printf\b/, + /^\s*wc\b/, + /^\s*sort\b/, + /^\s*uniq\b/, + /^\s*diff\b/, + /^\s*file\b/, + /^\s*stat\b/, + /^\s*du\b/, + /^\s*df\b/, + /^\s*tree\b/, + /^\s*which\b/, + /^\s*whereis\b/, + /^\s*type\b/, + /^\s*env\b/, + /^\s*printenv\b/, + /^\s*uname\b/, + /^\s*whoami\b/, + /^\s*id\b/, + /^\s*date\b/, + /^\s*cal\b/, + /^\s*uptime\b/, + /^\s*ps\b/, + /^\s*top\b/, + /^\s*htop\b/, + /^\s*free\b/, + /^\s*bat\b/, + /^\s*eza\b/, + /^\s*jq\b/, + /^\s*sed\s+-n/i, + /^\s*awk\b/, + /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get|blame|shortlog|describe|ls-files|ls-remote|fetch\s+--dry-run)/i, + /^\s*git\s+ls-/i, + /^\s*npm\s+(list|ls|view|info|search|outdated|audit|explain)/i, + /^\s*yarn\s+(list|info|why|audit)/i, + /^\s*node\s+--version/i, + /^\s*python[0-9.]*\s+--version/i, + /^\s*curl\s+[^\n]*(\s--\s*$|\s-[A-Za-z]*I|\s--head|\s--location.*--remote-header-name)/i, + /^\s*wget\s+-O\s*-|\s*wget\s+--spider/i, + /^\s*jq\b/, +]; + +/** + * Redirecciones inocuas que no deben contarse como destructivas: + * duplicación de descriptores (`2>&1`, `>&2`) y envío a /dev/null + * (`2>/dev/null`, `>>/dev/null`). + */ +function stripHarmlessRedirects(command: string): string { + return command + .replace(/(\d*)>{1,2}\s*\/dev\/null/g, " ") + .replace(/(\d*)>\s*&\d+/g, " "); +} + +/** + * true si el comando bash es seguro en modo solo lectura: + * coincide con la lista de comandos seguros y no contiene nada destructivo. + */ +export function isReadOnlySafeCommand(command: string): boolean { + const normalized = command.trim(); + if (!normalized) return false; + const stripped = stripHarmlessRedirects(normalized); + const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(stripped)); + if (isDestructive) return false; + return SAFE_PATTERNS.some((p) => p.test(normalized)); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..174bb43 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "pi-agent-switcher", + "version": "0.1.0", + "type": "module", + "description": "Agent switching in the style of opencode for pi: per-agent model, thinking level, tool allowlist, read-only role and system prompt.", + "keywords": ["pi-package", "pi", "pi-extension", "agents", "switcher", "opencode"], + "license": "MIT", + "author": "Toni Ramiro", + "homepage": "https://git.sargatxet.cloud/sargatxet/pi-agent-switcher#readme", + "repository": { "type": "git", "url": "git+https://git.sargatxet.cloud/sargatxet/pi-agent-switcher.git" }, + "bugs": { "url": "https://git.sargatxet.cloud/sargatxet/pi-agent-switcher/issues" }, + "engines": { "node": ">=22" }, + "pi": { "extensions": ["./extensions"] }, + "files": ["extensions", "README.md", "LICENSE", "package.json"], + "peerDependencies": { + "@earendil-works/pi-agent-core": "*", + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*" + }, + "peerDependenciesMeta": { + "@earendil-works/pi-agent-core": { "optional": true }, + "@earendil-works/pi-ai": { "optional": true }, + "@earendil-works/pi-coding-agent": { "optional": true } + } +}