/** * Nightshift configuration: defaults + validation (pure), plus JSON file loading. * File location: /nightshift.json (agentDir from pi's getAgentDir()). */ import { readFileSync } from "node:fs"; export type CompletionDelivery = "wakeup" | "quiet"; export interface NightshiftConfig { /** Default wait for job_output when wait:true without timeout_ms. */ waitTimeoutMs: number; /** Hard cap on any single wait; a larger model-supplied timeout_ms is clamped. */ maxWaitTimeoutMs: number; /** Wake turns one session may open before notices degrade to steer, reset by user input. */ maxConsecutiveWakes: number; /** 'quiet' never opens a turn; notices wait for the next real turn. */ completionDelivery: CompletionDelivery; } export const DEFAULT_CONFIG: NightshiftConfig = { waitTimeoutMs: 30_000, maxWaitTimeoutMs: 600_000, maxConsecutiveWakes: 3, completionDelivery: "wakeup", }; function fail(key: string, message: string): never { throw new Error(`nightshift config: ${key} ${message}`); } /** Validate + merge a raw config object (partial). Throws on any invalid value. */ export function resolveConfig(raw: unknown): NightshiftConfig { if (raw !== undefined && (typeof raw !== "object" || raw === null)) { throw new Error("nightshift config: expected a JSON object"); } const input = (raw ?? {}) as Record; const waitTimeoutMs = input.waitTimeoutMs ?? DEFAULT_CONFIG.waitTimeoutMs; const maxWaitTimeoutMs = input.maxWaitTimeoutMs ?? DEFAULT_CONFIG.maxWaitTimeoutMs; const maxConsecutiveWakes = input.maxConsecutiveWakes ?? DEFAULT_CONFIG.maxConsecutiveWakes; const completionDelivery = input.completionDelivery ?? DEFAULT_CONFIG.completionDelivery; if (typeof waitTimeoutMs !== "number" || !Number.isInteger(waitTimeoutMs) || waitTimeoutMs < 1) { fail("waitTimeoutMs", `must be a positive integer, got ${JSON.stringify(input.waitTimeoutMs)}`); } if (typeof maxWaitTimeoutMs !== "number" || !Number.isInteger(maxWaitTimeoutMs) || maxWaitTimeoutMs < 1) { fail("maxWaitTimeoutMs", `must be a positive integer, got ${JSON.stringify(input.maxWaitTimeoutMs)}`); } if (waitTimeoutMs > maxWaitTimeoutMs) { throw new Error(`nightshift config: waitTimeoutMs (${waitTimeoutMs}) exceeds maxWaitTimeoutMs (${maxWaitTimeoutMs})`); } // A budget is a count of turns: Infinity or a fraction cannot bound the // self-exciting wake chain this field exists to stop (dsh mirrors this). if (!Number.isSafeInteger(maxConsecutiveWakes) || maxConsecutiveWakes < 1) { fail("maxConsecutiveWakes", `must be a whole number of turns >= 1, got ${JSON.stringify(input.maxConsecutiveWakes)}`); } if (completionDelivery !== "wakeup" && completionDelivery !== "quiet") { fail("completionDelivery", `must be "wakeup" or "quiet", got ${JSON.stringify(input.completionDelivery)}`); } return { waitTimeoutMs, maxWaitTimeoutMs, maxConsecutiveWakes, completionDelivery }; } /** Load and resolve the config file. Malformed JSON or invalid values throw at startup. */ export function loadConfigFile(path: string): NightshiftConfig { let text: string; try { text = readFileSync(path, "utf8"); } catch { return DEFAULT_CONFIG; // no config file -> defaults } let raw: unknown; try { raw = JSON.parse(text); } catch (error) { throw new Error(`nightshift config: ${path} is not valid JSON: ${(error as Error).message}`); } return resolveConfig(raw); }