clementine-agent 1.18.99 → 1.18.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRD §6 Phase 4d / 1.18.101 — Path B (hook side-channel) session registry.
|
|
3
|
+
*
|
|
4
|
+
* The Claude Agent SDK's hook mechanism (PreToolUse, PostToolUse, SubagentStart,
|
|
5
|
+
* SubagentStop, Stop, Notification, etc.) lets command-type hooks defined in
|
|
6
|
+
* `.claude/settings.json` POST event JSON to an external endpoint. Path B is
|
|
7
|
+
* how the dashboard receives those events and merges them into the per-run
|
|
8
|
+
* event log so the Run detail viewer + Latency dashboard see real per-tool
|
|
9
|
+
* durations (not the path A heuristic).
|
|
10
|
+
*
|
|
11
|
+
* The challenge: hook events arrive with the SDK `session_id`, but the
|
|
12
|
+
* dashboard's RunEvent rows key off the dashboard-assigned `runId` UUID. This
|
|
13
|
+
* registry bridges the two — `runAgent` registers a `(sessionId, runId,
|
|
14
|
+
* eventLog)` tuple on the SystemMessage init, the hook ingest endpoint looks
|
|
15
|
+
* up by sessionId, and the entry clears on session_end so memory doesn't leak.
|
|
16
|
+
*
|
|
17
|
+
* Design notes:
|
|
18
|
+
* - In-memory only (Map). Reboot clears all sessions; that's correct because
|
|
19
|
+
* any in-flight runs are abandoned by the daemon restart sweep anyway.
|
|
20
|
+
* - Multiple concurrent runs are supported (one entry per active SDK session).
|
|
21
|
+
* - Best-effort: if a hook arrives after session_end (race), we silently drop
|
|
22
|
+
* the event rather than replay onto a closed run. The dashboard's run
|
|
23
|
+
* detail can show a "stale hook event" diagnostic if this becomes common.
|
|
24
|
+
*/
|
|
25
|
+
import type { EventLog } from '../gateway/event-log.js';
|
|
26
|
+
export interface HookSessionEntry {
|
|
27
|
+
/** Dashboard-assigned UUID linking back to CronRunEntry.id. */
|
|
28
|
+
runId: string;
|
|
29
|
+
/** EventLog instance owning the run's JSONL file. Reused so path B writes
|
|
30
|
+
* go to the same file as path A live events. */
|
|
31
|
+
eventLog: EventLog;
|
|
32
|
+
/** Wall-clock when the registration happened. Used for janitor cleanup
|
|
33
|
+
* if a session_end never fires (SDK crash / network blip). */
|
|
34
|
+
registeredAt: number;
|
|
35
|
+
/** Atomic counter used so path B writes get monotonically increasing seqs
|
|
36
|
+
* even when interleaved with path A. The registry hands out seq numbers
|
|
37
|
+
* via `nextSeq()` below; path A uses its own closure-local counter and
|
|
38
|
+
* the EventLog dedupes on disk via append-only ordering. */
|
|
39
|
+
seqCounter: number;
|
|
40
|
+
}
|
|
41
|
+
export declare function registerRunSession(sessionId: string, runId: string, eventLog: EventLog, seqStart?: number): void;
|
|
42
|
+
export declare function unregisterRunSession(sessionId: string): void;
|
|
43
|
+
export declare function getRunSession(sessionId: string): HookSessionEntry | null;
|
|
44
|
+
/** Hand out the next monotonic seq for path B writes on this session. The
|
|
45
|
+
* caller is responsible for actually appending the event; this function
|
|
46
|
+
* just bumps the counter and returns the prior value so writes are stable
|
|
47
|
+
* under concurrent calls. */
|
|
48
|
+
export declare function nextSeqForSession(sessionId: string): number | null;
|
|
49
|
+
/** Best-effort sweep — call from a periodic timer or before each lookup
|
|
50
|
+
* to keep stale entries from accumulating. Currently called from the
|
|
51
|
+
* ingest endpoint on every POST so we don't need a dedicated timer. */
|
|
52
|
+
export declare function sweepStaleSessions(): number;
|
|
53
|
+
/** Test-only: snapshot of the live map size + age distribution. Useful for
|
|
54
|
+
* janitor diagnostics in the dashboard. */
|
|
55
|
+
export declare function getRegistryStats(): {
|
|
56
|
+
count: number;
|
|
57
|
+
oldestAgeMs: number | null;
|
|
58
|
+
};
|
|
59
|
+
/** Test-only: clear the registry between tests. Never call from production. */
|
|
60
|
+
export declare function _resetRegistryForTests(): void;
|
|
61
|
+
//# sourceMappingURL=hook-session-registry.d.ts.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRD §6 Phase 4d / 1.18.101 — Path B (hook side-channel) session registry.
|
|
3
|
+
*
|
|
4
|
+
* The Claude Agent SDK's hook mechanism (PreToolUse, PostToolUse, SubagentStart,
|
|
5
|
+
* SubagentStop, Stop, Notification, etc.) lets command-type hooks defined in
|
|
6
|
+
* `.claude/settings.json` POST event JSON to an external endpoint. Path B is
|
|
7
|
+
* how the dashboard receives those events and merges them into the per-run
|
|
8
|
+
* event log so the Run detail viewer + Latency dashboard see real per-tool
|
|
9
|
+
* durations (not the path A heuristic).
|
|
10
|
+
*
|
|
11
|
+
* The challenge: hook events arrive with the SDK `session_id`, but the
|
|
12
|
+
* dashboard's RunEvent rows key off the dashboard-assigned `runId` UUID. This
|
|
13
|
+
* registry bridges the two — `runAgent` registers a `(sessionId, runId,
|
|
14
|
+
* eventLog)` tuple on the SystemMessage init, the hook ingest endpoint looks
|
|
15
|
+
* up by sessionId, and the entry clears on session_end so memory doesn't leak.
|
|
16
|
+
*
|
|
17
|
+
* Design notes:
|
|
18
|
+
* - In-memory only (Map). Reboot clears all sessions; that's correct because
|
|
19
|
+
* any in-flight runs are abandoned by the daemon restart sweep anyway.
|
|
20
|
+
* - Multiple concurrent runs are supported (one entry per active SDK session).
|
|
21
|
+
* - Best-effort: if a hook arrives after session_end (race), we silently drop
|
|
22
|
+
* the event rather than replay onto a closed run. The dashboard's run
|
|
23
|
+
* detail can show a "stale hook event" diagnostic if this becomes common.
|
|
24
|
+
*/
|
|
25
|
+
const sessions = new Map();
|
|
26
|
+
/** Janitor sweep: clear sessions that have been registered for more than this
|
|
27
|
+
* many ms without a session_end. Keeps the map bounded if the daemon stays
|
|
28
|
+
* up but a run dies in a way that bypasses our session_end handler. */
|
|
29
|
+
const STALE_SESSION_MS = 6 * 60 * 60 * 1000; // 6h — matches longest cron wall cap
|
|
30
|
+
export function registerRunSession(sessionId, runId, eventLog, seqStart = 0) {
|
|
31
|
+
if (!sessionId || !runId)
|
|
32
|
+
return;
|
|
33
|
+
sessions.set(sessionId, { runId, eventLog, registeredAt: Date.now(), seqCounter: seqStart });
|
|
34
|
+
}
|
|
35
|
+
export function unregisterRunSession(sessionId) {
|
|
36
|
+
if (!sessionId)
|
|
37
|
+
return;
|
|
38
|
+
sessions.delete(sessionId);
|
|
39
|
+
}
|
|
40
|
+
export function getRunSession(sessionId) {
|
|
41
|
+
return sessions.get(sessionId) ?? null;
|
|
42
|
+
}
|
|
43
|
+
/** Hand out the next monotonic seq for path B writes on this session. The
|
|
44
|
+
* caller is responsible for actually appending the event; this function
|
|
45
|
+
* just bumps the counter and returns the prior value so writes are stable
|
|
46
|
+
* under concurrent calls. */
|
|
47
|
+
export function nextSeqForSession(sessionId) {
|
|
48
|
+
const entry = sessions.get(sessionId);
|
|
49
|
+
if (!entry)
|
|
50
|
+
return null;
|
|
51
|
+
// Path B seqs start at 1_000_000 to keep them visually distinct from
|
|
52
|
+
// path A in the event log + so a sort by seq groups them after path A
|
|
53
|
+
// writes that share the same timestamp. Multi-million seq numbers are
|
|
54
|
+
// fine — the field is plain JSON number, no overflow risk for the
|
|
55
|
+
// forseeable future.
|
|
56
|
+
const seq = 1_000_000 + entry.seqCounter;
|
|
57
|
+
entry.seqCounter += 1;
|
|
58
|
+
return seq;
|
|
59
|
+
}
|
|
60
|
+
/** Best-effort sweep — call from a periodic timer or before each lookup
|
|
61
|
+
* to keep stale entries from accumulating. Currently called from the
|
|
62
|
+
* ingest endpoint on every POST so we don't need a dedicated timer. */
|
|
63
|
+
export function sweepStaleSessions() {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
let removed = 0;
|
|
66
|
+
for (const [sid, entry] of sessions.entries()) {
|
|
67
|
+
if (now - entry.registeredAt > STALE_SESSION_MS) {
|
|
68
|
+
sessions.delete(sid);
|
|
69
|
+
removed += 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return removed;
|
|
73
|
+
}
|
|
74
|
+
/** Test-only: snapshot of the live map size + age distribution. Useful for
|
|
75
|
+
* janitor diagnostics in the dashboard. */
|
|
76
|
+
export function getRegistryStats() {
|
|
77
|
+
if (sessions.size === 0)
|
|
78
|
+
return { count: 0, oldestAgeMs: null };
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
let oldest = 0;
|
|
81
|
+
for (const entry of sessions.values()) {
|
|
82
|
+
const age = now - entry.registeredAt;
|
|
83
|
+
if (age > oldest)
|
|
84
|
+
oldest = age;
|
|
85
|
+
}
|
|
86
|
+
return { count: sessions.size, oldestAgeMs: oldest };
|
|
87
|
+
}
|
|
88
|
+
/** Test-only: clear the registry between tests. Never call from production. */
|
|
89
|
+
export function _resetRegistryForTests() {
|
|
90
|
+
sessions.clear();
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=hook-session-registry.js.map
|
package/dist/agent/run-agent.js
CHANGED
|
@@ -325,6 +325,17 @@ export async function runAgent(prompt, opts) {
|
|
|
325
325
|
}
|
|
326
326
|
// PRD Phase 4a / 1.18.85: write the session_start Event row.
|
|
327
327
|
writeEvent({ kind: 'session_start', ts: new Date().toISOString(), sessionId });
|
|
328
|
+
// PRD Phase 4d / 1.18.101: register this session in the path B
|
|
329
|
+
// hook-session registry so /api/hooks/event POSTs from the SDK can
|
|
330
|
+
// resolve sessionId → runId/eventLog and write into the same JSONL.
|
|
331
|
+
// Best-effort — telemetry must never block the run from progressing.
|
|
332
|
+
try {
|
|
333
|
+
const { registerRunSession } = await import('./hook-session-registry.js');
|
|
334
|
+
registerRunSession(sessionId, runId, eventLog, eventSeq);
|
|
335
|
+
}
|
|
336
|
+
catch (regErr) {
|
|
337
|
+
logger.debug({ regErr }, 'runAgent: hook-session registry register failed (non-fatal)');
|
|
338
|
+
}
|
|
328
339
|
logger.debug({ sessionKey: opts.sessionKey, sdkSessionId: sessionId, runId }, 'runAgent: SDK session initialized');
|
|
329
340
|
continue;
|
|
330
341
|
}
|
|
@@ -428,6 +439,13 @@ export async function runAgent(prompt, opts) {
|
|
|
428
439
|
costUsd: totalCostUsd,
|
|
429
440
|
stopReason: subtype,
|
|
430
441
|
});
|
|
442
|
+
// PRD Phase 4d / 1.18.101: unregister from the hook-session registry.
|
|
443
|
+
// Late-arriving hook events for this sessionId silently drop after this.
|
|
444
|
+
try {
|
|
445
|
+
const { unregisterRunSession } = await import('./hook-session-registry.js');
|
|
446
|
+
unregisterRunSession(sessionId);
|
|
447
|
+
}
|
|
448
|
+
catch { /* non-fatal */ }
|
|
431
449
|
// Mirror cost to usage_log. Same shape as the existing
|
|
432
450
|
// logQueryResult, but standalone so we don't depend on
|
|
433
451
|
// PersonalAssistant's instance state.
|
|
@@ -466,6 +484,14 @@ export async function runAgent(prompt, opts) {
|
|
|
466
484
|
sessionId,
|
|
467
485
|
toolError: errMsg,
|
|
468
486
|
});
|
|
487
|
+
// PRD Phase 4d / 1.18.101: also clear path B registry on error path so
|
|
488
|
+
// the map doesn't leak entries when runs fail before session_end fires.
|
|
489
|
+
try {
|
|
490
|
+
const { unregisterRunSession } = await import('./hook-session-registry.js');
|
|
491
|
+
if (sessionId)
|
|
492
|
+
unregisterRunSession(sessionId);
|
|
493
|
+
}
|
|
494
|
+
catch { /* non-fatal */ }
|
|
469
495
|
// Translate the SDK's budget-exhaustion throw into a message that
|
|
470
496
|
// tells the user (a) what cap tripped and (b) how to raise it.
|
|
471
497
|
// The raw SDK string ("Claude Code returned an error result:
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -5720,6 +5720,87 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
5720
5720
|
res.status(500).json({ error: String(err) });
|
|
5721
5721
|
}
|
|
5722
5722
|
});
|
|
5723
|
+
// ── PRD Phase 4d / 1.18.101: Path B hook event ingest ──────────
|
|
5724
|
+
// The Claude Agent SDK supports `.claude/settings.json`-registered command
|
|
5725
|
+
// hooks (PreToolUse, PostToolUse, SubagentStart, SubagentStop, Stop,
|
|
5726
|
+
// Notification, etc.). When a hook fires the SDK pipes JSON to the
|
|
5727
|
+
// command's stdin; the command can POST that JSON to this endpoint to
|
|
5728
|
+
// get the event recorded in the same per-run JSONL the in-process tap
|
|
5729
|
+
// (path A) writes to. The result: real per-tool-call durations,
|
|
5730
|
+
// approval-required events, and stop-reason hooks land in the Run detail
|
|
5731
|
+
// viewer's waterfall + the Latency dashboard's split bar.
|
|
5732
|
+
//
|
|
5733
|
+
// Auth: dashboard token via X-Dashboard-Token header. The daemon
|
|
5734
|
+
// exposes the token via CLEMENTINE_DASHBOARD_TOKEN env var, which the
|
|
5735
|
+
// SDK subprocess inherits — settings.json hook commands curl this
|
|
5736
|
+
// endpoint with that header.
|
|
5737
|
+
app.post('/api/hooks/event', express.json({ limit: '256kb' }), async (req, res) => {
|
|
5738
|
+
try {
|
|
5739
|
+
const headerToken = String(req.header('x-dashboard-token') || '');
|
|
5740
|
+
if (!dashboardToken || headerToken !== dashboardToken) {
|
|
5741
|
+
res.status(401).json({ ok: false, error: 'invalid dashboard token' });
|
|
5742
|
+
return;
|
|
5743
|
+
}
|
|
5744
|
+
const body = (req.body ?? {});
|
|
5745
|
+
const sessionId = String(body.session_id || body.sessionId || '');
|
|
5746
|
+
const hookEventName = String(body.hook_event_name || body.hookEventName || 'unknown');
|
|
5747
|
+
if (!sessionId) {
|
|
5748
|
+
res.status(400).json({ ok: false, error: 'session_id required in payload' });
|
|
5749
|
+
return;
|
|
5750
|
+
}
|
|
5751
|
+
const { getRunSession, nextSeqForSession, sweepStaleSessions } = await import('../agent/hook-session-registry.js');
|
|
5752
|
+
// Best-effort sweep on every POST — keeps the map bounded without a timer.
|
|
5753
|
+
sweepStaleSessions();
|
|
5754
|
+
const entry = getRunSession(sessionId);
|
|
5755
|
+
if (!entry) {
|
|
5756
|
+
// Late-arriving hook for a session we already closed (or never
|
|
5757
|
+
// saw because path A registration didn't complete). Drop and tell
|
|
5758
|
+
// the caller — the curl exits 0 either way so it doesn't fail
|
|
5759
|
+
// the SDK's run.
|
|
5760
|
+
res.status(202).json({ ok: false, dropped: true, reason: 'session not registered (race or post-end)' });
|
|
5761
|
+
return;
|
|
5762
|
+
}
|
|
5763
|
+
const seq = nextSeqForSession(sessionId);
|
|
5764
|
+
// Synthesize a RunEvent. The hook payload's shape varies by event
|
|
5765
|
+
// kind; we extract the fields the dashboard waterfall renders and
|
|
5766
|
+
// stash the full payload for advanced filtering later.
|
|
5767
|
+
const ev = {
|
|
5768
|
+
runId: entry.runId,
|
|
5769
|
+
seq: seq ?? 1_000_000,
|
|
5770
|
+
kind: 'hook',
|
|
5771
|
+
ts: new Date().toISOString(),
|
|
5772
|
+
sessionId,
|
|
5773
|
+
hookEventName,
|
|
5774
|
+
};
|
|
5775
|
+
// Tool fields if present (PreToolUse / PostToolUse).
|
|
5776
|
+
if (typeof body.tool_name === 'string')
|
|
5777
|
+
ev.toolName = body.tool_name;
|
|
5778
|
+
if (typeof body.tool_use_id === 'string')
|
|
5779
|
+
ev.toolUseId = body.tool_use_id;
|
|
5780
|
+
if (body.tool_input !== undefined)
|
|
5781
|
+
ev.toolInput = body.tool_input;
|
|
5782
|
+
if (body.tool_response !== undefined)
|
|
5783
|
+
ev.toolResult = body.tool_response;
|
|
5784
|
+
// PostToolUse can carry an explicit duration_ms (the SDK's stopwatch
|
|
5785
|
+
// wraps the tool call). When present we surface it onto the event so
|
|
5786
|
+
// the latency dashboard sums real numbers instead of the heuristic.
|
|
5787
|
+
if (typeof body.duration_ms === 'number')
|
|
5788
|
+
ev.durationMs = body.duration_ms;
|
|
5789
|
+
if (typeof body.parent_tool_use_id === 'string')
|
|
5790
|
+
ev.parentToolUseId = body.parent_tool_use_id;
|
|
5791
|
+
// Errors: PostToolUse can flag a non-zero result; surface as toolError.
|
|
5792
|
+
if (body.is_error === true) {
|
|
5793
|
+
ev.toolError = typeof body.tool_response === 'string'
|
|
5794
|
+
? body.tool_response.slice(0, 500)
|
|
5795
|
+
: 'tool returned is_error=true';
|
|
5796
|
+
}
|
|
5797
|
+
entry.eventLog.append(ev);
|
|
5798
|
+
res.json({ ok: true, runId: entry.runId, seq });
|
|
5799
|
+
}
|
|
5800
|
+
catch (err) {
|
|
5801
|
+
res.status(500).json({ ok: false, error: String(err) });
|
|
5802
|
+
}
|
|
5803
|
+
});
|
|
5723
5804
|
// ── PRD Phase 4a / 1.18.85: per-run Event store reader ─────────
|
|
5724
5805
|
// Returns every event captured by path A (in-process tap in runAgent)
|
|
5725
5806
|
// for one run. Used by the new Run detail page (Phase 4b).
|
|
@@ -34325,7 +34406,7 @@ function buildAgentToolRow(cat, tool) {
|
|
|
34325
34406
|
var pathHint = (type === 'project' && tool.path) ? ' <span style="color:var(--text-muted);font-size:10px">' + esc(tool.path) + '</span>' : '';
|
|
34326
34407
|
var statusEl;
|
|
34327
34408
|
if (setupTarget) {
|
|
34328
|
-
statusEl = '<a class="tt-status ' + statusClass + '" href="#" onclick="event.preventDefault();event.stopPropagation();navigateTo(
|
|
34409
|
+
statusEl = '<a class="tt-status ' + statusClass + '" href="#" onclick="event.preventDefault();event.stopPropagation();navigateTo(\\x27' + setupTarget + '\\x27);hideAgentModal();" style="text-decoration:none"><span class="tt-dot"></span>' + esc(statusLabel) + ' →</a>';
|
|
34329
34410
|
} else {
|
|
34330
34411
|
statusEl = '<span class="tt-status ' + statusClass + '"><span class="tt-dot"></span>' + esc(statusLabel) + '</span>';
|
|
34331
34412
|
}
|