clementine-agent 1.18.100 → 1.18.102

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
@@ -0,0 +1,75 @@
1
+ /**
2
+ * PRD §6 Phase 4d / 1.18.102 — Path B installer.
3
+ *
4
+ * Drops a `.claude/settings.local.json` into a project's cwd that registers
5
+ * the SDK's command-type hooks to POST events at the dashboard's
6
+ * /api/hooks/event endpoint. The installer is opt-in per task — the user
7
+ * clicks "Enable hooks" on the task card after they've decided they want
8
+ * real per-tool latency.
9
+ *
10
+ * Why settings.local.json (not settings.json):
11
+ * - The SDK's `setting_sources=['project','local']` reads both. We use
12
+ * the 'local' source so we never touch the user's hand-written
13
+ * settings.json. settings.local.json is conventionally gitignored —
14
+ * our hooks are per-machine config (they reference a localhost dashboard
15
+ * token), not source-controllable.
16
+ * - A future "disable hooks" path can rm the file without affecting any
17
+ * hand-written project settings.
18
+ *
19
+ * Auth: the hook commands include the dashboard token in the X-Dashboard-Token
20
+ * header. The token is baked into the curl command at install time. If the
21
+ * dashboard restarts and rotates its token, the user has to re-enable hooks
22
+ * (a small UX cost we accept; the alternative is having hooks read the
23
+ * token from disk at fire-time which adds a syscall to every tool call).
24
+ */
25
+ export interface SettingsTemplateOptions {
26
+ /** Dashboard token to include in the X-Dashboard-Token header. Required. */
27
+ token: string;
28
+ /** Localhost port the dashboard is listening on. Defaults to 3030. */
29
+ port?: number;
30
+ /** Mark the file with a comment so users know who wrote it. */
31
+ installerVersion?: string;
32
+ }
33
+ /** Build the JSON content of .claude/settings.local.json. The shape matches
34
+ * the SDK's hook config schema: a top-level `hooks` map keyed by event name,
35
+ * each value an array of `{ hooks: [{ type, command }] }` matchers. The
36
+ * empty matcher means "always fire". */
37
+ export declare function buildSettingsTemplate(opts: SettingsTemplateOptions): Record<string, unknown>;
38
+ export interface InstallResult {
39
+ ok: boolean;
40
+ filePath: string;
41
+ /** Whether the file existed before this call. */
42
+ wasExisting: boolean;
43
+ /** Whether we replaced an existing installer-managed file vs writing fresh. */
44
+ wasUpdate: boolean;
45
+ /** Set when ok=false. */
46
+ error?: string;
47
+ }
48
+ /** Write/update .claude/settings.local.json in `workDir`. If the file
49
+ * already exists and is NOT installer-managed (no _clementine key), we
50
+ * bail out and refuse to overwrite to avoid clobbering user content. */
51
+ export declare function installPathBHooks(workDir: string, opts: SettingsTemplateOptions): InstallResult;
52
+ export interface HooksStatus {
53
+ /** Whether a settings.local.json exists in the workDir. */
54
+ installed: boolean;
55
+ /** Whether the file is one we wrote (has _clementine sentinel). */
56
+ managedByUs: boolean;
57
+ /** Resolved path we checked (helpful for diagnostic toasts). */
58
+ filePath: string;
59
+ /** When we installed it (ISO). null if not managed by us. */
60
+ installedAt?: string;
61
+ /** Installer version that wrote it. null if not managed by us. */
62
+ installerVersion?: string;
63
+ /** True if the file exists but came from somewhere else (user). */
64
+ conflictsWithUser: boolean;
65
+ }
66
+ /** Inspect a workDir's hook installation state. Used by the dashboard's
67
+ * task card to decide whether to render "Enable hooks" or "Hooks: on". */
68
+ export declare function getHooksStatus(workDir: string): HooksStatus;
69
+ /** Removes our installer-managed settings.local.json. Refuses if the file
70
+ * isn't ours (so a misclick doesn't delete user config). */
71
+ export declare function uninstallPathBHooks(workDir: string): {
72
+ ok: boolean;
73
+ error?: string;
74
+ };
75
+ //# sourceMappingURL=path-b-installer.d.ts.map
@@ -0,0 +1,183 @@
1
+ /**
2
+ * PRD §6 Phase 4d / 1.18.102 — Path B installer.
3
+ *
4
+ * Drops a `.claude/settings.local.json` into a project's cwd that registers
5
+ * the SDK's command-type hooks to POST events at the dashboard's
6
+ * /api/hooks/event endpoint. The installer is opt-in per task — the user
7
+ * clicks "Enable hooks" on the task card after they've decided they want
8
+ * real per-tool latency.
9
+ *
10
+ * Why settings.local.json (not settings.json):
11
+ * - The SDK's `setting_sources=['project','local']` reads both. We use
12
+ * the 'local' source so we never touch the user's hand-written
13
+ * settings.json. settings.local.json is conventionally gitignored —
14
+ * our hooks are per-machine config (they reference a localhost dashboard
15
+ * token), not source-controllable.
16
+ * - A future "disable hooks" path can rm the file without affecting any
17
+ * hand-written project settings.
18
+ *
19
+ * Auth: the hook commands include the dashboard token in the X-Dashboard-Token
20
+ * header. The token is baked into the curl command at install time. If the
21
+ * dashboard restarts and rotates its token, the user has to re-enable hooks
22
+ * (a small UX cost we accept; the alternative is having hooks read the
23
+ * token from disk at fire-time which adds a syscall to every tool call).
24
+ */
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import path from 'node:path';
27
+ /** Hooks we install. Picked for the latency dashboard's needs:
28
+ * PreToolUse + PostToolUse give us tool durations (PostToolUse carries
29
+ * duration_ms). SubagentStart/Stop close the gap path C handles via
30
+ * transcript backfill. Stop / Notification add nice-to-have signal but
31
+ * are minimal cost. */
32
+ const HOOK_EVENTS = [
33
+ 'PreToolUse',
34
+ 'PostToolUse',
35
+ 'SubagentStart',
36
+ 'SubagentStop',
37
+ 'Stop',
38
+ 'Notification',
39
+ 'UserPromptSubmit',
40
+ 'SessionStart',
41
+ 'PreCompact',
42
+ ];
43
+ /** Build the JSON content of .claude/settings.local.json. The shape matches
44
+ * the SDK's hook config schema: a top-level `hooks` map keyed by event name,
45
+ * each value an array of `{ hooks: [{ type, command }] }` matchers. The
46
+ * empty matcher means "always fire". */
47
+ export function buildSettingsTemplate(opts) {
48
+ const port = opts.port ?? 3030;
49
+ // Use POSIX `curl` — preinstalled on macOS and most Linuxes; Windows users
50
+ // running WSL or Git Bash also have it. We add `--max-time 2` so a
51
+ // wedged dashboard can't stall the SDK's tool execution.
52
+ const curlCmd = `curl -s --max-time 2 -X POST `
53
+ + `-H "X-Dashboard-Token: ${opts.token}" `
54
+ + `-H "Content-Type: application/json" `
55
+ + `--data-binary @- `
56
+ + `http://127.0.0.1:${port}/api/hooks/event`;
57
+ const hooks = {};
58
+ for (const eventName of HOOK_EVENTS) {
59
+ hooks[eventName] = [
60
+ {
61
+ // Empty matcher fires for every event; the dashboard endpoint
62
+ // can later expose a UI for restricting to specific tool names.
63
+ hooks: [{ type: 'command', command: curlCmd }],
64
+ },
65
+ ];
66
+ }
67
+ return {
68
+ // Sentinel field so we can detect (and update) installer-managed
69
+ // settings without touching anything else in the file.
70
+ _clementine: {
71
+ managedBy: 'clementine-agent path-b-installer',
72
+ installedAt: new Date().toISOString(),
73
+ installerVersion: opts.installerVersion ?? '1.18.102',
74
+ port,
75
+ },
76
+ hooks,
77
+ };
78
+ }
79
+ /** Write/update .claude/settings.local.json in `workDir`. If the file
80
+ * already exists and is NOT installer-managed (no _clementine key), we
81
+ * bail out and refuse to overwrite to avoid clobbering user content. */
82
+ export function installPathBHooks(workDir, opts) {
83
+ if (!workDir)
84
+ return { ok: false, filePath: '', wasExisting: false, wasUpdate: false, error: 'workDir required' };
85
+ if (!opts.token)
86
+ return { ok: false, filePath: '', wasExisting: false, wasUpdate: false, error: 'token required' };
87
+ const dir = path.join(workDir, '.claude');
88
+ const file = path.join(dir, 'settings.local.json');
89
+ let wasExisting = false;
90
+ let wasUpdate = false;
91
+ if (existsSync(file)) {
92
+ wasExisting = true;
93
+ try {
94
+ const raw = readFileSync(file, 'utf-8');
95
+ const parsed = JSON.parse(raw);
96
+ // Only proceed if the file was previously installed by us.
97
+ if (!parsed._clementine || typeof parsed._clementine !== 'object') {
98
+ return {
99
+ ok: false,
100
+ filePath: file,
101
+ wasExisting: true,
102
+ wasUpdate: false,
103
+ error: 'settings.local.json exists but was not created by clementine — refusing to overwrite. Move or delete the file and retry.',
104
+ };
105
+ }
106
+ wasUpdate = true;
107
+ }
108
+ catch (err) {
109
+ return {
110
+ ok: false,
111
+ filePath: file,
112
+ wasExisting: true,
113
+ wasUpdate: false,
114
+ error: 'could not parse existing settings.local.json: ' + String(err),
115
+ };
116
+ }
117
+ }
118
+ try {
119
+ mkdirSync(dir, { recursive: true });
120
+ const content = buildSettingsTemplate(opts);
121
+ writeFileSync(file, JSON.stringify(content, null, 2) + '\n');
122
+ return { ok: true, filePath: file, wasExisting, wasUpdate };
123
+ }
124
+ catch (err) {
125
+ return {
126
+ ok: false,
127
+ filePath: file,
128
+ wasExisting,
129
+ wasUpdate,
130
+ error: 'failed to write settings.local.json: ' + String(err),
131
+ };
132
+ }
133
+ }
134
+ /** Inspect a workDir's hook installation state. Used by the dashboard's
135
+ * task card to decide whether to render "Enable hooks" or "Hooks: on". */
136
+ export function getHooksStatus(workDir) {
137
+ const filePath = path.join(workDir, '.claude', 'settings.local.json');
138
+ if (!existsSync(filePath)) {
139
+ return { installed: false, managedByUs: false, filePath, conflictsWithUser: false };
140
+ }
141
+ try {
142
+ const raw = readFileSync(filePath, 'utf-8');
143
+ const parsed = JSON.parse(raw);
144
+ const sentinel = parsed._clementine;
145
+ if (sentinel && typeof sentinel === 'object') {
146
+ return {
147
+ installed: true,
148
+ managedByUs: true,
149
+ filePath,
150
+ installedAt: sentinel.installedAt,
151
+ installerVersion: sentinel.installerVersion,
152
+ conflictsWithUser: false,
153
+ };
154
+ }
155
+ return { installed: true, managedByUs: false, filePath, conflictsWithUser: true };
156
+ }
157
+ catch {
158
+ // Couldn't parse — treat as a user file we shouldn't touch.
159
+ return { installed: true, managedByUs: false, filePath, conflictsWithUser: true };
160
+ }
161
+ }
162
+ /** Removes our installer-managed settings.local.json. Refuses if the file
163
+ * isn't ours (so a misclick doesn't delete user config). */
164
+ export function uninstallPathBHooks(workDir) {
165
+ const filePath = path.join(workDir, '.claude', 'settings.local.json');
166
+ if (!existsSync(filePath))
167
+ return { ok: true };
168
+ const status = getHooksStatus(workDir);
169
+ if (!status.managedByUs) {
170
+ return { ok: false, error: 'settings.local.json is not managed by clementine — refusing to delete' };
171
+ }
172
+ try {
173
+ // Use unlinkSync via dynamic import to keep the static fs imports list
174
+ // tight; this path is rarely invoked.
175
+ const fs = require('node:fs');
176
+ fs.unlinkSync(filePath);
177
+ return { ok: true };
178
+ }
179
+ catch (err) {
180
+ return { ok: false, error: 'failed to remove: ' + String(err) };
181
+ }
182
+ }
183
+ //# sourceMappingURL=path-b-installer.js.map
@@ -253,7 +253,12 @@ export async function runAgent(prompt, opts) {
253
253
  systemPrompt: profileAppend
254
254
  ? { type: 'preset', preset: 'claude_code', append: profileAppend }
255
255
  : { type: 'preset', preset: 'claude_code' },
256
- settingSources: opts.settingSources ?? ['project'],
256
+ // PRD §6 Phase 4d / 1.18.102: read both project and local sources by
257
+ // default. The path B installer writes to .claude/settings.local.json
258
+ // (which the SDK reads under the 'local' source) so we never clobber
259
+ // the user's hand-written .claude/settings.json. Callers can still
260
+ // override settingSources via opts.
261
+ settingSources: opts.settingSources ?? ['project', 'local'],
257
262
  agents,
258
263
  // SDK's McpServerConfig is a union; cast at the boundary since
259
264
  // callers can mix stdio + http + sse server shapes.
@@ -325,6 +330,17 @@ export async function runAgent(prompt, opts) {
325
330
  }
326
331
  // PRD Phase 4a / 1.18.85: write the session_start Event row.
327
332
  writeEvent({ kind: 'session_start', ts: new Date().toISOString(), sessionId });
333
+ // PRD Phase 4d / 1.18.101: register this session in the path B
334
+ // hook-session registry so /api/hooks/event POSTs from the SDK can
335
+ // resolve sessionId → runId/eventLog and write into the same JSONL.
336
+ // Best-effort — telemetry must never block the run from progressing.
337
+ try {
338
+ const { registerRunSession } = await import('./hook-session-registry.js');
339
+ registerRunSession(sessionId, runId, eventLog, eventSeq);
340
+ }
341
+ catch (regErr) {
342
+ logger.debug({ regErr }, 'runAgent: hook-session registry register failed (non-fatal)');
343
+ }
328
344
  logger.debug({ sessionKey: opts.sessionKey, sdkSessionId: sessionId, runId }, 'runAgent: SDK session initialized');
329
345
  continue;
330
346
  }
@@ -428,6 +444,13 @@ export async function runAgent(prompt, opts) {
428
444
  costUsd: totalCostUsd,
429
445
  stopReason: subtype,
430
446
  });
447
+ // PRD Phase 4d / 1.18.101: unregister from the hook-session registry.
448
+ // Late-arriving hook events for this sessionId silently drop after this.
449
+ try {
450
+ const { unregisterRunSession } = await import('./hook-session-registry.js');
451
+ unregisterRunSession(sessionId);
452
+ }
453
+ catch { /* non-fatal */ }
431
454
  // Mirror cost to usage_log. Same shape as the existing
432
455
  // logQueryResult, but standalone so we don't depend on
433
456
  // PersonalAssistant's instance state.
@@ -466,6 +489,14 @@ export async function runAgent(prompt, opts) {
466
489
  sessionId,
467
490
  toolError: errMsg,
468
491
  });
492
+ // PRD Phase 4d / 1.18.101: also clear path B registry on error path so
493
+ // the map doesn't leak entries when runs fail before session_end fires.
494
+ try {
495
+ const { unregisterRunSession } = await import('./hook-session-registry.js');
496
+ if (sessionId)
497
+ unregisterRunSession(sessionId);
498
+ }
499
+ catch { /* non-fatal */ }
469
500
  // Translate the SDK's budget-exhaustion throw into a message that
470
501
  // tells the user (a) what cap tripped and (b) how to raise it.
471
502
  // The raw SDK string ("Claude Code returned an error result:
@@ -4594,6 +4594,101 @@ export async function cmdDashboard(opts) {
4594
4594
  // path on runAgentCron honors the signal and unwinds cleanly. The
4595
4595
  // CronScheduler's own catch path then writes the closing CronRunEntry
4596
4596
  // with status='error' + an "AbortError" message.
4597
+ // ── PRD §6 Phase 4d / 1.18.102: Path B opt-in install ──────────
4598
+ // Three endpoints for managing the .claude/settings.local.json hook
4599
+ // wiring on a per-task basis. Hooks are opt-in (the user picks which
4600
+ // tasks should pay the small per-tool overhead of curl-on-every-event)
4601
+ // and the installer refuses to overwrite a settings.local.json that
4602
+ // wasn't created by us.
4603
+ app.get('/api/cron/:job/hooks-status', async (req, res) => {
4604
+ try {
4605
+ const jobName = req.params.job;
4606
+ if (!jobName) {
4607
+ res.status(400).json({ ok: false, error: 'job required' });
4608
+ return;
4609
+ }
4610
+ // Find the job's workDir from the cron definitions.
4611
+ const { parseCronJobs } = await import('../gateway/cron-scheduler.js');
4612
+ const jobs = parseCronJobs();
4613
+ const job = jobs.find((j) => String(j.name).toLowerCase() === jobName.toLowerCase());
4614
+ if (!job) {
4615
+ res.status(404).json({ ok: false, error: `job "${jobName}" not found` });
4616
+ return;
4617
+ }
4618
+ if (!job.workDir) {
4619
+ res.json({ ok: true, status: { installed: false, managedByUs: false, filePath: '', conflictsWithUser: false }, message: 'Task has no workDir set — hooks need a project directory.' });
4620
+ return;
4621
+ }
4622
+ const { getHooksStatus } = await import('../agent/path-b-installer.js');
4623
+ const status = getHooksStatus(job.workDir);
4624
+ res.json({ ok: true, workDir: job.workDir, status });
4625
+ }
4626
+ catch (err) {
4627
+ res.status(500).json({ ok: false, error: String(err) });
4628
+ }
4629
+ });
4630
+ app.post('/api/cron/:job/enable-hooks', async (req, res) => {
4631
+ try {
4632
+ const jobName = req.params.job;
4633
+ if (!jobName) {
4634
+ res.status(400).json({ ok: false, error: 'job required' });
4635
+ return;
4636
+ }
4637
+ const { parseCronJobs } = await import('../gateway/cron-scheduler.js');
4638
+ const jobs = parseCronJobs();
4639
+ const job = jobs.find((j) => String(j.name).toLowerCase() === jobName.toLowerCase());
4640
+ if (!job) {
4641
+ res.status(404).json({ ok: false, error: `job "${jobName}" not found` });
4642
+ return;
4643
+ }
4644
+ if (!job.workDir) {
4645
+ res.status(400).json({ ok: false, error: 'task has no workDir set' });
4646
+ return;
4647
+ }
4648
+ const port = Number(process.env.PORT) || 3030;
4649
+ const { installPathBHooks } = await import('../agent/path-b-installer.js');
4650
+ const result = installPathBHooks(job.workDir, { token: dashboardToken, port });
4651
+ if (!result.ok) {
4652
+ res.status(409).json({ ...result, ok: false });
4653
+ return;
4654
+ }
4655
+ const { ok: _ignore, ...rest } = result;
4656
+ res.json({ ...rest, ok: true, message: result.wasUpdate ? 'Hooks updated.' : 'Hooks installed. The next run of this task will emit per-tool latency to the dashboard.' });
4657
+ }
4658
+ catch (err) {
4659
+ res.status(500).json({ ok: false, error: String(err) });
4660
+ }
4661
+ });
4662
+ app.post('/api/cron/:job/disable-hooks', async (req, res) => {
4663
+ try {
4664
+ const jobName = req.params.job;
4665
+ if (!jobName) {
4666
+ res.status(400).json({ ok: false, error: 'job required' });
4667
+ return;
4668
+ }
4669
+ const { parseCronJobs } = await import('../gateway/cron-scheduler.js');
4670
+ const jobs = parseCronJobs();
4671
+ const job = jobs.find((j) => String(j.name).toLowerCase() === jobName.toLowerCase());
4672
+ if (!job) {
4673
+ res.status(404).json({ ok: false, error: `job "${jobName}" not found` });
4674
+ return;
4675
+ }
4676
+ if (!job.workDir) {
4677
+ res.status(400).json({ ok: false, error: 'task has no workDir set' });
4678
+ return;
4679
+ }
4680
+ const { uninstallPathBHooks } = await import('../agent/path-b-installer.js');
4681
+ const result = uninstallPathBHooks(job.workDir);
4682
+ if (!result.ok) {
4683
+ res.status(409).json({ ok: false, error: result.error });
4684
+ return;
4685
+ }
4686
+ res.json({ ok: true, message: 'Hooks disabled. The next run of this task will use only the in-process tap (path A).' });
4687
+ }
4688
+ catch (err) {
4689
+ res.status(500).json({ ok: false, error: String(err) });
4690
+ }
4691
+ });
4597
4692
  app.post('/api/cron/run/:job/cancel', async (req, res) => {
4598
4693
  try {
4599
4694
  const jobName = req.params.job;
@@ -5720,6 +5815,87 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5720
5815
  res.status(500).json({ error: String(err) });
5721
5816
  }
5722
5817
  });
5818
+ // ── PRD Phase 4d / 1.18.101: Path B hook event ingest ──────────
5819
+ // The Claude Agent SDK supports `.claude/settings.json`-registered command
5820
+ // hooks (PreToolUse, PostToolUse, SubagentStart, SubagentStop, Stop,
5821
+ // Notification, etc.). When a hook fires the SDK pipes JSON to the
5822
+ // command's stdin; the command can POST that JSON to this endpoint to
5823
+ // get the event recorded in the same per-run JSONL the in-process tap
5824
+ // (path A) writes to. The result: real per-tool-call durations,
5825
+ // approval-required events, and stop-reason hooks land in the Run detail
5826
+ // viewer's waterfall + the Latency dashboard's split bar.
5827
+ //
5828
+ // Auth: dashboard token via X-Dashboard-Token header. The daemon
5829
+ // exposes the token via CLEMENTINE_DASHBOARD_TOKEN env var, which the
5830
+ // SDK subprocess inherits — settings.json hook commands curl this
5831
+ // endpoint with that header.
5832
+ app.post('/api/hooks/event', express.json({ limit: '256kb' }), async (req, res) => {
5833
+ try {
5834
+ const headerToken = String(req.header('x-dashboard-token') || '');
5835
+ if (!dashboardToken || headerToken !== dashboardToken) {
5836
+ res.status(401).json({ ok: false, error: 'invalid dashboard token' });
5837
+ return;
5838
+ }
5839
+ const body = (req.body ?? {});
5840
+ const sessionId = String(body.session_id || body.sessionId || '');
5841
+ const hookEventName = String(body.hook_event_name || body.hookEventName || 'unknown');
5842
+ if (!sessionId) {
5843
+ res.status(400).json({ ok: false, error: 'session_id required in payload' });
5844
+ return;
5845
+ }
5846
+ const { getRunSession, nextSeqForSession, sweepStaleSessions } = await import('../agent/hook-session-registry.js');
5847
+ // Best-effort sweep on every POST — keeps the map bounded without a timer.
5848
+ sweepStaleSessions();
5849
+ const entry = getRunSession(sessionId);
5850
+ if (!entry) {
5851
+ // Late-arriving hook for a session we already closed (or never
5852
+ // saw because path A registration didn't complete). Drop and tell
5853
+ // the caller — the curl exits 0 either way so it doesn't fail
5854
+ // the SDK's run.
5855
+ res.status(202).json({ ok: false, dropped: true, reason: 'session not registered (race or post-end)' });
5856
+ return;
5857
+ }
5858
+ const seq = nextSeqForSession(sessionId);
5859
+ // Synthesize a RunEvent. The hook payload's shape varies by event
5860
+ // kind; we extract the fields the dashboard waterfall renders and
5861
+ // stash the full payload for advanced filtering later.
5862
+ const ev = {
5863
+ runId: entry.runId,
5864
+ seq: seq ?? 1_000_000,
5865
+ kind: 'hook',
5866
+ ts: new Date().toISOString(),
5867
+ sessionId,
5868
+ hookEventName,
5869
+ };
5870
+ // Tool fields if present (PreToolUse / PostToolUse).
5871
+ if (typeof body.tool_name === 'string')
5872
+ ev.toolName = body.tool_name;
5873
+ if (typeof body.tool_use_id === 'string')
5874
+ ev.toolUseId = body.tool_use_id;
5875
+ if (body.tool_input !== undefined)
5876
+ ev.toolInput = body.tool_input;
5877
+ if (body.tool_response !== undefined)
5878
+ ev.toolResult = body.tool_response;
5879
+ // PostToolUse can carry an explicit duration_ms (the SDK's stopwatch
5880
+ // wraps the tool call). When present we surface it onto the event so
5881
+ // the latency dashboard sums real numbers instead of the heuristic.
5882
+ if (typeof body.duration_ms === 'number')
5883
+ ev.durationMs = body.duration_ms;
5884
+ if (typeof body.parent_tool_use_id === 'string')
5885
+ ev.parentToolUseId = body.parent_tool_use_id;
5886
+ // Errors: PostToolUse can flag a non-zero result; surface as toolError.
5887
+ if (body.is_error === true) {
5888
+ ev.toolError = typeof body.tool_response === 'string'
5889
+ ? body.tool_response.slice(0, 500)
5890
+ : 'tool returned is_error=true';
5891
+ }
5892
+ entry.eventLog.append(ev);
5893
+ res.json({ ok: true, runId: entry.runId, seq });
5894
+ }
5895
+ catch (err) {
5896
+ res.status(500).json({ ok: false, error: String(err) });
5897
+ }
5898
+ });
5723
5899
  // ── PRD Phase 4a / 1.18.85: per-run Event store reader ─────────
5724
5900
  // Returns every event captured by path A (in-process tap in runAgent)
5725
5901
  // for one run. Used by the new Run detail page (Phase 4b).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.100",
3
+ "version": "1.18.102",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",