claude-discover 1.9.0 → 1.9.1

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
  }
@@ -207,16 +433,20 @@ ${t.description || ""}`.trim()).join("\n\n");
207
433
  }
208
434
  }
209
435
  class RequestFile {
210
- constructor(sessionId, dir = REQUESTS_DIR) {
436
+ constructor(sessionId, { dir = REQUESTS_DIR, agentId = null } = {}) {
211
437
  this.filePath = join(dir, `${sessionId}.requests.jsonl`);
438
+ this.agentId = agentId;
439
+ }
440
+ #mine(rec) {
441
+ return (rec.requestHeaders?.["x-claude-code-agent-id"] ?? null) === this.agentId;
212
442
  }
213
443
  async lines() {
214
444
  const raw = await fsp.readFile(this.filePath, "utf8").catch(() => "");
215
445
  return raw.split("\n").filter(Boolean);
216
446
  }
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.
447
+ // `range` is { start, end } epoch ms. Refs are resolved over the whole file before the range and
448
+ // agent filters — an out-of-range (or another agent's) record may hold the $hash an in-range
449
+ // record refs — so callers see every request exactly as it was sent to the API.
220
450
  async read(range = null) {
221
451
  const parser = new RequestParser();
222
452
  const records = [];
@@ -229,8 +459,9 @@ class RequestFile {
229
459
  }
230
460
  records.push(parser.resolveRefs(rec));
231
461
  }
232
- if (!range) return records;
233
462
  return records.filter((r) => {
463
+ if (!this.#mine(r)) return false;
464
+ if (!range) return true;
234
465
  const t = Date.parse(r.timestamp);
235
466
  return t >= range.start && t <= range.end;
236
467
  });
@@ -238,25 +469,17 @@ class RequestFile {
238
469
  // System prompts (request.system), tool definitions (request.tools) and memory files
239
470
  // (CLAUDE.md / MEMORY.md / … inside a user message's `# claudeMd` system-reminder) — none of
240
471
  // which Claude Code records in the session transcript, so the request log is the only source.
472
+ // Works on read()'s output, so dedup wrappers and the agent scope are already handled.
241
473
  // Returns one record per unique system prompt / tool batch / file_path; readSession ships them
242
474
  // alongside the transcript items.
243
475
  async readInstructions() {
244
476
  const parser = new RequestParser();
245
477
  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
- }
478
+ for (const rec of await this.read()) {
256
479
  const record = (f) => ({ timestamp: rec.timestamp, ...f });
257
- const sys = hasSystem && parser.systemPrompt(rec);
480
+ const sys = parser.systemPrompt(rec);
258
481
  if (sys && !files.has(sys.hash)) files.set(sys.hash, record(sys));
259
- const tools = hasTools && parser.systemTools(rec);
482
+ const tools = parser.systemTools(rec);
260
483
  if (tools) files.set(tools.hash, record(tools));
261
484
  for (const f of parser.memoryFiles(rec)) {
262
485
  if (!files.has(f.file_path)) files.set(f.file_path, record(f));
@@ -373,7 +596,7 @@ class SessionParser {
373
596
  }
374
597
  if (t === "attachment") {
375
598
  obj.isMeta = true;
376
- this.prevMessage = obj;
599
+ if (obj.attachment?.type !== "queued_command") this.prevMessage = obj;
377
600
  return true;
378
601
  }
379
602
  if (t !== "user" && t !== "assistant") return true;
@@ -582,6 +805,8 @@ class SessionsScanner {
582
805
  constructor({ root = CLAUDE_PROJECTS_DIR } = {}) {
583
806
  this.root = root;
584
807
  this.watcher = null;
808
+ this.watchWanted = false;
809
+ this.walkDone = Promise.withResolvers();
585
810
  this.statCache = new StatCache();
586
811
  }
587
812
  async _listDirs(p) {
@@ -608,8 +833,17 @@ class SessionsScanner {
608
833
  // truthy — lets callers flush UI updates incrementally. An aborted `signal` (a superseded
609
834
  // scan) stops the walk: no further stats, onFile calls or emits. Once a walk has completed
610
835
  // 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 });
836
+ async scan(day, opts = {}) {
837
+ if (this.statCache.complete && this.watcher) {
838
+ return this.statCache.scan((stat) => this._inPeriod(stat, day), opts);
839
+ }
840
+ try {
841
+ await this._walk(day, opts);
842
+ } finally {
843
+ this.walkDone.resolve();
844
+ }
845
+ }
846
+ async _walk(day, { onFile, onBatchDone, onProgress, signal } = {}) {
613
847
  const projects = await this._listDirs(this.root);
614
848
  let done = 0;
615
849
  const progress = () => onProgress?.({ done, total: projects.length, scanning: done < projects.length });
@@ -631,7 +865,14 @@ class SessionsScanner {
631
865
  if (onBatchDone) await onBatchDone();
632
866
  progress();
633
867
  }
634
- watch({ onChange, onUnlink } = {}) {
868
+ // Starts chokidar once a scan has finished, and resolves when it's live. Its setup walks the whole
869
+ // projects dir to register watchers, and on a cold disk those stats are the bulk of startup, so it
870
+ // waits instead of racing our walk. The gap is self-healing: `ignoreInitial` skips files present at
871
+ // startup anyway, and a session still being written emits on its next append.
872
+ async watch({ onChange, onUnlink } = {}) {
873
+ this.watchWanted = true;
874
+ await this.walkDone.promise;
875
+ if (!this.watchWanted) return;
635
876
  const isUNC = this.root.startsWith("\\\\") || this.root.startsWith("//");
636
877
  this.watcher = chokidar.watch(this.root, {
637
878
  ignoreInitial: true,
@@ -655,6 +896,7 @@ class SessionsScanner {
655
896
  this.watcher.on("unlink", removed);
656
897
  }
657
898
  stop() {
899
+ this.watchWanted = false;
658
900
  if (this.watcher) {
659
901
  this.watcher.close();
660
902
  this.watcher = null;
@@ -873,14 +1115,21 @@ class SessionsService extends EventEmitter {
873
1115
  });
874
1116
  items.sort((a, b) => a._ts - b._ts);
875
1117
  for (const o of items) delete o._ts;
876
- const instructions = await new RequestFile(reader.sessionId).readInstructions();
1118
+ const instructions = await this.#requestFile(reader.sessionId, reader.parentSessionId).readInstructions();
877
1119
  return { items, instructions };
878
1120
  }
879
1121
  // Captured API requests for a session (see bin/proxy.mjs), period-filtered like readSession.
880
1122
  async readRequests(sessionId, date = null, granularity = "day") {
881
1123
  if (!/^[\w-]+$/.test(sessionId)) return [];
882
1124
  const range = date ? periodBounds(date, granularity) : null;
883
- return new RequestFile(sessionId).read(range);
1125
+ return this.#requestFile(sessionId, this.metaCache.byId(sessionId)?.parentSessionId).read(range);
1126
+ }
1127
+ // Subagents send the parent's session id, so the proxy logs their requests into the parent's
1128
+ // file, tagged x-claude-code-agent-id — route their reads there. The transcript filename is
1129
+ // `agent-<id>` while the header carries the bare <id>.
1130
+ #requestFile(sessionId, parentSessionId) {
1131
+ const agentId = parentSessionId && sessionId.startsWith("agent-") ? sessionId.slice("agent-".length) : null;
1132
+ return agentId ? new RequestFile(parentSessionId, { agentId }) : new RequestFile(sessionId);
884
1133
  }
885
1134
  // 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
1135
  async scan(period, { watch = false } = {}) {
@@ -1027,6 +1276,7 @@ class WorkHours {
1027
1276
  }
1028
1277
  class AgentRunner {
1029
1278
  async run(text, sender, systemTools = false, cache = false) {
1279
+ const { query } = await import("@anthropic-ai/claude-agent-sdk");
1030
1280
  const send = (chunk) => {
1031
1281
  if (!sender.isDestroyed()) sender.send("agent:output", chunk);
1032
1282
  };
@@ -1258,6 +1508,12 @@ class MainWindow {
1258
1508
  send(channel, payload) {
1259
1509
  if (this.win && !this.win.isDestroyed()) this.win.webContents.send(channel, payload);
1260
1510
  }
1511
+ focus() {
1512
+ if (!this.win || this.win.isDestroyed()) return;
1513
+ if (this.win.isMinimized()) this.win.restore();
1514
+ this.win.show();
1515
+ this.win.focus();
1516
+ }
1261
1517
  }
1262
1518
  const mtimeOf = (path2) => fs.statSync(path2, { throwIfNoEntry: false })?.mtimeMs;
1263
1519
  class ClaudeSettings {
@@ -1349,15 +1605,9 @@ class ProxySwitch {
1349
1605
  const baseUrl = settings.env?.ANTHROPIC_BASE_URL;
1350
1606
  if (baseUrl && baseUrl !== PROXY_URL)
1351
1607
  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")]);
1608
+ const launcher = utilityProcess.fork(PROXY_PATH, ["--restart"], { stdio: "ignore" });
1609
+ const exited = new Promise((resolve) => launcher.once("message", resolve));
1610
+ const outcome = await Promise.race([exited, this.#settle(true).then((ok) => ok ? "up" : "unresponsive")]).finally(() => launcher.kill());
1361
1611
  if (outcome !== "up")
1362
1612
  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
1613
  const hook = settings.findHook("SessionStart", HOOK_BASENAME);
@@ -1503,7 +1753,51 @@ class Switchers {
1503
1753
  }
1504
1754
  }
1505
1755
  }
1506
- app.whenReady().then(() => {
1756
+ const SCHEME = "claude-discover";
1757
+ class DeepLink extends EventEmitter {
1758
+ #pending = null;
1759
+ #live = false;
1760
+ // the renderer has pulled the cold-start link, so later links can be pushed to it
1761
+ // Call at startup, before app.whenReady(). Returns false when another instance already holds the
1762
+ // lock — the caller must then quit, our argv has been forwarded to the window that owns it.
1763
+ requestLock() {
1764
+ if (!app.requestSingleInstanceLock()) return false;
1765
+ app.on("second-instance", (_e, argv) => this.emit("open", findTarget(argv)));
1766
+ app.on("open-url", (e, url) => {
1767
+ e.preventDefault();
1768
+ this.#deliver(findTarget([url]));
1769
+ });
1770
+ this.#register();
1771
+ this.#pending = findTarget(process.argv);
1772
+ return true;
1773
+ }
1774
+ takePending() {
1775
+ this.#live = true;
1776
+ const target = this.#pending;
1777
+ this.#pending = null;
1778
+ return target;
1779
+ }
1780
+ // @macOS Launch Services reuses the running bundle instead of launching a second process, so links
1781
+ // come as events — and a cold-start one fires before the renderer exists, hence the park.
1782
+ #deliver(target) {
1783
+ if (!target) return;
1784
+ if (this.#live) this.emit("open", target);
1785
+ else this.#pending = target;
1786
+ }
1787
+ // @windows Rewritten on every launch — idempotent, and the last launched checkout wins.
1788
+ #register() {
1789
+ if (process.platform !== "win32") return;
1790
+ if (IS_EPHEMERAL) return;
1791
+ app.setAsDefaultProtocolClient(SCHEME, process.execPath, [app.getAppPath()]);
1792
+ }
1793
+ }
1794
+ function findTarget(argv) {
1795
+ const link = URL.parse(argv.find((a) => a.startsWith(`${SCHEME}://`)) ?? "");
1796
+ const id = link?.searchParams.get("id");
1797
+ return id ? { id, date: link.searchParams.get("date") } : null;
1798
+ }
1799
+ const deepLink = new DeepLink();
1800
+ function start() {
1507
1801
  const win = new MainWindow();
1508
1802
  const agentRunner = new AgentRunner();
1509
1803
  const workHours = new WorkHours();
@@ -1511,6 +1805,10 @@ app.whenReady().then(() => {
1511
1805
  sessionsService.on("update", (sessions) => win.send("sessions:update", sessions));
1512
1806
  sessionsService.on("progress", (p) => win.send("sessions:scan-progress", p));
1513
1807
  sessionsService.start();
1808
+ deepLink.on("open", (target) => {
1809
+ win.focus();
1810
+ if (target) win.send("deeplink:open-session", target);
1811
+ });
1514
1812
  win.create();
1515
1813
  app.on("activate", () => {
1516
1814
  if (BrowserWindow.getAllWindows().length === 0) win.create();
@@ -1522,6 +1820,7 @@ app.whenReady().then(() => {
1522
1820
  ipcMain.handle("work-hours:set", (_e, data) => workHours.write(data));
1523
1821
  ipcMain.handle("agent:run", (e, text, systemTools, cache) => agentRunner.run(text, e.sender, systemTools, cache));
1524
1822
  ipcMain.handle("shell:open-link", (_e, href, baseFile) => openLinkSafely(href, baseFile));
1823
+ ipcMain.handle("deeplink:take-pending", () => deepLink.takePending());
1525
1824
  ipcMain.on("find:query", (_e, text, options) => win.findBar?.query(text, options));
1526
1825
  ipcMain.on("find:stop", () => win.findBar?.stop());
1527
1826
  ipcMain.on("find:close", () => win.findBar?.hide());
@@ -1536,7 +1835,13 @@ app.whenReady().then(() => {
1536
1835
  ipcMain.handle("switch:activate", (_e, name) => switchers.activate(name));
1537
1836
  ipcMain.handle("switch:deactivate", (_e, name) => switchers.deactivate(name));
1538
1837
  ipcMain.handle("switch:keep-active", (_e, name, value) => switchers.setKeepActive(name, value));
1539
- });
1838
+ }
1540
1839
  app.on("window-all-closed", () => {
1541
1840
  if (process.platform !== "darwin") app.quit();
1542
1841
  });
1842
+ const isFirstInstance = deepLink.requestLock();
1843
+ if (isFirstInstance) {
1844
+ app.whenReady().then(start);
1845
+ } else {
1846
+ app.exit(0);
1847
+ }
@@ -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
  });