// Persistence for pi-subagents operational settings. // - Global: ~/.pi/agent/subagents.json (via getAgentDir()) — manual defaults, never written here // - Project: /.pi/subagents.json — written by /agents → Settings; overrides global on load import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import type { JoinMode, WidgetMode } from "./types.js"; export interface SubagentsSettings { maxConcurrent?: number; /** * 0 = unlimited — the extension's single source of truth for that convention: * `normalizeMaxTurns()` in agent-runner.ts treats 0 → `undefined`, and the * `/agents` → Settings input prompt explicitly says "0 = unlimited". */ defaultMaxTurns?: number; graceTurns?: number; defaultJoinMode?: JoinMode; /** * Master switch for the schedule subagent feature. Defaults to `true`. * When `false`: the `Agent` tool's `schedule` param + its guideline are * stripped from the tool spec at registration (zero LLM-context cost), the * scheduler doesn't bind to the session, and the `/agents → Scheduled jobs` * menu entry is hidden. Schema-level removal applies at extension load * (next pi session); runtime menu/runtime-fire short-circuit is immediate. */ schedulingEnabled?: boolean; /** * When true, the effective model of each subagent spawn is validated * against `enabledModels` from pi's settings — both global * (`/settings.json`) and project-local (`/.pi/settings.json`), * with project overriding global (mirrors pi's SettingsManager deep-merge). * * scopeModels guards against runtime LLM choices, not user-level config. * Out-of-scope handling reflects this: * - Caller-supplied via `Agent({ model: "..." })` (only when frontmatter * has no `model:`, since frontmatter is authoritative): hard error * returned to the orchestrator, listing the allowed models. The LLM * made an explicit out-of-scope choice and gets explicit feedback. * - Frontmatter-pinned: warning toast + the pinned model runs. The * agent's author/installer chose this; trust it. * - Parent-inherited (neither caller nor frontmatter sets a model): * warning toast + parent's model runs. The user chose the parent's * model when starting the session; trust it. * * No-op when pi's `enabledModels` is empty or absent — nothing to validate * against. Defaults to false: subagents may use any model. */ scopeModels?: boolean; /** * When true, the three built-in default agents (general-purpose, Explore, Plan) * are not registered at startup. User-defined agents from project/global custom * agent dirs are completely unaffected — only the hardcoded DEFAULT_AGENTS are suppressed. * Defaults to false. */ disableDefaultAgents?: boolean; /** * Which Agent tool description the LLM sees. "full" (default) is the rich * Claude Code-style prompt; "compact" is a ~75% smaller version (one-line * agent type list, terse usage notes) for small/local models where tool-spec * tokens are expensive; "custom" reads `.pi/agent-tool-description.md` * (project, falling back to `/agent-tool-description.md`) with * `{{placeholder}}` substitution — a missing/empty file falls back to "full". * The mode is read once at tool registration — changing it applies on the * next pi session. */ toolDescriptionMode?: ToolDescriptionMode; /** * Display mode for the persistent above-editor agent widget: * - `all`: show every agent (foreground + background). * - `background`: hide foreground agents — they already render inline as the * Agent tool result, so the widget would otherwise double-render them * (#118); everything else (background, queued, scheduled, RPC) stays. * - `off`: hide the widget entirely. * Defaults to `all`. Pure-UI and applied live (toggling refreshes the * widget). */ widgetMode?: WidgetMode; /** * Project/global default for writing each subagent's `.output` transcript * (a JSON-lines copy of the run, stored under the OS temp dir). * Defaults to `true`. Set `false` to make transcripts opt-in for the whole * project (e.g. a repo that shouldn't leave run transcripts on disk for backup * or DLP tooling to ingest). A custom agent's `output_transcript` frontmatter * overrides this per agent. This governs only the transcript — it does NOT * affect the persisted pi session (`persist_session`), worktree commits * (`isolation: worktree`), or memory files. */ outputTranscript?: boolean; } export type ToolDescriptionMode = "full" | "compact" | "custom"; /** Setter hooks used by applySettings to wire persisted values into in-memory state. */ export interface SettingsAppliers { setMaxConcurrent: (n: number) => void; setDefaultMaxTurns: (n: number) => void; setGraceTurns: (n: number) => void; setDefaultJoinMode: (mode: JoinMode) => void; setSchedulingEnabled: (b: boolean) => void; setScopeModels: (enabled: boolean) => void; setDisableDefaultAgents: (b: boolean) => void; setToolDescriptionMode: (mode: ToolDescriptionMode) => void; setWidgetMode: (mode: WidgetMode) => void; setOutputTranscript: (b: boolean) => void; } /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */ export type SettingsEmit = (event: string, payload: unknown) => void; const VALID_JOIN_MODES: ReadonlySet = new Set(["async", "group", "smart"]); const VALID_TOOL_DESCRIPTION_MODES: ReadonlySet = new Set(["full", "compact", "custom"]); const VALID_WIDGET_MODES: ReadonlySet = new Set(["all", "background", "off"]); // Sanity ceilings — prevent hand-edited configs from asking for values that // make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough // that any realistic power-user setting passes through. const MAX_CONCURRENT_CEILING = 1024; const MAX_TURNS_CEILING = 10_000; const GRACE_TURNS_CEILING = 1_000; /** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */ function sanitize(raw: unknown): SubagentsSettings { if (!raw || typeof raw !== "object") return {}; const r = raw as Record; const out: SubagentsSettings = {}; if ( Number.isInteger(r.maxConcurrent) && (r.maxConcurrent as number) >= 1 && (r.maxConcurrent as number) <= MAX_CONCURRENT_CEILING ) { out.maxConcurrent = r.maxConcurrent as number; } if ( Number.isInteger(r.defaultMaxTurns) && (r.defaultMaxTurns as number) >= 0 && (r.defaultMaxTurns as number) <= MAX_TURNS_CEILING ) { out.defaultMaxTurns = r.defaultMaxTurns as number; } if ( Number.isInteger(r.graceTurns) && (r.graceTurns as number) >= 1 && (r.graceTurns as number) <= GRACE_TURNS_CEILING ) { out.graceTurns = r.graceTurns as number; } if (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) { out.defaultJoinMode = r.defaultJoinMode as JoinMode; } if (typeof r.schedulingEnabled === "boolean") { out.schedulingEnabled = r.schedulingEnabled; } if (typeof r.scopeModels === "boolean") { out.scopeModels = r.scopeModels; } if (typeof r.disableDefaultAgents === "boolean") { out.disableDefaultAgents = r.disableDefaultAgents; } if (typeof r.toolDescriptionMode === "string" && VALID_TOOL_DESCRIPTION_MODES.has(r.toolDescriptionMode)) { out.toolDescriptionMode = r.toolDescriptionMode as ToolDescriptionMode; } if (typeof r.widgetMode === "string" && VALID_WIDGET_MODES.has(r.widgetMode)) { out.widgetMode = r.widgetMode as WidgetMode; } if (typeof r.outputTranscript === "boolean") { out.outputTranscript = r.outputTranscript; } return out; } function globalPath(): string { return join(getAgentDir(), "subagents.json"); } function projectPath(cwd: string): string { return join(cwd, ".pi", "subagents.json"); } /** * Read a settings file. Missing file is silent (returns `{}`). A file that * exists but can't be parsed emits a warning to stderr so users aren't * silently reverted to defaults — and still returns `{}` so startup proceeds. */ function readSettingsFile(path: string): SubagentsSettings { if (!existsSync(path)) return {}; try { return sanitize(JSON.parse(readFileSync(path, "utf-8"))); } catch (err) { const reason = err instanceof Error ? err.message : String(err); console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`); return {}; } } /** Load merged settings: global provides defaults, project overrides. */ export function loadSettings(cwd: string = process.cwd()): SubagentsSettings { return { ...readSettingsFile(globalPath()), ...readSettingsFile(projectPath(cwd)) }; } /** * Write project-local settings. Global is never touched from code. * Returns `true` on success, `false` if the write (or mkdir) failed so the * caller can surface a warning — persistence isn't fatal but isn't silent. */ export function saveSettings(s: SubagentsSettings, cwd: string = process.cwd()): boolean { const path = projectPath(cwd); try { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, JSON.stringify(s, null, 2), "utf-8"); return true; } catch { return false; } } /** Apply persisted settings to the in-memory state via caller-supplied setters. */ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers): void { if (typeof s.maxConcurrent === "number") appliers.setMaxConcurrent(s.maxConcurrent); if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns); if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns); if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode); if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled); if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels); if (typeof s.disableDefaultAgents === "boolean") appliers.setDisableDefaultAgents(s.disableDefaultAgents); if (s.toolDescriptionMode) appliers.setToolDescriptionMode(s.toolDescriptionMode); if (s.widgetMode) appliers.setWidgetMode(s.widgetMode); if (typeof s.outputTranscript === "boolean") appliers.setOutputTranscript(s.outputTranscript); } /** * Format the user-facing toast for a settings mutation. Pure function — * routes the success/failure of `saveSettings` into the right message + level * so the UI layer (index.ts) stays a thin wire between input and notification. */ export function persistToastFor( successMsg: string, persisted: boolean, ): { message: string; level: "info" | "warning" } { return persisted ? { message: successMsg, level: "info" } : { message: `${successMsg} (session only; failed to persist)`, level: "warning" }; } /** * Load merged settings, apply them to in-memory state, and emit the * `subagents:settings_loaded` lifecycle event. Returns the loaded settings so * callers can log/inspect. Extension init wires this once. */ export function applyAndEmitLoaded( appliers: SettingsAppliers, emit: SettingsEmit, cwd: string = process.cwd(), ): SubagentsSettings { const settings = loadSettings(cwd); applySettings(settings, appliers); emit("subagents:settings_loaded", { settings }); return settings; } /** * Persist a settings snapshot, emit the `subagents:settings_changed` event * (regardless of persist outcome so listeners see the in-memory change), and * return the toast the UI should display. Event payload carries the `persisted` * flag so listeners can react to write failures. */ export function saveAndEmitChanged( snapshot: SubagentsSettings, successMsg: string, emit: SettingsEmit, cwd: string = process.cwd(), ): { message: string; level: "info" | "warning" } { const persisted = saveSettings(snapshot, cwd); emit("subagents:settings_changed", { settings: snapshot, persisted }); return persistToastFor(successMsg, persisted); }