// Persistence for pi-subagents operational settings. // - Global: $PI_CODING_AGENT_DIR/extension-data/pi-subagents/config.json — manual defaults, never written here // - Project: //extension-data/pi-subagents/config.json — written by /agents → Settings; overrides global import { loadMigratedJsonConfig, type NormalizedJsonConfig, saveJsonConfig, } from "./config-storage.js"; import { getGlobalSubagentsSettingsPath, getLegacyGlobalSubagentsSettingsPath, getLegacyProjectSubagentsSettingsPath, getProjectSubagentsSettingsPath, } from "./config-paths.js"; 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 the project * `extension-data/pi-subagents/agent-tool-description.md` (falling back to * the corresponding global extension-data file) 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; /** * Whether the Claude Code-style FleetView (the navigable main+subagents list * rendered below the editor) is shown. Defaults to `true`. Pure-UI: when off, * the list never registers and the global key handler never captures input. */ fleetView?: boolean; /** * 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 `background`. 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; setFleetView: (b: boolean) => 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.fleetView === "boolean") { out.fleetView = r.fleetView; } 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 normalizeSettings(raw: unknown): NormalizedJsonConfig { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { throw new Error("the root value must be a JSON object"); } const value = sanitize(raw); const dropped = Object.keys(raw as Record).filter((key) => !(key in value)); return { value, dropped }; } /** Load merged settings: global provides defaults, project overrides. */ export function loadSettings(cwd: string = process.cwd()): SubagentsSettings { const global = loadMigratedJsonConfig({ canonicalPath: getGlobalSubagentsSettingsPath(), legacyPath: getLegacyGlobalSubagentsSettingsPath(), scope: "global settings", normalize: normalizeSettings, fallback: {}, }); const project = loadMigratedJsonConfig({ canonicalPath: getProjectSubagentsSettingsPath(cwd), legacyPath: getLegacyProjectSubagentsSettingsPath(cwd), scope: "project settings", normalize: normalizeSettings, fallback: {}, }); return { ...global, ...project }; } /** * Atomically write project-local settings to the canonical path. Global is * never touched from code. Returns `true` only after a semantic re-read. */ export function saveSettings(s: SubagentsSettings, cwd: string = process.cwd()): boolean { return saveJsonConfig({ canonicalPath: getProjectSubagentsSettingsPath(cwd), value: s, normalize: normalizeSettings, }); } /** 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 (typeof s.fleetView === "boolean") appliers.setFleetView(s.fleetView); 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); }