claude-discover 1.9.0 → 1.9.2

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  Ever wondered what Claude Code is **actually** doing all day?
5
5
 
6
- <img width="1660" height="1256" alt="claude-discover-dimmed-screenshot" src="https://github.com/user-attachments/assets/708d05c7-8769-47d8-b5bd-14337d1d6037" />
6
+ <img width="1660" alt="claude-discover-dimmed-screenshot" src="https://github.com/user-attachments/assets/708d05c7-8769-47d8-b5bd-14337d1d6037" />
7
7
  <br><br>
8
8
 
9
9
  **Claude Discover** is a desktop tool that shows you what **Claude Code** is actually doing. It collects all your sessions (from the CLI, Desktop, or SDK) and puts them on one timeline, where you can read every conversation, peek at the system prompts, memories, and tools, inspect the raw API traffic behind it all, and see exactly where your tokens - and your money - went. (Spoiler: mostly on re-sending tool definitions.)
package/bin/proxy.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  import fs from 'node:fs'
10
10
  import path from 'node:path'
11
11
  import http from 'node:http'
12
+ import { spawn } from 'node:child_process'
12
13
  import https from 'node:https'
13
14
  import zlib from 'node:zlib'
14
15
  import crypto from 'node:crypto'
@@ -227,7 +228,19 @@ function logError(err, context) {
227
228
  }
228
229
 
229
230
  const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
230
- if (isMain) {
231
+ if (process.parentPort) {
232
+ // Forked as an Electron utility process (ProxySwitch): Chromium gives those a clean handle
233
+ // table, unlike the app's main process whose children inherit the dev CDP server's socket on
234
+ // Windows and keep its port bound after the app quits. Utility processes die with the app,
235
+ // so re-spawn detached from here (as node — no system `node` required) and relay an early
236
+ // exit code back as a message until the app kills this relay.
237
+ const child = spawn(process.execPath, process.argv.slice(1), {
238
+ detached: true, stdio: 'ignore', windowsHide: true,
239
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
240
+ })
241
+ child.once('exit', code => { process.parentPort.postMessage(code); process.exit(0) })
242
+ }
243
+ else if (isMain) {
231
244
  const args = parseArgs({
232
245
  options: {
233
246
  restart: { type: 'boolean' }, // replace an already-running instance instead of exiting
package/out/main/main.js CHANGED
@@ -1,21 +1,248 @@
1
- import { BrowserWindow, dialog, app, Menu, shell, WebContentsView, ipcMain } from "electron";
1
+ import { BrowserWindow, dialog, app, Menu, shell, WebContentsView, utilityProcess, ipcMain } from "electron";
2
2
  import { EventEmitter } from "node:events";
3
- import { parseISO, endOfWeek, startOfWeek, endOfMonth, startOfMonth, endOfDay, startOfDay } from "date-fns";
4
3
  import fs from "node:fs";
5
4
  import fsp from "node:fs/promises";
6
5
  import path, { join, dirname, basename } from "node:path";
7
6
  import os, { homedir } from "node:os";
7
+ import crypto from "node:crypto";
8
8
  import chokidar from "chokidar";
9
9
  import { sync } from "write-file-atomic";
10
- import { query } from "@anthropic-ai/claude-agent-sdk";
11
10
  import { pathToFileURL, fileURLToPath } from "node:url";
12
11
  import windowStateKeeper from "electron-window-state";
13
12
  import contextMenu from "electron-context-menu";
14
- import { spawn } from "node:child_process";
15
- import __cjs_mod__ from "node:module";
16
- const __filename = import.meta.filename;
17
- const __dirname = import.meta.dirname;
18
- const require2 = __cjs_mod__.createRequire(import.meta.url);
13
+ const millisecondsInMinute = 6e4;
14
+ const millisecondsInHour = 36e5;
15
+ const constructFromSymbol = /* @__PURE__ */ Symbol.for("constructDateFrom");
16
+ function constructFrom(date, value) {
17
+ if (typeof date === "function") return date(value);
18
+ if (date && typeof date === "object" && constructFromSymbol in date)
19
+ return date[constructFromSymbol](value);
20
+ if (date instanceof Date) return new date.constructor(value);
21
+ return new Date(value);
22
+ }
23
+ function toDate(argument, context) {
24
+ return constructFrom(context || argument, argument);
25
+ }
26
+ let defaultOptions = {};
27
+ function getDefaultOptions() {
28
+ return defaultOptions;
29
+ }
30
+ function startOfWeek(date, options) {
31
+ const defaultOptions2 = getDefaultOptions();
32
+ const weekStartsOn = options?.weekStartsOn ?? options?.locale?.options?.weekStartsOn ?? defaultOptions2.weekStartsOn ?? defaultOptions2.locale?.options?.weekStartsOn ?? 0;
33
+ const _date = toDate(date, options?.in);
34
+ const day = _date.getDay();
35
+ const diff = (day < weekStartsOn ? 7 : 0) + day - weekStartsOn;
36
+ _date.setDate(_date.getDate() - diff);
37
+ _date.setHours(0, 0, 0, 0);
38
+ return _date;
39
+ }
40
+ function startOfDay(date, options) {
41
+ const _date = toDate(date, options?.in);
42
+ _date.setHours(0, 0, 0, 0);
43
+ return _date;
44
+ }
45
+ function endOfDay(date, options) {
46
+ const _date = toDate(date, options?.in);
47
+ _date.setHours(23, 59, 59, 999);
48
+ return _date;
49
+ }
50
+ function endOfMonth(date, options) {
51
+ const _date = toDate(date, options?.in);
52
+ const month = _date.getMonth();
53
+ _date.setFullYear(_date.getFullYear(), month + 1, 0);
54
+ _date.setHours(23, 59, 59, 999);
55
+ return _date;
56
+ }
57
+ function startOfMonth(date, options) {
58
+ const _date = toDate(date, options?.in);
59
+ _date.setDate(1);
60
+ _date.setHours(0, 0, 0, 0);
61
+ return _date;
62
+ }
63
+ function endOfWeek(date, options) {
64
+ const defaultOptions2 = getDefaultOptions();
65
+ const weekStartsOn = options?.weekStartsOn ?? options?.locale?.options?.weekStartsOn ?? defaultOptions2.weekStartsOn ?? defaultOptions2.locale?.options?.weekStartsOn ?? 0;
66
+ const _date = toDate(date, options?.in);
67
+ const day = _date.getDay();
68
+ const diff = (day < weekStartsOn ? -7 : 0) + 6 - (day - weekStartsOn);
69
+ _date.setDate(_date.getDate() + diff);
70
+ _date.setHours(23, 59, 59, 999);
71
+ return _date;
72
+ }
73
+ function parseISO(argument, options) {
74
+ const invalidDate = () => constructFrom(options?.in, NaN);
75
+ const additionalDigits = options?.additionalDigits ?? 2;
76
+ const dateStrings = splitDateString(argument);
77
+ let date;
78
+ if (dateStrings.date) {
79
+ const parseYearResult = parseYear(dateStrings.date, additionalDigits);
80
+ date = parseDate(parseYearResult.restDateString, parseYearResult.year);
81
+ }
82
+ if (!date || isNaN(+date)) return invalidDate();
83
+ const timestamp = +date;
84
+ let time = 0;
85
+ let offset;
86
+ if (dateStrings.time) {
87
+ time = parseTime(dateStrings.time);
88
+ if (isNaN(time)) return invalidDate();
89
+ }
90
+ if (dateStrings.timezone) {
91
+ offset = parseTimezone(dateStrings.timezone);
92
+ if (isNaN(offset)) return invalidDate();
93
+ } else {
94
+ const tmpDate = new Date(timestamp + time);
95
+ const result = toDate(0, options?.in);
96
+ result.setFullYear(
97
+ tmpDate.getUTCFullYear(),
98
+ tmpDate.getUTCMonth(),
99
+ tmpDate.getUTCDate()
100
+ );
101
+ result.setHours(
102
+ tmpDate.getUTCHours(),
103
+ tmpDate.getUTCMinutes(),
104
+ tmpDate.getUTCSeconds(),
105
+ tmpDate.getUTCMilliseconds()
106
+ );
107
+ return result;
108
+ }
109
+ return toDate(timestamp + time + offset, options?.in);
110
+ }
111
+ const patterns = {
112
+ dateTimeDelimiter: /[T ]/,
113
+ timeZoneDelimiter: /[Z ]/i,
114
+ timezone: /([Z+-].*)$/
115
+ };
116
+ const dateRegex = /^-?(?:(\d{3})|(\d{2})(?:-?(\d{2}))?|W(\d{2})(?:-?(\d{1}))?|)$/;
117
+ const timeRegex = /^(\d{2}(?:[.,]\d*)?)(?::?(\d{2}(?:[.,]\d*)?))?(?::?(\d{2}(?:[.,]\d*)?))?$/;
118
+ const timezoneRegex = /^([+-])(\d{2})(?::?(\d{2}))?$/;
119
+ function splitDateString(dateString) {
120
+ const dateStrings = {};
121
+ const array = dateString.split(patterns.dateTimeDelimiter);
122
+ let timeString;
123
+ if (array.length > 2) {
124
+ return dateStrings;
125
+ }
126
+ if (/:/.test(array[0])) {
127
+ timeString = array[0];
128
+ } else {
129
+ dateStrings.date = array[0];
130
+ timeString = array[1];
131
+ if (patterns.timeZoneDelimiter.test(dateStrings.date)) {
132
+ dateStrings.date = dateString.split(patterns.timeZoneDelimiter)[0];
133
+ timeString = dateString.substr(
134
+ dateStrings.date.length,
135
+ dateString.length
136
+ );
137
+ }
138
+ }
139
+ if (timeString) {
140
+ const token = patterns.timezone.exec(timeString);
141
+ if (token) {
142
+ dateStrings.time = timeString.replace(token[1], "");
143
+ dateStrings.timezone = token[1];
144
+ } else {
145
+ dateStrings.time = timeString;
146
+ }
147
+ }
148
+ return dateStrings;
149
+ }
150
+ function parseYear(dateString, additionalDigits) {
151
+ const regex = new RegExp(
152
+ "^(?:(\\d{4}|[+-]\\d{" + (4 + additionalDigits) + "})|(\\d{2}|[+-]\\d{" + (2 + additionalDigits) + "})$)"
153
+ );
154
+ const captures = dateString.match(regex);
155
+ if (!captures) return { year: NaN, restDateString: "" };
156
+ const year = captures[1] ? parseInt(captures[1]) : null;
157
+ const century = captures[2] ? parseInt(captures[2]) : null;
158
+ return {
159
+ year: century === null ? year : century * 100,
160
+ restDateString: dateString.slice((captures[1] || captures[2]).length)
161
+ };
162
+ }
163
+ function parseDate(dateString, year) {
164
+ if (year === null) return /* @__PURE__ */ new Date(NaN);
165
+ const captures = dateString.match(dateRegex);
166
+ if (!captures) return /* @__PURE__ */ new Date(NaN);
167
+ const isWeekDate = !!captures[4];
168
+ const dayOfYear = parseDateUnit(captures[1]);
169
+ const month = parseDateUnit(captures[2]) - 1;
170
+ const day = parseDateUnit(captures[3]);
171
+ const week = parseDateUnit(captures[4]);
172
+ const dayOfWeek = parseDateUnit(captures[5]) - 1;
173
+ if (isWeekDate) {
174
+ if (!validateWeekDate(year, week, dayOfWeek)) {
175
+ return /* @__PURE__ */ new Date(NaN);
176
+ }
177
+ return dayOfISOWeekYear(year, week, dayOfWeek);
178
+ } else {
179
+ const date = /* @__PURE__ */ new Date(0);
180
+ if (!validateDate(year, month, day) || !validateDayOfYearDate(year, dayOfYear)) {
181
+ return /* @__PURE__ */ new Date(NaN);
182
+ }
183
+ date.setUTCFullYear(year, month, Math.max(dayOfYear, day));
184
+ return date;
185
+ }
186
+ }
187
+ function parseDateUnit(value) {
188
+ return value ? parseInt(value) : 1;
189
+ }
190
+ function parseTime(timeString) {
191
+ const captures = timeString.match(timeRegex);
192
+ if (!captures) return NaN;
193
+ const hours = parseTimeUnit(captures[1]);
194
+ const minutes = parseTimeUnit(captures[2]);
195
+ const seconds = parseTimeUnit(captures[3]);
196
+ if (!validateTime(hours, minutes, seconds)) {
197
+ return NaN;
198
+ }
199
+ return hours * millisecondsInHour + minutes * millisecondsInMinute + seconds * 1e3;
200
+ }
201
+ function parseTimeUnit(value) {
202
+ return value && parseFloat(value.replace(",", ".")) || 0;
203
+ }
204
+ function parseTimezone(timezoneString) {
205
+ if (timezoneString === "Z") return 0;
206
+ const captures = timezoneString.match(timezoneRegex);
207
+ if (!captures) return 0;
208
+ const sign = captures[1] === "+" ? -1 : 1;
209
+ const hours = parseInt(captures[2]);
210
+ const minutes = captures[3] && parseInt(captures[3]) || 0;
211
+ if (!validateTimezone(hours, minutes)) {
212
+ return NaN;
213
+ }
214
+ return sign * (hours * millisecondsInHour + minutes * millisecondsInMinute);
215
+ }
216
+ function dayOfISOWeekYear(isoWeekYear, week, day) {
217
+ const date = /* @__PURE__ */ new Date(0);
218
+ date.setUTCFullYear(isoWeekYear, 0, 4);
219
+ const fourthOfJanuaryDay = date.getUTCDay() || 7;
220
+ const diff = (week - 1) * 7 + day + 1 - fourthOfJanuaryDay;
221
+ date.setUTCDate(date.getUTCDate() + diff);
222
+ return date;
223
+ }
224
+ const daysInMonths = [31, null, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
225
+ function isLeapYearIndex(year) {
226
+ return year % 400 === 0 || year % 4 === 0 && year % 100 !== 0;
227
+ }
228
+ function validateDate(year, month, date) {
229
+ return month >= 0 && month <= 11 && date >= 1 && date <= (daysInMonths[month] || (isLeapYearIndex(year) ? 29 : 28));
230
+ }
231
+ function validateDayOfYearDate(year, dayOfYear) {
232
+ return dayOfYear >= 1 && dayOfYear <= (isLeapYearIndex(year) ? 366 : 365);
233
+ }
234
+ function validateWeekDate(_year, week, day) {
235
+ return week >= 1 && week <= 53 && day >= 0 && day <= 6;
236
+ }
237
+ function validateTime(hours, minutes, seconds) {
238
+ if (hours === 24) {
239
+ return minutes === 0 && seconds === 0;
240
+ }
241
+ return seconds >= 0 && seconds < 60 && minutes >= 0 && minutes < 60 && hours >= 0 && hours < 25;
242
+ }
243
+ function validateTimezone(_hours, minutes) {
244
+ return minutes >= 0 && minutes <= 59;
245
+ }
19
246
  class SessionFile {
20
247
  constructor(filePath) {
21
248
  this.filePath = filePath;
@@ -70,6 +297,7 @@ const DATA_DIR$1 = process.env.CLAUDE_DISCOVER_DIR || path.join(os.homedir(), ".
70
297
  const REQUESTS_DIR = path.join(DATA_DIR$1, "requests");
71
298
  const ERROR_LOG_PATH = path.join(DATA_DIR$1, "proxy.error.log");
72
299
  const jsonBytes = (x) => x == null ? 0 : Buffer.byteLength(JSON.stringify(x));
300
+ const contentHash = (s) => crypto.createHash("sha256").update(s).digest("base64url").slice(0, 16);
73
301
  const MEMORY_LABELS = {
74
302
  "user's private global instructions for all projects": "User",
75
303
  "project instructions, checked into the codebase": "Project",
@@ -79,9 +307,9 @@ const MEMORY_LABELS = {
79
307
  const MEMORY_FILES = { User: "CLAUDE.md", Project: "CLAUDE.md", Local: "CLAUDE.local.md", Auto: "MEMORY.md" };
80
308
  function parseClaudeMd(text) {
81
309
  const marker = "\n# claudeMd\n";
82
- const start = text.indexOf(marker);
83
- if (start === -1) return [];
84
- let region = text.slice(start + marker.length);
310
+ const start2 = text.indexOf(marker);
311
+ if (start2 === -1) return [];
312
+ let region = text.slice(start2 + marker.length);
85
313
  const ends = [/\n# [a-z][a-zA-Z0-9]*\r?\n/, /\n\s*IMPORTANT: this context/, /<\/system-reminder>/].map((re) => region.search(re)).filter((i) => i !== -1);
86
314
  if (ends.length) region = region.slice(0, Math.min(...ends));
87
315
  const heads = [...region.matchAll(/^Contents of (.+?) \(([^)]*)\):\r?\n/gm)];
@@ -108,7 +336,7 @@ const classifyRequest = (req, url) => {
108
336
  const user = (typeof content === "string" ? content : blocks?.find((b) => b?.type === "text")?.text ?? "").trimStart().slice(0, 300);
109
337
  const tail = (blocks?.findLast((b) => b?.type === "text")?.text ?? user).slice(0, 600);
110
338
  if (req.max_tokens === 1 || user.trim() === "quota") return ["quota", "Quota probe"];
111
- if (sysText.includes("You are a security monitor") || user.startsWith("<transcript>")) return ["security", "Security check"];
339
+ if (sysText.includes("You are a security monitor") || user.startsWith("<transcript>")) return ["security", "Auto-mode check"];
112
340
  if (sysText.includes("Generate a concise, sentence-case title")) return ["title", "Session title"];
113
341
  if (sysText.includes("performing a web search")) return ["web", "Web search"];
114
342
  if (user.startsWith("[SUGGESTION MODE")) return ["suggest", "Suggestions"];
@@ -165,40 +393,38 @@ class RequestParser {
165
393
  rec.resSize = jsonBytes(rec.response);
166
394
  return rec;
167
395
  }
168
- // The request's system prompt, only when this record logs it in full ({ $hash, value } — repeats
169
- // are $refs). `system` is either a plain string or an array of text blocks.
396
+ // The request's system prompt, from a resolveRefs'd record — a plain string or an array of
397
+ // text blocks. The content hash is the dedup key: repeats collapse in the caller's map.
170
398
  systemPrompt(rec) {
171
399
  const sys = rec.request?.system;
172
- if (!sys?.$hash) return null;
173
- const value = this.resolve(sys.value);
174
- const content = typeof value === "string" ? value : value.map((b) => b?.text ?? "").join("\n\n");
400
+ if (sys == null || sys.$ref) return null;
401
+ const content = typeof sys === "string" ? sys : sys.map((b) => b?.text ?? "").join("\n\n");
175
402
  const [kind, kindLabel] = stripKind(rec.request);
176
- return { file_path: "System Prompt", memory_type: kindLabel || "", model: rec.request.model, content, hash: sys.$hash, kind };
403
+ return { file_path: "System Prompt", memory_type: kindLabel || "", model: rec.request.model, content, hash: contentHash(content), kind };
177
404
  }
178
- // The request's tool definitions, only when this record logs the array in full (repeats are
179
- // $refs). The array grows as deferred tools load mid-session, so only tools this parser hasn't
180
- // seen yet are returned — the first request yields the full set, later arrays just the additions.
405
+ // The request's tool definitions, from a resolveRefs'd record. The array grows as deferred
406
+ // tools load mid-session, so only tools this parser hasn't seen yet are returned — the first
407
+ // request yields the full set, later arrays just the additions.
181
408
  systemTools(rec) {
182
409
  const tools = rec.request?.tools;
183
- if (!tools?.$hash) return null;
184
- const fresh = this.resolve(tools.value).filter((t) => t?.name && !this.seenTools.has(t.name));
410
+ if (!Array.isArray(tools)) return null;
411
+ const fresh = tools.filter((t) => t?.name && !this.seenTools.has(t.name));
185
412
  if (!fresh.length) return null;
186
413
  for (const t of fresh) this.seenTools.add(t.name);
187
- const content = fresh.map((t) => `## ${t.name}
188
-
189
- ${t.description || ""}`.trim()).join("\n\n");
414
+ const schema = (t) => t.input_schema ? "```json\n" + JSON.stringify(t.input_schema, null, 2) + "\n```" : "";
415
+ const content = fresh.map((t) => [`## ${t.name}`, t.description || "", schema(t)].filter(Boolean).join("\n\n")).join("\n\n");
190
416
  const [kind, kindLabel] = stripKind(rec.request);
191
417
  const label = [kindLabel, `${fresh.length} tool${fresh.length === 1 ? "" : "s"}`].filter(Boolean).join(", ");
192
- return { file_path: "System Tools", memory_type: label, model: rec.request.model, content, hash: tools.$hash, kind };
418
+ return { file_path: "System Tools", memory_type: label, model: rec.request.model, content, hash: contentHash(content), kind };
193
419
  }
194
- // Memory files (CLAUDE.md / MEMORY.md / …) carried by the record's messages inside `# claudeMd`
195
- // system-reminders — one { file_path, memory_type, content } per file listed.
420
+ // Memory files (CLAUDE.md / MEMORY.md / …) carried by a resolveRefs'd record's messages inside
421
+ // `# claudeMd` system-reminders — one { file_path, memory_type, content } per file listed.
196
422
  memoryFiles(rec) {
197
423
  const files = [];
198
424
  for (const m of rec.request?.messages || []) {
199
- const content = (m?.value ?? m)?.content;
425
+ const content = m?.content;
200
426
  for (const part of Array.isArray(content) ? content : [{ text: content }]) {
201
- const text = (part?.value ?? part)?.text;
427
+ const text = part?.text;
202
428
  if (typeof text !== "string" || !text.includes("<system-reminder>")) continue;
203
429
  files.push(...parseClaudeMd(text));
204
430
  }
@@ -206,17 +432,22 @@ ${t.description || ""}`.trim()).join("\n\n");
206
432
  return files;
207
433
  }
208
434
  }
435
+ const agentKey = (id) => id?.includes("@") ? id.slice(0, id.indexOf("@")) : id?.replace(/^a/, "").replace(/-[0-9a-f]{16}$/, "") ?? null;
209
436
  class RequestFile {
210
- constructor(sessionId, dir = REQUESTS_DIR) {
437
+ constructor(sessionId, { dir = REQUESTS_DIR, agentId = null } = {}) {
211
438
  this.filePath = join(dir, `${sessionId}.requests.jsonl`);
439
+ this.agentKey = agentKey(agentId);
440
+ }
441
+ #mine(rec) {
442
+ return agentKey(rec.requestHeaders?.["x-claude-code-agent-id"] ?? null) === this.agentKey;
212
443
  }
213
444
  async lines() {
214
445
  const raw = await fsp.readFile(this.filePath, "utf8").catch(() => "");
215
446
  return raw.split("\n").filter(Boolean);
216
447
  }
217
- // `range` is { start, end } epoch ms. Refs are resolved over the whole file before the
218
- // range filter — an out-of-range record may hold the $hash an in-range record refs —
219
- // so callers see every request exactly as it was sent to the API.
448
+ // `range` is { start, end } epoch ms. Refs are resolved over the whole file before the range and
449
+ // agent filters — an out-of-range (or another agent's) record may hold the $hash an in-range
450
+ // record refs — so callers see every request exactly as it was sent to the API.
220
451
  async read(range = null) {
221
452
  const parser = new RequestParser();
222
453
  const records = [];
@@ -229,8 +460,9 @@ class RequestFile {
229
460
  }
230
461
  records.push(parser.resolveRefs(rec));
231
462
  }
232
- if (!range) return records;
233
463
  return records.filter((r) => {
464
+ if (!this.#mine(r)) return false;
465
+ if (!range) return true;
234
466
  const t = Date.parse(r.timestamp);
235
467
  return t >= range.start && t <= range.end;
236
468
  });
@@ -238,25 +470,17 @@ class RequestFile {
238
470
  // System prompts (request.system), tool definitions (request.tools) and memory files
239
471
  // (CLAUDE.md / MEMORY.md / … inside a user message's `# claudeMd` system-reminder) — none of
240
472
  // which Claude Code records in the session transcript, so the request log is the only source.
473
+ // Works on read()'s output, so dedup wrappers and the agent scope are already handled.
241
474
  // Returns one record per unique system prompt / tool batch / file_path; readSession ships them
242
475
  // alongside the transcript items.
243
476
  async readInstructions() {
244
477
  const parser = new RequestParser();
245
478
  const files = /* @__PURE__ */ new Map();
246
- for (const line of await this.lines()) {
247
- const hasSystem = line.includes('"system":{"$hash"');
248
- const hasTools = line.includes('"tools":{"$hash"');
249
- if (!hasSystem && !hasTools && !line.includes("# claudeMd")) continue;
250
- let rec;
251
- try {
252
- rec = JSON.parse(line);
253
- } catch {
254
- continue;
255
- }
479
+ for (const rec of await this.read()) {
256
480
  const record = (f) => ({ timestamp: rec.timestamp, ...f });
257
- const sys = hasSystem && parser.systemPrompt(rec);
481
+ const sys = parser.systemPrompt(rec);
258
482
  if (sys && !files.has(sys.hash)) files.set(sys.hash, record(sys));
259
- const tools = hasTools && parser.systemTools(rec);
483
+ const tools = parser.systemTools(rec);
260
484
  if (tools) files.set(tools.hash, record(tools));
261
485
  for (const f of parser.memoryFiles(rec)) {
262
486
  if (!files.has(f.file_path)) files.set(f.file_path, record(f));
@@ -373,7 +597,7 @@ class SessionParser {
373
597
  }
374
598
  if (t === "attachment") {
375
599
  obj.isMeta = true;
376
- this.prevMessage = obj;
600
+ if (obj.attachment?.type !== "queued_command") this.prevMessage = obj;
377
601
  return true;
378
602
  }
379
603
  if (t !== "user" && t !== "assistant") return true;
@@ -582,6 +806,8 @@ class SessionsScanner {
582
806
  constructor({ root = CLAUDE_PROJECTS_DIR } = {}) {
583
807
  this.root = root;
584
808
  this.watcher = null;
809
+ this.watchWanted = false;
810
+ this.walkDone = Promise.withResolvers();
585
811
  this.statCache = new StatCache();
586
812
  }
587
813
  async _listDirs(p) {
@@ -608,8 +834,17 @@ class SessionsScanner {
608
834
  // truthy — lets callers flush UI updates incrementally. An aborted `signal` (a superseded
609
835
  // scan) stops the walk: no further stats, onFile calls or emits. Once a walk has completed
610
836
  // with the watcher live, the StatCache mirrors the disk and serves later scans in memory.
611
- async scan(day, { onFile, onBatchDone, onProgress, signal } = {}) {
612
- if (this.statCache.complete && this.watcher) return this.statCache.scan((stat) => this._inPeriod(stat, day), { onFile, onBatchDone, onProgress, signal });
837
+ async scan(day, opts = {}) {
838
+ if (this.statCache.complete && this.watcher) {
839
+ return this.statCache.scan((stat) => this._inPeriod(stat, day), opts);
840
+ }
841
+ try {
842
+ await this._walk(day, opts);
843
+ } finally {
844
+ this.walkDone.resolve();
845
+ }
846
+ }
847
+ async _walk(day, { onFile, onBatchDone, onProgress, signal } = {}) {
613
848
  const projects = await this._listDirs(this.root);
614
849
  let done = 0;
615
850
  const progress = () => onProgress?.({ done, total: projects.length, scanning: done < projects.length });
@@ -631,7 +866,14 @@ class SessionsScanner {
631
866
  if (onBatchDone) await onBatchDone();
632
867
  progress();
633
868
  }
634
- watch({ onChange, onUnlink } = {}) {
869
+ // Starts chokidar once a scan has finished, and resolves when it's live. Its setup walks the whole
870
+ // projects dir to register watchers, and on a cold disk those stats are the bulk of startup, so it
871
+ // waits instead of racing our walk. The gap is self-healing: `ignoreInitial` skips files present at
872
+ // startup anyway, and a session still being written emits on its next append.
873
+ async watch({ onChange, onUnlink } = {}) {
874
+ this.watchWanted = true;
875
+ await this.walkDone.promise;
876
+ if (!this.watchWanted) return;
635
877
  const isUNC = this.root.startsWith("\\\\") || this.root.startsWith("//");
636
878
  this.watcher = chokidar.watch(this.root, {
637
879
  ignoreInitial: true,
@@ -655,6 +897,7 @@ class SessionsScanner {
655
897
  this.watcher.on("unlink", removed);
656
898
  }
657
899
  stop() {
900
+ this.watchWanted = false;
658
901
  if (this.watcher) {
659
902
  this.watcher.close();
660
903
  this.watcher = null;
@@ -873,14 +1116,21 @@ class SessionsService extends EventEmitter {
873
1116
  });
874
1117
  items.sort((a, b) => a._ts - b._ts);
875
1118
  for (const o of items) delete o._ts;
876
- const instructions = await new RequestFile(reader.sessionId).readInstructions();
1119
+ const instructions = await this.#requestFile(reader.sessionId, reader.parentSessionId).readInstructions();
877
1120
  return { items, instructions };
878
1121
  }
879
1122
  // Captured API requests for a session (see bin/proxy.mjs), period-filtered like readSession.
880
1123
  async readRequests(sessionId, date = null, granularity = "day") {
881
1124
  if (!/^[\w-]+$/.test(sessionId)) return [];
882
1125
  const range = date ? periodBounds(date, granularity) : null;
883
- return new RequestFile(sessionId).read(range);
1126
+ return this.#requestFile(sessionId, this.metaCache.byId(sessionId)?.parentSessionId).read(range);
1127
+ }
1128
+ // Subagents send the parent's session id, so the proxy logs their requests into the parent's
1129
+ // file, tagged x-claude-code-agent-id — route their reads there. The transcript filename is
1130
+ // `agent-<id>`; RequestFile normalizes that id against the header's own spelling.
1131
+ #requestFile(sessionId, parentSessionId) {
1132
+ const agentId = parentSessionId && sessionId.startsWith("agent-") ? sessionId.slice("agent-".length) : null;
1133
+ return agentId ? new RequestFile(parentSessionId, { agentId }) : new RequestFile(sessionId);
884
1134
  }
885
1135
  // Scan `period` ({ start, end } epoch ms, optional `key`/`date`; see periodBounds). watch:true (UI) streams 'update' events per dir batch and returns the current cache immediately; watch:false (CLIs/tests) awaits completion and returns the deduped sessions with no emits.
886
1136
  async scan(period, { watch = false } = {}) {
@@ -1027,6 +1277,7 @@ class WorkHours {
1027
1277
  }
1028
1278
  class AgentRunner {
1029
1279
  async run(text, sender, systemTools = false, cache = false) {
1280
+ const { query } = await import("@anthropic-ai/claude-agent-sdk");
1030
1281
  const send = (chunk) => {
1031
1282
  if (!sender.isDestroyed()) sender.send("agent:output", chunk);
1032
1283
  };
@@ -1258,6 +1509,12 @@ class MainWindow {
1258
1509
  send(channel, payload) {
1259
1510
  if (this.win && !this.win.isDestroyed()) this.win.webContents.send(channel, payload);
1260
1511
  }
1512
+ focus() {
1513
+ if (!this.win || this.win.isDestroyed()) return;
1514
+ if (this.win.isMinimized()) this.win.restore();
1515
+ this.win.show();
1516
+ this.win.focus();
1517
+ }
1261
1518
  }
1262
1519
  const mtimeOf = (path2) => fs.statSync(path2, { throwIfNoEntry: false })?.mtimeMs;
1263
1520
  class ClaudeSettings {
@@ -1349,15 +1606,9 @@ class ProxySwitch {
1349
1606
  const baseUrl = settings.env?.ANTHROPIC_BASE_URL;
1350
1607
  if (baseUrl && baseUrl !== PROXY_URL)
1351
1608
  throw new Error(`Leaving your existing env.ANTHROPIC_BASE_URL in place (${baseUrl}) — remove it from settings.json to enable capture.`);
1352
- const child = spawn(process.execPath, [PROXY_PATH, "--restart"], {
1353
- detached: true,
1354
- stdio: "ignore",
1355
- windowsHide: true,
1356
- env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }
1357
- });
1358
- const exited = new Promise((resolve) => child.once("exit", resolve));
1359
- child.unref();
1360
- const outcome = await Promise.race([exited, this.#settle(true).then((ok) => ok ? "up" : "unresponsive")]);
1609
+ const launcher = utilityProcess.fork(PROXY_PATH, ["--restart"], { stdio: "ignore" });
1610
+ const exited = new Promise((resolve) => launcher.once("message", resolve));
1611
+ const outcome = await Promise.race([exited, this.#settle(true).then((ok) => ok ? "up" : "unresponsive")]).finally(() => launcher.kill());
1361
1612
  if (outcome !== "up")
1362
1613
  throw new Error(outcome === 2 ? "Cannot reach api.anthropic.com — not enabling capture. Check your network and try again." : `Proxy did not start — see ${ERROR_LOG_PATH}`);
1363
1614
  const hook = settings.findHook("SessionStart", HOOK_BASENAME);
@@ -1503,7 +1754,51 @@ class Switchers {
1503
1754
  }
1504
1755
  }
1505
1756
  }
1506
- app.whenReady().then(() => {
1757
+ const SCHEME = "claude-discover";
1758
+ class DeepLink extends EventEmitter {
1759
+ #pending = null;
1760
+ #live = false;
1761
+ // the renderer has pulled the cold-start link, so later links can be pushed to it
1762
+ // Call at startup, before app.whenReady(). Returns false when another instance already holds the
1763
+ // lock — the caller must then quit, our argv has been forwarded to the window that owns it.
1764
+ requestLock() {
1765
+ if (!app.requestSingleInstanceLock()) return false;
1766
+ app.on("second-instance", (_e, argv) => this.emit("open", findTarget(argv)));
1767
+ app.on("open-url", (e, url) => {
1768
+ e.preventDefault();
1769
+ this.#deliver(findTarget([url]));
1770
+ });
1771
+ this.#register();
1772
+ this.#pending = findTarget(process.argv);
1773
+ return true;
1774
+ }
1775
+ takePending() {
1776
+ this.#live = true;
1777
+ const target = this.#pending;
1778
+ this.#pending = null;
1779
+ return target;
1780
+ }
1781
+ // @macOS Launch Services reuses the running bundle instead of launching a second process, so links
1782
+ // come as events — and a cold-start one fires before the renderer exists, hence the park.
1783
+ #deliver(target) {
1784
+ if (!target) return;
1785
+ if (this.#live) this.emit("open", target);
1786
+ else this.#pending = target;
1787
+ }
1788
+ // @windows Rewritten on every launch — idempotent, and the last launched checkout wins.
1789
+ #register() {
1790
+ if (process.platform !== "win32") return;
1791
+ if (IS_EPHEMERAL) return;
1792
+ app.setAsDefaultProtocolClient(SCHEME, process.execPath, [app.getAppPath()]);
1793
+ }
1794
+ }
1795
+ function findTarget(argv) {
1796
+ const link = URL.parse(argv.find((a) => a.startsWith(`${SCHEME}://`)) ?? "");
1797
+ const id = link?.searchParams.get("id");
1798
+ return id ? { id, date: link.searchParams.get("date") } : null;
1799
+ }
1800
+ const deepLink = new DeepLink();
1801
+ function start() {
1507
1802
  const win = new MainWindow();
1508
1803
  const agentRunner = new AgentRunner();
1509
1804
  const workHours = new WorkHours();
@@ -1511,6 +1806,10 @@ app.whenReady().then(() => {
1511
1806
  sessionsService.on("update", (sessions) => win.send("sessions:update", sessions));
1512
1807
  sessionsService.on("progress", (p) => win.send("sessions:scan-progress", p));
1513
1808
  sessionsService.start();
1809
+ deepLink.on("open", (target) => {
1810
+ win.focus();
1811
+ if (target) win.send("deeplink:open-session", target);
1812
+ });
1514
1813
  win.create();
1515
1814
  app.on("activate", () => {
1516
1815
  if (BrowserWindow.getAllWindows().length === 0) win.create();
@@ -1522,6 +1821,7 @@ app.whenReady().then(() => {
1522
1821
  ipcMain.handle("work-hours:set", (_e, data) => workHours.write(data));
1523
1822
  ipcMain.handle("agent:run", (e, text, systemTools, cache) => agentRunner.run(text, e.sender, systemTools, cache));
1524
1823
  ipcMain.handle("shell:open-link", (_e, href, baseFile) => openLinkSafely(href, baseFile));
1824
+ ipcMain.handle("deeplink:take-pending", () => deepLink.takePending());
1525
1825
  ipcMain.on("find:query", (_e, text, options) => win.findBar?.query(text, options));
1526
1826
  ipcMain.on("find:stop", () => win.findBar?.stop());
1527
1827
  ipcMain.on("find:close", () => win.findBar?.hide());
@@ -1536,7 +1836,13 @@ app.whenReady().then(() => {
1536
1836
  ipcMain.handle("switch:activate", (_e, name) => switchers.activate(name));
1537
1837
  ipcMain.handle("switch:deactivate", (_e, name) => switchers.deactivate(name));
1538
1838
  ipcMain.handle("switch:keep-active", (_e, name, value) => switchers.setKeepActive(name, value));
1539
- });
1839
+ }
1540
1840
  app.on("window-all-closed", () => {
1541
1841
  if (process.platform !== "darwin") app.quit();
1542
1842
  });
1843
+ const isFirstInstance = deepLink.requestLock();
1844
+ if (isFirstInstance) {
1845
+ app.whenReady().then(start);
1846
+ } else {
1847
+ app.exit(0);
1848
+ }
@@ -24,5 +24,7 @@ electron.contextBridge.exposeInMainWorld("api", {
24
24
  onAgentOutput: subscribe("agent:output"),
25
25
  openLink: (href, baseFile) => electron.ipcRenderer.invoke("shell:open-link", href, baseFile),
26
26
  onFindActive: subscribe("find:active"),
27
- findClose: () => electron.ipcRenderer.send("find:close")
27
+ findClose: () => electron.ipcRenderer.send("find:close"),
28
+ onDeepLink: subscribe("deeplink:open-session"),
29
+ takeDeepLink: () => electron.ipcRenderer.invoke("deeplink:take-pending")
28
30
  });