/** * User-level settings for pi-dynamic-workflows. * * Stored separately from Pi's own settings.json so extension preferences remain * stable without depending on host-internal config shape. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { MAX_AGENT_RETRIES, MAX_CONCURRENCY } from "./config.js"; import { type ContextPrimitives, isSystemPromptMode, RESERVED_MODE_NAMES } from "./context-mode.js"; import { workflowHomeDir, workflowProjectPaths } from "./workflow-paths.js"; export interface WorkflowSettings { keywordTriggerEnabled?: boolean; defaultAgentTimeoutMs?: number | null; /** * Default hard timeout for a run, in milliseconds. When unset (undefined) * the runtime constant still applies. Set to null to disable the run-wide * timeout explicitly. Positive finite numbers are retained; invalid / * zero / negative / non-numeric values are dropped. Normalized exactly like * `defaultAgentTimeoutMs`. */ defaultWorkflowTimeoutMs?: number | null; /** Default max concurrent agents per run. Clamped to the runtime maximum. */ defaultConcurrency?: number; /** Default retry attempts after recoverable agent failures. */ defaultAgentRetries?: number; /** Default hard per-agent provider input/context token cap. */ defaultAgentMaxContextTokens?: number | null; /** Default reserve subtracted from model context windows for occupancy warnings. */ defaultAgentContextReserveTokens?: number | null; /** Bottom task-panel display mode: "detailed" (default — per-phase/per-agent tree with status, tokens, model, and each finished agent's result preview) | "compact" (one line per run). */ progressPanelMode?: "compact" | "detailed"; /** Max agents shown per phase in detailed progress mode (default 8). */ progressPanelMaxAgents?: number; /** * Whether each subagent gets a persisted NDJSON transcript under * `//subagents/`. Default true (matches Claude Code, which * writes `agent-.jsonl` per subagent so failed runs are debuggable). * Set false to keep subagent sessions in-memory only. */ persistSubagentTranscripts?: boolean; /** * herdr TUI status bridge: "auto" (default — mirror live workflow status into * the host herdr pane's cell when running inside herdr; a no-op otherwise) or * "off" (never report). The `PI_WORKFLOWS_HERDR=0` env var also disables it. * See docs/herdr-integration.md. */ herdrStatus?: "auto" | "off"; /** * herdr pane-spawn: "off" (default — do not spawn a real pi per run) or * "auto" (enable pane-spawn for isolated runs only when inside herdr, * i.e. HERDR_PANE_ID present). Opt-in because a real pi per run multiplies * VM memory. See docs/herdr-integration.md §4. */ herdrPaneSpawn?: "off" | "auto"; /** * Maximum concurrent herdr-pane-spawned runs (VM memory cap). * Default 4. Must be a positive integer; invalid values fall back to default. * See docs/herdr-integration.md §6. */ herdrMaxPanes?: number; /** * Project-defined context modes, merged OVER the built-ins (focused|isolated| * scoped|legacy) for `--mode ` and agentType frontmatter. Each name maps * to the full inheritance primitive set. Built-in names are reserved and * silently ignored. Entries missing or mistyping any field are dropped (the * feature stays opt-in and the built-ins remain available regardless). */ contextModes?: Record; } export interface WorkflowSettingsStore { load(): WorkflowSettings; save(settings: WorkflowSettings): void; } export interface WorkflowSettingsOptions { /** Explicit settings path, primarily for tests and migrations. */ settingsPath?: string; /** Project cwd whose project-level settings should override global settings. */ cwd?: string; /** Explicit project settings path, primarily for tests. */ projectSettingsPath?: string; /** Save destination when using saveWorkflowSettings with cwd. Default: global. */ scope?: "global" | "project"; } /** Path to the user-level workflow settings JSON file (~/.pi/workflows/settings.json). */ export function getWorkflowSettingsPath(): string { return join(workflowHomeDir(), "settings.json"); } /** Path to this project's optional workflow settings override. */ export function getWorkflowProjectSettingsPath(cwd: string): string { return workflowProjectPaths(cwd).settingsPath; } /** Load settings from disk. Missing, corrupt, or invalid files resolve to {}. */ export function loadWorkflowSettings(settingsPathOrOptions?: string | WorkflowSettingsOptions): WorkflowSettings { const options = normalizeOptions(settingsPathOrOptions); const globalSettings = readSettings(options.settingsPath ?? getWorkflowSettingsPath()); const projectPath = options.projectSettingsPath ?? (options.cwd ? getWorkflowProjectSettingsPath(options.cwd) : undefined); if (!projectPath) return globalSettings; return { ...globalSettings, ...readSettings(projectPath) }; } /** Merge known settings into the user-level settings file. */ export function saveWorkflowSettings( settings: WorkflowSettings, settingsPathOrOptions?: string | WorkflowSettingsOptions, ): void { const options = normalizeOptions(settingsPathOrOptions); const projectPath = options.projectSettingsPath ?? (options.cwd ? getWorkflowProjectSettingsPath(options.cwd) : undefined); const path = options.scope === "project" && projectPath ? projectPath : (options.settingsPath ?? getWorkflowSettingsPath()); const dir = dirname(path); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const existing = readObject(path); writeFileSync(path, `${JSON.stringify({ ...existing, ...normalizeSettings(settings) }, null, 2)}\n`, "utf-8"); } /** Save a global preference and update an existing project override if one is present. */ export function saveWorkflowSettingsForCwd(settings: WorkflowSettings, cwd: string): void { saveWorkflowSettings(settings); const projectPath = getWorkflowProjectSettingsPath(cwd); if (existsSync(projectPath)) { saveWorkflowSettings(settings, { projectSettingsPath: projectPath, scope: "project" }); } } function normalizeOptions(settingsPathOrOptions?: string | WorkflowSettingsOptions): WorkflowSettingsOptions { return typeof settingsPathOrOptions === "string" ? { settingsPath: settingsPathOrOptions } : (settingsPathOrOptions ?? {}); } function readSettings(path: string): WorkflowSettings { if (!existsSync(path)) return {}; try { return normalizeSettings(JSON.parse(readFileSync(path, "utf-8"))); } catch { return {}; } } function normalizeSettings(value: unknown): WorkflowSettings { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; const raw = value as Record; const settings: WorkflowSettings = {}; if (typeof raw.keywordTriggerEnabled === "boolean") { settings.keywordTriggerEnabled = raw.keywordTriggerEnabled; } if (raw.defaultAgentTimeoutMs === null) { settings.defaultAgentTimeoutMs = null; } else if ( typeof raw.defaultAgentTimeoutMs === "number" && Number.isFinite(raw.defaultAgentTimeoutMs) && raw.defaultAgentTimeoutMs > 0 ) { settings.defaultAgentTimeoutMs = raw.defaultAgentTimeoutMs; } if (raw.defaultWorkflowTimeoutMs === null) { settings.defaultWorkflowTimeoutMs = null; } else if ( typeof raw.defaultWorkflowTimeoutMs === "number" && Number.isFinite(raw.defaultWorkflowTimeoutMs) && raw.defaultWorkflowTimeoutMs > 0 ) { settings.defaultWorkflowTimeoutMs = raw.defaultWorkflowTimeoutMs; } const defaultConcurrency = normalizeInteger(raw.defaultConcurrency, 1, MAX_CONCURRENCY); if (defaultConcurrency !== undefined) settings.defaultConcurrency = defaultConcurrency; const defaultAgentRetries = normalizeInteger(raw.defaultAgentRetries, 0, MAX_AGENT_RETRIES); if (defaultAgentRetries !== undefined) settings.defaultAgentRetries = defaultAgentRetries; if (raw.defaultAgentMaxContextTokens === null) { settings.defaultAgentMaxContextTokens = null; } else { const maxContextTokens = normalizeInteger(raw.defaultAgentMaxContextTokens, 1, Number.MAX_SAFE_INTEGER); if (maxContextTokens !== undefined) settings.defaultAgentMaxContextTokens = maxContextTokens; } if (raw.defaultAgentContextReserveTokens === null) { settings.defaultAgentContextReserveTokens = null; } else { const reserveTokens = normalizeInteger(raw.defaultAgentContextReserveTokens, 1, Number.MAX_SAFE_INTEGER); if (reserveTokens !== undefined) settings.defaultAgentContextReserveTokens = reserveTokens; } if (raw.progressPanelMode === "compact" || raw.progressPanelMode === "detailed") { settings.progressPanelMode = raw.progressPanelMode; } if ( typeof raw.progressPanelMaxAgents === "number" && Number.isFinite(raw.progressPanelMaxAgents) && raw.progressPanelMaxAgents >= 1 ) { settings.progressPanelMaxAgents = Math.min(1000, Math.floor(raw.progressPanelMaxAgents)); } if (typeof raw.persistSubagentTranscripts === "boolean") { settings.persistSubagentTranscripts = raw.persistSubagentTranscripts; } if (raw.herdrStatus === "auto" || raw.herdrStatus === "off") { settings.herdrStatus = raw.herdrStatus; } if (raw.herdrPaneSpawn === "off" || raw.herdrPaneSpawn === "auto") { settings.herdrPaneSpawn = raw.herdrPaneSpawn; } { const maxPanes = normalizeInteger(raw.herdrMaxPanes, 1, 64); if (maxPanes !== undefined) settings.herdrMaxPanes = maxPanes; } const contextModes = normalizeContextModes(raw.contextModes); if (contextModes) settings.contextModes = contextModes; return settings; } /** * Validate a `contextModes` map. Each entry must fully specify the primitive set * (three booleans + a valid systemPromptMode); partial/mistyped entries and any * reserved built-in name are dropped. Returns undefined when nothing valid * remains so the built-in registry is used unchanged. */ function normalizeContextModes(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const out: Record = {}; let any = false; for (const [name, entry] of Object.entries(value as Record)) { if (RESERVED_MODE_NAMES.has(name) || !entry || typeof entry !== "object") continue; const e = entry as Record; if ( typeof e.inheritProjectContext !== "boolean" || typeof e.inheritSkills !== "boolean" || typeof e.inheritMainRules !== "boolean" || !isSystemPromptMode(e.systemPromptMode) ) { continue; } out[name] = { inheritProjectContext: e.inheritProjectContext, systemPromptMode: e.systemPromptMode, inheritSkills: e.inheritSkills, inheritMainRules: e.inheritMainRules, }; any = true; } return any ? out : undefined; } function normalizeInteger(value: unknown, min: number, max: number): number | undefined { if (typeof value !== "number" || !Number.isFinite(value) || value < min) return undefined; return Math.min(max, Math.floor(value)); } function readObject(path: string): Record { if (!existsSync(path)) return {}; try { const parsed = JSON.parse(readFileSync(path, "utf-8")); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; } catch { return {}; } }