// Persistence for pi-subagents operational settings. // - Global: ~/.pi/agent/subagents.json (agentDir injected at construction) — manual defaults, never written here // - Project: /.pi/subagents.json — written by /subagents:settings; overrides global on load import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { type LayeredSettingsSource, loadLayeredSettings } from "#src/layered-settings"; export interface SubagentsSettings { maxConcurrent?: number; /** * 0 = unlimited — the extension's single source of truth for that convention: * `normalizeMaxTurns()` in turn-limits.ts treats 0 → `undefined`, and the * `/agents` → Settings input prompt explicitly says "0 = unlimited". */ defaultMaxTurns?: number; graceTurns?: number; consumedSessionRetentionMinutes?: number; unconsumedSessionRetentionMinutes?: number; abortAllOnInterrupt?: boolean; /** false or "none" fails closed; an agent name selects an explicit fallback. */ fallbackSubagent?: string | false; } /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */ export type SettingsEmit = (event: string, payload: unknown) => void; const DEFAULT_MAX_CONCURRENT = 4; const DEFAULT_GRACE_TURNS = 5; const DEFAULT_CONSUMED_RETENTION_MINUTES = 10; const DEFAULT_UNCONSUMED_RETENTION_MINUTES = 720; /** * Owns runtime settings and their load/save/persist cycle. * Replaces the scattered free-function + SettingsAppliers callback pattern. */ export class SettingsManager { private _defaultMaxTurns: number | undefined = undefined; private _graceTurns: number = DEFAULT_GRACE_TURNS; private _maxConcurrent: number = DEFAULT_MAX_CONCURRENT; private _consumedSessionRetentionMinutes = DEFAULT_CONSUMED_RETENTION_MINUTES; private _unconsumedSessionRetentionMinutes = DEFAULT_UNCONSUMED_RETENTION_MINUTES; private _abortAllOnInterrupt = true; private _fallbackSubagent: string | false | undefined; private readonly emit: SettingsEmit; private readonly cwd: string; private readonly agentDir: string; private readonly onMaxConcurrentChanged: (() => void) | undefined; constructor(deps: { emit: SettingsEmit; cwd: string; agentDir: string; onMaxConcurrentChanged?: () => void }) { this.emit = deps.emit; this.cwd = deps.cwd; this.agentDir = deps.agentDir; this.onMaxConcurrentChanged = deps.onMaxConcurrentChanged; } // ── defaultMaxTurns: 0 or undefined → unlimited (undefined); else max(1, n) ── get defaultMaxTurns(): number | undefined { return this._defaultMaxTurns; } set defaultMaxTurns(n: number | undefined) { if (n == null || n === 0) { this._defaultMaxTurns = undefined; } else { this._defaultMaxTurns = Math.max(1, n); } } // ── graceTurns: minimum 1 ── get graceTurns(): number { return this._graceTurns; } set graceTurns(n: number) { this._graceTurns = Math.max(1, n); } // ── maxConcurrent: minimum 1 ── get maxConcurrent(): number { return this._maxConcurrent; } set maxConcurrent(n: number) { this._maxConcurrent = Math.max(1, n); } get consumedSessionRetentionMinutes(): number { return this._consumedSessionRetentionMinutes; } set consumedSessionRetentionMinutes(n: number) { this._consumedSessionRetentionMinutes = Math.max(0, n); } get unconsumedSessionRetentionMinutes(): number { return this._unconsumedSessionRetentionMinutes; } set unconsumedSessionRetentionMinutes(n: number) { this._unconsumedSessionRetentionMinutes = Math.max(0, n); } get abortAllOnInterrupt(): boolean { return this._abortAllOnInterrupt; } set abortAllOnInterrupt(value: boolean) { this._abortAllOnInterrupt = value; } get fallbackSubagent(): string | false | undefined { return this._fallbackSubagent; } set fallbackSubagent(value: string | false | undefined) { this._fallbackSubagent = value === false || value === "none" ? false : typeof value === "string" && value.trim() ? value.trim() : undefined; } // ── Lifecycle methods ── /** * Load merged settings (global + project), apply to in-memory values, * and emit the `subagents:settings_loaded` lifecycle event. * Returns the raw loaded settings object. */ load(): SubagentsSettings { const settings = loadSettings(this.agentDir, this.cwd); if (typeof settings.maxConcurrent === "number") this.maxConcurrent = settings.maxConcurrent; if (typeof settings.defaultMaxTurns === "number") this.defaultMaxTurns = settings.defaultMaxTurns; if (typeof settings.graceTurns === "number") this.graceTurns = settings.graceTurns; if (typeof settings.consumedSessionRetentionMinutes === "number") this.consumedSessionRetentionMinutes = settings.consumedSessionRetentionMinutes; if (typeof settings.unconsumedSessionRetentionMinutes === "number") this.unconsumedSessionRetentionMinutes = settings.unconsumedSessionRetentionMinutes; if (typeof settings.abortAllOnInterrupt === "boolean") this.abortAllOnInterrupt = settings.abortAllOnInterrupt; if (settings.fallbackSubagent !== undefined) this.fallbackSubagent = settings.fallbackSubagent; this.emit("subagents:settings_loaded", { settings }); return settings; } /** * Snapshot current in-memory values for persistence. * `defaultMaxTurns` uses 0 as the on-disk marker for unlimited (undefined). */ snapshot(): Required { return { maxConcurrent: this._maxConcurrent, defaultMaxTurns: this._defaultMaxTurns ?? 0, graceTurns: this._graceTurns, consumedSessionRetentionMinutes: this._consumedSessionRetentionMinutes, unconsumedSessionRetentionMinutes: this._unconsumedSessionRetentionMinutes, abortAllOnInterrupt: this._abortAllOnInterrupt, fallbackSubagent: this._fallbackSubagent ?? "general-purpose", }; } /** * Set maxConcurrent, notify interested parties, persist, and return the toast. * Owns the full consequence chain so callers just say what they want. */ applyMaxConcurrent(n: number): { message: string; level: "info" | "warning" } { this.maxConcurrent = n; // setter normalizes: max(1, n) this.onMaxConcurrentChanged?.(); return this.saveAndNotify(`Max concurrency set to ${this.maxConcurrent}`); } /** * Set defaultMaxTurns, persist, and return the toast. * Pass 0 for unlimited (maps to undefined internally). */ applyDefaultMaxTurns(n: number): { message: string; level: "info" | "warning" } { this.defaultMaxTurns = n === 0 ? undefined : n; // setter normalizes further const label = this.defaultMaxTurns == null ? "unlimited" : String(this.defaultMaxTurns); return this.saveAndNotify(`Default max turns set to ${label}`); } /** * Set graceTurns, persist, and return the toast. */ applyGraceTurns(n: number): { message: string; level: "info" | "warning" } { this.graceTurns = n; // setter normalizes: max(1, n) return this.saveAndNotify(`Grace turns set to ${this.graceTurns}`); } applyConsumedSessionRetention(n: number): { message: string; level: "info" | "warning" } { this.consumedSessionRetentionMinutes = n; return this.saveAndNotify(`Consumed session retention set to ${this.consumedSessionRetentionMinutes} minutes`); } applyUnconsumedSessionRetention(n: number): { message: string; level: "info" | "warning" } { this.unconsumedSessionRetentionMinutes = n; return this.saveAndNotify(`Unconsumed session retention set to ${this.unconsumedSessionRetentionMinutes} minutes`); } applyAbortAllOnInterrupt(value: boolean): { message: string; level: "info" | "warning" } { this.abortAllOnInterrupt = value; return this.saveAndNotify(`Abort all subagents on ESC ${value ? "enabled" : "disabled"}`); } applyFallbackSubagent(value: string | false | undefined): { message: string; level: "info" | "warning" } { this.fallbackSubagent = value; const label = this.fallbackSubagent === false ? "none (fail closed)" : this.fallbackSubagent ?? "general-purpose"; return this.saveAndNotify(`Unknown agent fallback set to ${label}`); } /** * Persist the current snapshot, emit `subagents:settings_changed`, * and return the toast the UI should display. */ saveAndNotify(successMsg: string): { message: string; level: "info" | "warning" } { const snap = this.snapshot(); const persisted = saveSettings(snap, this.cwd); this.emit("subagents:settings_changed", { settings: snap, persisted }); return persistToastFor(successMsg, persisted); } } // 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; const RETENTION_MINUTES_CEILING = 60 * 24 * 365; /** 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; } for (const key of ["consumedSessionRetentionMinutes", "unconsumedSessionRetentionMinutes"] as const) { if (Number.isInteger(r[key]) && (r[key] as number) >= 0 && (r[key] as number) <= RETENTION_MINUTES_CEILING) { out[key] = r[key] as number; } } if (typeof r.abortAllOnInterrupt === "boolean") out.abortAllOnInterrupt = r.abortAllOnInterrupt; if (r.fallbackSubagent === false || r.fallbackSubagent === "none") out.fallbackSubagent = false; else if (typeof r.fallbackSubagent === "string" && r.fallbackSubagent.trim()) { out.fallbackSubagent = r.fallbackSubagent.trim(); } return out; } function projectPath(cwd: string): string { return join(cwd, ".pi", "subagents.json"); } /** Load merged settings: global provides defaults, project overrides. */ export function loadSettings(agentDir: string, cwd: string): SubagentsSettings { return loadLayeredSettings({ agentDir, cwd, filename: "subagents.json", sanitize, warnLabel: "pi-subagents", } satisfies LayeredSettingsSource); } /** * 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; } } /** * 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" }; }