feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)
Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from Settings → Dashboard to the Claude Code tab's AgentHub sub-tab (ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub. A home that is or contains the OS home is refused (EBADHOME). Settings persistence: dashboard-saved settings go to ~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin cache an update wipes. Load order: real env > the checkout's .env > that file. The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put. C1: codex-usage.js reads Codex rollout journals into sessions (new column tool) and token_usage, tracked per file in codex_rollouts; forks deduped by total vector, sub-agent threads folded into the root; pricing-sync also prices OpenAI models; PricingSettings with a vendor filter. C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on a click, through a stable forwarder; Codex trust is never written. The hook route creates tool='codex' sessions and folds sub-agent hooks into the root. D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab; Gemini pricing. Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=, clear-data covering the new tables, Codex child-thread events moving no status, hooks owning status only while current, EFOREIGN for a forwarder that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE honoured from .env. Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
This commit is contained in:
+238
-68
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @file Express router for handling incoming hook events from Claude CLI. It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts, detects compaction events, and broadcasts updates to connected clients via WebSocket.
|
||||
* @file Express router for handling incoming hook events from Claude CLI — and from Codex (`tool: "codex"`, sent through server/lib/codex-hooks.js: such a session is created as a Codex one, its rollout is never parsed as a Claude transcript, a PermissionRequest becomes the permission Notification, and a sub-agent thread's hooks land on the root session). It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts, detects compaction events, and broadcasts updates to connected clients via WebSocket.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -19,6 +19,9 @@ const lanesLib = require("../lib/lanes");
|
||||
const { broadcastLane } = require("./lanes");
|
||||
const { detect } = require("../lib/stage-detect");
|
||||
const { getPipeline } = require("../lib/pipelines");
|
||||
const path = require("path");
|
||||
const { threadOf } = require("../lib/codex-usage");
|
||||
const { agyCliDirs } = require("../lib/antigravity-home");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -166,12 +169,13 @@ const LANE_DONE_HOOKS = new Set(["Stop", "SessionEnd"]);
|
||||
* running/idle, so without this an adopted lane read "idle" for the entire
|
||||
* time Claude was working in it.
|
||||
*/
|
||||
function touchLaneFromHook(hookType, data) {
|
||||
function touchLaneFromHook(hookType, data, tool = "claude") {
|
||||
try {
|
||||
if (!data || !data.cwd) return;
|
||||
// Hooks are only ever emitted by the Claude Code CLI, so pin to the
|
||||
// `claude` lane at this cwd — never the `codex` lane that might share it.
|
||||
const lane = lanesLib.resolveLaneByCwd(data.cwd, "claude");
|
||||
// Pin to the lane of the CLI that sent the hook — a Claude hook never
|
||||
// touches the codex lane sharing its directory, nor a Codex hook the
|
||||
// claude one.
|
||||
const lane = lanesLib.resolveLaneByCwd(data.cwd, tool);
|
||||
if (!lane) return;
|
||||
|
||||
// Infer the lane's stage from this one tool event, best-effort. A real
|
||||
@@ -188,30 +192,38 @@ function touchLaneFromHook(hookType, data) {
|
||||
// of that bookkeeping because the block below returns early when the patch is
|
||||
// empty — the common case — so detection placed after it would be unreachable
|
||||
// on most hooks.
|
||||
try {
|
||||
// Credit the lane whose files/directory the tool actually touched, which
|
||||
// is not always the session's lane - see resolveLaneForWork. Bookkeeping
|
||||
// below still uses `lane`, the session's own.
|
||||
const workLane = lanesLib.resolveLaneForWork(data.cwd, data.tool_input) || lane;
|
||||
const detection = detect(getPipeline(workLane.pipeline), {
|
||||
tool_name: data.tool_name,
|
||||
tool_input: data.tool_input,
|
||||
});
|
||||
if (detection) {
|
||||
const detectResult = lanesLib.recordDetection(workLane.id, detection);
|
||||
if (detectResult.written) broadcastLane(workLane.id);
|
||||
// Only a claude lane carries a pipeline; every other tool's lane has no
|
||||
// stage to infer.
|
||||
if (tool === "claude") {
|
||||
try {
|
||||
// Credit the lane whose files/directory the tool actually touched, which
|
||||
// is not always the session's lane - see resolveLaneForWork. Bookkeeping
|
||||
// below still uses `lane`, the session's own.
|
||||
const workLane = lanesLib.resolveLaneForWork(data.cwd, data.tool_input) || lane;
|
||||
const detection = detect(getPipeline(workLane.pipeline), {
|
||||
tool_name: data.tool_name,
|
||||
tool_input: data.tool_input,
|
||||
});
|
||||
if (detection) {
|
||||
const detectResult = lanesLib.recordDetection(workLane.id, detection);
|
||||
if (detectResult.written) broadcastLane(workLane.id);
|
||||
}
|
||||
} catch {
|
||||
/* an inference is never worth dropping real bookkeeping for */
|
||||
}
|
||||
} catch {
|
||||
/* an inference is never worth dropping real bookkeeping for */
|
||||
}
|
||||
|
||||
const patch = {};
|
||||
// A Codex sub-agent's tool call (see processEvent): not the user's session
|
||||
// resuming, so it neither clears needs_action nor moves the lane.
|
||||
const childEvent = tool === "codex" && data._codexChild === true;
|
||||
const idleNudge = hookType === "Notification" && IDLE_NUDGE_RE.test(data.message || "");
|
||||
if (data.session_id && lane.session_id !== data.session_id) patch.session_id = data.session_id;
|
||||
if (hookType === "Notification" && !idleNudge) {
|
||||
patch.needs_action = data.message || "needs you";
|
||||
patch.needs_action_session = data.session_id || null;
|
||||
} else if (
|
||||
!childEvent &&
|
||||
lane.needs_action &&
|
||||
data.session_id &&
|
||||
data.session_id === (lane.needs_action_session || lane.session_id)
|
||||
@@ -226,7 +238,7 @@ function touchLaneFromHook(hookType, data) {
|
||||
|
||||
// Only idle↔running is mirrored: provisioning and failed are lifecycle
|
||||
// states this path must never stomp on.
|
||||
if (!lane.run_id && (lane.status === "idle" || lane.status === "running")) {
|
||||
if (!childEvent && !lane.run_id && (lane.status === "idle" || lane.status === "running")) {
|
||||
const next = LANE_WORKING_HOOKS.has(hookType)
|
||||
? "running"
|
||||
: LANE_DONE_HOOKS.has(hookType) || idleNudge
|
||||
@@ -243,40 +255,51 @@ function touchLaneFromHook(hookType, data) {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSession(sessionId, data) {
|
||||
const setSessionTool = db.prepare("UPDATE sessions SET tool = ? WHERE id = ?");
|
||||
|
||||
/** The session's `<id>-main` agent, created and broadcast. */
|
||||
function createMainAgent(session) {
|
||||
const mainAgentId = `${session.id}-main`;
|
||||
const sessionLabel = session.name || `Session ${session.id.slice(0, 8)}`;
|
||||
stmts.insertAgent.run(
|
||||
mainAgentId,
|
||||
session.id,
|
||||
`Main Agent - ${sessionLabel}`,
|
||||
"main",
|
||||
null,
|
||||
"working",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
const mainAgent = stmts.getAgent.get(mainAgentId);
|
||||
if (mainAgent) broadcast("agent_created", mainAgent);
|
||||
}
|
||||
|
||||
const SESSION_LABEL = { codex: "Codex", antigravity: "Antigravity" };
|
||||
|
||||
function ensureSession(sessionId, data, tool = "claude") {
|
||||
let session = stmts.getSession.get(sessionId);
|
||||
if (!session) {
|
||||
stmts.insertSession.run(
|
||||
sessionId,
|
||||
data.session_name || `Session ${sessionId.slice(0, 8)}`,
|
||||
data.session_name || `${SESSION_LABEL[tool] || "Session"} ${sessionId.slice(0, 8)}`,
|
||||
"active",
|
||||
data.cwd || null,
|
||||
data.model || null,
|
||||
null
|
||||
);
|
||||
if (tool !== "claude") setSessionTool.run(tool, sessionId);
|
||||
session = stmts.getSession.get(sessionId);
|
||||
if (!session) {
|
||||
console.error(`[HOOKS] Failed to create session ${sessionId} — insert returned no row`);
|
||||
return null;
|
||||
}
|
||||
broadcast("session_created", session);
|
||||
|
||||
// Create main agent for new session
|
||||
const mainAgentId = `${sessionId}-main`;
|
||||
const sessionLabel = session.name || `Session ${sessionId.slice(0, 8)}`;
|
||||
stmts.insertAgent.run(
|
||||
mainAgentId,
|
||||
sessionId,
|
||||
`Main Agent - ${sessionLabel}`,
|
||||
"main",
|
||||
null,
|
||||
"working",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
const mainAgent = stmts.getAgent.get(mainAgentId);
|
||||
if (mainAgent) broadcast("agent_created", mainAgent);
|
||||
createMainAgent(session);
|
||||
} else if (tool !== "claude" && !stmts.getAgent.get(`${sessionId}-main`)) {
|
||||
// A Codex session the journal poll imported first has no main agent yet.
|
||||
createMainAgent(session);
|
||||
}
|
||||
|
||||
// First-seen transcript_path → write to session row so the periodic sweep
|
||||
@@ -284,7 +307,9 @@ function ensureSession(sessionId, data) {
|
||||
// (NULL/'' check), so subsequent hooks for the same session are no-ops.
|
||||
// Type guard: hook payloads are unvalidated JSON — a non-string value would
|
||||
// make better-sqlite3 throw inside the surrounding processEvent transaction.
|
||||
if (typeof data.transcript_path === "string" && data.transcript_path) {
|
||||
// A Codex rollout is not a Claude transcript, and every sweep that reads
|
||||
// transcript_path parses Claude's format — it is never stored for one.
|
||||
if (tool === "claude" && typeof data.transcript_path === "string" && data.transcript_path) {
|
||||
stmts.setSessionTranscriptPath.run(data.transcript_path, sessionId);
|
||||
}
|
||||
return session;
|
||||
@@ -419,11 +444,14 @@ function applyFirstUserDescriptor(sessionId, result) {
|
||||
if (refreshedAgent) broadcast("agent_updated", refreshedAgent);
|
||||
}
|
||||
|
||||
const processEvent = db.transaction((hookType, data) => {
|
||||
const processEvent = db.transaction((hookType, data, tool = "claude") => {
|
||||
const sessionId = data.session_id;
|
||||
if (!sessionId) return null;
|
||||
|
||||
const session = ensureSession(sessionId, data);
|
||||
const session = ensureSession(sessionId, data, tool);
|
||||
// Only a Claude transcript is parsed here; a Codex session's tokens, model
|
||||
// and name come from its rollout journal (server/lib/codex-usage.js).
|
||||
const transcriptPath = tool === "claude" ? data.transcript_path : null;
|
||||
|
||||
// Remote household hooks (aideck-hook.js on other machines) cannot rely on
|
||||
// the transcript being readable on THIS host (the JSONL lives on the remote
|
||||
@@ -442,6 +470,11 @@ const processEvent = db.transaction((hookType, data) => {
|
||||
|
||||
let mainAgent = getMainAgent(sessionId);
|
||||
const mainAgentId = mainAgent?.id ?? null;
|
||||
// A Codex sub-agent's tool call folded onto its root session (codexHookInput):
|
||||
// work, but not the user resuming — it neither reactivates the session, nor
|
||||
// clears its waiting flag, nor puts the main agent to work. Its own Stop is
|
||||
// dropped, so anything it set here would never be undone.
|
||||
const childEvent = tool === "codex" && data._codexChild === true;
|
||||
|
||||
// Reactivate non-active sessions when we receive hook events proving the session is alive.
|
||||
// - UserPromptSubmit and PreToolUse always reactivate (user actively retried, even from error).
|
||||
@@ -455,6 +488,7 @@ const processEvent = db.transaction((hookType, data) => {
|
||||
const isStopLike = hookType === "Stop" || hookType === "SubagentStop";
|
||||
const isImportedOrAbandoned = session.status === "completed" || session.status === "abandoned";
|
||||
const needsReactivation =
|
||||
!childEvent &&
|
||||
session.status !== "active" &&
|
||||
isNonTerminalEvent &&
|
||||
(isUserAction ||
|
||||
@@ -495,12 +529,14 @@ const processEvent = db.transaction((hookType, data) => {
|
||||
// (reason='notification'), in which case the flag is preserved
|
||||
// (clearAwaitingInputRespectingActor). Computed before the clear because
|
||||
// the Agent-spawn branch below mutates agent rows.
|
||||
clearAwaitingInputRespectingActor(
|
||||
sessionId,
|
||||
mainAgentId,
|
||||
subagentIsActorNow(sessionId, mainAgent),
|
||||
true
|
||||
);
|
||||
if (!childEvent) {
|
||||
clearAwaitingInputRespectingActor(
|
||||
sessionId,
|
||||
mainAgentId,
|
||||
subagentIsActorNow(sessionId, mainAgent),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// If the tool is Agent, a subagent is being created
|
||||
if (toolName === "Agent") {
|
||||
@@ -562,6 +598,7 @@ const processEvent = db.transaction((hookType, data) => {
|
||||
}
|
||||
if (
|
||||
mainAgent &&
|
||||
!childEvent &&
|
||||
!subagentIsActor &&
|
||||
(mainAgent.status === "working" || mainAgent.status === "waiting")
|
||||
) {
|
||||
@@ -581,12 +618,14 @@ const processEvent = db.transaction((hookType, data) => {
|
||||
// a clear here, we'd be stuck in waiting until the next PreToolUse.
|
||||
// Same actor-guard as PreToolUse: a background subagent's PostToolUse
|
||||
// must not clear a genuine 'notification' wait held by the main agent.
|
||||
clearAwaitingInputRespectingActor(
|
||||
sessionId,
|
||||
mainAgentId,
|
||||
subagentIsActorNow(sessionId, mainAgent),
|
||||
true
|
||||
);
|
||||
if (!childEvent) {
|
||||
clearAwaitingInputRespectingActor(
|
||||
sessionId,
|
||||
mainAgentId,
|
||||
subagentIsActorNow(sessionId, mainAgent),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: PostToolUse for "Agent" tool fires immediately when a subagent is
|
||||
// backgrounded — it does NOT mean the subagent finished its work.
|
||||
@@ -787,7 +826,7 @@ const processEvent = db.transaction((hookType, data) => {
|
||||
// tail. A transient API error that the CLI retried past (successful turns
|
||||
// after it) has recovered, so exiting after a long healthy run must land
|
||||
// as `completed`, not a stale `error` frozen from days earlier.
|
||||
const endResult = data.transcript_path ? transcriptCache.extract(data.transcript_path) : null;
|
||||
const endResult = transcriptPath ? transcriptCache.extract(transcriptPath) : null;
|
||||
const finalSessionStatus =
|
||||
endSession?.status === "error" && isErrorAtTail(endResult) ? "error" : "completed";
|
||||
const allAgents = stmts.listAgentsBySession.all(sessionId);
|
||||
@@ -824,8 +863,10 @@ const processEvent = db.transaction((hookType, data) => {
|
||||
|
||||
case "Notification": {
|
||||
const msg = data.message || "Notification received";
|
||||
// Tag compaction-related notifications so they show as Compaction events
|
||||
if (/compact|compress|context.*(reduc|truncat|summar)/i.test(msg)) {
|
||||
// Tag compaction-related notifications so they show as Compaction events.
|
||||
// Claude's only: another CLI's Notification is a permission prompt, whose
|
||||
// message quotes the command ("tar --compress …").
|
||||
if (tool === "claude" && /compact|compress|context.*(reduc|truncat|summar)/i.test(msg)) {
|
||||
eventType = "Compaction";
|
||||
summary = msg;
|
||||
} else if (isWaitingForUserMessage(msg)) {
|
||||
@@ -862,8 +903,8 @@ const processEvent = db.transaction((hookType, data) => {
|
||||
//
|
||||
// Also detects compaction events (isCompactSummary in JSONL) and creates a
|
||||
// Compaction agent + event so the dashboard shows when context was compressed.
|
||||
if (data.transcript_path) {
|
||||
const result = transcriptCache.extract(data.transcript_path);
|
||||
if (transcriptPath) {
|
||||
const result = transcriptCache.extract(transcriptPath);
|
||||
if (result) {
|
||||
const { tokensByModel, compaction, latestModel } = result;
|
||||
|
||||
@@ -1115,8 +1156,8 @@ const processEvent = db.transaction((hookType, data) => {
|
||||
|
||||
// Evict transcript from cache on SessionEnd — session is done, no more reads expected.
|
||||
// Must happen after token extraction above to avoid re-populating the cache.
|
||||
if (hookType === "SessionEnd" && data.transcript_path) {
|
||||
transcriptCache.invalidate(data.transcript_path);
|
||||
if (hookType === "SessionEnd" && transcriptPath) {
|
||||
transcriptCache.invalidate(transcriptPath);
|
||||
}
|
||||
|
||||
// Bump session updated_at on every event
|
||||
@@ -1144,13 +1185,131 @@ const processEvent = db.transaction((hookType, data) => {
|
||||
return event;
|
||||
});
|
||||
|
||||
// A Codex sub-agent thread's own lifecycle is not the user's session's: its
|
||||
// Stop is not the end of the user's turn, nor its SessionStart a new session.
|
||||
const CODEX_CHILD_DROPPED = new Set([
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"Stop",
|
||||
"Interrupt",
|
||||
"UserPromptSubmit",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A Codex hook, in the terms the rest of this route speaks. A hook from a
|
||||
* sub-agent thread (its rollout's session_meta names another root session)
|
||||
* is put on the root session, or dropped for a lifecycle event; a
|
||||
* PermissionRequest becomes the Notification Claude Code sends for a
|
||||
* permission prompt, so the waiting flag, needs_action, alerts and the
|
||||
* browser notification follow unchanged; an Interrupt (declined permission,
|
||||
* Esc) ends the turn like a Stop. Null means "ignore this hook".
|
||||
*/
|
||||
function codexHookInput(hookType, data) {
|
||||
let d = data;
|
||||
const thread = threadOf(data.transcript_path);
|
||||
if (thread && thread.sessionId !== thread.id) {
|
||||
if (CODEX_CHILD_DROPPED.has(hookType)) return null;
|
||||
// Marked: a sub-agent's tool call is not the user resuming the root.
|
||||
d = { ...d, session_id: thread.sessionId, _codexChild: true };
|
||||
}
|
||||
if (hookType === "PermissionRequest") {
|
||||
const input = d.tool_input && typeof d.tool_input === "object" ? d.tool_input : {};
|
||||
const what = [input.description, input.command, d.tool_name].find(
|
||||
(v) => typeof v === "string" && v.trim()
|
||||
);
|
||||
const message = `Codex needs your permission: ${String(what || "a tool call").slice(0, 200)}`;
|
||||
return { hookType: "Notification", data: { ...d, message } };
|
||||
}
|
||||
// A declined permission or Esc ends the turn with Interrupt, not Stop, and
|
||||
// leaves Codex at its prompt just as a Stop does.
|
||||
if (hookType === "Interrupt") return { hookType: "Stop", data: d };
|
||||
return { hookType, data: d };
|
||||
}
|
||||
|
||||
/**
|
||||
* An Antigravity (`agy`) hook, in the terms the rest of this route speaks.
|
||||
* Its payload names the conversation `conversationId` and the workspace
|
||||
* `workspacePaths[0]`; a run's first `PreInvocation` (or the first after a
|
||||
* Stop) is the user's prompt, a `PostToolUse` carries `toolCall.{name,args}`,
|
||||
* a `Stop` ends the turn — as an error when it carries one, and not at all
|
||||
* while `fullyIdle` is false (background work still running). A hook
|
||||
* whose transcript is not under the CLI's own directory is the Antigravity
|
||||
* IDE's (it loads the same global hooks.json) and is ignored, as is every
|
||||
* other event. Null means "ignore this hook".
|
||||
*/
|
||||
const AGY_TURN_GAP_MS = 120_000;
|
||||
|
||||
function antigravityHookInput(hookType, data) {
|
||||
const id = data.conversationId;
|
||||
if (typeof id !== "string" || !/^[0-9a-f-]{36}$/.test(id)) return null;
|
||||
if (typeof data.transcriptPath !== "string") return null;
|
||||
const transcript = path.resolve(data.transcriptPath);
|
||||
if (!agyCliDirs().some((dir) => transcript.startsWith(dir + path.sep))) return null;
|
||||
const cwd = Array.isArray(data.workspacePaths) ? data.workspacePaths[0] : null;
|
||||
const d = {
|
||||
session_id: id,
|
||||
cwd: typeof cwd === "string" && path.isAbsolute(cwd) ? cwd : undefined,
|
||||
model: typeof data.modelName === "string" ? data.modelName : undefined,
|
||||
};
|
||||
if (hookType === "PreInvocation") {
|
||||
// A run's first model call is the user's prompt; so is the first one after
|
||||
// a Stop, after a turn that ended with none (an API error, a refused
|
||||
// permission), or on a session the poll already settled — whatever agy
|
||||
// numbers it. Inside a turn a model call follows a hook within seconds.
|
||||
const last = stmts.lastEvent.get(id);
|
||||
const turn =
|
||||
data.invocationNum === 0 ||
|
||||
!last ||
|
||||
last.event_type === "Stop" ||
|
||||
stmts.getSession.get(id)?.status !== "active" ||
|
||||
Date.now() - Date.parse(last.created_at) > AGY_TURN_GAP_MS;
|
||||
return turn ? { hookType: "UserPromptSubmit", data: d } : null;
|
||||
}
|
||||
if (hookType === "PostToolUse") {
|
||||
const call = data.toolCall && typeof data.toolCall === "object" ? data.toolCall : {};
|
||||
return {
|
||||
hookType,
|
||||
data: {
|
||||
...d,
|
||||
tool_name: typeof call.name === "string" ? call.name : "unknown",
|
||||
// Two identical calls in a row stay two events (the duplicate guard).
|
||||
step: Number.isInteger(data.stepIdx) ? data.stepIdx : undefined,
|
||||
tool_input: call.args && typeof call.args === "object" ? call.args : {},
|
||||
...(typeof data.error === "string" &&
|
||||
data.error && { tool_response: { error: data.error } }),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (hookType === "Stop") {
|
||||
// Background work still running: the turn is not over yet.
|
||||
if (data.fullyIdle === false) return null;
|
||||
const failed =
|
||||
(typeof data.error === "string" && data.error !== "") ||
|
||||
(typeof data.terminationReason === "string" && /error|fail/i.test(data.terminationReason));
|
||||
return { hookType, data: failed ? { ...d, stop_reason: "error" } : d };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput };
|
||||
|
||||
router.post("/event", (req, res) => {
|
||||
const { hook_type, data } = req.body;
|
||||
let { hook_type, data } = req.body;
|
||||
if (!hook_type || !data) {
|
||||
return res.status(400).json({
|
||||
error: { code: "INVALID_INPUT", message: "hook_type and data are required" },
|
||||
});
|
||||
}
|
||||
// Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js),
|
||||
// `antigravity` from agy's (antigravity-hooks.js), anything else is Claude
|
||||
// Code — an allowlist, never a value stored as sent.
|
||||
const tool = Object.hasOwn(HOOK_TOOLS, req.body.tool) ? req.body.tool : "claude";
|
||||
if (tool !== "claude") {
|
||||
if (!data || typeof data !== "object") return res.json({ ok: true, ignored: true });
|
||||
const mapped = HOOK_TOOLS[tool](hook_type, data);
|
||||
if (!mapped) return res.json({ ok: true, ignored: true });
|
||||
({ hookType: hook_type, data } = mapped);
|
||||
}
|
||||
|
||||
// The same hook delivered twice — AgentHub's hooks registered twice (plugin
|
||||
// + a leftover checkout install, two marketplaces), or two servers on one DB
|
||||
@@ -1166,7 +1325,7 @@ router.post("/event", (req, res) => {
|
||||
return res.json({ ok: true, duplicate: true });
|
||||
}
|
||||
|
||||
const result = processEvent(hook_type, data);
|
||||
const result = processEvent(hook_type, data, tool);
|
||||
if (!result) {
|
||||
return res.status(400).json({
|
||||
error: { code: "MISSING_SESSION", message: "session_id is required in data" },
|
||||
@@ -1174,7 +1333,7 @@ router.post("/event", (req, res) => {
|
||||
}
|
||||
|
||||
// ponytail: full lane scan per hook — fine at dozens of lanes; index by cwd prefix if lane counts grow.
|
||||
touchLaneFromHook(hook_type, data);
|
||||
touchLaneFromHook(hook_type, data, tool);
|
||||
|
||||
res.json({ ok: true, event: result });
|
||||
|
||||
@@ -1191,7 +1350,12 @@ router.post("/event", (req, res) => {
|
||||
// tool calls that aren't yet in the events table. Subagent tool_use blocks
|
||||
// never fire hooks on the parent session — this scan is the only path that
|
||||
// attributes them to the subagent's agent_id.
|
||||
if (hook_type === "SubagentStop" && data.session_id && data.transcript_path) {
|
||||
if (
|
||||
tool === "claude" &&
|
||||
hook_type === "SubagentStop" &&
|
||||
data.session_id &&
|
||||
data.transcript_path
|
||||
) {
|
||||
// Models the MAIN transcript wrote. The subagent scan must SKIP these
|
||||
// buckets — the main-transcript writer above owns them, and writing one
|
||||
// from two sources of different magnitude would trip replaceTokenUsage's
|
||||
@@ -1245,6 +1409,7 @@ router.post("/event", (req, res) => {
|
||||
// Run on the lifecycle hooks that bracket a workflow finishing, off the
|
||||
// response path and fail-safe — a partial/absent journal is expected.
|
||||
if (
|
||||
tool === "claude" &&
|
||||
["Stop", "SubagentStop", "SessionEnd"].includes(hook_type) &&
|
||||
data.session_id &&
|
||||
data.transcript_path
|
||||
@@ -1329,6 +1494,8 @@ function watchdogCheck() {
|
||||
// is a delayed rsync mirror on this host and their activity clock is the
|
||||
// remote machine's, so the transcript-tail error/interrupt heuristics below
|
||||
// would misfire. Their lifecycle is owned by server/lib/remote-sync.js.
|
||||
// Codex sessions (tool = 'codex') have no Claude transcript to tail; their
|
||||
// lifecycle is owned by server/lib/codex-usage.js.
|
||||
const staleSessions = db
|
||||
.prepare(
|
||||
`SELECT s.id, s.status, s.cwd,
|
||||
@@ -1338,7 +1505,7 @@ function watchdogCheck() {
|
||||
ORDER BY e.created_at DESC LIMIT 1) as last_data
|
||||
FROM sessions s
|
||||
WHERE s.status IN ('active', 'error') AND s.updated_at < ?
|
||||
AND (s.source = 'local' OR s.source IS NULL)`
|
||||
AND (s.source = 'local' OR s.source IS NULL) AND s.tool = 'claude'`
|
||||
)
|
||||
.all(cutoff);
|
||||
|
||||
@@ -1560,12 +1727,15 @@ function livenessReap({ ignoreIdleGate = false } = {}) {
|
||||
// absolute (e.g. /home/ubuntu/matroid), so the posix-cwd guard below does not
|
||||
// catch them. Their liveness is owned entirely by the sync/import path
|
||||
// (server/lib/remote-sync.js reconciles their status from the mirrored
|
||||
// transcript), so the local process probe must leave them alone.
|
||||
// transcript), so the local process probe must leave them alone. A Codex
|
||||
// session (`tool = 'codex'`) is spared for the same reason: a `claude`
|
||||
// process probe proves nothing about it, and codex-usage.js settles it from
|
||||
// its own journals.
|
||||
const activeSessions = db
|
||||
.prepare(
|
||||
`SELECT id, name, cwd, transcript_path, updated_at FROM sessions
|
||||
WHERE status = 'active' AND cwd IS NOT NULL AND cwd <> ''
|
||||
AND (source = 'local' OR source IS NULL)`
|
||||
AND (source = 'local' OR source IS NULL) AND tool = 'claude'`
|
||||
)
|
||||
.all();
|
||||
if (activeSessions.length === 0) return; // nothing to check — skip the ps/lsof cost
|
||||
|
||||
Reference in New Issue
Block a user