import { createHash, randomUUID } from "node:crypto"; import { createRequire } from "node:module"; import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { SETTINGS_ANNOUNCE_EVENT, SETTINGS_DISCOVER_EVENT, SETTINGS_PROTOCOL_VERSION, type ConfiguredSettingValue, type JsonValue, type SettingDefinition, type SettingsAnnounceEventV1, type SettingsChange, type SettingsContextV1, type SettingsDiscoverEventV1, type SettingsProviderV1, type SettingsResourceConflict, type SettingsResourceRevision, type SettingsSnapshot, type SettingsValidationIssue, } from "pi-maestro-settings-core/v1"; import { CODEX_HOOK_EVENTS, type CodexHooksFile } from "../hooks/schema.ts"; const require = createRequire(import.meta.url); const properLockfile = require("proper-lockfile") as { lock(path: string, options: { realpath: boolean; stale: number; update: number; retries: { retries: number; factor: number; minTimeout: number; maxTimeout: number }; }): Promise<() => Promise>; }; /** * Aggregating hooks provider. Reads/writes `.pi/hooks.json` (codex hooks format) * and surfaces each event's matcher group as a list-crud row * (event / matcher / command). The underlying /hooks installer logic is not * touched — this is a read/write adapter so hooks are configurable in-shell. */ const PROVIDER_ID = "pi-maestro-flow-hooks"; const PROVIDER_VERSION = "1.0.0"; const RESOURCE_ID = "hooks.json"; interface SettingsEventBus { on(event: string, handler: (payload: unknown) => void): void | (() => void); emit(event: string, payload: unknown): void; } const EVENT_OPTIONS = CODEX_HOOK_EVENTS.map((value) => ({ value, labelKey: `hooks.event.${value}` })); const DEFINITIONS: readonly SettingDefinition[] = [ { key: "hooks.entries", group: "hooks.group.entries", order: 0, labelKey: "hooks.entries", descriptionKey: "hooks.entries.description", scopes: ["project"], merge: "override", activation: "next-invocation", sensitivity: "private", reversibility: "full", editor: { kind: "list-crud", addLabelKey: "hooks.entries.add", itemLabelKey: "hooks.entries.item", itemFields: [ { key: "event", group: "hooks.group.field", order: 0, labelKey: "hooks.field.event", scopes: ["project"], merge: "override", activation: "next-invocation", sensitivity: "public", reversibility: "full", editor: { kind: "enum", options: EVENT_OPTIONS } }, { key: "matcher", group: "hooks.group.field", order: 1, labelKey: "hooks.field.matcher", scopes: ["project"], merge: "override", activation: "next-invocation", sensitivity: "public", reversibility: "full", editor: { kind: "text" } }, { key: "command", group: "hooks.group.field", order: 2, labelKey: "hooks.field.command", scopes: ["project"], merge: "override", activation: "next-invocation", sensitivity: "public", reversibility: "full", editor: { kind: "text" } }, ], }, }, ]; const CATALOGS = { en: { "hooks.provider": "Hooks", "hooks.provider.description": "Codex hooks installed in .pi/hooks.json", "hooks.group.entries": "Installed hooks", "hooks.group.field": "Hook", "hooks.entries": "Installed hooks", "hooks.entries.description": "Per-event hook commands. Advanced editing (timeouts, trust) stays in /hooks.", "hooks.entries.add": "Add hook", "hooks.entries.item": "{event} {matcher}", "hooks.field.event": "Event", "hooks.field.matcher": "Matcher", "hooks.field.command": "Command", "hooks.event.SessionStart": "SessionStart", "hooks.event.SubagentStart": "SubagentStart", "hooks.event.PreToolUse": "PreToolUse", "hooks.event.PermissionRequest": "PermissionRequest", "hooks.event.PostToolUse": "PostToolUse", "hooks.event.PreCompact": "PreCompact", "hooks.event.PostCompact": "PostCompact", "hooks.event.UserPromptSubmit": "UserPromptSubmit", "hooks.event.SubagentStop": "SubagentStop", "hooks.event.Stop": "Stop", "hooks.settings.unknownKey": "Unknown hooks setting", "hooks.settings.invalidValue": "Invalid value for this hooks setting", }, "zh-CN": { "hooks.provider": "Hooks", "hooks.provider.description": "安装于 .pi/hooks.json 的 Codex hooks", "hooks.group.entries": "已安装 hooks", "hooks.group.field": "Hook", "hooks.entries": "已安装 hooks", "hooks.entries.description": "每个事件的 hook 命令。高级编辑(超时、信任)保留在 /hooks。", "hooks.entries.add": "添加 hook", "hooks.entries.item": "{event} {matcher}", "hooks.field.event": "事件", "hooks.field.matcher": "匹配器", "hooks.field.command": "命令", "hooks.event.SessionStart": "SessionStart", "hooks.event.SubagentStart": "SubagentStart", "hooks.event.PreToolUse": "PreToolUse", "hooks.event.PermissionRequest": "PermissionRequest", "hooks.event.PostToolUse": "PostToolUse", "hooks.event.PreCompact": "PreCompact", "hooks.event.PostCompact": "PostCompact", "hooks.event.UserPromptSubmit": "UserPromptSubmit", "hooks.event.SubagentStop": "SubagentStop", "hooks.event.Stop": "Stop", "hooks.settings.unknownKey": "未知的 hooks 设置", "hooks.settings.invalidValue": "该 hooks 设置的取值无效", }, } as const; export interface HooksSettingsProvider extends SettingsProviderV1 { readonly providerId: typeof PROVIDER_ID; readonly instanceId: string; } export interface HooksSettingsProviderOptions { getConfigPath?: (cwd: string) => string; } interface HooksDocument { path: string; content: string; raw: CodexHooksFile; revision: SettingsResourceRevision; error?: string; } interface PreparedHooksChange { token: string; transactionId: string; path: string; temporaryPath: string; beforeContent: string; items: JsonValue; release: () => Promise; committedRevision?: SettingsResourceRevision; } export function createHooksSettingsProvider(options: HooksSettingsProviderOptions = {}): HooksSettingsProvider { const instanceId = randomUUID(); const getPath = options.getConfigPath ?? ((cwd: string) => join(cwd, ".pi", "hooks.json")); const prepared = new Map(); const readDocument = (cwd: string): HooksDocument => { const path = getPath(cwd); if (!existsSync(path)) return { path, content: "", raw: { hooks: {} }, revision: revision(path, "") }; const content = readFileSync(path, "utf8"); try { const parsed = JSON.parse(content) as unknown; const raw = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as CodexHooksFile : { hooks: {} }; return { path, content, raw, revision: revision(path, content) }; } catch (error) { return { path, content, raw: { hooks: {} }, revision: revision(path, content), error: error instanceof Error ? error.message : String(error) }; } }; /** Flatten hooks[event] matcher groups into [{event, matcher, command}]. */ const flatten = (raw: CodexHooksFile): JsonValue => { const items: Array> = []; const hooks = raw.hooks ?? {}; for (const [event, groups] of Object.entries(hooks)) { if (!Array.isArray(groups)) continue; for (const group of groups) { if (!group || typeof group !== "object" || !Array.isArray(group.hooks)) continue; const command = group.hooks.find((h) => Boolean(h && typeof h === "object" && (h as Record).type === "command")); items.push({ event, matcher: typeof group.matcher === "string" ? group.matcher : "", command: command && typeof (command as Record).command === "string" ? (command as Record).command : "", }); } } return items as unknown as JsonValue; }; /** Rebuild hooks[event] matcher groups from [{event, matcher, command}], preserving $schema. */ const unflatten = (items: JsonValue, previous: CodexHooksFile): CodexHooksFile => { const hooks: CodexHooksFile["hooks"] = {}; if (Array.isArray(items)) { for (const item of items) { if (!item || typeof item !== "object" || Array.isArray(item)) continue; const record = item as Record; if (typeof record.event !== "string" || typeof record.command !== "string" || !record.command) continue; const event = record.event as keyof CodexHooksFile["hooks"]; const matcher = typeof record.matcher === "string" && record.matcher ? record.matcher : undefined; const group: CodexHooksFile["hooks"][keyof CodexHooksFile["hooks"]] extends readonly (infer G)[] ? G : never = { ...(matcher ? { matcher } : {}), hooks: [{ type: "command", command: record.command, timeout: 600 }], } as CodexHooksFile["hooks"][keyof CodexHooksFile["hooks"]] extends readonly (infer G)[] ? G : never; const list = hooks[event] ?? []; list.push(group); hooks[event] = list; } } const result: Partial = { ...(typeof previous.$schema === "string" ? { $schema: previous.$schema } : {}), hooks }; if (Object.keys(hooks).length === 0) delete result.hooks; return result as CodexHooksFile; }; const snapshot = (doc: HooksDocument, cwd: string): SettingsSnapshot => { const value = flatten(doc.raw); const configured: ConfiguredSettingValue[] = [{ key: "hooks.entries", scope: "project", state: doc.error ? "invalid" : "set", ...(doc.error ? { messageKey: doc.error } : { value }), resource: doc.revision.resource, }]; return { providerId: PROVIDER_ID, providerInstanceId: instanceId, configured: { values: configured, resources: [doc.revision] }, effective: { values: [{ key: "hooks.entries", value, source: doc.error ? "default" : "configured", scope: "project", resource: doc.revision.resource }] }, }; }; const validateChanges = (changes: readonly SettingsChange[], doc: HooksDocument): { issues: SettingsValidationIssue[]; conflicts: SettingsResourceConflict[] } => { const issues: SettingsValidationIssue[] = []; for (const change of changes) { if (change.key !== "hooks.entries") issues.push({ severity: "error", messageKey: "hooks.settings.unknownKey", key: change.key, scope: change.scope }); else if (change.scope !== "project") issues.push({ severity: "error", messageKey: "hooks.settings.invalidValue", key: change.key, scope: change.scope }); else if (change.operation === "set" && !Array.isArray(change.value)) issues.push({ severity: "error", messageKey: "hooks.settings.invalidValue", key: change.key, scope: change.scope }); } const conflicts: SettingsResourceConflict[] = []; for (const expected of requestRevisions ?? []) { if (expected.resource.providerId !== PROVIDER_ID) continue; if (doc.revision.etag !== expected.etag) conflicts.push({ resource: doc.revision.resource, expectedEtag: expected.etag, actualEtag: doc.revision.etag, messageKey: "settings.conflict" }); } return { issues, conflicts }; }; let requestRevisions: readonly SettingsResourceRevision[] | undefined; return { providerId: PROVIDER_ID, instanceId, describe: () => ({ id: PROVIDER_ID, version: PROVIDER_VERSION, instanceId, labelKey: "hooks.provider", descriptionKey: "hooks.provider.description", order: 14, capabilities: { read: true, write: true, prepareCommit: true, rollback: "full", hotUpdate: true }, settings: DEFINITIONS, catalogs: CATALOGS, }), read: (request) => snapshot(readDocument(request.context.cwd), request.context.cwd), validate: (request) => { requestRevisions = request.expectedRevisions; const { issues, conflicts } = validateChanges(request.changes, readDocument(request.context.cwd)); return { valid: issues.length === 0 && conflicts.length === 0, issues, conflicts }; }, prepare: async (request) => { requestRevisions = request.expectedRevisions; const cwd = request.context.cwd; const doc = readDocument(cwd); const { issues, conflicts } = validateChanges(request.changes, doc); if (issues.length > 0 || conflicts.length > 0) return { prepared: false, validation: { valid: false, issues, conflicts } }; const path = getPath(cwd); const release = await properLockfile.lock(path, { realpath: false, stale: 10_000, update: 2_000, retries: { retries: 4, factor: 1.5, minTimeout: 25, maxTimeout: 250 }, }); try { const current = readDocument(cwd); const change = request.changes.find((entry) => entry.key === "hooks.entries"); const nextRaw = change?.operation === "set" ? unflatten(change.value as JsonValue, current.raw) : current.raw; const content = `${JSON.stringify(nextRaw, null, 2)}\n`; const token = randomUUID(); const temporaryPath = `${path}.${process.pid}.${token}.tmp`; mkdirSync(dirname(path), { recursive: true }); writeFileSync(temporaryPath, content, { encoding: "utf8", flag: "wx", mode: 0o600 }); prepared.set(token, { token, transactionId: request.transactionId, path, temporaryPath, beforeContent: current.content, items: change?.operation === "set" ? change.value as JsonValue : [], release, }); return { prepared: true, prepareToken: token, validation: { valid: true, issues: [] }, activation: [{ boundary: "next-invocation", keys: ["hooks.entries"] }] }; } catch (error) { await release().catch(() => undefined); throw error; } }, commit: async (request) => { const state = prepared.get(request.prepareToken); if (!state || state.transactionId !== request.transactionId) throw new Error("prepared hooks transaction is unavailable"); let published = false; try { renameSync(state.temporaryPath, state.path); published = true; const doc = readDocument(request.context.cwd); state.committedRevision = doc.revision; return { snapshot: snapshot(doc, request.context.cwd), revisions: [doc.revision], changedKeys: ["hooks.entries"], activation: [{ boundary: "next-invocation", keys: ["hooks.entries"] }] }; } catch (error) { if (published) { try { writeFileSync(state.path, state.beforeContent); } catch { /* best effort */ } } throw error; } finally { await state.release().catch(() => undefined); } }, abort: async (request) => { for (const [token, entry] of [...prepared.entries()]) { if (entry.transactionId !== request.transactionId) continue; prepared.delete(token); try { if (existsSync(entry.temporaryPath)) rmSync(entry.temporaryPath); } finally { await entry.release().catch(() => undefined); } } }, rollback: async (request) => { let rolledBack = false; for (const [token, entry] of [...prepared.entries()]) { if (entry.transactionId !== request.transactionId) continue; const release = await properLockfile.lock(entry.path, { realpath: false, stale: 10_000, update: 2_000, retries: { retries: 4, factor: 1.5, minTimeout: 25, maxTimeout: 250 }, }); try { const current = readDocument(request.context.cwd); if (entry.committedRevision && current.revision.etag !== entry.committedRevision.etag) continue; writeFileSync(entry.path, entry.beforeContent); rolledBack = true; prepared.delete(token); } finally { await release(); } } const doc = readDocument(request.context.cwd); return { rolledBack, snapshot: snapshot(doc, request.context.cwd) }; }, applyRuntime: (request) => { for (const [token, entry] of [...prepared.entries()]) { if (entry.transactionId === request.transactionId) { prepared.delete(token); void entry.release(); } } return { appliedKeys: [], deferred: [{ boundary: "next-invocation", keys: ["hooks.entries"] }], failed: [] }; }, }; } export function registerHooksSettingsProvider(events: SettingsEventBus, provider: HooksSettingsProvider): () => void { const announce = (requestId?: string): void => { const payload: SettingsAnnounceEventV1 = { version: SETTINGS_PROTOCOL_VERSION, requestId, providerId: provider.providerId, instanceId: provider.instanceId, provider, }; events.emit(SETTINGS_ANNOUNCE_EVENT, payload); }; const result = events.on(SETTINGS_DISCOVER_EVENT, (payload) => { if (isDiscover(payload)) announce(payload.requestId); }); announce(); return () => { if (typeof result === "function") result(); }; } function revision(path: string, content: string): SettingsResourceRevision { return { resource: { providerId: PROVIDER_ID, scope: "project", id: RESOURCE_ID }, etag: createHash("sha256").update(content || "").digest("hex"), size: Buffer.byteLength(content), }; } function isDiscover(payload: unknown): payload is SettingsDiscoverEventV1 { return Boolean(payload && typeof payload === "object" && (payload as Partial).version === SETTINGS_PROTOCOL_VERSION && typeof (payload as Partial).requestId === "string"); }