Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "desktop",
"version": "0.15.99",
"version": "0.15.102",
"description": "ClosedLoop Desktop",
"author": "ClosedLoop AI <support@closedloop.ai>",
"private": true,
Expand Down
80 changes: 80 additions & 0 deletions apps/desktop/scripts/agent-monitor-codex/codex-hook-handler.js
Original file line number Diff line number Diff line change
@@ -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);
145 changes: 144 additions & 1 deletion apps/desktop/scripts/agent-monitor-codex/codex-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -30,6 +33,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.
Expand All @@ -42,6 +55,130 @@ 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 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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

codexHooksInstalled() caches the first hooks-installed result for the sidecar process, but the hook opt-in can change while that process is running. If startup imports happen before opt-in, dedup stays off and hook plus rollout rows duplicate after enabling; if startup sees hooks installed, disabling can leave dedup on and suppress rollout fallback events. Drop the process-lifetime cache or invalidate it from the toggle/restart path; durable external_event_id dedup would avoid reading hook config state entirely.

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)
// 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",
Comment on lines +128 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve rollout events when the existing row is not a hook duplicate

When Codex hooks are disabled, skipped, or miss one event, this query still treats any existing events row as a hook duplicate because it has no hook-source discriminator. In a live rollout file that is re-imported after a previous poll, a newly appended event with the same session id, event type, tool name, and second as an earlier rollout-imported event is filtered out before importSession() sees it, so rapid repeated tool calls can disappear from the timeline instead of being backfilled by the watcher.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the latest commit on this branch. Gated the filter on whether ~/.codex/hooks.json actually contains our codex-hook-handler.js. When it doesn't, there cannot be any hook-sourced rows in the events table to dedup against — the filter is now a no-op in that case. This eliminates the false-positive vector for the ~99% of users who haven't opted into Codex hooks.

New test "dedup: gate-off when Codex hooks are not installed — filter is a no-op" seeds a row that the unguarded filter would have treated as a hook duplicate, points CODEX_HOME at a directory with no hooks.json, and asserts the incoming rollout-tail event survives.

Known v1 limitation (your cases (b) and (c) — hooks installed but a specific event missed or skipped): rapid same-second same-tool calls in that scenario can still collapse. The proper fix is a source column on the events table (so hook-inserted rows are explicitly discriminable from rollout-tail rows); that's invasive enough to merit its own FEA and will be filed as a follow-up.

);
dedupStmtCache.set(dbModule, stmt);
return stmt;
}

function filterEventsAlreadyCapturedByHooks(dbModule, session) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This filter looks for session.events, but the rollout-tail parser returns the normalized importer shape with toolUses, apiErrors, toolResultErrors, etc., and the watcher passes that object into importCodexSession. That makes the new dedup path a no-op for real rollout imports. Even after wiring the right shape, tuple matching by session/event/tool/second does not satisfy PLN-767's durable external_event_id contract and can both drop legitimate same-second tool events and miss duplicates when timestamps drift. Please move dedup to the event identity layer: add events.external_event_id plus a partial unique index, derive the same ID in hook and rollout paths, insert with INSERT OR IGNORE, and cover the real parser-to-importer path.

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);
} 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
Expand Down Expand Up @@ -107,4 +244,10 @@ 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,
resetCodexHooksInstalledCache,
};
85 changes: 85 additions & 0 deletions apps/desktop/scripts/build-agent-monitor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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`)),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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\") {",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__provider comes from the JSON body received by /api/hooks/event, so a local process can forge a hook POST and mark any session as Codex by setting this field. That can poison harness grouping and any future harness-scoped behavior. Derive provider from trusted server-side handler/route metadata, or strip untrusted __provider before generated route logic, and add an abuse regression for forged hook payloads.

" 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.
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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).",
Expand Down
Loading