Publica agent-switcher: extensión de pi para cambio de agentes estilo opencode

This commit is contained in:
sargatxet committed 2026-09-28 19:22:36 +02:00
commit e4b86fc6eb
7 files changed
+1156

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store
+21
View File
@@ -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.
+133
View File
@@ -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 <ruta-del-repo>/extensions/agent-switcher/index.ts`
## Comandos
| Comando / atajo | Acción |
|---|---|
| `/agent` | Selector de agentes (lista si no hay TUI) |
| `/agent <nombre>` | Cambiar al agente `<nombre>` (también alias) |
| `/agents` | Listar agentes con su estado y modelo |
| `/agent model` | Modelo efectivo del agente activo |
| `/agent model <ref>` | 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
- `<proyecto>/.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 <nombre>` |
| `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 <ref>` 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 <ruta-del-repo>/extensions/agent-switcher/index.ts`.
+269
View File
@@ -0,0 +1,269 @@
/**
* Definiciones de agente al estilo opencode.
*
* Cada agente es un archivo Markdown en `<agent-dir>/agents/*.md` (o en
* `<cwd>/.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
* ---
* <cuerpo: system prompt del agente>
*
* 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<string>(["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<string, string>; body: string } {
const fields = new Map<string, string>();
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: `<agent-dir>/agents` (o $PI_AGENTS_DIR) y
* `<cwd>/.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<string, AgentDefinition>();
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. <step>: 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",
},
];
+582
View File
@@ -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)
* <cwd>/.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<string, OverrideRecord>;
/** Overrides de thinking por agente. Solo se aplican los `explicit: true`. */
thinking?: Record<string, OverrideRecord>;
}
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<string, OverrideRecord>();
const thinkingOverrides = new Map<string, OverrideRecord>();
/** 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<unknown> = 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<any> | 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<any>; error?: string } {
let all: Model<any>[];
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<T>(fn: () => Promise<T>): Promise<T> {
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<void> {
return enqueue(() => applyAgentPolicy(agent, ctx, announce));
}
async function applyAgentPolicy(
agent: AgentDefinition,
ctx: ExtensionContext,
announce: boolean,
): Promise<void> {
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<void> {
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<void> {
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 <nombre> cambiar de agente",
" /agent list listar agentes y su estado",
" /agent model modelo efectivo del agente activo",
" /agent model <ref> 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 <ref>|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<string>();
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<string>();
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<typeof pi.registerShortcut>[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();
});
}
+123
View File
@@ -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));
}
+25
View File
@@ -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 }
}
}