/** * Subagents v2 — Config loader. * * Reads ~/.pi/agent/subagents.json for defaults per harness. * Merges with environment and defaults. */ import * as fs from "node:fs"; import * as path from "node:path"; import type { CodexSandbox, HarnessKind, SubagentSpawnParams, SubagentsConfig, TrustConfig, } from "./types.ts"; // ── Defaults ────────────────────────────────────────────────────────────────── export const DEFAULT_MAX_CONCURRENT = 4; const DEFAULT_CONFIG: SubagentsConfig = { defaultHarness: "pi", maxConcurrent: DEFAULT_MAX_CONCURRENT, codex: { sandbox: "workspace-write", maxEffort: "high", }, trust: { restrictCwd: false, allowedDirs: [], }, }; // ── Paths ───────────────────────────────────────────────────────────────────── function getAgentDir(): string { // Use PI_AGENT_DIR or default to ~/.pi/agent if (process.env.PI_AGENT_DIR) return process.env.PI_AGENT_DIR; const home = process.env.HOME || process.env.USERPROFILE || "."; return path.join(home, ".pi", "agent"); } function configPath(): string { return path.join(getAgentDir(), "subagents.json"); } // ── Loader ──────────────────────────────────────────────────────────────────── let cachedConfig: SubagentsConfig | null = null; export function loadConfig(): SubagentsConfig { if (cachedConfig) return cachedConfig; const filePath = configPath(); let fileConfig: Partial = {}; try { if (fs.existsSync(filePath)) { const raw = fs.readFileSync(filePath, "utf-8"); fileConfig = JSON.parse(raw) as Partial; } } catch { // Silently use defaults. } cachedConfig = mergeConfig(DEFAULT_CONFIG, fileConfig); return cachedConfig; } export function reloadConfig(): SubagentsConfig { cachedConfig = null; return loadConfig(); } function mergeConfig( base: SubagentsConfig, overrides: Partial, ): SubagentsConfig { return { defaultHarness: overrides.defaultHarness ?? base.defaultHarness, maxConcurrent: overrides.maxConcurrent ?? base.maxConcurrent, harnesses: { ...base.harnesses, ...overrides.harnesses }, profiles: { ...base.profiles, ...overrides.profiles }, codex: { ...base.codex, ...overrides.codex }, trust: { ...base.trust, ...overrides.trust }, }; } // ── Resolvers ───────────────────────────────────────────────────────────────── export function resolveHarnessKind(requested?: HarnessKind): HarnessKind { const config = loadConfig(); return requested ?? config.defaultHarness ?? "pi"; } /** * Apply a named role profile to a spawn request. Explicit request fields win * over profile values, which then fall back to per-harness defaults later. */ export function resolveSpawnParams(params: SubagentSpawnParams): SubagentSpawnParams { if (!params.profile) return { ...params }; const profile = loadConfig().profiles?.[params.profile]; if (!profile) { throw new Error(`Unknown subagent profile: ${params.profile}`); } return { task: params.task, profile: params.profile, harness: params.harness ?? profile.harness, label: params.label, cwd: params.cwd, model: params.model ?? profile.model, thinking: params.thinking ?? profile.thinking, maxTurns: params.maxTurns ?? profile.maxTurns, codexSandbox: params.codexSandbox ?? profile.codexSandbox, }; } export function resolveModel(harness: HarnessKind, override?: string): string | undefined { const config = loadConfig(); const harnessCfg = config.harnesses?.[harness]; return override ?? harnessCfg?.model; } export function resolveThinking(harness: HarnessKind, override?: string): string | undefined { const config = loadConfig(); const harnessCfg = config.harnesses?.[harness]; return override ?? harnessCfg?.thinking; } export function resolveCodexSandbox(override?: CodexSandbox): CodexSandbox { const config = loadConfig(); const sandbox = override ?? config.codex?.sandbox ?? "workspace-write"; // danger-full-access requires explicit opt-in in config if (sandbox === "danger-full-access" && config.codex?.sandbox !== "danger-full-access") { return config.codex?.sandbox ?? "workspace-write"; } return sandbox; } export function resolveCodexPath(): string { const config = loadConfig(); return config.codex?.path ?? resolveCodexBinary(); } export interface CodexInvocation { command: string; argsPrefix: string[]; } /** * Resolve a pipe-safe Codex invocation. * * npm installs expose `codex.cmd` on Windows. Spawning that wrapper through * `shell: true` breaks the app-server's stdio JSONL transport, while spawning * a .cmd file directly is unsupported by Node. Resolve the wrapper's JS * entrypoint and run it with the current Node executable instead. */ export function resolveCodexInvocation(): CodexInvocation { const configured = resolveCodexPath(); const resolved = resolveCommandOnPath(configured) ?? configured; if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(resolved)) { const script = findCodexWrapperScript(resolved); if (!script) { throw new Error( `Cannot resolve the Codex JS entrypoint behind Windows wrapper: ${resolved}. ` + "Set CODEX_PATH or subagents.json codex.path to codex.exe or a standard npm codex.cmd install.", ); } return { command: process.execPath, argsPrefix: [script] }; } return { command: resolved, argsPrefix: [] }; } export function resolveCodexMaxEffort(): "low" | "medium" | "high" { const config = loadConfig(); return config.codex?.maxEffort ?? "high"; } export function resolveMaxConcurrent(harness?: HarnessKind): number { const config = loadConfig(); const harnessCfg = harness ? config.harnesses?.[harness] : undefined; return harnessCfg?.maxConcurrent ?? config.maxConcurrent ?? DEFAULT_MAX_CONCURRENT; } // ── Trust store ─────────────────────────────────────────────────────────────── export function isCwdAllowed(cwd: string): boolean { const config = loadConfig(); const trust = config.trust; if (!trust?.restrictCwd) return true; if (!trust.allowedDirs?.length) return false; const normalized = path.resolve(cwd).toLowerCase(); return trust.allowedDirs.some((dir) => normalized.startsWith(path.resolve(dir).toLowerCase()), ); } export function getAllowedCwd(requested: string | undefined, defaultCwd: string): string { const target = requested ?? defaultCwd; if (!isCwdAllowed(target)) { throw new Error( `cwd "${target}" is not in the trust store allowlist. ` + `Add it to ~/.pi/agent/subagents.json → trust.allowedDirs or disable trust.restrictCwd.`, ); } return target; } // ── Codex binary resolution ────────────────────────────────────────────────── export function resolveCodexBinary(): string { if (process.env.CODEX_PATH) return process.env.CODEX_PATH; if (process.platform === "win32") { // Prefer a native binary when one is explicitly available on PATH. Most // npm installs expose only codex.cmd, handled by resolveCodexInvocation. return resolveCommandOnPath("codex.exe") ?? resolveCommandOnPath("codex.cmd") ?? "codex.cmd"; } return resolveCommandOnPath("codex") ?? "codex"; } function resolveCommandOnPath(command: string): string | undefined { if (path.isAbsolute(command) || command.includes("/") || command.includes("\\")) { return fs.existsSync(command) ? path.resolve(command) : undefined; } const names = process.platform === "win32" && !/\.(?:exe|cmd|bat)$/i.test(command) ? [`${command}.exe`, `${command}.cmd`, `${command}.bat`, command] : [command]; for (const directory of (process.env.PATH ?? "").split(path.delimiter)) { if (!directory) continue; for (const name of names) { const candidate = path.join(directory, name); if (fs.existsSync(candidate)) return candidate; } } return undefined; } function findCodexWrapperScript(wrapperPath: string): string | undefined { const wrapperDir = path.dirname(wrapperPath); const candidates = [ path.join(wrapperDir, "node_modules", "@openai", "codex", "bin", "codex.js"), path.join(wrapperDir, "..", "node_modules", "@openai", "codex", "bin", "codex.js"), ]; return candidates.find((candidate) => fs.existsSync(candidate)); }