/** * Worker, scheduler and concurrency configuration block (§17.2). * * Same shape and precedence as `bash/config.ts`: defaults, then * `~/.pi/agent/agi/config.json`, then `/.pi/agi/config.json` gated on * project trust (R-CONF-1 — `piBinary`, timeouts and cwd policy are all * code-execution-adjacent). Only the keys Phases 4 and 5 implement are read; the * rest of §17.2 belongs to later phases and guessing at their semantics now would * produce a schema nothing honours. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import type { ConfigDiagnostic } from "../bash/config.ts"; /** §11.6 attention thresholds. */ export interface AttentionConfig { enabled: boolean; idleMs: number; longRunningMs: number; stalledToolMs: number; failedToolAttempts: number; failureWindowMs: number; contextPercent: number; compactionChurn: number; } export interface WorkerConfig { /** R-SLEEP-8. Tick interval; minimum 60000 prevents a pathological loop. */ tickMs: number; /** R-SLEEP-11. Backoff ceiling. */ tickMaxMs: number; /** R-SLEEP-11. Consecutive no-op ticks before the interval doubles. */ tickQuietThreshold: number; /** R-TOOL-21a. Floor for agi_sleep({ms}). */ minSleepMs: number; /** R-TOOL-21a. The 10-minute cap. */ maxSleepMs: number; /** R-SLEEP-22. Headless drain budget. */ headlessDrainMs: number; notifyOnComplete: boolean; notifyOnAttention: boolean; sleepIndicator: boolean; attention: AttentionConfig; /** R-CONC-1. Workers running at once. Fixed at one for iterative delegation. */ maxConcurrentWorkers: number; /** R-CONC-8. Orchestrator is depth 0, workers depth 1. */ maxDepth: number; /** §9.3. A cwd outside the repo is refused unless this is set. */ allowExternalCwd: boolean; /** R-WORK-10. Bytes of result text handed to the orchestrator. */ maxResultBytes: number; /** R-EXEC-4 step 0: an explicit override beats resolution. */ piBinary: string | null; widget: boolean; widgetMaxRows: number; inspectorBindings: { steer: string; interrupt: string; stop: string; resume: string; events: string; }; } export const DEFAULT_ATTENTION_CONFIG: AttentionConfig = { enabled: true, idleMs: 60_000, longRunningMs: 900_000, stalledToolMs: 300_000, failedToolAttempts: 3, failureWindowMs: 300_000, contextPercent: 90, compactionChurn: 3, }; export const DEFAULT_WORKER_CONFIG: WorkerConfig = { tickMs: 600_000, tickMaxMs: 3_600_000, tickQuietThreshold: 3, minSleepMs: 1_000, maxSleepMs: 600_000, headlessDrainMs: 1_800_000, notifyOnComplete: true, notifyOnAttention: true, sleepIndicator: true, attention: DEFAULT_ATTENTION_CONFIG, maxConcurrentWorkers: 1, maxDepth: 1, allowExternalCwd: false, maxResultBytes: 16_384, piBinary: null, widget: true, widgetMaxRows: 4, inspectorBindings: { steer: "s", interrupt: "i", stop: "shift+d", resume: "r", events: "e" }, }; /** pi's `KeyId` base keys, from `@earendil-works/pi-tui`'s `keys.ts` union. */ const KEY_MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]); const KEY_SPECIALS = new Set([ "escape", "esc", "enter", "return", "tab", "space", "backspace", "delete", "insert", "clear", "home", "end", "pageUp", "pageDown", "up", "down", "left", "right", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12", ]); const KEY_SYMBOLS = new Set([..."`-=[]\\;',./!@#$%^&*()_+|~{}:<>?"]); /** * Is this a key identifier `matchesKey` can actually match? * * `KeyId` is a compile-time union, so a value out of a JSON config reaches * `matchesKey` as an unchecked cast: a typo produces a binding that silently never * fires, with no diagnostic, unlike every other key in this loader. */ export function isValidKeyId(value: string): boolean { const parts = value.split("+"); const base = parts.pop(); if (base === undefined || base.length === 0) return false; const seen = new Set(); for (const modifier of parts) { if (!KEY_MODIFIERS.has(modifier) || seen.has(modifier)) return false; seen.add(modifier); } if (KEY_SPECIALS.has(base)) return true; if (base.length === 1) return /[a-z0-9]/.test(base) || KEY_SYMBOLS.has(base); return false; } /** * R-UI-11/12. Bindings are validated and required to be distinct. * * `handleInput` tests `stop` before `resume`, so two actions sharing a key made the * later one unreachable *and* turned its keypresses into an unresumable stop. A * collision is therefore a config error, not a preference. */ export function resolveInspectorBindings( block: Record | undefined, base: WorkerConfig["inspectorBindings"], source: string, diagnostics: ConfigDiagnostic[], ): WorkerConfig["inspectorBindings"] { const keys: Array = ["steer", "interrupt", "stop", "resume", "events"]; const resolved: Record = { ...base }; const taken = new Map(); for (const key of keys) { const raw = block?.[key]; const value = typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : base[key]; if (!isValidKeyId(value)) { diagnostics.push({ severity: "warning", message: `${source}: ui.inspectorBindings.${key} '${value}' is not a valid key id; ignoring the inspector binding block.`, }); return { ...base }; } const owner = taken.get(value); if (owner !== undefined) { diagnostics.push({ severity: "warning", message: `${source}: ui.inspectorBindings.${key} '${value}' collides with ${owner}; ignoring the inspector binding block so every action stays distinct.`, }); return { ...base }; } resolved[key] = value; taken.set(value, key); } return resolved as WorkerConfig["inspectorBindings"]; } const RANGES: Record = { // R-SLEEP-8: the 60s floor is what stops a misconfigured tick from becoming a // polling loop that costs a turn a second. tickMs: { min: 60_000, max: 86_400_000 }, tickMaxMs: { min: 60_000, max: 86_400_000 }, tickQuietThreshold: { min: 1, max: 100 }, minSleepMs: { min: 0, max: 600_000 }, // R-TOOL-21a: the cap is itself capped. An orchestrator asleep longer than ten // minutes is indistinguishable from one that is hung, so raising this past the // documented ceiling defeats the rule the key exists to implement. maxSleepMs: { min: 1_000, max: 600_000 }, headlessDrainMs: { min: 60_000, max: 86_400_000 }, "attention.idleMs": { min: 5_000, max: 86_400_000 }, "attention.longRunningMs": { min: 60_000, max: 86_400_000 }, "attention.stalledToolMs": { min: 10_000, max: 86_400_000 }, "attention.failedToolAttempts": { min: 1, max: 100 }, "attention.failureWindowMs": { min: 10_000, max: 86_400_000 }, "attention.contextPercent": { min: 10, max: 100 }, "attention.compactionChurn": { min: 1, max: 100 }, maxConcurrentWorkers: { min: 1, max: 1 }, maxDepth: { min: 0, max: 2 }, maxResultBytes: { min: 1024, max: 1_048_576 }, widgetMaxRows: { min: 1, max: 20 }, }; const KNOWN_TOP_KEYS = new Set([ "tickMs", "tickMaxMs", "tickQuietThreshold", "minSleepMs", "maxSleepMs", "headlessDrainMs", "notifyOnComplete", "notifyOnAttention", "maxConcurrentWorkers", "maxDepth", "allowExternalCwd", "maxResultBytes", "piBinary", ]); const KNOWN_ATTENTION_KEYS = new Set([ "enabled", "idleMs", "longRunningMs", "stalledToolMs", "failedToolAttempts", "failureWindowMs", "contextPercent", "compactionChurn", ]); function readJson(path: string, diagnostics: ConfigDiagnostic[]): Record | undefined { let raw: string; try { raw = readFileSync(path, "utf8"); } catch { return undefined; } try { const parsed: unknown = JSON.parse(raw); if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { diagnostics.push({ severity: "warning", message: `${path}: top level must be a JSON object; ignored.` }); return undefined; } return parsed as Record; } catch (error) { // R-CONF-2: fall back to defaults, but loudly. A malformed config must never // stop pi from starting, and must never look like it took effect. diagnostics.push({ severity: "warning", message: `${path}: malformed JSON (${(error as Error).message}). Worker defaults are in effect.`, }); return undefined; } } function rangedIntOf( key: string, value: unknown, fallback: number, source: string, diagnostics: ConfigDiagnostic[], ): number { if (value === undefined) return fallback; if (typeof value !== "number" || !Number.isFinite(value)) { diagnostics.push({ severity: "warning", message: `${source}: ${key} must be a number, got ${JSON.stringify(value)}; using ${fallback}.`, }); return fallback; } const floored = Math.floor(value); const range = RANGES[key]; if (range === undefined) return floored; if (floored < range.min) { diagnostics.push({ severity: "warning", message: `${source}: ${key} of ${value} is below the minimum ${range.min}; clamped.`, }); return range.min; } if (floored > range.max) { diagnostics.push({ severity: "warning", message: `${source}: ${key} of ${value} is above the maximum ${range.max}; clamped.`, }); return range.max; } return floored; } function boolOf(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } function mergeAttention( base: AttentionConfig, block: unknown, diagnostics: ConfigDiagnostic[], source: string, ): AttentionConfig { if (block === undefined) return base; if (block === null || typeof block !== "object" || Array.isArray(block)) { diagnostics.push({ severity: "warning", message: `${source}: attention must be a JSON object; ignored.` }); return base; } const record = block as Record; for (const key of Object.keys(record)) { if (!KNOWN_ATTENTION_KEYS.has(key)) { diagnostics.push({ severity: "info", message: `${source}: unknown key 'attention.${key}' was ignored.` }); } } return { enabled: boolOf(record.enabled, base.enabled), idleMs: rangedIntOf("attention.idleMs", record.idleMs, base.idleMs, source, diagnostics), longRunningMs: rangedIntOf("attention.longRunningMs", record.longRunningMs, base.longRunningMs, source, diagnostics), stalledToolMs: rangedIntOf("attention.stalledToolMs", record.stalledToolMs, base.stalledToolMs, source, diagnostics), failedToolAttempts: rangedIntOf("attention.failedToolAttempts", record.failedToolAttempts, base.failedToolAttempts, source, diagnostics), failureWindowMs: rangedIntOf("attention.failureWindowMs", record.failureWindowMs, base.failureWindowMs, source, diagnostics), contextPercent: rangedIntOf("attention.contextPercent", record.contextPercent, base.contextPercent, source, diagnostics), compactionChurn: rangedIntOf("attention.compactionChurn", record.compactionChurn, base.compactionChurn, source, diagnostics), }; } export function mergeWorkerConfig( base: WorkerConfig, block: Record | undefined, diagnostics: ConfigDiagnostic[] = [], source = "config", ): WorkerConfig { if (block === undefined) return base; const ui = block.ui; const uiBlock = ui !== null && typeof ui === "object" && !Array.isArray(ui) ? (ui as Record) : undefined; const inspectorRaw = uiBlock?.inspectorBindings; const inspectorBlock = inspectorRaw !== null && typeof inspectorRaw === "object" && !Array.isArray(inspectorRaw) ? (inspectorRaw as Record) : undefined; const inspectorBindings = resolveInspectorBindings(inspectorBlock, base.inspectorBindings, source, diagnostics); let piBinary = base.piBinary; if (block.piBinary !== undefined) { if (typeof block.piBinary === "string" && block.piBinary.length > 0) piBinary = block.piBinary; else if (block.piBinary === null) piBinary = null; else diagnostics.push({ severity: "warning", message: `${source}: piBinary must be a string or null; ignored.` }); } const tickMs = rangedIntOf("tickMs", block.tickMs, base.tickMs, source, diagnostics); let tickMaxMs = rangedIntOf("tickMaxMs", block.tickMaxMs, base.tickMaxMs, source, diagnostics); if (tickMaxMs < tickMs) { // R-SLEEP-11 doubles *up to* tickMaxMs. A ceiling below the base interval // makes the backoff meaningless rather than merely aggressive, so it is // raised to the interval and said out loud. diagnostics.push({ severity: "warning", message: `${source}: tickMaxMs (${tickMaxMs}) is below tickMs (${tickMs}); raised to tickMs, so backoff is disabled.`, }); tickMaxMs = tickMs; } const maxSleepMs = rangedIntOf("maxSleepMs", block.maxSleepMs, base.maxSleepMs, source, diagnostics); let minSleepMs = rangedIntOf("minSleepMs", block.minSleepMs, base.minSleepMs, source, diagnostics); if (minSleepMs > maxSleepMs) { // agi_sleep clamps to [minSleepMs, maxSleepMs]; an inverted range would make // the result text ("capped"/"floored") depend on clamp order rather than on // what the orchestrator asked for. diagnostics.push({ severity: "warning", message: `${source}: minSleepMs (${minSleepMs}) exceeds maxSleepMs (${maxSleepMs}); lowered to maxSleepMs.`, }); minSleepMs = maxSleepMs; } return { tickMs, tickMaxMs, tickQuietThreshold: rangedIntOf("tickQuietThreshold", block.tickQuietThreshold, base.tickQuietThreshold, source, diagnostics), minSleepMs, maxSleepMs, headlessDrainMs: rangedIntOf("headlessDrainMs", block.headlessDrainMs, base.headlessDrainMs, source, diagnostics), notifyOnComplete: boolOf(block.notifyOnComplete, base.notifyOnComplete), notifyOnAttention: boolOf(block.notifyOnAttention, base.notifyOnAttention), sleepIndicator: boolOf(uiBlock?.sleepIndicator, base.sleepIndicator), attention: mergeAttention(base.attention, block.attention, diagnostics, source), maxConcurrentWorkers: rangedIntOf("maxConcurrentWorkers", block.maxConcurrentWorkers, base.maxConcurrentWorkers, source, diagnostics), maxDepth: rangedIntOf("maxDepth", block.maxDepth, base.maxDepth, source, diagnostics), allowExternalCwd: boolOf(block.allowExternalCwd, base.allowExternalCwd), maxResultBytes: rangedIntOf("maxResultBytes", block.maxResultBytes, base.maxResultBytes, source, diagnostics), piBinary, widget: boolOf(uiBlock?.widget, base.widget), widgetMaxRows: rangedIntOf("widgetMaxRows", uiBlock?.widgetMaxRows, base.widgetMaxRows, source, diagnostics), inspectorBindings, }; } function workerBlockOf(path: string, diagnostics: ConfigDiagnostic[]): Record | undefined { const root = readJson(path, diagnostics); if (root === undefined) return undefined; // R-CONF-2: report unknown top-level keys this loader recognizes the namespace // of. Keys owned by other blocks (bash, ui, enabledByDefault) are left alone — // they are not unknown, just not ours. // // The tick*/sleep*/min*/max* prefixes were a blanket exemption while Phase 5 was // unimplemented. Now that those keys are real and validated, the exemption would // swallow exactly the typo it is most important to report (`tickMss` silently // doing nothing), so they are matched by name in KNOWN_TOP_KEYS instead. const foreign = new Set(["bash", "attention", "ui", "enabledByDefault"]); for (const key of Object.keys(root)) { if (foreign.has(key) || KNOWN_TOP_KEYS.has(key)) continue; if (key.startsWith("worktree") || key.startsWith("memory") || key.startsWith("note")) continue; if (key.startsWith("orchestrator") || key.startsWith("context") || key.startsWith("direct")) continue; if (key.startsWith("inspection") || key.startsWith("cost")) continue; diagnostics.push({ severity: "info", message: `${path}: unknown key '${key}' was ignored.` }); } return root; } export interface LoadedWorkerConfig { config: WorkerConfig; diagnostics: ConfigDiagnostic[]; } export function loadWorkerConfigWithDiagnostics( cwd: string, options: { isProjectTrusted?: () => boolean } = {}, ): LoadedWorkerConfig { const diagnostics: ConfigDiagnostic[] = []; let config = DEFAULT_WORKER_CONFIG; const userPath = join(getAgentDir(), "agi", "config.json"); config = mergeWorkerConfig(config, workerBlockOf(userPath, diagnostics), diagnostics, userPath); const projectPath = join(cwd, ".pi", "agi", "config.json"); let trusted = false; try { trusted = options.isProjectTrusted?.() === true; } catch { trusted = false; } if (trusted) { config = mergeWorkerConfig(config, workerBlockOf(projectPath, diagnostics), diagnostics, projectPath); } else if (readJson(projectPath, []) !== undefined) { diagnostics.push({ severity: "warning", message: `${projectPath} was ignored because this project is not trusted (R-CONF-1). ` + `It can set piBinary and worker timeouts, so it requires project trust.`, }); } return { config, diagnostics }; } export function loadWorkerConfig(cwd: string, options: { isProjectTrusted?: () => boolean } = {}): WorkerConfig { return loadWorkerConfigWithDiagnostics(cwd, options).config; }