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
@@ -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:
@@ -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(\'' + setupTarget + '\');hideAgentModal();" style="text-decoration:none"><span class="tt-dot"></span>' + esc(statusLabel) + ' →</a>';
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.99",
3
+ "version": "1.18.101",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",