clementine-agent 1.18.101 → 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,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.
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.101",
3
+ "version": "1.18.102",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",