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.
- package/dist/agent/hook-session-registry.d.ts +61 -0
- package/dist/agent/hook-session-registry.js +92 -0
- package/dist/agent/path-b-installer.d.ts +75 -0
- package/dist/agent/path-b-installer.js +183 -0
- package/dist/agent/run-agent.js +32 -1
- package/dist/cli/dashboard.js +176 -0
- package/package.json +1 -1
|
@@ -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
|
package/dist/agent/run-agent.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -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).
|