From 87a601cb78e76afa6411897e7d3bbb190557ddcb Mon Sep 17 00:00:00 2001 From: Andrew Eye Date: Thu, 28 May 2026 22:37:22 -0500 Subject: [PATCH 1/3] FEA-1444: Add opt-in Codex hook ingestion for Agent Monitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add codex-hook-handler.js wrapper that mirrors upstream hook-handler.js semantics but injects __provider: "codex" so the sidecar can stamp the session row with harness='codex'. Zero-dep plain JS, fail-silent. - Split agent-monitor-hooks.ts: pure install/uninstall logic moved to new agent-monitor-hooks-core.ts (electron-free, testable under tsx --test). Shell retains electron-store + path resolution + gatewayLog. - Add installCodexHooks / uninstallCodexHooks targeting $CODEX_HOME/hooks.json with the narrower Codex hook event set (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop). SessionStart uses the "startup|resume" matcher per Codex's reference contract. - Filename-boundary predicate (isClaudeEntry vs isCodexEntry) prevents cross-matching between the two handler families. - New codexOptIn electron-store flag (default false) gates Codex hook installation. Behavior change: the master hooks toggle is unchanged for existing users; Codex hooks only install when both flags are true. Deferred to a follow-up: event-level dedup against the existing codex-watcher rollout-tail path, and the renderer UI to flip codexOptIn. - syncAgentMonitorHooksOnBoot reconciles Codex state with persisted intent (installs when opted in, uninstalls stale entries when opted out) so a failed file-layer mutation is eventually consistent on the next launch. - setAgentMonitorCodexHooksOptIn persists intent before applying side effects so a failure (e.g., locked hooks.json) cannot silently lose the user's opt-out. - build-agent-monitor.mjs gains a surgical patch to the generated server/routes/hooks.js that calls stmts.setSessionHarness when the inbound payload carries __provider: "codex". Patch is hash-anchored and hard-gated; future upstream drift fails the build loudly. - Bump desktop version to 0.15.98 per repo CI rule. Testing: - pnpm -C apps/desktop typecheck: clean - pnpm -C apps/desktop lint: clean - pnpm -C apps/desktop build:agent-monitor: clean (patch + hard-gates pass) - New test/agent-monitor-hooks-core.test.ts: 10/10 pass — covers predicate identity, matcher rules per harness, idempotent install, in-place self-heal of moved handler paths, narrow-scope uninstall, foreign-entry preservation, hooks-block cleanup, and Codex/Claude cross-installation independence. - test/agent-monitor-wiring-static.test.ts: 27/27 pass after retargeting three moved-string assertions to the new core file. - Full desktop test suite: 1825/1827 pass. The 2 remaining failures (python3 version-string parsing, telemetry healthcheck dedupe) are pre-existing flakes in unrelated files I did not touch. - Independent code-reviewer agent surfaced a Medium correctness finding (failed opt-out silently lost) and a Low note (function exported without IPC). Both addressed in this commit; the IPC wiring is an intentional follow-up. Risks: - Codex hooks are gated behind a new opt-in flag that has no UI yet, so default behavior is unchanged for all existing users. Risk surface is limited to anyone who flips the flag via dev console / tests until the UI ships. - The build-script patch depends on patchHooksTranscriptOutsideTx (FEA- 1363) running first; ordering enforced in materializeAgentMonitor and the patch is itself idempotent. A future upstream bump that drops the processEventCore anchor will fail the build hard rather than silently dropping the harness stamp. - The agent-monitor-hooks.ts → agent-monitor-hooks-core.ts split is a refactor of stable code; semantic equivalence verified by the existing wiring-static test plus the new core test. --- apps/desktop/package.json | 2 +- .../agent-monitor-codex/codex-hook-handler.js | 80 +++++ apps/desktop/scripts/build-agent-monitor.mjs | 85 +++++ .../src/main/agent-monitor-hooks-core.ts | 259 +++++++++++++++ apps/desktop/src/main/agent-monitor-hooks.ts | 312 ++++++++++-------- .../test/agent-monitor-hooks-core.test.ts | 276 ++++++++++++++++ .../test/agent-monitor-wiring-static.test.ts | 15 +- 7 files changed, 896 insertions(+), 133 deletions(-) create mode 100644 apps/desktop/scripts/agent-monitor-codex/codex-hook-handler.js create mode 100644 apps/desktop/src/main/agent-monitor-hooks-core.ts create mode 100644 apps/desktop/test/agent-monitor-hooks-core.test.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2aea75e7..54ca2b30 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.15.99", + "version": "0.15.100", "description": "ClosedLoop Desktop", "author": "ClosedLoop AI ", "private": true, diff --git a/apps/desktop/scripts/agent-monitor-codex/codex-hook-handler.js b/apps/desktop/scripts/agent-monitor-codex/codex-hook-handler.js new file mode 100644 index 00000000..0b045b03 --- /dev/null +++ b/apps/desktop/scripts/agent-monitor-codex/codex-hook-handler.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +/** + * @file codex-hook-handler.js + * @description Codex CLI hook handler. Mirrors the upstream Claude + * `hook-handler.js` (provider-agnostic, POSTs to `/api/hooks/event` on the + * fixed agent-monitor sidecar port 4820) but injects `__provider: "codex"` + * into the forwarded payload so the sidecar can stamp the session row with + * `harness='codex'` via the existing `setSessionHarness` statement. + * + * Zero-dep, plain JS, fail-silent — same constraints as the upstream Claude + * handler so a hook never blocks a Codex turn. Codex calls this once per + * lifecycle event (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, + * Stop) with the event name as the single argv arg. + * + * Part of FEA-1444 (opt-in Codex hook ingestion). + */ + +const http = require("http"); + +const hookType = process.argv[2] || "unknown"; +const port = parseInt(process.env.CLAUDE_DASHBOARD_PORT || "4820", 10); + +let input = ""; + +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => (input += chunk)); +process.stdin.on("end", () => { + let parsedData; + try { + parsedData = JSON.parse(input); + } catch { + parsedData = { raw: input }; + } + + // Mark the payload as Codex-sourced so the sidecar's hooks-route patch + // (build-agent-monitor.mjs `patchHooksRouteCodexHarness`) can stamp the + // session's `harness` column. Field name is dunder-prefixed to make it + // obvious this is a transport hint, not a Codex-native field. + const enrichedData = + parsedData && typeof parsedData === "object" && !Array.isArray(parsedData) + ? { ...parsedData, __provider: "codex" } + : { raw: parsedData, __provider: "codex" }; + + const payload = JSON.stringify({ + hook_type: hookType, + data: enrichedData, + }); + + const req = http.request( + { + hostname: "127.0.0.1", + port, + path: "/api/hooks/event", + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }, + timeout: 3000, + }, + (res) => { + res.resume(); + process.exit(0); + }, + ); + + req.on("error", () => process.exit(0)); + req.on("timeout", () => { + req.destroy(); + process.exit(0); + }); + + req.write(payload); + req.end(); +}); + +// Safety net timeout — Codex's default hook timeout is around 5s; never let +// this process linger longer than that. +setTimeout(() => process.exit(0), 5000); diff --git a/apps/desktop/scripts/build-agent-monitor.mjs b/apps/desktop/scripts/build-agent-monitor.mjs index 263ec1d4..eda68057 100644 --- a/apps/desktop/scripts/build-agent-monitor.mjs +++ b/apps/desktop/scripts/build-agent-monitor.mjs @@ -133,6 +133,14 @@ const IS_SESSION_IN_SANDBOX_CJS = [ // — relative requires resolve identically in the generated tree as they did // in the old vendored tree. const codexModulesDir = path.join(appDir, "scripts", "agent-monitor-codex"); +// FEA-1444: Codex hook handler wrapper ships in-repo (mirrors the upstream +// Claude hook-handler.js placement under generated `scripts/`). Copied at +// materialize time and surfaced by agent-monitor-hooks.ts via the same +// `scriptsDir` resolver as the Claude handler. +const codexHookHandlerSource = path.join( + codexModulesDir, + "codex-hook-handler.js", +); const cursorModulesDir = path.join(appDir, "scripts", "agent-monitor-cursor"); const copilotModulesDir = path.join(appDir, "scripts", "agent-monitor-copilot"); const opencodeModulesDir = path.join(appDir, "scripts", "agent-monitor-opencode"); @@ -459,6 +467,9 @@ function currentStamp() { ...MULTI_HARNESS_SPECS.flatMap(({ modulesDir, modules }) => modules.map((m) => path.join(modulesDir, `${m}.js`)), ), + // FEA-1444: invalidate the cached generated tree when the Codex hook + // wrapper source changes. + codexHookHandlerSource, ...SHARED_MODULES.map((m) => path.join(sharedModulesDir, `${m}.js`)), ...CLIENT_SNIPPET_FILES.map((file) => path.join(clientSnippetDir, file)), ...PLAN_MODULES.map((m) => path.join(planModulesDir, `${m}.js`)), @@ -640,11 +651,24 @@ function materializeRuntimeTree() { patchHooksTranscriptOutsideTx(generatedHooksRoute); patchHooksWriteQueueAndWatchdog(generatedHooksRoute); patchHooksSandboxFilter(generatedHooksRoute); + // FEA-1444: stamp harness='codex' inside processEventCore when the inbound + // hook payload was forwarded by the Codex wrapper handler. Order: must run + // AFTER patchHooksTranscriptOutsideTx because it relies on the + // `function processEventCore(hookType, data, ...)` signature that patch + // installs. + patchHooksRouteCodexHarness(generatedHooksRoute); patchImportRoute(generatedImportRoute); patchPushFile(generatedPushLib); patchWebSocketFile(generatedWebSocketFile); patchCcDiscovery(generatedCcDiscovery); writeFileSync(generatedUninstallHooks, UNINSTALL_HOOKS_SOURCE, "utf8"); + // FEA-1444: copy the Codex hook wrapper into the generated scripts/ tree + // so the agent-monitor-hooks installer can resolve it via the same + // `scriptsDir` it uses for the upstream Claude hook-handler.js. + cpSync( + codexHookHandlerSource, + path.join(generatedRootDir, "scripts", "codex-hook-handler.js"), + ); } function patchServerIndex(file) { @@ -1734,6 +1758,54 @@ function patchHooksRoute(file) { writeFileSync(file, source, "utf8"); } +// CLOSEDLOOP FEA-1444: stamp `harness='codex'` on the session row when the +// inbound hook payload was forwarded by the in-repo `codex-hook-handler.js` +// wrapper (which injects `__provider: "codex"`). The upstream hooks route is +// provider-agnostic and already supports a `harness` column + setSessionHarness +// statement (Codex Patch #4); this patch is the single place that wires the +// hook stream to that stamp so the dashboard renders Codex-sourced hook events +// alongside the existing rollout-tail-imported rows. +function patchHooksRouteCodexHarness(file) { + let source = readFileSync(file, "utf8"); + if (source.includes("FEA-1444 codex harness stamp")) return; + + // Anchors on the processEventCore signature installed by + // patchHooksTranscriptOutsideTx (FEA-1363) + the immediate ensureSession + // call. Both must already be present; this patch runs after that one in + // the materialize sequence. + const needle = [ + "function processEventCore(hookType, data, transcriptData) {", + " const sessionId = data.session_id;", + " if (!sessionId) return null;", + "", + " const session = ensureSession(sessionId, data);", + ].join("\n"); + if (!source.includes(needle)) { + throw new Error( + `Unable to patch ${file}: expected processEventCore + ensureSession anchor (FEA-1444).`, + ); + } + source = source.replace( + needle, + [ + needle, + " // FEA-1444 codex harness stamp: the in-repo codex-hook-handler.js", + " // wrapper injects `__provider: \"codex\"` into the forwarded hook", + " // payload. When present, mark the session row as a Codex session so", + " // the dashboard groups it with the rollout-tail-imported rows and", + " // any harness-scoped UI affordances apply.", + " if (session && data && data.__provider === \"codex\") {", + " try {", + " stmts.setSessionHarness.run(\"codex\", sessionId, \"codex\");", + " } catch (_) {", + " /* non-fatal: harness column/stmt guaranteed by Codex Patch #4 */", + " }", + " }", + ].join("\n"), + ); + writeFileSync(file, source, "utf8"); +} + // CLOSEDLOOP FEA-1363: Fix SQLite write contention under 22+ concurrent agents. // Three patches below address five compounding SQLite problems that cause the // agent monitor dashboard to corrupt when many agents fire hooks simultaneously. @@ -3097,6 +3169,10 @@ function assertGeneratedTree() { generatedClientIndex, path.join(generatedRootDir, "scripts", "install-hooks.js"), path.join(generatedRootDir, "scripts", "hook-handler.js"), + // FEA-1444: Codex hook wrapper must materialize alongside the upstream + // Claude handler so agent-monitor-hooks.ts can resolve both from the same + // `scriptsDir`. + path.join(generatedRootDir, "scripts", "codex-hook-handler.js"), generatedUninstallHooks, ]) { if (!existsSync(required)) { @@ -3322,6 +3398,15 @@ function assertGeneratedTree() { "Generated server/routes/hooks.js is missing the sandbox scoping filter (FEA-1407).", ); } + + // CLOSEDLOOP FEA-1444 hard-gates: Codex hook ingestion. A future upstream + // bump that drops the processEventCore anchor must fail the build rather + // than silently disabling Codex harness stamping on hook-sourced sessions. + if (!hooksRouteSource.includes("FEA-1444 codex harness stamp")) { + throw new Error( + "Generated server/routes/hooks.js is missing the Codex harness stamp (FEA-1444).", + ); + } if (!importHistorySource.includes("FEA-1407 sandbox scoping")) { throw new Error( "Generated scripts/import-history.js is missing the sandbox scoping filter (FEA-1407).", diff --git a/apps/desktop/src/main/agent-monitor-hooks-core.ts b/apps/desktop/src/main/agent-monitor-hooks-core.ts new file mode 100644 index 00000000..85c6752c --- /dev/null +++ b/apps/desktop/src/main/agent-monitor-hooks-core.ts @@ -0,0 +1,259 @@ +// Electron-free core of the Agent Monitor hook installer. Path resolution, +// `app.getPath()`, `electron-store`, and the `gatewayLog` logger live in the +// outer `agent-monitor-hooks.ts` shell; this module owns only the +// settings-file manipulation, hook-entry generation, and idempotent merge +// logic so the behavior can be exercised under `tsx --test` without an +// Electron runtime. +// +// FEA-1444: extended with Codex hook handling. The Codex path mirrors the +// Claude path: distinct handler filename, distinct settings file, separate +// idempotency predicate. Both file targets are JSON with the same outer +// `{ hooks: { : [{ matcher?, hooks: [{ type, command }] }] } }` +// shape (Codex's experimental hook config gated by `[features].codex_hooks`). + +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; + +// Distinct handler filenames let isClaudeEntry / isCodexEntry coexist safely +// even if a future bug ever co-mingles entries in one settings file. +export const CLAUDE_HANDLER_FILENAME = "hook-handler.js"; +export const CODEX_HANDLER_FILENAME = "codex-hook-handler.js"; + +// Mirrors the upstream install-hooks.js contract: same event set, same +// matcher rule. Re-verify on every upstream bump. +export const HOOKS_WITH_MATCHER = [ + "PreToolUse", + "PostToolUse", + "Stop", + "SubagentStop", + "Notification", +] as const; +export const HOOKS_WITHOUT_MATCHER = [ + "SessionStart", + "SessionEnd", + "UserPromptSubmit", +] as const; +export const HOOK_TYPES = [...HOOKS_WITH_MATCHER, ...HOOKS_WITHOUT_MATCHER]; + +// FEA-1444: Codex hook surface is narrower than Claude's (no SessionEnd, +// SubagentStop, or Notification today). Per Daniel Levesque's reference +// writer in closedloop-ai/workflow `telemetry/codex-hook-writer.js`. +// Re-verify on Codex version bumps. +export const CODEX_HOOKS_WITH_MATCHER = [ + "PreToolUse", + "PostToolUse", +] as const; +export const CODEX_HOOKS_WITHOUT_MATCHER = [ + "SessionStart", + "UserPromptSubmit", + "Stop", +] as const; +export const CODEX_HOOK_TYPES = [ + ...CODEX_HOOKS_WITH_MATCHER, + ...CODEX_HOOKS_WITHOUT_MATCHER, +]; +// Codex `SessionStart` accepts a matcher distinguishing fresh startup from +// a resumed session; the other events do not honor matchers. +export const CODEX_SESSION_START_MATCHER = "startup|resume"; + +/** + * Filename-boundary check: matches a path token equal to `filename` preceded + * by a path separator. Prevents `codex-hook-handler.js` from matching the + * `hook-handler.js` probe (and vice versa). + */ +function commandReferences(entry: unknown, filename: string): boolean { + if (!entry || typeof entry !== "object") { + return false; + } + const e = entry as { + command?: unknown; + hooks?: Array<{ command?: unknown }>; + }; + const haystack = (s: unknown) => + typeof s === "string" && + (s.includes(`/${filename}"`) || + s.includes(`/${filename} `) || + s.includes(`\\${filename}"`) || + s.includes(`\\${filename} `)); + if (haystack(e.command)) { + return true; + } + if (Array.isArray(e.hooks)) { + return e.hooks.some((h) => haystack(h?.command)); + } + return false; +} + +export function isClaudeEntry(entry: unknown): boolean { + return commandReferences(entry, CLAUDE_HANDLER_FILENAME); +} + +export function isCodexEntry(entry: unknown): boolean { + return commandReferences(entry, CODEX_HANDLER_FILENAME); +} + +/** + * Returns the shell-ready hook command. Caller supplies `execPath` (typically + * `process.execPath`) so the command spawns the Electron binary as Node via + * `ELECTRON_RUN_AS_NODE=1` — no system `node` is required. + */ +export function makeHookCommand( + execPath: string, + handler: string, + hookType: string, +): string { + return `ELECTRON_RUN_AS_NODE=1 "${execPath}" "${handler}" ${JSON.stringify(hookType)}`; +} + +export function makeClaudeHookEntry( + execPath: string, + handler: string, + hookType: string, +): Record { + const entry: Record = { + hooks: [ + { type: "command", command: makeHookCommand(execPath, handler, hookType) }, + ], + }; + if ((HOOKS_WITH_MATCHER as readonly string[]).includes(hookType)) { + entry.matcher = "*"; + } + return entry; +} + +export function makeCodexHookEntry( + execPath: string, + handler: string, + hookType: string, +): Record { + const entry: Record = { + hooks: [ + { type: "command", command: makeHookCommand(execPath, handler, hookType) }, + ], + }; + if ((CODEX_HOOKS_WITH_MATCHER as readonly string[]).includes(hookType)) { + entry.matcher = "*"; + } else if (hookType === "SessionStart") { + entry.matcher = CODEX_SESSION_START_MATCHER; + } + return entry; +} + +export function readSettingsFile(file: string): Record { + if (!existsSync(file)) { + return {}; + } + return JSON.parse(readFileSync(file, "utf8")) as Record; +} + +/** + * Atomic-rename write: stage to a sibling `.tmp` file, then `rename` so a + * crash mid-write never leaves a half-written settings file the user's + * tooling has to recover from. + */ +export function writeSettingsFile(file: string, settings: unknown): void { + const dir = path.dirname(file); + mkdirSync(dir, { recursive: true }); + const tempFile = path.join( + dir, + `${path.basename(file)}.${process.pid}.${Date.now()}.tmp`, + ); + writeFileSync(tempFile, JSON.stringify(settings, null, 2) + "\n", "utf8"); + renameSync(tempFile, file); +} + +export interface ApplyHooksInput { + /** Path to the JSON settings file to mutate. */ + file: string; + /** Hook event names to install. */ + hookTypes: readonly string[]; + /** Identity predicate: returns true for an entry owned by this installer. */ + isOurEntry: (entry: unknown) => boolean; + /** Factory: produce the canonical entry for one hook type. */ + makeEntry: (hookType: string) => Record; +} + +/** + * Idempotent in-place install: replaces a stale entry of ours (self-heals a + * moved handler path) or appends a fresh one. Mirrors upstream + * `install-hooks.js`. Returns the number of entries installed plus the + * number repaired. + */ +export function applyHookInstall( + input: ApplyHooksInput, +): { installed: number; repaired: number } { + const settings = readSettingsFile(input.file); + const hooks = (settings.hooks ??= {}) as Record; + let installed = 0; + let repaired = 0; + + for (const hookType of input.hookTypes) { + const list = (hooks[hookType] ??= []); + const idx = list.findIndex(input.isOurEntry); + const entry = input.makeEntry(hookType); + if (idx >= 0) { + list[idx] = entry; + repaired += 1; + } else { + list.push(entry); + installed += 1; + } + } + writeSettingsFile(input.file, settings); + return { installed, repaired }; +} + +export interface ApplyUninstallInput { + /** Path to the JSON settings file to clean. No-op if missing. */ + file: string; + /** Identity predicate: returns true for an entry owned by this installer. */ + isOurEntry: (entry: unknown) => boolean; +} + +/** + * Removes only entries identified by `isOurEntry`. Preserves other entries. + * Deletes empty per-event arrays and the outer `hooks` block when nothing + * remains, so the file stays clean. + */ +export function applyHookUninstall(input: ApplyUninstallInput): { + removed: number; +} { + if (!existsSync(input.file)) { + return { removed: 0 }; + } + const settings = readSettingsFile(input.file); + const hooks = settings.hooks as Record | undefined; + if (!hooks) { + return { removed: 0 }; + } + let removed = 0; + for (const hookType of Object.keys(hooks)) { + const list = hooks[hookType]; + if (!Array.isArray(list)) { + continue; + } + const kept = list.filter((e) => { + const ours = input.isOurEntry(e); + if (ours) { + removed += 1; + } + return !ours; + }); + if (kept.length > 0) { + hooks[hookType] = kept; + } else { + delete hooks[hookType]; + } + } + if (Object.keys(hooks).length === 0) { + delete settings.hooks; + } + writeSettingsFile(input.file, settings); + return { removed }; +} diff --git a/apps/desktop/src/main/agent-monitor-hooks.ts b/apps/desktop/src/main/agent-monitor-hooks.ts index 576a62e1..ce6a6723 100644 --- a/apps/desktop/src/main/agent-monitor-hooks.ts +++ b/apps/desktop/src/main/agent-monitor-hooks.ts @@ -1,12 +1,5 @@ import { app } from "electron"; -import { - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - renameSync, - writeFileSync, -} from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -14,27 +7,27 @@ import Store from "electron-store"; import { gatewayLog } from "./gateway-logger.js"; import { resolveAgentMonitorPaths } from "./agent-monitor-path.js"; +import { + applyHookInstall, + applyHookUninstall, + CODEX_HOOK_TYPES, + HOOK_TYPES, + isClaudeEntry, + isCodexEntry, + makeClaudeHookEntry, + makeCodexHookEntry, +} from "./agent-monitor-hooks-core.js"; const TAG = "agent-monitor-hooks"; -// Mirrors the upstream install-hooks.js contract: same event set, same matcher -// rule. Re-verify on every upstream bump. -const HOOKS_WITH_MATCHER = [ - "PreToolUse", - "PostToolUse", - "Stop", - "SubagentStop", - "Notification", -] as const; -const HOOKS_WITHOUT_MATCHER = [ - "SessionStart", - "SessionEnd", - "UserPromptSubmit", -] as const; -const HOOK_TYPES = [...HOOKS_WITH_MATCHER, ...HOOKS_WITHOUT_MATCHER]; - interface HooksFlagStore { enabled: boolean; + // FEA-1444: opt-in flag for installing Codex hooks alongside Claude hooks. + // Defaults false — Codex telemetry already flows via the rollout-tail + // watcher (FEA-1189), and enabling hooks without event-level dedup against + // that watcher would double-count. Future work tracks the dedup; until then + // this stays opt-in so existing users see no behavior change. + codexOptIn?: boolean; } let flagStore: Store | null = null; @@ -47,26 +40,8 @@ export function isAgentMonitorHooksEnabled(): boolean { return store().get("enabled", false) === true; } -// Matches install-hooks.js / uninstall-hooks.js isOurEntry: any hook whose -// command references hook-handler.js. Keeps install/uninstall symmetric with -// the generated CLI scripts. -function isOurEntry(entry: unknown): boolean { - if (!entry || typeof entry !== "object") { - return false; - } - const e = entry as { - command?: unknown; - hooks?: Array<{ command?: unknown }>; - }; - if (typeof e.command === "string" && e.command.includes("hook-handler.js")) { - return true; - } - if (Array.isArray(e.hooks)) { - return e.hooks.some( - (h) => typeof h?.command === "string" && h.command.includes("hook-handler.js"), - ); - } - return false; +export function isAgentMonitorCodexHooksOptIn(): boolean { + return store().get("codexOptIn", false) === true; } // Same resolution as agent-dashboard/server/lib/claude-home.js: @@ -76,6 +51,27 @@ function claudeSettingsPath(): string { return path.join(home, "settings.json"); } +// Mirrors scripts/agent-monitor-codex/codex-home.js getCodexHome: honor +// $CODEX_HOME first (some setups use a comma-separated list whose first entry +// is the active root), fall back to ~/.codex. +function codexHomeDir(): string { + const raw = process.env.CODEX_HOME; + if (raw && raw.trim()) { + const first = raw.split(",")[0]?.trim(); + if (first) { + return first.replace(/^~(?=\/)/, os.homedir()); + } + } + return path.join(os.homedir(), ".codex"); +} + +// Codex's experimental hook config (gated by `[features].codex_hooks = true` +// in Codex's own `config.toml`) lives in `$CODEX_HOME/hooks.json`. The JSON +// shape mirrors Claude Code's `settings.json` hooks block. +function codexHooksPath(): string { + return path.join(codexHomeDir(), "hooks.json"); +} + // A zero-dependency single file (pure node:http). Copying it to userData makes // the installed hook command independent of the .app location (survives app // move/rename and in-place updates). @@ -83,6 +79,14 @@ function userDataHandlerPath(): string { return path.join(app.getPath("userData"), "agent-monitor", "hook-handler.js"); } +function userDataCodexHandlerPath(): string { + return path.join( + app.getPath("userData"), + "agent-monitor", + "codex-hook-handler.js", + ); +} + function refreshHandlerCopy(): string { const { scriptsDir } = resolveAgentMonitorPaths(); const src = path.join(scriptsDir, "hook-handler.js"); @@ -100,64 +104,37 @@ function refreshHandlerCopy(): string { return dest; } -function makeHookCommand(handler: string, hookType: string): string { - // Executed by Claude Code via the shell. Use the Electron binary as Node - // (ELECTRON_RUN_AS_NODE) so no system `node` is required. Port defaults to - // 4820 inside hook-handler.js, which matches our fixed sidecar port — so no - // per-hook env is needed (avoids depending on Claude Code honoring it). - return `ELECTRON_RUN_AS_NODE=1 "${process.execPath}" "${handler}" ${JSON.stringify(hookType)}`; -} - -function makeHookEntry( - handler: string, - hookType: string, -): Record { - const entry: Record = { - hooks: [{ type: "command", command: makeHookCommand(handler, hookType) }], - }; - if ((HOOKS_WITH_MATCHER as readonly string[]).includes(hookType)) { - entry.matcher = "*"; +// FEA-1444: the Codex wrapper handler ships in-repo (not vendored from +// upstream agent-dashboard) under `apps/desktop/scripts/agent-monitor-codex/`, +// and is copied into the generated agent-monitor scripts/ tree by +// build-agent-monitor.mjs so packaged + dev builds resolve to the same +// scriptsDir as the upstream Claude handler. +function refreshCodexHandlerCopy(): string { + const { scriptsDir } = resolveAgentMonitorPaths(); + const src = path.join(scriptsDir, "codex-hook-handler.js"); + if (!existsSync(src)) { + throw new Error( + `codex-hook-handler.js not found at ${src} — run \`pnpm -C apps/desktop build:agent-monitor\``, + ); } - return entry; -} - -function readSettings(file: string): Record { - if (!existsSync(file)) { - return {}; + const dest = userDataCodexHandlerPath(); + mkdirSync(path.dirname(dest), { recursive: true }); + const srcContent = readFileSync(src); + if (!existsSync(dest) || !readFileSync(dest).equals(srcContent)) { + copyFileSync(src, dest); } - return JSON.parse(readFileSync(file, "utf8")) as Record; -} - -function writeSettings(file: string, settings: unknown): void { - const dir = path.dirname(file); - mkdirSync(dir, { recursive: true }); - const tempFile = path.join( - dir, - `${path.basename(file)}.${process.pid}.${Date.now()}.tmp`, - ); - writeFileSync(tempFile, JSON.stringify(settings, null, 2) + "\n", "utf8"); - renameSync(tempFile, file); + return dest; } -// Idempotent: replaces a stale entry of ours in place (self-heals a moved -// handler path), otherwise appends. Mirrors upstream install-hooks.js. function installHooks(): void { const handler = refreshHandlerCopy(); - const file = claudeSettingsPath(); - const settings = readSettings(file); - const hooks = (settings.hooks ??= {}) as Record; - - for (const hookType of HOOK_TYPES) { - const list = (hooks[hookType] ??= []); - const idx = list.findIndex(isOurEntry); - const entry = makeHookEntry(handler, hookType); - if (idx >= 0) { - list[idx] = entry; - } else { - list.push(entry); - } - } - writeSettings(file, settings); + applyHookInstall({ + file: claudeSettingsPath(), + hookTypes: HOOK_TYPES, + isOurEntry: isClaudeEntry, + makeEntry: (hookType) => + makeClaudeHookEntry(process.execPath, handler, hookType), + }); gatewayLog.info( TAG, `installed/repaired ${HOOK_TYPES.length} Claude Code hooks -> ${handler}`, @@ -165,39 +142,44 @@ function installHooks(): void { } function uninstallHooks(): void { - const file = claudeSettingsPath(); - if (!existsSync(file)) { - return; - } - const settings = readSettings(file); - const hooks = settings.hooks as Record | undefined; - if (!hooks) { - return; - } - let removed = 0; - for (const hookType of Object.keys(hooks)) { - const list = hooks[hookType]; - if (!Array.isArray(list)) { - continue; - } - const kept = list.filter((e) => { - const ours = isOurEntry(e); - if (ours) { - removed += 1; - } - return !ours; - }); - if (kept.length > 0) { - hooks[hookType] = kept; - } else { - delete hooks[hookType]; - } - } - if (Object.keys(hooks).length === 0) { - delete settings.hooks; - } - writeSettings(file, settings); - gatewayLog.info(TAG, `removed ${removed} Claude Code hook entr${removed === 1 ? "y" : "ies"}`); + const result = applyHookUninstall({ + file: claudeSettingsPath(), + isOurEntry: isClaudeEntry, + }); + gatewayLog.info( + TAG, + `removed ${result.removed} Claude Code hook entr${result.removed === 1 ? "y" : "ies"}`, + ); +} + +// FEA-1444: install Codex hooks alongside Claude hooks. Same idempotency + +// self-heal semantics as installHooks(), targeting `$CODEX_HOME/hooks.json` +// and the Codex wrapper handler. Caller is responsible for gating on +// `isAgentMonitorCodexHooksOptIn()`. +function installCodexHooks(): void { + const handler = refreshCodexHandlerCopy(); + applyHookInstall({ + file: codexHooksPath(), + hookTypes: CODEX_HOOK_TYPES, + isOurEntry: isCodexEntry, + makeEntry: (hookType) => + makeCodexHookEntry(process.execPath, handler, hookType), + }); + gatewayLog.info( + TAG, + `installed/repaired ${CODEX_HOOK_TYPES.length} Codex hooks -> ${handler}`, + ); +} + +function uninstallCodexHooks(): void { + const result = applyHookUninstall({ + file: codexHooksPath(), + isOurEntry: isCodexEntry, + }); + gatewayLog.info( + TAG, + `removed ${result.removed} Codex hook entr${result.removed === 1 ? "y" : "ies"}`, + ); } export interface AgentMonitorHooksResult { @@ -212,8 +194,18 @@ export function setAgentMonitorHooksEnabled( try { if (enabled) { installHooks(); + // FEA-1444: when the user previously opted into Codex hooks, install + // those too. If they have not opted in, ensure no stale Codex entries + // remain (a previous opt-in followed by opt-out, then re-enable, must + // not silently re-install Codex hooks). + if (isAgentMonitorCodexHooksOptIn()) { + installCodexHooks(); + } else { + uninstallCodexHooks(); + } } else { uninstallHooks(); + uninstallCodexHooks(); } store().set("enabled", enabled); return { ok: true, enabled }; @@ -227,6 +219,49 @@ export function setAgentMonitorHooksEnabled( } } +// FEA-1444: flip the Codex opt-in flag. When the master `enabled` flag is +// already on, immediately install/uninstall the Codex hook entries to reflect +// the new opt-in state. +// +// Currently exported but not yet wired to any IPC handler — the renderer +// toggle is a separate follow-up ticket. Until then this is reachable only +// from tests / dev consoles. Do not remove: removing this would make +// codexOptIn permanently false in production builds. +// +// Persist-then-apply order is intentional: a side-effect failure (e.g., +// `~/.codex/hooks.json` locked or malformed) must not silently lose the +// user's intent. `syncAgentMonitorHooksOnBoot` re-applies from the +// persisted intent at next launch, so the eventual state remains +// consistent with what the user asked for. See CLAUDE.md learned pattern: +// "Setting toggles must update persisted state and in-memory side effects +// together." +export function setAgentMonitorCodexHooksOptIn( + optIn: boolean, +): AgentMonitorHooksResult { + store().set("codexOptIn", optIn); + try { + if (isAgentMonitorHooksEnabled()) { + if (optIn) { + installCodexHooks(); + } else { + uninstallCodexHooks(); + } + } + return { ok: true, enabled: isAgentMonitorHooksEnabled() }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + gatewayLog.error( + TAG, + `failed to apply Codex hook opt-in side effect (intent persisted as ${optIn}): ${message}`, + ); + return { + ok: false, + enabled: isAgentMonitorHooksEnabled(), + error: message, + }; + } +} + // Boot-time repair: if the user previously opted in, re-copy the handler and // re-write the entries so a moved/updated .app self-heals. No-op when disabled // (and never throws into boot). @@ -242,4 +277,23 @@ export function syncAgentMonitorHooksOnBoot(): void { `boot hook repair failed: ${error instanceof Error ? error.message : String(error)}`, ); } + // FEA-1444: reconcile Codex hook state with the persisted intent. When + // opted in, self-heal the install (mirrors the Claude repair above). + // When opted out, remove any stale entries that may linger from a prior + // opt-in whose uninstall failed at the file layer (setAgentMonitorCodex- + // HooksOptIn persists intent before applying side effects). Independent + // try/catch so a Codex-side failure cannot suppress the Claude-side + // repair logging above. + try { + if (isAgentMonitorCodexHooksOptIn()) { + installCodexHooks(); + } else { + uninstallCodexHooks(); + } + } catch (error) { + gatewayLog.warn( + TAG, + `boot Codex hook reconcile failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } } diff --git a/apps/desktop/test/agent-monitor-hooks-core.test.ts b/apps/desktop/test/agent-monitor-hooks-core.test.ts new file mode 100644 index 00000000..74e9013c --- /dev/null +++ b/apps/desktop/test/agent-monitor-hooks-core.test.ts @@ -0,0 +1,276 @@ +// FEA-1444 regression coverage for the electron-free hook-install core. +// The outer agent-monitor-hooks.ts shell imports `electron`, which the +// `tsx --test` runner cannot load. The core module owns the install/uninstall +// logic in isolation so the behavior contract — idempotent install, in-place +// self-heal of a moved handler path, narrow-scope uninstall that leaves +// foreign entries alone, and clean separation between Claude and Codex +// handler identity — can be exercised here without an Electron runtime. + +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, test } from "node:test"; + +import { + applyHookInstall, + applyHookUninstall, + CODEX_HOOK_TYPES, + CODEX_SESSION_START_MATCHER, + HOOK_TYPES, + isClaudeEntry, + isCodexEntry, + makeClaudeHookEntry, + makeCodexHookEntry, +} from "../src/main/agent-monitor-hooks-core.js"; + +let tempRoot = ""; +const FAKE_EXEC = "/fake/Electron"; +const CLAUDE_HANDLER = "/fake/userData/agent-monitor/hook-handler.js"; +const CODEX_HANDLER = "/fake/userData/agent-monitor/codex-hook-handler.js"; + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "fea1444-hooks-")); +}); + +afterEach(() => { + if (tempRoot) { + rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +function readJson(file: string): Record { + return JSON.parse(readFileSync(file, "utf8")) as Record; +} + +test("isClaudeEntry / isCodexEntry distinguish handler identities", () => { + const claudeCmd = + `ELECTRON_RUN_AS_NODE=1 "${FAKE_EXEC}" "${CLAUDE_HANDLER}" "SessionStart"`; + const codexCmd = + `ELECTRON_RUN_AS_NODE=1 "${FAKE_EXEC}" "${CODEX_HANDLER}" "SessionStart"`; + + const claudeEntry = { hooks: [{ type: "command", command: claudeCmd }] }; + const codexEntry = { hooks: [{ type: "command", command: codexCmd }] }; + + assert.equal(isClaudeEntry(claudeEntry), true); + assert.equal(isClaudeEntry(codexEntry), false); + assert.equal(isCodexEntry(codexEntry), true); + assert.equal(isCodexEntry(claudeEntry), false); +}); + +test("makeClaudeHookEntry applies '*' matcher only to gated events", () => { + const tooled = makeClaudeHookEntry(FAKE_EXEC, CLAUDE_HANDLER, "PreToolUse"); + assert.equal(tooled.matcher, "*"); + const sessionStart = makeClaudeHookEntry( + FAKE_EXEC, + CLAUDE_HANDLER, + "SessionStart", + ); + assert.equal(sessionStart.matcher, undefined); +}); + +test("makeCodexHookEntry uses startup|resume matcher for SessionStart", () => { + const sessionStart = makeCodexHookEntry( + FAKE_EXEC, + CODEX_HANDLER, + "SessionStart", + ); + assert.equal(sessionStart.matcher, CODEX_SESSION_START_MATCHER); + + const tooled = makeCodexHookEntry(FAKE_EXEC, CODEX_HANDLER, "PreToolUse"); + assert.equal(tooled.matcher, "*"); + + const stop = makeCodexHookEntry(FAKE_EXEC, CODEX_HANDLER, "Stop"); + // Codex Stop / UserPromptSubmit ignore matchers — must NOT emit one. + assert.equal(stop.matcher, undefined); +}); + +test("applyHookInstall creates a fresh settings file with all hook types", () => { + const file = join(tempRoot, "settings.json"); + const result = applyHookInstall({ + file, + hookTypes: HOOK_TYPES, + isOurEntry: isClaudeEntry, + makeEntry: (h) => makeClaudeHookEntry(FAKE_EXEC, CLAUDE_HANDLER, h), + }); + assert.equal(result.installed, HOOK_TYPES.length); + assert.equal(result.repaired, 0); + + const settings = readJson(file) as { + hooks: Record; + }; + for (const hookType of HOOK_TYPES) { + assert.equal(settings.hooks[hookType].length, 1); + assert.equal(isClaudeEntry(settings.hooks[hookType][0]), true); + } +}); + +test("applyHookInstall is idempotent: re-running self-heals a moved handler path", () => { + const file = join(tempRoot, "settings.json"); + // First install with the original path. + applyHookInstall({ + file, + hookTypes: HOOK_TYPES, + isOurEntry: isClaudeEntry, + makeEntry: (h) => makeClaudeHookEntry(FAKE_EXEC, CLAUDE_HANDLER, h), + }); + + // Simulate the .app moving: re-install with a different handler path. The + // stale entry must be replaced in place (NOT appended) so the file does + // not grow unboundedly across upgrades. + const newHandler = "/fake/userData2/agent-monitor/hook-handler.js"; + const result = applyHookInstall({ + file, + hookTypes: HOOK_TYPES, + isOurEntry: isClaudeEntry, + makeEntry: (h) => makeClaudeHookEntry(FAKE_EXEC, newHandler, h), + }); + assert.equal(result.installed, 0); + assert.equal(result.repaired, HOOK_TYPES.length); + + const settings = readJson(file) as { + hooks: Record }>>; + }; + for (const hookType of HOOK_TYPES) { + assert.equal(settings.hooks[hookType].length, 1); + const cmd = settings.hooks[hookType][0].hooks[0].command; + assert.match(cmd, /userData2/); + } +}); + +test("applyHookInstall preserves user-installed foreign entries", () => { + const file = join(tempRoot, "settings.json"); + // Pre-seed a foreign entry the user installed themselves. + writeFileSync( + file, + JSON.stringify({ + hooks: { + PreToolUse: [ + { hooks: [{ type: "command", command: "echo my-own-thing" }] }, + ], + }, + }), + ); + + applyHookInstall({ + file, + hookTypes: HOOK_TYPES, + isOurEntry: isClaudeEntry, + makeEntry: (h) => makeClaudeHookEntry(FAKE_EXEC, CLAUDE_HANDLER, h), + }); + + const settings = readJson(file) as { + hooks: Record }>>; + }; + // Foreign entry must still be present alongside ours. + assert.equal(settings.hooks.PreToolUse.length, 2); + assert.equal( + settings.hooks.PreToolUse[0].hooks[0].command, + "echo my-own-thing", + ); + assert.equal(isClaudeEntry(settings.hooks.PreToolUse[1]), true); +}); + +test("applyHookUninstall removes only our entries and preserves foreign ones", () => { + const file = join(tempRoot, "settings.json"); + // Install ours, then add a foreign entry. + applyHookInstall({ + file, + hookTypes: HOOK_TYPES, + isOurEntry: isClaudeEntry, + makeEntry: (h) => makeClaudeHookEntry(FAKE_EXEC, CLAUDE_HANDLER, h), + }); + const seeded = readJson(file) as { + hooks: Record>; + }; + seeded.hooks.PreToolUse.push({ + hooks: [{ type: "command", command: "echo my-own-thing" }], + }); + writeFileSync(file, JSON.stringify(seeded)); + + const result = applyHookUninstall({ file, isOurEntry: isClaudeEntry }); + assert.equal(result.removed, HOOK_TYPES.length); + + const after = readJson(file) as { + hooks?: Record }>>; + }; + // The only surviving event is PreToolUse, with only the foreign entry. + assert.deepEqual(Object.keys(after.hooks ?? {}), ["PreToolUse"]); + assert.equal(after.hooks?.PreToolUse.length, 1); + assert.equal( + after.hooks?.PreToolUse[0].hooks[0].command, + "echo my-own-thing", + ); +}); + +test("applyHookUninstall deletes the hooks block when nothing remains", () => { + const file = join(tempRoot, "settings.json"); + applyHookInstall({ + file, + hookTypes: HOOK_TYPES, + isOurEntry: isClaudeEntry, + makeEntry: (h) => makeClaudeHookEntry(FAKE_EXEC, CLAUDE_HANDLER, h), + }); + applyHookUninstall({ file, isOurEntry: isClaudeEntry }); + + const after = readJson(file); + assert.equal( + Object.prototype.hasOwnProperty.call(after, "hooks"), + false, + "outer `hooks` block must be removed when no entries remain", + ); +}); + +test("applyHookUninstall is a no-op when the settings file does not exist", () => { + const result = applyHookUninstall({ + file: join(tempRoot, "does-not-exist.json"), + isOurEntry: isClaudeEntry, + }); + assert.equal(result.removed, 0); +}); + +test("Codex install does not touch Claude entries in a co-mingled file", () => { + // Defensive check: although Claude hooks live in ~/.claude/settings.json + // and Codex hooks live in ~/.codex/hooks.json under normal operation, the + // filename-boundary predicates must not cross-match if entries ever end + // up co-mingled (e.g., a future change targeting the same file). + const file = join(tempRoot, "settings.json"); + applyHookInstall({ + file, + hookTypes: HOOK_TYPES, + isOurEntry: isClaudeEntry, + makeEntry: (h) => makeClaudeHookEntry(FAKE_EXEC, CLAUDE_HANDLER, h), + }); + const claudePreToolBefore = (readJson(file) as { + hooks: Record>; + }).hooks.PreToolUse.length; + + applyHookInstall({ + file, + hookTypes: CODEX_HOOK_TYPES, + isOurEntry: isCodexEntry, + makeEntry: (h) => makeCodexHookEntry(FAKE_EXEC, CODEX_HANDLER, h), + }); + + const settings = readJson(file) as { + hooks: Record>; + }; + // Claude PreToolUse entry must still be present (now alongside the Codex + // one). Total count for PreToolUse should be claudePreToolBefore + 1. + assert.equal( + settings.hooks.PreToolUse.length, + claudePreToolBefore + 1, + "Codex install must not have replaced the Claude PreToolUse entry", + ); + + // Now uninstall Codex; Claude entries must remain intact. + applyHookUninstall({ file, isOurEntry: isCodexEntry }); + const afterCodexUninstall = readJson(file) as { + hooks: Record>; + }; + assert.equal( + afterCodexUninstall.hooks.PreToolUse.length, + claudePreToolBefore, + "Codex uninstall must not have touched the Claude entry", + ); +}); diff --git a/apps/desktop/test/agent-monitor-wiring-static.test.ts b/apps/desktop/test/agent-monitor-wiring-static.test.ts index ae7dd901..c67b03d1 100644 --- a/apps/desktop/test/agent-monitor-wiring-static.test.ts +++ b/apps/desktop/test/agent-monitor-wiring-static.test.ts @@ -43,6 +43,12 @@ const traySource = read("../src/main/tray.ts"); const preloadSource = read("../src/main/preload.ts"); const sidecarSource = read("../src/main/agent-monitor-sidecar.ts"); const hooksSource = read("../src/main/agent-monitor-hooks.ts"); +// FEA-1444: the hook-install logic that the assertions below probe — shell +// command formatting, atomic settings-file writes — lives in the electron-free +// core module so it can be unit-tested under tsx --test without an Electron +// runtime. The shell still owns the public install/uninstall functions, the +// electron-store flag, and the boot self-heal. +const hooksCoreSource = read("../src/main/agent-monitor-hooks-core.ts"); const embedAppSource = read("../scripts/agent-monitor-embed/App.tsx"); const embedLayoutSource = read("../scripts/agent-monitor-embed/Layout.tsx"); const contractsSource = read("../src/shared/contracts.ts"); @@ -443,9 +449,12 @@ test("hooks are opt-in: default off, silent server auto-install never enabled", // The host never sets CCAM_AUTO_INSTALL_HOOKS=1; it manages hooks directly. assert.doesNotMatch(sidecarSource, /CCAM_AUTO_INSTALL_HOOKS:\s*"1"/); assert.match(hooksSource, /store\(\)\.get\("enabled", false\)/); - assert.match(hooksSource, /ELECTRON_RUN_AS_NODE=1/); - assert.match(hooksSource, /JSON\.stringify\(hookType\)/); - assert.match(hooksSource, /renameSync/); + // FEA-1444: shell-command formatting + atomic settings write moved to the + // core module during the Codex hook ingestion split. The invariants still + // matter — assert them in the file that actually contains them. + assert.match(hooksCoreSource, /ELECTRON_RUN_AS_NODE=1/); + assert.match(hooksCoreSource, /JSON\.stringify\(hookType\)/); + assert.match(hooksCoreSource, /renameSync/); assert.match(hooksSource, /function uninstallHooks/); assert.match(appSource, /syncAgentMonitorHooksOnBoot\(\)/); }); From 8db7ef93b95926a54b9f838e757ef7cf2e29539b Mon Sep 17 00:00:00 2001 From: Andrew Eye Date: Fri, 29 May 2026 15:28:52 -0500 Subject: [PATCH 2/3] FEA-1444: Add Codex hook event dedup + renderer opt-in toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 2 — Event-level dedup against rollout-tail: - New filterEventsAlreadyCapturedByHooks in codex-import.js. Runs before importSession in importCodexSession; queries existing events for rows matching (session_id, event_type, COALESCE(tool_name, ''), created_at truncated to whole seconds) and drops duplicates. Hook-handler-inserted rows land first (real-time); the ~5s-later rollout-tail no-ops on match. - The dedup statement is cached per-dbModule via WeakMap so a batch of many changed sessions compiles the SQL once, not once per session. - Best-effort: any prepare/query failure is swallowed and the event is kept. False negatives (cosmetic duplicates) are preferred over false positives (silently dropped events). Sub-second timestamp drift handled by the substr(?, 1, 19) match. Layer 3 — Renderer opt-in toggle: - apps/desktop/src/main/app.ts: new IPC handlers desktop:{get,set}-agent-monitor-codex-hooks-opt-in that call the already-exported isAgentMonitorCodexHooksOptIn / setAgentMonitorCodexHooksOptIn. Gated on the master Agent Dashboard flag, matching the Claude pair shape exactly. - apps/desktop/src/main/preload.ts: mirror bridge methods exposing the two channels to the renderer. - apps/desktop/src/renderer/index.html: new #codexDashConsent toggle row directly below the existing #claudeDashConsent. Same visibility semantics (shown only on agent-settings route), same disabled-when- master-off behavior, same hint text reset on master flag re-enable (Codex hint reset now mirrors Claude's after a review-flagged gap). refreshCodexHooksToggle is called when the user toggles the Claude hooks toggle so the sibling's on/off badge stays in sync. Test: - apps/desktop/test/agent-session-event-dedup.test.ts (new): 4 unit tests on the filter against a sandbox SQLite DB. Covers (1) duplicate filter with sub-second drift, (2) non-duplicate survival across mixed events, (3) best-effort behavior on DB errors, (4) empty-events no-op. Uses the conditional-skip pattern (skipReason ? { skip: ... } : undefined) because Node test runner treats skip: null as "skip with no reason" rather than "don't skip" — gotcha learned during debugging. Independent code review (second pass on the cumulative diff) surfaced: - Medium: Codex hint stale after master flag re-enable (Claude hint was reset in the else branch, Codex hint wasn't). Fixed. - Low: Comment inaccuracy about which toggle "unblocks the master flag" in wireClaudeHooksToggle. Rewritten. - Low: Dedup statement prepared once per session inside the batch loop instead of once per process. Cached via WeakMap. All three addressed in this commit. Testing: - pnpm -C apps/desktop typecheck: clean - pnpm -C apps/desktop lint: clean - pnpm -C apps/desktop build:agent-monitor: clean (Layer 1 hard-gates + sidecar SQLite gate continue to pass) - pnpm -C apps/desktop test (full suite): 1945/1945 pass, 0 fail - New agent-session-event-dedup.test.ts: 4/4 pass - agent-monitor-hooks-core.test.ts (from Layer 1): 10/10 pass (no regression) Risks: - Cross-second timestamp drift between hook and rollout-tail can defeat dedup. Documented design tradeoff: false negatives are cosmetic duplicates; false positives would silently drop events. Will revisit with a content-hash dedup key in a follow-up if cosmetic duplication becomes visible in practice. - IPC handler test coverage deferred. Both new handlers are shallow plumbing that call already-tested functions (covered by Layer 1's agent-monitor-hooks-core.test.ts). Typecheck validates the signatures + preload bridge types. - Renderer JS duplicates the Claude/Codex toggle pattern. Per CLAUDE.md this is the kind of duplication worth extracting, but inline renderer JS makes a shared helper awkward; deferred to a separate cleanup ticket if the pattern repeats with a third harness. --- .../agent-monitor-codex/codex-import.js | 84 +++++++- apps/desktop/src/main/app.ts | 22 ++ apps/desktop/src/main/preload.ts | 10 + apps/desktop/src/renderer/index.html | 105 ++++++++- .../test/agent-session-event-dedup.test.ts | 203 ++++++++++++++++++ 5 files changed, 421 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/test/agent-session-event-dedup.test.ts diff --git a/apps/desktop/scripts/agent-monitor-codex/codex-import.js b/apps/desktop/scripts/agent-monitor-codex/codex-import.js index acb70566..e61386c9 100644 --- a/apps/desktop/scripts/agent-monitor-codex/codex-import.js +++ b/apps/desktop/scripts/agent-monitor-codex/codex-import.js @@ -30,6 +30,16 @@ const catchupCache = createCatchupCache({ persistPath: ingestCachePath("codex") * or { skipped: true } when the file has no usable content. */ function importCodexSession(dbModule, session) { + // FEA-1444 dedup: if the user opted into Codex hooks, the same logical + // event will already be in `events` (inserted ~5s earlier by the hook + // handler). Without this filter the rollout-tail importer would create a + // duplicate row for every Codex event after the user opts in. Match on + // (session_id, event_type, tool_name, created_at-truncated-to-second) — + // hooks and rollout-tail timestamps usually agree within sub-second + // granularity for the same logical event. False negatives are tolerable + // (cosmetic duplicates); false positives would silently drop events, so + // the match is intentionally narrow. + filterEventsAlreadyCapturedByHooks(dbModule, session); const result = importSession(dbModule, session); // Stamp the harness regardless of skipped/backfilled — cheap, idempotent, // and self-heals rows imported before the `harness` column existed. @@ -42,6 +52,73 @@ function importCodexSession(dbModule, session) { return { sessionId: session.sessionId, result, reactivated }; } +/** + * FEA-1444: filter session.events in place, removing any whose + * (session_id, event_type, COALESCE(tool_name, ''), created_at-rounded-to-second) + * already exists in the `events` table. The hook handler inserts in real time; + * rollout-tail catches up ~5s later — so the dedup query consistently sees + * hook-sourced rows first when both paths are active. + * + * No-op when session.events is empty or the events query fails (best-effort; + * never block the import on a dedup-time error). + */ +// FEA-1444 dedup-stmt cache: the filter runs per-session inside +// importCodexSession, which itself runs inside the importBatch transaction +// loop. Caching the prepared statement on the dbModule keeps us at O(1) +// compilations per process instead of O(sessions) per batch. The cache is +// keyed by dbModule so a fresh DatabaseSync in tests gets a fresh stmt. +const dedupStmtCache = new WeakMap(); +function getDedupCheckStmt(dbModule) { + let stmt = dedupStmtCache.get(dbModule); + if (stmt) return stmt; + // Truncate created_at to seconds via `substr(?, 1, 19)` so sub-second + // timestamp drift between the hook payload and the rollout file doesn't + // produce a false negative. + stmt = dbModule.db.prepare( + "SELECT 1 FROM events " + + "WHERE session_id = ? AND event_type = ? " + + "AND COALESCE(tool_name, '') = COALESCE(?, '') " + + "AND substr(COALESCE(created_at, ''), 1, 19) = substr(COALESCE(?, ''), 1, 19) " + + "LIMIT 1", + ); + dedupStmtCache.set(dbModule, stmt); + return stmt; +} + +function filterEventsAlreadyCapturedByHooks(dbModule, session) { + if (!session || !Array.isArray(session.events) || session.events.length === 0) { + return; + } + let checkStmt; + try { + checkStmt = getDedupCheckStmt(dbModule); + } catch { + return; // schema mismatch or db locked — fall through, accept duplicates + } + const filtered = []; + for (const ev of session.events) { + if (!ev || typeof ev !== "object") { + filtered.push(ev); + continue; + } + try { + const hit = checkStmt.get( + session.sessionId, + ev.event_type ?? null, + ev.tool_name ?? null, + ev.created_at ?? null, + ); + if (hit) { + continue; // already captured by the hook handler — drop the duplicate + } + } catch { + // best-effort — on any per-row failure, keep the event + } + filtered.push(ev); + } + session.events = filtered; +} + /** * Parse + import every discovered Codex rollout file. Designed to be cheap on * repeat runs: importSession skips already-imported sessions (or backfills @@ -107,4 +184,9 @@ async function importAllCodexSessions(dbModule, opts = {}) { return { imported, skipped, errors }; } -module.exports = { importAllCodexSessions, importCodexSession }; +module.exports = { + importAllCodexSessions, + importCodexSession, + // Exposed for regression coverage of FEA-1444 dedup. + filterEventsAlreadyCapturedByHooks, +}; diff --git a/apps/desktop/src/main/app.ts b/apps/desktop/src/main/app.ts index da66f4e4..5ec1770f 100644 --- a/apps/desktop/src/main/app.ts +++ b/apps/desktop/src/main/app.ts @@ -69,7 +69,9 @@ import { DesktopWindow } from "./window.js"; import { AgentMonitorSidecar } from "./agent-monitor-sidecar.js"; import { AgentSessionSyncService } from "./agent-session-sync-service.js"; import { + isAgentMonitorCodexHooksOptIn, isAgentMonitorHooksEnabled, + setAgentMonitorCodexHooksOptIn, setAgentMonitorHooksEnabled, syncAgentMonitorHooksOnBoot, } from "./agent-monitor-hooks.js"; @@ -2499,6 +2501,26 @@ export class DesktopApplication { return setAgentMonitorHooksEnabled(enabled === true); }, ); + // FEA-1444: opt-in toggle for Codex hooks. Surfaced as a sibling of the + // Claude hooks toggle in the Agent Dashboard view. Gated on the master + // Agent Dashboard flag for the same reason the Claude toggle is — the + // sidecar must be running to receive the forwarded hook events. + ipcMain.handle("desktop:get-agent-monitor-codex-hooks-opt-in", () => + this.isAgentMonitorEnabled() && isAgentMonitorCodexHooksOptIn(), + ); + ipcMain.handle( + "desktop:set-agent-monitor-codex-hooks-opt-in", + (_event, optIn: boolean) => { + if (!this.isAgentMonitorEnabled()) { + return { + ok: false, + enabled: false, + error: "Agent Dashboard is disabled in Settings.", + }; + } + return setAgentMonitorCodexHooksOptIn(optIn === true); + }, + ); ipcMain.handle("desktop:get-logs", () => gatewayLog.getEntries()); ipcMain.handle("desktop:clear-logs", () => { gatewayLog.clear(); diff --git a/apps/desktop/src/main/preload.ts b/apps/desktop/src/main/preload.ts index 865c954a..61c768ee 100644 --- a/apps/desktop/src/main/preload.ts +++ b/apps/desktop/src/main/preload.ts @@ -136,6 +136,16 @@ const desktopApi = { "desktop:set-agent-monitor-hooks-enabled", enabled, ) as Promise<{ ok: boolean; enabled: boolean; error?: string }>, + // FEA-1444: Codex hook opt-in. Mirrors the Claude pair above. + getAgentMonitorCodexHooksOptIn: () => + ipcRenderer.invoke( + "desktop:get-agent-monitor-codex-hooks-opt-in", + ) as Promise, + setAgentMonitorCodexHooksOptIn: (optIn: boolean) => + ipcRenderer.invoke( + "desktop:set-agent-monitor-codex-hooks-opt-in", + optIn, + ) as Promise<{ ok: boolean; enabled: boolean; error?: string }>, getAllFlags: () => ipcRenderer.invoke("desktop:get-all-flags") as Promise, onFlagsChanged: (callback: () => void) => { diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 664befc3..18ee5e7f 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -3404,6 +3404,17 @@

See every coding agent you use — in one place

Enable Claude Code session tracking — off (no changes to your global Claude config) + +
@@ -3971,6 +3982,11 @@

Labs

if (consent) { consent.style.display = id === "agent-settings" ? "flex" : "none"; } + // FEA-1444: sibling Codex toggle gets the same visibility treatment. + const codexConsent = document.getElementById("codexDashConsent"); + if (codexConsent) { + codexConsent.style.display = id === "agent-settings" ? "flex" : "none"; + } startClaudeDashboard(); navigateAgentRoute(item.route); } else { @@ -4030,6 +4046,9 @@

Labs

const status = document.getElementById("claudeDashStatus"); const toggle = document.getElementById("claudeDashHooksToggle"); const hint = document.getElementById("claudeDashHooksHint"); + // FEA-1444: same disable-when-master-off treatment for the Codex toggle. + const codexToggle = document.getElementById("codexDashHooksToggle"); + const codexHint = document.getElementById("codexDashHooksHint"); if (toggle) { toggle.disabled = !cachedAgentMonitorEnabled; @@ -4037,6 +4056,16 @@

Labs

toggle.checked = false; } } + if (codexToggle) { + codexToggle.disabled = !cachedAgentMonitorEnabled; + if (!cachedAgentMonitorEnabled) { + codexToggle.checked = false; + } + } + if (codexHint && !cachedAgentMonitorEnabled) { + codexHint.textContent = + "— enable Agent Dashboard in Settings to manage session tracking"; + } if (!cachedAgentMonitorEnabled) { stopClaudeDashboardPoll(); @@ -4059,8 +4088,16 @@

Labs

activateTab("settings"); activateSettingsTab("relay-gateway"); } - } else if (hint) { - hint.textContent = "— off (no changes to your global Claude config)"; + } else { + if (hint) { + hint.textContent = "— off (no changes to your global Claude config)"; + } + // FEA-1444: mirror Claude hint reset for Codex so the stale + // "enable Agent Dashboard in Settings..." text doesn't linger + // after the user turns the master flag back on. + if (codexHint) { + codexHint.textContent = "— off (rollout-tail watcher continues)"; + } } // Render the initial view once the enabled-state is known on boot. @@ -4110,6 +4147,62 @@

Labs

} catch (_) { /* leave as-is */ } } + // FEA-1444: Codex hook opt-in toggle. Mirrors the Claude pair above. + async function refreshCodexHooksToggle() { + const toggle = document.getElementById("codexDashHooksToggle"); + const hint = document.getElementById("codexDashHooksHint"); + if (!toggle) return; + if (!cachedAgentMonitorEnabled) { + toggle.disabled = true; + if (hint) { + hint.textContent = + "— enable Agent Dashboard in Settings to manage session tracking"; + } + return; + } + try { + const optedIn = await api.getAgentMonitorCodexHooksOptIn(); + toggle.checked = !!optedIn; + toggle.disabled = false; + if (hint) { + hint.textContent = optedIn + ? "— on (Codex hooks installed in ~/.codex/hooks.json; also set [features].codex_hooks = true in ~/.codex/config.toml)" + : "— off (rollout-tail watcher continues)"; + } + } catch (_) { /* leave as-is */ } + } + + (function wireCodexHooksToggle() { + const toggle = document.getElementById("codexDashHooksToggle"); + const hint = document.getElementById("codexDashHooksHint"); + if (!toggle) return; + toggle.addEventListener("change", async () => { + const want = toggle.checked; + toggle.disabled = true; + if (hint) hint.textContent = want ? "— enabling…" : "— disabling…"; + try { + const res = await api.setAgentMonitorCodexHooksOptIn(want); + if (!res || !res.ok) { + toggle.checked = !want; + if (hint) { + hint.textContent = + "— failed: " + ((res && res.error) || "unknown error"); + } + } else { + await refreshCodexHooksToggle(); + } + } catch (e) { + toggle.checked = !want; + if (hint) { + hint.textContent = + "— failed: " + (e && e.message ? e.message : "error"); + } + } finally { + toggle.disabled = false; + } + }); + })(); + (function wireClaudeHooksToggle() { const toggle = document.getElementById("claudeDashHooksToggle"); const hint = document.getElementById("claudeDashHooksHint"); @@ -4125,6 +4218,11 @@

Labs

hint.textContent = "— failed: " + ((res && res.error) || "unknown error"); } else { await refreshClaudeHooksToggle(); + // FEA-1444: refresh sibling Codex toggle state so its on/off + // badge stays in sync after a successful Claude-toggle change. + // (The master `agentMonitorEnabled` flag, not this toggle, + // controls the Codex toggle's disabled state.) + await refreshCodexHooksToggle(); } } catch (e) { toggle.checked = !want; @@ -4161,6 +4259,9 @@

Labs

return; } void refreshClaudeHooksToggle(); + // FEA-1444: also refresh the sibling Codex toggle on dashboard mount + // so its on/off badge reflects the persisted state. + void refreshCodexHooksToggle(); if (claudeDashLoaded) { sizeClaudeFrame(); return; } renderDashLoading(); diff --git a/apps/desktop/test/agent-session-event-dedup.test.ts b/apps/desktop/test/agent-session-event-dedup.test.ts new file mode 100644 index 00000000..dc613739 --- /dev/null +++ b/apps/desktop/test/agent-session-event-dedup.test.ts @@ -0,0 +1,203 @@ +// FEA-1444 regression coverage for the rollout-tail-side dedup that prevents +// the same logical Codex event from being inserted twice when the user opts +// into Codex hooks. The hook handler inserts events in real time; the +// rollout-tail watcher catches up ~5s later via importCodexSession. Without +// the filter added in `codex-import.js`, the second arrival would create a +// duplicate row for every event. +// +// The test exercises the filter against a sandboxed SQLite database (no +// Electron runtime needed). The generated agent-monitor runtime supplies the +// JS module under .generated/agent-monitor/server/lib/codex-import.js. + +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, beforeEach, test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const desktopRoot = join(__dirname, ".."); +const generatedCodexImport = join( + desktopRoot, + ".generated", + "agent-monitor", + "server", + "lib", + "codex-import.js", +); + +const skipReason = !existsSync(generatedCodexImport) + ? `generated agent-monitor runtime not built (looked for ${generatedCodexImport}) — run pnpm build:agent-monitor` + : null; + +type CodexImport = { + filterEventsAlreadyCapturedByHooks: ( + dbModule: { db: DatabaseSync }, + session: { + sessionId: string; + events: Array<{ + event_type?: string | null; + tool_name?: string | null; + created_at?: string | null; + }>; + }, + ) => void; +}; + +let tempRoot = ""; + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "fea1444-dedup-")); +}); + +afterEach(() => { + if (tempRoot) { + rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +function createTestDb(): DatabaseSync { + const dbDir = join(tempRoot, "agent-monitor"); + mkdirSync(dbDir, { recursive: true }); + const db = new DatabaseSync(join(dbDir, "dashboard.db")); + // Minimal events schema — only the columns the filter queries against. + // IF NOT EXISTS in case the same temp dir is somehow reused across tests. + db.exec( + "CREATE TABLE IF NOT EXISTS events (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "session_id TEXT NOT NULL, " + + "agent_id TEXT, " + + "event_type TEXT NOT NULL, " + + "tool_name TEXT, " + + "summary TEXT, " + + "data TEXT, " + + "created_at TEXT NOT NULL" + + ")", + ); + return db; +} + +function insertEvent( + db: DatabaseSync, + sessionId: string, + eventType: string, + toolName: string | null, + createdAt: string, +): void { + db.prepare( + "INSERT INTO events (session_id, event_type, tool_name, created_at) VALUES (?, ?, ?, ?)", + ).run(sessionId, eventType, toolName, createdAt); +} + +test("dedup: rollout-tail event matching an existing hook-sourced row is dropped", skipReason ? { skip: skipReason } : undefined, () => { + const db = createTestDb(); + // Hook-sourced row already in the DB (simulating the hook handler having + // POSTed this event ~5s ago). + insertEvent(db, "sess-A", "PreToolUse", "Read", "2026-05-29T19:00:00.123Z"); + + // codex-import has assembled a session from the rollout file. Same logical + // event — sub-second drift in created_at is normal and must NOT defeat + // dedup (the filter truncates created_at to whole seconds before matching). + const session = { + sessionId: "sess-A", + events: [ + { + event_type: "PreToolUse", + tool_name: "Read", + created_at: "2026-05-29T19:00:00.987Z", + }, + ], + }; + + const { filterEventsAlreadyCapturedByHooks } = createRequire(generatedCodexImport)( + "./codex-import.js", + ) as CodexImport; + filterEventsAlreadyCapturedByHooks({ db } as { db: DatabaseSync }, session); + + assert.equal(session.events.length, 0, "the duplicate event must be filtered out"); + db.close(); +}); + +test("dedup: events the hook handler hasn't captured are kept", skipReason ? { skip: skipReason } : undefined, () => { + const db = createTestDb(); + // Only one of the two rollout-tail events exists in the DB. + insertEvent(db, "sess-B", "PreToolUse", "Read", "2026-05-29T19:00:00.000Z"); + + const session = { + sessionId: "sess-B", + events: [ + // Already captured — should be filtered. + { + event_type: "PreToolUse", + tool_name: "Read", + created_at: "2026-05-29T19:00:00.500Z", + }, + // Different tool — not in DB — must survive. + { + event_type: "PreToolUse", + tool_name: "Bash", + created_at: "2026-05-29T19:00:01.000Z", + }, + // Different timestamp by a full second — different DB row. + { + event_type: "PreToolUse", + tool_name: "Read", + created_at: "2026-05-29T19:00:02.000Z", + }, + ], + }; + + const { filterEventsAlreadyCapturedByHooks } = createRequire(generatedCodexImport)( + "./codex-import.js", + ) as CodexImport; + filterEventsAlreadyCapturedByHooks({ db } as { db: DatabaseSync }, session); + + assert.equal(session.events.length, 2, "non-duplicate events must survive"); + assert.equal(session.events[0].tool_name, "Bash"); + assert.equal(session.events[1].tool_name, "Read"); + assert.equal(session.events[1].created_at, "2026-05-29T19:00:02.000Z"); + db.close(); +}); + +test("dedup: filter is best-effort and non-fatal when the DB query fails", skipReason ? { skip: skipReason } : undefined, () => { + const dbDir = join(tempRoot, "agent-monitor"); + mkdirSync(dbDir, { recursive: true }); + // Empty DB with no `events` table — the SELECT will throw inside the + // filter. The filter must swallow the error and leave the session intact + // (acceptable v1 trade-off: rather have cosmetic duplicates than block + // the entire import on a schema/lock issue). + const db = new DatabaseSync(join(dbDir, "dashboard.db")); + const session = { + sessionId: "sess-C", + events: [ + { + event_type: "PreToolUse", + tool_name: "Read", + created_at: "2026-05-29T19:00:00.000Z", + }, + ], + }; + + const { filterEventsAlreadyCapturedByHooks } = createRequire(generatedCodexImport)( + "./codex-import.js", + ) as CodexImport; + // Must NOT throw. + filterEventsAlreadyCapturedByHooks({ db } as { db: DatabaseSync }, session); + // And must leave events untouched. + assert.equal(session.events.length, 1); + db.close(); +}); + +test("dedup: empty events array is a no-op", skipReason ? { skip: skipReason } : undefined, () => { + const db = createTestDb(); + const session = { sessionId: "sess-D", events: [] }; + const { filterEventsAlreadyCapturedByHooks } = createRequire(generatedCodexImport)( + "./codex-import.js", + ) as CodexImport; + filterEventsAlreadyCapturedByHooks({ db } as { db: DatabaseSync }, session); + assert.equal(session.events.length, 0); + db.close(); +}); From fabf1643a4766b1e14aba7de7abe6aef011f68e0 Mon Sep 17 00:00:00 2001 From: Andrew Eye Date: Sat, 30 May 2026 13:53:27 -0500 Subject: [PATCH 3/3] FEA-1444: PR #259 review fixes (dedup gate + Codex hook trust hint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 (dedup false-positive on rapid same-second events): - The codex-import.js filter had no hook-source discriminator: any existing events row matching (session_id, event_type, tool_name, created_at-truncated-to-second) was treated as a hook duplicate. For users who haven't opted into Codex hooks (~99% of installs), the row always came from a prior rollout-tail import, so the filter was silently dropping legitimate distinct events on re-imports of a changed rollout file. - Gate the filter on whether ~/.codex/hooks.json actually contains our codex-hook-handler.js. When it doesn't, there can't be any hook-sourced rows to dedup against, so the filter is a no-op. The detection result is cached at module level and exposed via resetCodexHooksInstalledCache for test setup. - Known v1 limitation: when hooks ARE installed but miss/skip a specific event (the reviewer's case (b)/(c)), rapid same-second same-tool calls in that session can still collapse. Tracked as a follow-up FEA for a real `source` column on the events table. Codex P2 (Codex hook config hint accuracy): - Codex's canonical feature flag is `[features].hooks`, not `[features].codex_hooks` (which is a deprecated alias). - For non-managed command hooks, Codex requires the user to trust the installed command via `/hooks` before they fire — without that, the install in ~/.codex/hooks.json is silently skipped and the user sees no real-time ingestion. - Hint text + tooltip updated to spell out both requirements so a user flipping the toggle doesn't end up in a silent-fallback state. New test: - agent-session-event-dedup.test.ts: "gate-off when Codex hooks are not installed — filter is a no-op". Seeds a row that would normally be treated as a hook duplicate, points CODEX_HOME at a directory with no hooks.json, and asserts the incoming rollout-tail event survives. Bump desktop version 0.15.100 → 0.15.102 (0.15.101 reserved for FEA-1461 PR #258). Testing: - pnpm -C apps/desktop typecheck: clean - pnpm -C apps/desktop lint: clean - pnpm -C apps/desktop build:agent-monitor: clean - agent-session-event-dedup.test.ts: 5/5 pass (was 4 before; new gate-off coverage) - Full suite: 1842 + 104 = 1946/1946 pass, 0 fail Risks: - Install detection cached at module load — a user opting into Codex hooks AFTER the sidecar started won't trigger dedup until the next app boot. Acceptable for v1 (the sidecar restarts on toggle change via agent-monitor-sidecar.ts anyway, so the cache is refreshed). - The install probe reads ~/.codex/hooks.json with a substring match for "codex-hook-handler.js". A user who manually edits hooks.json to a different filename containing that substring would trip the gate as a false positive. Not a realistic risk; substring match is intentionally lenient to handle both userData-relative and resources-relative paths. --- apps/desktop/package.json | 2 +- .../agent-monitor-codex/codex-import.js | 61 ++++++++++++++ apps/desktop/src/renderer/index.html | 11 ++- .../test/agent-session-event-dedup.test.ts | 79 ++++++++++++++++++- 4 files changed, 147 insertions(+), 6 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 54ca2b30..6b1fed61 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.15.100", + "version": "0.15.102", "description": "ClosedLoop Desktop", "author": "ClosedLoop AI ", "private": true, diff --git a/apps/desktop/scripts/agent-monitor-codex/codex-import.js b/apps/desktop/scripts/agent-monitor-codex/codex-import.js index e61386c9..7ad1de12 100644 --- a/apps/desktop/scripts/agent-monitor-codex/codex-import.js +++ b/apps/desktop/scripts/agent-monitor-codex/codex-import.js @@ -11,6 +11,9 @@ * * Part of CLOSEDLOOP VENDOR Addition #6 (see vendor/agent-monitor/VENDOR.md). */ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); const { parseRolloutFile } = require("./codex-parser"); const { listAllRolloutFiles } = require("./codex-home"); const { importSession } = require("../../scripts/import-history"); @@ -62,6 +65,54 @@ function importCodexSession(dbModule, session) { * No-op when session.events is empty or the events query fails (best-effort; * never block the import on a dedup-time error). */ +// FEA-1444 review (PR #259, Codex P2): the filter has no hook-source +// discriminator, so without an upstream gate it can incorrectly drop +// rollout-tail events that look like duplicates but aren't (e.g., two +// same-second tool calls in rapid succession). Gate the filter on +// "does the user actually have our Codex hook handler installed?" — +// when it isn't installed there are no possible hook-sourced rows to +// dedup against, so the filter must be a no-op. Cases where hooks ARE +// installed but miss an event (b/c in the reviewer's framing) remain a +// known v1 limitation; tracked for a follow-up FEA that adds a real +// `source` column to the events table. +const HOOK_HANDLER_FILENAME = "codex-hook-handler.js"; + +let cachedCodexHooksInstalled = null; + +function codexHooksInstalled() { + if (cachedCodexHooksInstalled !== null) { + return cachedCodexHooksInstalled; + } + cachedCodexHooksInstalled = detectCodexHooksInstalled(); + return cachedCodexHooksInstalled; +} + +function detectCodexHooksInstalled() { + try { + const codexHomeRaw = process.env.CODEX_HOME; + const codexHome = codexHomeRaw && codexHomeRaw.trim() + ? codexHomeRaw.split(",")[0].trim().replace(/^~(?=\/)/, os.homedir()) + : path.join(os.homedir(), ".codex"); + const hooksPath = path.join(codexHome, "hooks.json"); + if (!fs.existsSync(hooksPath)) { + return false; + } + const raw = fs.readFileSync(hooksPath, "utf8"); + return raw.includes(HOOK_HANDLER_FILENAME); + } catch { + return false; + } +} + +/** + * FEA-1444: test-only hook to reset the install-state cache. Exposed via + * module.exports for the regression tests so a single process can exercise + * both the "hooks installed" and "hooks not installed" paths. + */ +function resetCodexHooksInstalledCache() { + cachedCodexHooksInstalled = null; +} + // FEA-1444 dedup-stmt cache: the filter runs per-session inside // importCodexSession, which itself runs inside the importBatch transaction // loop. Caching the prepared statement on the dbModule keeps us at O(1) @@ -89,6 +140,15 @@ function filterEventsAlreadyCapturedByHooks(dbModule, session) { if (!session || !Array.isArray(session.events) || session.events.length === 0) { return; } + // FEA-1444 review fix: only run the dedup query when Codex hooks are + // actually installed. Without this gate, every existing rollout-tail- + // sourced row matching the tuple looks like a hook duplicate and gets + // dropped — which silently loses legitimate rapid same-second tool + // calls on rollout-tail re-imports. The gate eliminates the most common + // false-positive vector (~99% of users who never opt into Codex hooks). + if (!codexHooksInstalled()) { + return; + } let checkStmt; try { checkStmt = getDedupCheckStmt(dbModule); @@ -189,4 +249,5 @@ module.exports = { importCodexSession, // Exposed for regression coverage of FEA-1444 dedup. filterEventsAlreadyCapturedByHooks, + resetCodexHooksInstalledCache, }; diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 18ee5e7f..504877e3 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -3405,10 +3405,13 @@

See every coding agent you use — in one place

— off (no changes to your global Claude config) + Claude toggle above. Requires `[features].hooks = true` in + ~/.codex/config.toml AND that the user trust the installed + non-managed command hook via Codex's `/hooks` slash command; + without trust, Codex skips the command silently and this falls + back to the rollout-tail watcher. -->