Publica agent-switcher: extensión de pi para cambio de agentes estilo opencode
This commit is contained in:
commit
e4b86fc6eb
7 files changed
+1156
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user