import { existsSync, readFileSync } from "fs"; import { join } from "path"; import { hostname } from "os"; import { getIntercomDirPath } from "./broker/paths.ts"; import { defaultMachineName } from "./cross-machine-envelope.ts"; const DEFAULT_ASK_TIMEOUT_MS = 10 * 60 * 1000; const INTERCOM_SCOPE_ID_ENV = "PI_INTERCOM_SCOPE_ID"; export function getAskTimeoutMs(): number { const raw = process.env.PI_INTERCOM_ASK_TIMEOUT_MS; if (raw === undefined || raw.trim() === "") { return DEFAULT_ASK_TIMEOUT_MS; } const value = Number(raw); if (!Number.isSafeInteger(value) || value <= 0) { throw new Error("PI_INTERCOM_ASK_TIMEOUT_MS must be a positive integer number of milliseconds"); } return value; } export function getIntercomScopeId(env: NodeJS.ProcessEnv = process.env): string | undefined { const scopeId = env[INTERCOM_SCOPE_ID_ENV]?.trim(); return scopeId ? scopeId : undefined; } export type InboundTriggerPolicy = "always" | "replies" | "never"; export type BusyDeliveryPolicy = "steer" | "human-first"; export interface CrossMachineConfig { /** Name peers use for this host in their Herdr saved-machine lists. */ machineName: string; /** Command invoked through SSH on remote machines. */ remoteCommand: string; } export interface IntercomConfig { /** Broker command used to spawn the broker process (e.g. "npx" or "bun") */ brokerCommand: string; /** Arguments passed to the broker command before the broker script path */ brokerArgs: string[]; /** Require confirmation before non-reply sends from interactive sessions */ confirmSend: boolean; /** Controls whether inbound broker messages may automatically trigger a model turn */ inboundTrigger: InboundTriggerPolicy; /** Delivery priority for peers arriving during interactive agent runs */ busyDelivery: BusyDeliveryPolicy; /** Optional custom status suffix shown after automatic lifecycle status */ status?: string; /** Optional stable intercom session ID for restart-stable addressing */ stableId?: string; /** Enable/disable intercom (default: true) */ enabled: boolean; /** Show reply hint in incoming messages (default: true) */ replyHint: boolean; /** Cross-machine discovery and SSH relay settings. */ crossMachine: CrossMachineConfig; } export function getConfigPath(intercomDir: string = getIntercomDirPath()): string { return join(intercomDir, "config.json"); } const defaults: IntercomConfig = { brokerCommand: "npx", brokerArgs: ["--no-install", "tsx"], confirmSend: false, inboundTrigger: "always", busyDelivery: "steer", enabled: true, replyHint: true, crossMachine: { machineName: defaultMachineName(hostname()), remoteCommand: "pi-intercom", }, }; export function loadConfig(): IntercomConfig { const configPath = getConfigPath(); if (!existsSync(configPath)) { return { ...defaults, crossMachine: { ...defaults.crossMachine } }; } try { const raw = readFileSync(configPath, "utf-8"); const parsed: unknown = JSON.parse(raw); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new Error("Config must be a JSON object"); } const parsedConfig = parsed as Record; const config: IntercomConfig = { ...defaults, crossMachine: { ...defaults.crossMachine }, }; if (Object.hasOwn(parsedConfig, "brokerCommand")) { if (typeof parsedConfig.brokerCommand !== "string") { throw new Error(`"brokerCommand" must be a string`); } const brokerCommand = parsedConfig.brokerCommand.trim(); if (!brokerCommand) { throw new Error(`"brokerCommand" must not be empty`); } config.brokerCommand = brokerCommand; } if (Object.hasOwn(parsedConfig, "brokerArgs")) { if (!Array.isArray(parsedConfig.brokerArgs)) { throw new Error(`"brokerArgs" must be an array`); } const brokerArgs: string[] = []; for (const arg of parsedConfig.brokerArgs) { if (typeof arg !== "string") { throw new Error(`"brokerArgs" items must be strings`); } brokerArgs.push(arg); } config.brokerArgs = brokerArgs; } if (Object.hasOwn(parsedConfig, "confirmSend")) { if (typeof parsedConfig.confirmSend !== "boolean") { throw new Error(`"confirmSend" must be a boolean`); } config.confirmSend = parsedConfig.confirmSend; } if (Object.hasOwn(parsedConfig, "enabled")) { if (typeof parsedConfig.enabled !== "boolean") { throw new Error(`"enabled" must be a boolean`); } config.enabled = parsedConfig.enabled; } if (Object.hasOwn(parsedConfig, "inboundTrigger")) { if ( parsedConfig.inboundTrigger !== "always" && parsedConfig.inboundTrigger !== "replies" && parsedConfig.inboundTrigger !== "never" ) { throw new Error(`"inboundTrigger" must be "always", "replies", or "never"`); } config.inboundTrigger = parsedConfig.inboundTrigger; } if (Object.hasOwn(parsedConfig, "busyDelivery")) { if (parsedConfig.busyDelivery !== "steer" && parsedConfig.busyDelivery !== "human-first") { throw new Error(`"busyDelivery" must be "steer" or "human-first"`); } config.busyDelivery = parsedConfig.busyDelivery; } if (Object.hasOwn(parsedConfig, "replyHint")) { if (typeof parsedConfig.replyHint !== "boolean") { throw new Error(`"replyHint" must be a boolean`); } config.replyHint = parsedConfig.replyHint; } if (Object.hasOwn(parsedConfig, "status")) { if (typeof parsedConfig.status !== "string") { throw new Error(`"status" must be a string`); } config.status = parsedConfig.status; } if (Object.hasOwn(parsedConfig, "stableId")) { if (typeof parsedConfig.stableId !== "string") { throw new Error(`"stableId" must be a string`); } const stableId = parsedConfig.stableId.trim(); if (!stableId) { throw new Error(`"stableId" must not be empty`); } config.stableId = stableId; } if (Object.hasOwn(parsedConfig, "crossMachine")) { const value = parsedConfig.crossMachine; if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error(`"crossMachine" must be an object`); } const crossMachine = value as Record; if (Object.hasOwn(crossMachine, "machineName")) { if (typeof crossMachine.machineName !== "string" || !crossMachine.machineName.trim()) { throw new Error(`"crossMachine.machineName" must be a non-empty string`); } config.crossMachine.machineName = crossMachine.machineName.trim(); } if (Object.hasOwn(crossMachine, "remoteCommand")) { if (typeof crossMachine.remoteCommand !== "string" || !crossMachine.remoteCommand.trim()) { throw new Error(`"crossMachine.remoteCommand" must be a non-empty string`); } config.crossMachine.remoteCommand = crossMachine.remoteCommand.trim(); } } return config; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to load intercom config at ${configPath}: ${message}`, { cause: error }); } }