import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { DEFAULT_EXTENSION_CONFIG } from "./defaultConfig"; import { redactForAgentTick } from "./redaction"; import type { AgentTickExtensionConfig, CommandDisclosurePolicy, CommandRuleConfig, SanctionPolicy, StatusMessageMirrorConfig, StatusState, ToolActivityVisibility, } from "./types"; export type ExtensionConfigWarning = { path?: string; message: string; }; export type ResolveExtensionConfigOptions = { env?: Record; cwd?: string; homeDirectory?: string; onWarning?: (warning: ExtensionConfigWarning) => void; }; type DeepPartial = T extends Record ? { [K in keyof T]?: DeepPartial } : T; function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function deepMerge(base: T, override: unknown): T { if (!isPlainObject(base) || !isPlainObject(override)) return override as T; const result: Record = { ...base }; for (const [key, value] of Object.entries(override)) { if (value === undefined) continue; const current = result[key]; result[key] = isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value; } return result as T; } function readJson(path: string, onWarning?: (warning: ExtensionConfigWarning) => void): unknown | undefined { if (!existsSync(path)) return undefined; try { return JSON.parse(readFileSync(path, "utf8")); } catch (error) { onWarning?.({ path, message: `Could not read pi-agent-tick config: ${redactForAgentTick(error instanceof Error ? error.message : String(error))}`, }); return undefined; } } function asBoolean(value: unknown, fallback: boolean): boolean { if (typeof value === "boolean") return value; if (typeof value === "string") { const normalized = value.trim().toLowerCase(); if (["1", "true", "yes", "on"].includes(normalized)) return true; if (["0", "false", "no", "off"].includes(normalized)) return false; } return fallback; } function asPositiveInteger(value: unknown, fallback: number): number { const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN; return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback; } function asStatusState(value: unknown, fallback: StatusState): StatusState { return value === "working" || value === "waiting" || value === "blocked" || value === "done" || value === "failed" ? value : fallback; } function asStatusContentMode(value: unknown, fallback: StatusMessageMirrorConfig["contentMode"]): StatusMessageMirrorConfig["contentMode"] { return value === "plain" || value === "private" ? value : fallback; } function asAssistantMirrorMode(value: unknown, fallback: StatusMessageMirrorConfig["sendAssistant"]): StatusMessageMirrorConfig["sendAssistant"] { return value === "final-only" || value === "every-message" || value === "off" ? value : fallback; } function asPolicy(value: unknown, fallback: SanctionPolicy): SanctionPolicy { return value === "allow-by-default" || value === "approve-by-default" ? value : fallback; } function asDisclosurePolicy(value: unknown, fallback: CommandDisclosurePolicy): CommandDisclosurePolicy { return value === "never" || value === "safe-only" || value === "always" ? value : fallback; } function asToolActivityVisibility(value: unknown, fallback: ToolActivityVisibility): ToolActivityVisibility { return value === "off" || value === "names" || value === "summaries" || value === "details" ? value : fallback; } function asString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function validateRegex( field: "commandRegex" | "argsRegex" | "rawRegex", value: string | undefined, ruleId: string, sourcePath: string | undefined, onWarning?: (warning: ExtensionConfigWarning) => void, ): string | undefined { if (!value) return undefined; try { void new RegExp(value); return value; } catch (error) { onWarning?.({ path: sourcePath, message: `Ignoring invalid ${field} for sanction rule ${ruleId}: ${redactForAgentTick(error instanceof Error ? error.message : String(error))}`, }); return undefined; } } function normalizeRule( ruleId: string, value: unknown, fallback: CommandRuleConfig | undefined, sourcePath: string | undefined, onWarning?: (warning: ExtensionConfigWarning) => void, ): CommandRuleConfig { const object = isPlainObject(value) ? value : {}; const base = fallback ?? { enabled: true }; return { enabled: asBoolean(object.enabled, base.enabled ?? true), command: asString(object.command) ?? base.command, commandRegex: validateRegex( "commandRegex", asString(object.commandRegex) ?? base.commandRegex, ruleId, sourcePath, onWarning, ), argsRegex: validateRegex( "argsRegex", asString(object.argsRegex) ?? base.argsRegex, ruleId, sourcePath, onWarning, ), rawRegex: validateRegex( "rawRegex", asString(object.rawRegex) ?? base.rawRegex, ruleId, sourcePath, onWarning, ), riskClass: asString(object.riskClass) ?? base.riskClass, reason: asString(object.reason) ?? base.reason, }; } function normalizeRuleMap( value: unknown, fallback: Record, sourcePath: string | undefined, onWarning?: (warning: ExtensionConfigWarning) => void, ): Record { const object = isPlainObject(value) ? value : {}; const ids = new Set([...Object.keys(fallback), ...Object.keys(object)]); const result: Record = {}; for (const id of ids) { result[id] = normalizeRule(id, object[id], fallback[id], sourcePath, onWarning); } return result; } function normalizeConfig( rawConfig: AgentTickExtensionConfig, sourcePath: string | undefined, onWarning?: (warning: ExtensionConfigWarning) => void, ): AgentTickExtensionConfig { const defaults = DEFAULT_EXTENSION_CONFIG; const statusInput = rawConfig.status ?? defaults.status; const heartbeatInput = isPlainObject(statusInput.heartbeat) ? statusInput.heartbeat : {}; const sanctionsInput = rawConfig.sanctions ?? defaults.sanctions; const mirrorInput = isPlainObject(statusInput.messageMirroring) ? statusInput.messageMirroring : {}; const defaultMirror = defaults.status.messageMirroring; const toolActivityInput = isPlainObject(statusInput.toolActivity) ? statusInput.toolActivity : {}; const defaultToolActivity = defaults.status.toolActivity; const hooks: AgentTickExtensionConfig["status"]["hooks"] = {}; const inputHooks = isPlainObject(statusInput.hooks) ? statusInput.hooks : {}; const defaultHooks = defaults.status.hooks; for (const hookName of new Set([...Object.keys(defaultHooks), ...Object.keys(inputHooks)])) { const defaultHook = defaultHooks[hookName] ?? { send: false, state: "working" as const, message: "none" }; const inputHook = isPlainObject(inputHooks[hookName]) ? inputHooks[hookName] : {}; hooks[hookName] = { send: asBoolean(inputHook.send, defaultHook.send), state: asStatusState(inputHook.state, defaultHook.state), message: typeof inputHook.message === "string" ? inputHook.message : defaultHook.message, }; } const tools: AgentTickExtensionConfig["sanctions"]["tools"] = {}; const inputTools = isPlainObject(sanctionsInput.tools) ? sanctionsInput.tools : {}; const defaultTools = defaults.sanctions.tools; for (const toolName of new Set([...Object.keys(defaultTools), ...Object.keys(inputTools)])) { const defaultTool = defaultTools[toolName] ?? { enabled: true, parseShell: false, allow: {}, deny: {} }; const inputTool = isPlainObject(inputTools[toolName]) ? inputTools[toolName] : {}; tools[toolName] = { enabled: asBoolean(inputTool.enabled, defaultTool.enabled), parseShell: asBoolean(inputTool.parseShell, defaultTool.parseShell), allow: normalizeRuleMap(inputTool.allow, defaultTool.allow, sourcePath, onWarning), deny: normalizeRuleMap(inputTool.deny, defaultTool.deny, sourcePath, onWarning), }; } const approvalInput = isPlainObject(sanctionsInput.approval) ? sanctionsInput.approval : {}; const defaultApproval = defaults.sanctions.approval; return { status: { enabled: asBoolean(statusInput.enabled, defaults.status.enabled), includeTaskSummary: asBoolean(statusInput.includeTaskSummary, defaults.status.includeTaskSummary), taskSummaryMaxChars: asPositiveInteger(statusInput.taskSummaryMaxChars, defaults.status.taskSummaryMaxChars), heartbeat: { enabled: asBoolean(heartbeatInput.enabled, defaults.status.heartbeat.enabled), intervalMs: asPositiveInteger(heartbeatInput.intervalMs, defaults.status.heartbeat.intervalMs), message: typeof heartbeatInput.message === "string" ? heartbeatInput.message : defaults.status.heartbeat.message, }, hooks, messageMirroring: { enabled: asBoolean(mirrorInput.enabled, defaultMirror.enabled), contentMode: asStatusContentMode(mirrorInput.contentMode, defaultMirror.contentMode), sendAssistant: asAssistantMirrorMode(mirrorInput.sendAssistant, defaultMirror.sendAssistant), sendUser: asBoolean(mirrorInput.sendUser, defaultMirror.sendUser), includeToolUseTurns: asBoolean(mirrorInput.includeToolUseTurns, defaultMirror.includeToolUseTurns), includeThinking: asBoolean(mirrorInput.includeThinking, defaultMirror.includeThinking), maxBodyChars: asPositiveInteger(mirrorInput.maxBodyChars, defaultMirror.maxBodyChars), previewMaxChars: asPositiveInteger(mirrorInput.previewMaxChars, defaultMirror.previewMaxChars), collapsedByDefault: asBoolean(mirrorInput.collapsedByDefault, defaultMirror.collapsedByDefault), includeContextUsage: asBoolean(mirrorInput.includeContextUsage, defaultMirror.includeContextUsage), }, toolActivity: { enabled: asBoolean(toolActivityInput.enabled, defaultToolActivity.enabled), visibility: asToolActivityVisibility(toolActivityInput.visibility, defaultToolActivity.visibility), detailContentMode: "private", maxDetailChars: asPositiveInteger(toolActivityInput.maxDetailChars, defaultToolActivity.maxDetailChars), }, }, sanctions: { enabled: asBoolean(sanctionsInput.enabled, defaults.sanctions.enabled), policy: asPolicy(sanctionsInput.policy, defaults.sanctions.policy), tools, approval: { local: asBoolean(approvalInput.local, defaultApproval.local), remote: asBoolean(approvalInput.remote, defaultApproval.remote), timeoutMs: asPositiveInteger(approvalInput.timeoutMs, defaultApproval.timeoutMs), discloseCommand: asDisclosurePolicy(approvalInput.discloseCommand, defaultApproval.discloseCommand), }, }, }; } function explicitConfigPaths(env: Record): string[] { const raw = env.AGENT_TICK_FEATURES_CONFIG; if (!raw?.trim()) return []; return raw.split(/[,:]/).map((part) => part.trim()).filter(Boolean); } export function resolveAgentTickExtensionConfig(options: ResolveExtensionConfigOptions = {}): AgentTickExtensionConfig { const env = options.env ?? process.env; const cwd = options.cwd ?? process.cwd(); const homeDirectory = options.homeDirectory ?? homedir(); const onWarning = options.onWarning; const configPaths = [ join(homeDirectory, ".config", "agent-tick", "features.json"), join(cwd, ".agent-tick", "features.json"), ...explicitConfigPaths(env).map((path) => resolve(cwd, path)), ]; let merged = clone(DEFAULT_EXTENSION_CONFIG) as AgentTickExtensionConfig; let lastSourcePath: string | undefined; for (const path of configPaths) { const parsed = readJson(path, onWarning) as DeepPartial | undefined; if (!parsed) continue; merged = deepMerge(merged, parsed); lastSourcePath = path; } return normalizeConfig(merged, lastSourcePath, onWarning); }