import { access, readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"; import type { Static } from "typebox"; import { Type } from "typebox"; import { Check } from "typebox/value"; export const codemodeSettingsSchema = Type.Object( { languages: Type.Optional( Type.Object( { py: Type.Optional(Type.Boolean()), js: Type.Optional(Type.Boolean()), rb: Type.Optional(Type.Boolean()), ts: Type.Optional(Type.Boolean()), }, { additionalProperties: false } ) ), cellTimeoutSeconds: Type.Optional(Type.Number({ minimum: 1 })), bridgeTimeoutSeconds: Type.Optional(Type.Number({ minimum: 1 })), parallelPoolWidth: Type.Optional(Type.Number({ minimum: 1 })), hardenedCells: Type.Optional(Type.Boolean()), jitless: Type.Optional(Type.Boolean()), taskTools: Type.Optional( Type.Object( { task: Type.Optional(Type.String()), output: Type.Optional(Type.String()), }, { additionalProperties: false } ) ), outputSink: Type.Optional( Type.Object( { headBytes: Type.Optional(Type.Number({ minimum: 0 })), maxColumns: Type.Optional(Type.Number({ minimum: 0 })), chunkThrottleMs: Type.Optional(Type.Number({ minimum: 0 })), }, { additionalProperties: false } ) ), statusEvents: Type.Optional(Type.Boolean()), promotion: Type.Optional( Type.Object( { enabled: Type.Optional(Type.Boolean()), }, { additionalProperties: false } ) ), }, { additionalProperties: false } ); export type CodemodeSettingsInput = Static; export interface CodemodeTaskTools { readonly output: string; readonly task: string; } export interface CodemodeOutputSink { /** Optional sink-level preview batching (ms); unset keeps per-chunk onChunk delivery. */ readonly chunkThrottleMs?: number; readonly headBytes: number; readonly maxColumns: number; } export interface CodemodeSettings { readonly bridgeTimeoutSeconds: number; readonly cellTimeoutSeconds: number; readonly hardenedCells: boolean; readonly jitless: boolean; readonly languages: { readonly py: boolean; readonly js: boolean; readonly rb: boolean; readonly ts: boolean; }; readonly outputSink?: CodemodeOutputSink; readonly parallelPoolWidth: number; readonly promotion?: { readonly enabled?: boolean }; readonly statusEvents?: boolean; readonly taskTools?: CodemodeTaskTools; } export type ResolvedCodemodeSettings = CodemodeSettings & { readonly taskTools: CodemodeTaskTools; readonly outputSink: CodemodeOutputSink; readonly statusEvents: boolean; readonly promotion: { readonly enabled: boolean }; }; export interface LoadCodemodeSettingsOptions { readonly cwd?: string; readonly homeDir?: string; } export interface LoadedCodemodeSettings { readonly settings: ResolvedCodemodeSettings; /** The last contributing config file, or null when only defaults apply. */ readonly source: string | null; /** Per-setting provenance: "default", "global", or "project". */ readonly sources: Record; readonly warnings: readonly string[]; } // OMP settings-schema.ts:3211-3299 has language/path settings only; eval.ts:427 // defaults timeout to 30s, and codemode pins concurrency-bridge.ts:30 width to 4. export const defaultCodemodeSettings: ResolvedCodemodeSettings = { languages: { py: true, js: true, rb: false, ts: false, }, cellTimeoutSeconds: 120, bridgeTimeoutSeconds: 120, parallelPoolWidth: 4, hardenedCells: false, jitless: false, taskTools: { task: "task", output: "task_output", }, outputSink: { headBytes: 20_480, maxColumns: 768, }, statusEvents: true, promotion: { enabled: true, }, }; /** Every editable setting key, for per-setting source tracking. */ export const SETTING_KEYS = [ "languages.py", "languages.js", "languages.rb", "languages.ts", "cellTimeoutSeconds", "bridgeTimeoutSeconds", "parallelPoolWidth", "hardenedCells", "jitless", "taskTools.task", "taskTools.output", "outputSink.headBytes", "outputSink.maxColumns", "outputSink.chunkThrottleMs", "statusEvents", "promotion.enabled", ] as const; const languageEnvironmentFlags = { py: "PI_CODEMODE_PY", js: "PI_CODEMODE_JS", rb: "PI_CODEMODE_RB", ts: "PI_CODEMODE_TS", } as const; type Environment = Readonly>; export async function loadCodemodeSettings( options: LoadCodemodeSettingsOptions = {} ): Promise { const cwd = options.cwd ?? process.cwd(); const homeDir = options.homeDir ?? homedir(); const candidates: ReadonlyArray<{ readonly label: string; readonly path: string; }> = [ { label: "global", path: join(homeDir, CONFIG_DIR_NAME, "agent", "codemode.json"), }, { label: "project", path: join(cwd, CONFIG_DIR_NAME, "codemode.json") }, ]; const warnings: string[] = []; const sources: Record = {}; for (const key of SETTING_KEYS) { sources[key] = "default"; } let settings = structuredClone(defaultCodemodeSettings); let source: string | null = null; for (const candidate of candidates) { if (!(await fileExists(candidate.path))) { continue; } const loaded = await loadSettingsFile(candidate.path); if (loaded.warnings.length > 0) { warnings.push(...loaded.warnings); } if (loaded.input === null) { continue; } settings = mergeSettings(settings, loaded.input); markSettingSources(sources, loaded.input, candidate.label); source = candidate.path; } return { settings, source, sources, warnings }; } export function resolveEnabledLanguages( settings: CodemodeSettings, env: Environment = process.env ): CodemodeSettings["languages"] { return { py: resolveLanguage( settings.languages.py, env[languageEnvironmentFlags.py] ), js: resolveLanguage( settings.languages.js, env[languageEnvironmentFlags.js] ), rb: resolveLanguage( settings.languages.rb, env[languageEnvironmentFlags.rb] ), ts: resolveLanguage( settings.languages.ts, env[languageEnvironmentFlags.ts] ), }; } async function loadSettingsFile( path: string ): Promise<{ input: CodemodeSettingsInput | null; warnings: string[] }> { const raw = await readFile(path, "utf8"); let parsed: unknown; try { parsed = JSON.parse(raw); } catch (error) { const message = error instanceof Error ? error.message : String(error); return { input: null, warnings: [ `Invalid JSON in ${path}: ${message}. Falling back to codemode defaults.`, ], }; } if (!Check(codemodeSettingsSchema, parsed)) { return { input: null, warnings: [ `Invalid codemode settings in ${path}. Falling back to codemode defaults.`, ], }; } return { input: parsed, warnings: [] }; } /** Records which file ("global" or "project") provided each present key. */ function markSettingSources( sources: Record, input: CodemodeSettingsInput, label: string ): void { if (input.languages !== undefined) { for (const key of ["py", "js", "rb", "ts"] as const) { if (input.languages[key] !== undefined) { sources[`languages.${key}`] = label; } } } if (input.taskTools !== undefined) { for (const key of ["task", "output"] as const) { if (input.taskTools[key] !== undefined) { sources[`taskTools.${key}`] = label; } } } if (input.outputSink !== undefined) { for (const key of ["headBytes", "maxColumns", "chunkThrottleMs"] as const) { if (input.outputSink[key] !== undefined) { sources[`outputSink.${key}`] = label; } } } for (const key of [ "cellTimeoutSeconds", "bridgeTimeoutSeconds", "parallelPoolWidth", "hardenedCells", "jitless", "statusEvents", ] as const) { if (input[key] !== undefined) { sources[key] = label; } } if (input.promotion?.enabled !== undefined) { sources["promotion.enabled"] = label; } } function mergeSettings( base: ResolvedCodemodeSettings, input: CodemodeSettingsInput ): ResolvedCodemodeSettings { return { languages: { py: input.languages?.py ?? base.languages.py, js: input.languages?.js ?? base.languages.js, rb: input.languages?.rb ?? base.languages.rb, ts: input.languages?.ts ?? base.languages.ts, }, cellTimeoutSeconds: input.cellTimeoutSeconds ?? base.cellTimeoutSeconds, bridgeTimeoutSeconds: input.bridgeTimeoutSeconds ?? base.bridgeTimeoutSeconds, parallelPoolWidth: input.parallelPoolWidth ?? base.parallelPoolWidth, hardenedCells: input.hardenedCells ?? base.hardenedCells, jitless: input.jitless ?? base.jitless, taskTools: { task: input.taskTools?.task ?? base.taskTools.task, output: input.taskTools?.output ?? base.taskTools.output, }, outputSink: { headBytes: input.outputSink?.headBytes ?? base.outputSink.headBytes, maxColumns: input.outputSink?.maxColumns ?? base.outputSink.maxColumns, ...(input.outputSink?.chunkThrottleMs === undefined ? {} : { chunkThrottleMs: input.outputSink.chunkThrottleMs }), }, statusEvents: input.statusEvents ?? base.statusEvents, promotion: { enabled: input.promotion?.enabled ?? base.promotion.enabled, }, }; } function resolveLanguage( fileSetting: boolean, environmentValue: string | undefined ): boolean { if (environmentValue === undefined) { return fileSetting; } switch (environmentValue.trim().toLowerCase()) { case "0": case "false": return false; case "1": case "true": return true; default: return fileSetting; } } async function fileExists(path: string): Promise { try { await access(path); return true; } catch { return false; } } /** * Resolves a configured parallel pool width to a usable integer: non-finite * values (e.g. NaN from an env override) fall back to 1; finite values are * truncated and clamped to a minimum of 1. */ export function resolveParallelPoolWidth(value: number): number { return Number.isFinite(value) ? Math.max(1, Math.trunc(value)) : 1; }