agent-file-stash 0.5.0 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +70 -8
  2. package/dist/cli.mjs +812 -196
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -7,39 +7,111 @@ import { createHash } from "node:crypto";
7
7
 
8
8
  // packages/sdk/src/differ.ts
9
9
  var CONTEXT = 3;
10
+ var MAX_EDIT_DISTANCE = 2500;
11
+ var MAX_SNAKE_STEPS = 2e7;
10
12
  function computeDiff(oldContent, newContent, filePath) {
11
13
  const oldLines = oldContent.split("\n");
12
14
  const newLines = newContent.split("\n");
13
- const lcs = longestCommonSubsequence(oldLines, newLines);
14
- const { rawLines, linesChanged } = buildRawLines(oldLines, newLines, lcs);
15
+ const shortest = Math.min(oldLines.length, newLines.length);
16
+ let prefix = 0;
17
+ while (prefix < shortest && oldLines[prefix] === newLines[prefix]) prefix++;
18
+ let suffix = 0;
19
+ while (suffix < shortest - prefix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]) suffix++;
20
+ const n = oldLines.length - prefix - suffix;
21
+ const m = newLines.length - prefix - suffix;
22
+ if (n === 0 && m === 0) {
23
+ return { diff: "", linesChanged: 0, hasChanges: false, changedNewLines: /* @__PURE__ */ new Set() };
24
+ }
25
+ const edits = myersEdits(oldLines, newLines, prefix, n, m) ?? {
26
+ removed: new Uint8Array(n).fill(1),
27
+ added: new Uint8Array(m).fill(1)
28
+ };
29
+ const { rawLines, linesChanged } = buildRawLines(oldLines, newLines, prefix, suffix, edits);
15
30
  const changedNewLines = collectChangedLines(rawLines);
16
- if (linesChanged === 0) {
17
- return { diff: "", linesChanged: 0, hasChanges: false, changedNewLines };
18
- }
19
31
  const hunkGroups = groupIntoHunks(rawLines);
20
32
  const diff = formatDiff(hunkGroups, filePath);
21
33
  return { diff, linesChanged, hasChanges: true, changedNewLines };
22
34
  }
23
- function buildRawLines(oldLines, newLines, lcs) {
35
+ function myersEdits(oldLines, newLines, off, n, m) {
36
+ const removed = new Uint8Array(n);
37
+ const added = new Uint8Array(m);
38
+ if (n === 0 || m === 0) return { removed: removed.fill(1), added: added.fill(1) };
39
+ if (Math.abs(n - m) > MAX_EDIT_DISTANCE) return null;
40
+ const max = Math.min(n + m, MAX_EDIT_DISTANCE);
41
+ const base = max + 1;
42
+ const v = new Int32Array(2 * max + 3);
43
+ const trace = [];
44
+ let steps = 0;
45
+ let found = -1;
46
+ for (let d = 0; d <= max && found < 0; d++) {
47
+ for (let k = -d; k <= d; k += 2) {
48
+ let x2 = k === -d || k !== d && v[base + k - 1] < v[base + k + 1] ? v[base + k + 1] : v[base + k - 1] + 1;
49
+ let y2 = x2 - k;
50
+ while (x2 < n && y2 < m && oldLines[off + x2] === newLines[off + y2]) {
51
+ x2++;
52
+ y2++;
53
+ steps++;
54
+ }
55
+ if (steps > MAX_SNAKE_STEPS) return null;
56
+ v[base + k] = x2;
57
+ if (x2 >= n && y2 >= m) {
58
+ found = d;
59
+ break;
60
+ }
61
+ }
62
+ if (found >= 0) break;
63
+ const snapshot = new Int32Array(d + 1);
64
+ for (let k = -d, i = 0; k <= d; k += 2, i++) snapshot[i] = v[base + k];
65
+ trace.push(snapshot);
66
+ }
67
+ if (found < 0) return null;
68
+ let x = n, y = m;
69
+ for (let d = found; d > 0; d--) {
70
+ const k = x - y;
71
+ const prev = trace[d - 1];
72
+ const at = (diagonal) => prev[diagonal + d - 1 >> 1];
73
+ const down = k === -d || k !== d && at(k - 1) < at(k + 1);
74
+ const prevK = down ? k + 1 : k - 1;
75
+ const prevX = at(prevK);
76
+ const prevY = prevX - prevK;
77
+ if (down) added[prevY] = 1;
78
+ else removed[prevX] = 1;
79
+ x = prevX;
80
+ y = prevY;
81
+ }
82
+ return { removed, added };
83
+ }
84
+ function buildRawLines(oldLines, newLines, prefix, suffix, edits) {
24
85
  const rawLines = [];
25
- let oldIdx = 0, newIdx = 0, lcsIdx = 0, linesChanged = 0;
26
- while (oldIdx < oldLines.length || newIdx < newLines.length) {
27
- const atLcsMatch = lcsIdx < lcs.length && oldIdx < oldLines.length && oldLines[oldIdx] === lcs[lcsIdx] && newIdx < newLines.length && newLines[newIdx] === lcs[lcsIdx];
28
- if (atLcsMatch) {
29
- rawLines.push({ type: "keep", line: oldLines[oldIdx], oldLine: oldIdx + 1, newLine: newIdx + 1 });
30
- oldIdx++;
31
- newIdx++;
32
- lcsIdx++;
33
- } else if (newIdx < newLines.length && (lcsIdx >= lcs.length || newLines[newIdx] !== lcs[lcsIdx])) {
34
- rawLines.push({ type: "add", line: newLines[newIdx], oldLine: oldIdx + 1, newLine: newIdx + 1 });
35
- newIdx++;
86
+ const { removed, added } = edits;
87
+ let linesChanged = 0;
88
+ for (let i = 0; i < prefix; i++) {
89
+ rawLines.push({ type: "keep", line: oldLines[i], oldLine: i + 1, newLine: i + 1 });
90
+ }
91
+ let oi = 0, ni = 0;
92
+ while (oi < removed.length || ni < added.length) {
93
+ if (oi < removed.length && ni < added.length && !removed[oi] && !added[ni]) {
94
+ rawLines.push({ type: "keep", line: oldLines[prefix + oi], oldLine: prefix + oi + 1, newLine: prefix + ni + 1 });
95
+ oi++;
96
+ ni++;
97
+ continue;
98
+ }
99
+ while (ni < added.length && added[ni]) {
100
+ rawLines.push({ type: "add", line: newLines[prefix + ni], oldLine: prefix + oi + 1, newLine: prefix + ni + 1 });
101
+ ni++;
36
102
  linesChanged++;
37
- } else {
38
- rawLines.push({ type: "remove", line: oldLines[oldIdx], oldLine: oldIdx + 1, newLine: newIdx + 1 });
39
- oldIdx++;
103
+ }
104
+ while (oi < removed.length && removed[oi]) {
105
+ rawLines.push({ type: "remove", line: oldLines[prefix + oi], oldLine: prefix + oi + 1, newLine: prefix + ni + 1 });
106
+ oi++;
40
107
  linesChanged++;
41
108
  }
42
109
  }
110
+ const oldTail = prefix + removed.length;
111
+ const newTail = prefix + added.length;
112
+ for (let i = 0; i < suffix; i++) {
113
+ rawLines.push({ type: "keep", line: oldLines[oldTail + i], oldLine: oldTail + i + 1, newLine: newTail + i + 1 });
114
+ }
43
115
  return { rawLines, linesChanged };
44
116
  }
45
117
  function collectChangedLines(rawLines) {
@@ -83,13 +155,8 @@ function groupIntoHunks(rawLines) {
83
155
  }
84
156
  function fillContextGap(current, rawLines, lastChangeIdx, upTo) {
85
157
  const contextEnd = lastChangeIdx + CONTEXT + 1;
86
- const inCurrent = new Set(current);
87
158
  for (let c = contextEnd; c < upTo; c++) {
88
- const line = rawLines[c];
89
- if (!inCurrent.has(line)) {
90
- current.push(line);
91
- inCurrent.add(line);
92
- }
159
+ current.push(rawLines[c]);
93
160
  }
94
161
  }
95
162
  function formatDiff(hunkGroups, filePath) {
@@ -108,38 +175,6 @@ function formatDiff(hunkGroups, filePath) {
108
175
  }
109
176
  return lines.join("\n");
110
177
  }
111
- var LCS_LINE_LIMIT = 5e3;
112
- function longestCommonSubsequence(a, b) {
113
- const m = a.length;
114
- const n = b.length;
115
- if (m > LCS_LINE_LIMIT || n > LCS_LINE_LIMIT) {
116
- return [];
117
- }
118
- const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
119
- for (let i2 = 1; i2 <= m; i2++) {
120
- for (let j2 = 1; j2 <= n; j2++) {
121
- if (a[i2 - 1] === b[j2 - 1]) {
122
- dp[i2][j2] = dp[i2 - 1][j2 - 1] + 1;
123
- } else {
124
- dp[i2][j2] = Math.max(dp[i2 - 1][j2], dp[i2][j2 - 1]);
125
- }
126
- }
127
- }
128
- const result = [];
129
- let i = m, j = n;
130
- while (i > 0 && j > 0) {
131
- if (a[i - 1] === b[j - 1]) {
132
- result.unshift(a[i - 1]);
133
- i--;
134
- j--;
135
- } else if (dp[i - 1][j] > dp[i][j - 1]) {
136
- i--;
137
- } else {
138
- j--;
139
- }
140
- }
141
- return result;
142
- }
143
178
 
144
179
  // packages/sdk/src/exclude.ts
145
180
  import { basename, dirname } from "node:path";
@@ -173,8 +208,8 @@ function isExcludedPath(absPath, extraPatterns = []) {
173
208
  return dirname(absPath).split(/[\\/]/).some((seg) => SECRET_DIRS.has(seg.toLowerCase()));
174
209
  }
175
210
 
176
- // packages/sdk/src/stash.ts
177
- var SCHEMA = `
211
+ // packages/sdk/src/migrations.ts
212
+ var SCHEMA_V1 = `
178
213
  CREATE TABLE IF NOT EXISTS file_versions (
179
214
  path TEXT NOT NULL,
180
215
  hash TEXT NOT NULL,
@@ -221,11 +256,62 @@ CREATE TABLE IF NOT EXISTS session_stats (
221
256
 
222
257
  INSERT OR IGNORE INTO stats (key, value) VALUES ('tokens_saved', 0);
223
258
  `;
259
+ var MIGRATIONS = [{ version: 1, up: (db) => db.exec(SCHEMA_V1) }];
260
+ var SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
261
+ var SchemaTooNewError = class extends Error {
262
+ constructor(found, supported) {
263
+ super(`database schema version ${found} is newer than this release supports (${supported}); upgrade agent-file-stash`);
264
+ this.name = "SchemaTooNewError";
265
+ }
266
+ };
267
+ function readUserVersion(db) {
268
+ const row = db.prepare("PRAGMA user_version").get();
269
+ return row.user_version;
270
+ }
271
+ function runMigrations(db, migrations = MIGRATIONS) {
272
+ const target = migrations[migrations.length - 1].version;
273
+ const check = (version) => {
274
+ if (version > target) throw new SchemaTooNewError(version, target);
275
+ };
276
+ check(readUserVersion(db));
277
+ for (const step of migrations) {
278
+ if (readUserVersion(db) >= step.version) continue;
279
+ db.exec("BEGIN IMMEDIATE");
280
+ try {
281
+ const current = readUserVersion(db);
282
+ check(current);
283
+ if (current < step.version) {
284
+ step.up(db);
285
+ db.exec(`PRAGMA user_version = ${step.version}`);
286
+ }
287
+ db.exec("COMMIT");
288
+ } catch (err) {
289
+ try {
290
+ db.exec("ROLLBACK");
291
+ } catch {
292
+ }
293
+ throw err;
294
+ }
295
+ }
296
+ }
297
+
298
+ // packages/sdk/src/stash.ts
224
299
  var DEFAULT_MAX_LINES = 2e3;
225
300
  var DEFAULT_MAX_CHARS = 1e5;
226
301
  var DEFAULT_MAX_STORE_BYTES = 1e6;
227
302
  var HARD_MAX_READ_BYTES = 64 * 1024 * 1024;
228
303
  var BINARY_SNIFF_CHARS = 8192;
304
+ var SCOPE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
305
+ var SCOPE_DELIMITER = "::";
306
+ var MAX_SCOPES_PER_SESSION = 32;
307
+ var LIFETIME_KEYS = [
308
+ "reads_total",
309
+ "baseline_tokens_total",
310
+ "sent_tokens_total",
311
+ "sessions_total",
312
+ "overhead_tokens_total",
313
+ "counters_since"
314
+ ];
229
315
  function positiveOr(value, fallback) {
230
316
  return Number.isInteger(value) && value > 0 ? value : fallback;
231
317
  }
@@ -330,6 +416,7 @@ async function waitForLockRelease(lockPath) {
330
416
  const deadline = Date.now() + RECOVERY_LOCK_WAIT_MS;
331
417
  while (existsSync(lockPath) && Date.now() < deadline) await sleep(RECOVERY_LOCK_POLL_MS);
332
418
  }
419
+ var BASE_SESSION_SQL = `(CASE WHEN instr(session_id, '${SCOPE_DELIMITER}') > 0 THEN substr(session_id, 1, instr(session_id, '${SCOPE_DELIMITER}') - 1) ELSE session_id END)`;
333
420
  function queryOne(db, sql, params = []) {
334
421
  const rows = db.prepare(sql).all(...params);
335
422
  return rows[0];
@@ -348,9 +435,10 @@ var StashStore = class {
348
435
  recoveryAttempted = false;
349
436
  recoveredFrom;
350
437
  reason;
438
+ sessionCounted = false;
351
439
  constructor(config) {
352
440
  this.dbPath = config.dbPath;
353
- this.sessionId = config.sessionId;
441
+ this.sessionId = config.sessionId.replace(/:{2,}/g, "_");
354
442
  this.exclude = config.exclude ?? [];
355
443
  this.recoverCorrupt = config.recoverCorrupt ?? true;
356
444
  this.quiet = config.quiet ?? false;
@@ -390,7 +478,7 @@ var StashStore = class {
390
478
  try {
391
479
  db.exec("PRAGMA busy_timeout=5000");
392
480
  db.exec("PRAGMA journal_mode=WAL");
393
- db.exec(SCHEMA);
481
+ runMigrations(db);
394
482
  this.registerSession(db);
395
483
  this.pruneClosedSessions(db);
396
484
  } catch (err) {
@@ -478,8 +566,9 @@ var StashStore = class {
478
566
  db.prepare("DELETE FROM sessions WHERE session_id = ?").run(s.session_id);
479
567
  }
480
568
  }
481
- db.exec("DELETE FROM session_reads WHERE session_id NOT IN (SELECT session_id FROM sessions)");
482
- db.exec("DELETE FROM session_ranges WHERE session_id NOT IN (SELECT session_id FROM sessions)");
569
+ for (const table of ["session_reads", "session_ranges"]) {
570
+ db.exec(`DELETE FROM ${table} WHERE ${BASE_SESSION_SQL} NOT IN (SELECT session_id FROM sessions)`);
571
+ }
483
572
  db.exec("DELETE FROM session_stats WHERE session_id NOT IN (SELECT session_id FROM sessions)");
484
573
  db.exec(
485
574
  "DELETE FROM file_versions WHERE NOT EXISTS (SELECT 1 FROM session_reads r WHERE r.path = file_versions.path AND r.hash = file_versions.hash)"
@@ -506,26 +595,26 @@ var StashStore = class {
506
595
  const size = statSync(absPath).size;
507
596
  const now = Date.now();
508
597
  if (size > HARD_MAX_READ_BYTES) {
509
- const result = { stashed: false, content: `[filestash: file too large (${size} bytes); not read]`, hash: "" };
510
- return { absPath, size, content: "", hash: "", lines: 0, now, early: { result, countRead: false } };
598
+ const result2 = { stashed: false, content: `[filestash: file too large (${size} bytes); not read]`, hash: "" };
599
+ return { absPath, size, content: "", hash: "", lines: 0, now, early: { result: result2, countRead: false } };
511
600
  }
512
601
  const content = readFileSync(absPath, "utf-8");
513
602
  const hash = contentHash(content);
514
603
  const lines = content.split("\n").length;
515
604
  const snap = { absPath, size, content, hash, lines, now };
516
605
  if (content.slice(0, BINARY_SNIFF_CHARS).includes("\0")) {
517
- const result = { stashed: false, content: `[filestash: binary file (${size} bytes), not shown]`, hash, totalLines: lines };
518
- snap.early = { result, countRead: true };
606
+ const result2 = { stashed: false, content: `[filestash: binary file (${size} bytes), not shown]`, hash, totalLines: lines };
607
+ snap.early = { result: result2, countRead: true };
519
608
  }
520
609
  return snap;
521
610
  }
522
611
  earlyResult(snap) {
523
- const { result, countRead } = snap.early;
612
+ const { result: result2, countRead } = snap.early;
524
613
  if (countRead) {
525
- const tokens = estimateTokens(result.content);
614
+ const tokens = estimateTokens(result2.content);
526
615
  this.guarded((db) => this.recordRead(db, tokens, tokens));
527
616
  }
528
- return result;
617
+ return result2;
529
618
  }
530
619
  isStoreable(snap) {
531
620
  return snap.size <= this.maxStoreBytes && !isExcludedPath(snap.absPath, this.exclude);
@@ -551,7 +640,8 @@ var StashStore = class {
551
640
  now,
552
641
  size: snap.size,
553
642
  slice: slice.text,
554
- midLine: slice.midLine
643
+ midLine: slice.midLine,
644
+ tid: this.trackingId(options?.scope)
555
645
  };
556
646
  if (this.db) {
557
647
  try {
@@ -562,6 +652,22 @@ var StashStore = class {
562
652
  }
563
653
  return this.plainResult(state);
564
654
  }
655
+ trackingId(scope) {
656
+ return scope !== void 0 && SCOPE_PATTERN.test(scope) ? `${this.sessionId}${SCOPE_DELIMITER}${scope}` : this.sessionId;
657
+ }
658
+ ensureScopeCapacity(db, tid) {
659
+ if (tid === this.sessionId) return;
660
+ if (queryOne(db, "SELECT 1 FROM session_reads WHERE session_id = ? LIMIT 1", [tid])) return;
661
+ const prefix = `${this.sessionId}${SCOPE_DELIMITER}`;
662
+ const scopes = db.prepare(
663
+ "SELECT session_id, MAX(read_at) AS last FROM session_reads WHERE substr(session_id, 1, ?) = ? GROUP BY session_id ORDER BY last ASC, session_id ASC"
664
+ ).all(prefix.length, prefix);
665
+ const excess = scopes.length - MAX_SCOPES_PER_SESSION + 1;
666
+ for (const { session_id } of scopes.slice(0, Math.max(0, excess))) {
667
+ db.prepare("DELETE FROM session_reads WHERE session_id = ?").run(session_id);
668
+ db.prepare("DELETE FROM session_ranges WHERE session_id = ?").run(session_id);
669
+ }
670
+ }
565
671
  plainResult(s) {
566
672
  return { stashed: false, content: s.slice, hash: s.currentHash, totalLines: s.currentLines };
567
673
  }
@@ -574,9 +680,10 @@ var StashStore = class {
574
680
  const lastRead = queryOne(
575
681
  db,
576
682
  "SELECT hash FROM session_reads WHERE session_id = ? AND path = ?",
577
- [this.sessionId, state.absPath]
683
+ [state.tid, state.absPath]
578
684
  );
579
685
  if (!lastRead) {
686
+ this.ensureScopeCapacity(db, state.tid);
580
687
  return this.handleFirstRead(db, state);
581
688
  }
582
689
  const lastHash = lastRead.hash;
@@ -589,43 +696,43 @@ var StashStore = class {
589
696
  this.storeVersion(db, s.absPath, s.currentHash, s.currentContent, s.currentLines, s.now);
590
697
  db.prepare(
591
698
  "INSERT OR REPLACE INTO session_reads (session_id, path, hash, read_at) VALUES (?, ?, ?, ?)"
592
- ).run(this.sessionId, s.absPath, s.currentHash, s.now);
593
- this.clearRanges(db, s.absPath);
699
+ ).run(s.tid, s.absPath, s.currentHash, s.now);
700
+ this.clearRanges(db, s.tid, s.absPath);
594
701
  return this.fullSlice(db, s);
595
702
  }
596
703
  requestedEnd(s) {
597
704
  return Math.min(s.rangeEnd, s.currentLines);
598
705
  }
599
- getRanges(db, absPath, hash) {
706
+ getRanges(db, tid, absPath, hash) {
600
707
  const rows = db.prepare(
601
708
  "SELECT start_line, end_line FROM session_ranges WHERE session_id = ? AND path = ? AND hash = ?"
602
- ).all(this.sessionId, absPath, hash);
709
+ ).all(tid, absPath, hash);
603
710
  return rows.map((r) => [r.start_line, r.end_line]);
604
711
  }
605
712
  coversRequest(db, s, hash) {
606
- return coversRange(this.getRanges(db, s.absPath, hash), s.rangeStart, this.requestedEnd(s));
713
+ return coversRange(this.getRanges(db, s.tid, s.absPath, hash), s.rangeStart, this.requestedEnd(s));
607
714
  }
608
- clearRanges(db, absPath) {
609
- db.prepare("DELETE FROM session_ranges WHERE session_id = ? AND path = ?").run(this.sessionId, absPath);
715
+ clearRanges(db, tid, absPath) {
716
+ db.prepare("DELETE FROM session_ranges WHERE session_id = ? AND path = ?").run(tid, absPath);
610
717
  }
611
- addRange(db, absPath, hash, start, end) {
718
+ addRange(db, tid, absPath, hash, start, end) {
612
719
  if (end < start) return;
613
- const merged = mergeIntervals([...this.getRanges(db, absPath, hash), [start, end]]);
614
- db.prepare("DELETE FROM session_ranges WHERE session_id = ? AND path = ? AND hash = ?").run(this.sessionId, absPath, hash);
720
+ const merged = mergeIntervals([...this.getRanges(db, tid, absPath, hash), [start, end]]);
721
+ db.prepare("DELETE FROM session_ranges WHERE session_id = ? AND path = ? AND hash = ?").run(tid, absPath, hash);
615
722
  const insert = db.prepare(
616
723
  "INSERT INTO session_ranges (session_id, path, hash, start_line, end_line) VALUES (?, ?, ?, ?, ?)"
617
724
  );
618
- for (const [a, b] of merged) insert.run(this.sessionId, absPath, hash, a, b);
725
+ for (const [a, b] of merged) insert.run(tid, absPath, hash, a, b);
619
726
  }
620
727
  handleUncovered(db, s) {
621
728
  db.prepare(
622
729
  "UPDATE session_reads SET read_at = ? WHERE session_id = ? AND path = ?"
623
- ).run(s.now, this.sessionId, s.absPath);
730
+ ).run(s.now, s.tid, s.absPath);
624
731
  return this.fullSlice(db, s);
625
732
  }
626
733
  fullSlice(db, s) {
627
734
  const content = s.slice;
628
- this.addRange(db, s.absPath, s.currentHash, s.rangeStart, this.requestedEnd(s));
735
+ this.addRange(db, s.tid, s.absPath, s.currentHash, s.rangeStart, this.requestedEnd(s));
629
736
  this.recordRead(db, estimateTokens(content), estimateTokens(content));
630
737
  return { stashed: false, content, hash: s.currentHash, totalLines: s.currentLines };
631
738
  }
@@ -636,7 +743,7 @@ var StashStore = class {
636
743
  const text = label(saved);
637
744
  const sent = estimateTokens(text);
638
745
  const base = { stashed: true, hash: s.currentHash, totalLines: s.currentLines, linesChanged: 0 };
639
- this.addRange(db, s.absPath, s.currentHash, s.rangeStart, this.requestedEnd(s));
746
+ this.addRange(db, s.tid, s.absPath, s.currentHash, s.rangeStart, this.requestedEnd(s));
640
747
  if (sent >= baseline) {
641
748
  this.recordRead(db, baseline, baseline);
642
749
  return { ...base, content };
@@ -647,7 +754,7 @@ var StashStore = class {
647
754
  handleUnchanged(db, s) {
648
755
  db.prepare(
649
756
  "UPDATE session_reads SET read_at = ? WHERE session_id = ? AND path = ?"
650
- ).run(s.now, this.sessionId, s.absPath);
757
+ ).run(s.now, s.tid, s.absPath);
651
758
  return this.unchangedLabel(
652
759
  db,
653
760
  s,
@@ -660,12 +767,12 @@ var StashStore = class {
660
767
  "SELECT content, lines FROM file_versions WHERE path = ? AND hash = ?",
661
768
  [s.absPath, lastHash]
662
769
  );
663
- const oldFullyDelivered = !!oldVersion && coversRange(this.getRanges(db, s.absPath, lastHash), 1, oldVersion.lines);
770
+ const oldFullyDelivered = !!oldVersion && coversRange(this.getRanges(db, s.tid, s.absPath, lastHash), 1, oldVersion.lines);
664
771
  this.storeVersion(db, s.absPath, s.currentHash, s.currentContent, s.currentLines, s.now);
665
772
  db.prepare(
666
773
  "UPDATE session_reads SET hash = ?, read_at = ? WHERE session_id = ? AND path = ?"
667
- ).run(s.currentHash, s.now, this.sessionId, s.absPath);
668
- this.clearRanges(db, s.absPath);
774
+ ).run(s.currentHash, s.now, s.tid, s.absPath);
775
+ this.clearRanges(db, s.tid, s.absPath);
669
776
  if (oldVersion && oldFullyDelivered) {
670
777
  const diffResult = computeDiff(oldVersion.content, s.currentContent, s.absPath);
671
778
  if (diffResult.hasChanges) {
@@ -688,7 +795,7 @@ var StashStore = class {
688
795
  const contentTokens = estimateTokens(s.currentContent);
689
796
  const diffTokens = estimateTokens(diffResult.diff);
690
797
  if (diffTokens >= contentTokens) return this.fullSlice(db, s);
691
- this.addRange(db, s.absPath, s.currentHash, 1, s.currentLines);
798
+ this.addRange(db, s.tid, s.absPath, s.currentHash, 1, s.currentLines);
692
799
  this.recordRead(db, contentTokens, diffTokens);
693
800
  return {
694
801
  stashed: true,
@@ -711,29 +818,31 @@ var StashStore = class {
711
818
  ).run(absPath, hash, content, lines, now);
712
819
  }
713
820
  // Resets session tracking for the file and returns the first slice regardless of what was delivered before. Counts as a plain read (baseline equals sent).
714
- async readFileFull(filePath) {
821
+ async readFileFull(filePath, scope) {
715
822
  await this.init();
716
823
  const snap = this.readFileSnapshot(filePath);
717
824
  if (snap.early) return this.earlyResult(snap);
718
825
  const { absPath, content, hash, lines, now } = snap;
719
826
  const slice = sliceLines(content, 0, 0, this.maxLines, this.maxChars);
720
- const result = { stashed: false, content: slice.text, hash, totalLines: lines };
721
- if (!this.db) return result;
827
+ const result2 = { stashed: false, content: slice.text, hash, totalLines: lines };
828
+ if (!this.db) return result2;
722
829
  try {
723
830
  const tokens = estimateTokens(slice.text);
724
831
  if (this.isStoreable(snap)) {
832
+ const tid = this.trackingId(scope);
833
+ this.ensureScopeCapacity(this.db, tid);
725
834
  this.storeVersion(this.db, absPath, hash, content, lines, now);
726
835
  this.db.prepare(
727
836
  "INSERT OR REPLACE INTO session_reads (session_id, path, hash, read_at) VALUES (?, ?, ?, ?)"
728
- ).run(this.sessionId, absPath, hash, now);
729
- this.clearRanges(this.db, absPath);
730
- this.addRange(this.db, absPath, hash, slice.start, slice.end);
837
+ ).run(tid, absPath, hash, now);
838
+ this.clearRanges(this.db, tid, absPath);
839
+ this.addRange(this.db, tid, absPath, hash, slice.start, slice.end);
731
840
  }
732
841
  this.recordRead(this.db, tokens, tokens);
733
842
  } catch (err) {
734
843
  this.degrade(err);
735
844
  }
736
- return result;
845
+ return result2;
737
846
  }
738
847
  async onFileDeleted(filePath) {
739
848
  await this.init();
@@ -759,6 +868,8 @@ var StashStore = class {
759
868
  const versionRow = queryOne(db, "SELECT COUNT(DISTINCT path) as c FROM file_versions");
760
869
  const tokenRow = queryOne(db, "SELECT value FROM stats WHERE key = 'tokens_saved'");
761
870
  const sessionStat = (key) => queryOne(db, "SELECT value FROM session_stats WHERE session_id = ? AND key = ?", [this.sessionId, key])?.value ?? 0;
871
+ const lifetime = (key) => queryOne(db, "SELECT value FROM stats WHERE key = ?", [key])?.value ?? 0;
872
+ const since = queryOne(db, "SELECT value FROM stats WHERE key = 'counters_since'")?.value;
762
873
  return {
763
874
  filesTracked: versionRow?.c ?? 0,
764
875
  tokensSaved: tokenRow?.value ?? 0,
@@ -766,6 +877,12 @@ var StashStore = class {
766
877
  sessionReads: sessionStat("reads"),
767
878
  sessionBaselineTokens: sessionStat("baseline_tokens"),
768
879
  sessionSentTokens: sessionStat("sent_tokens"),
880
+ lifetimeSessions: lifetime("sessions_total"),
881
+ lifetimeReads: lifetime("reads_total"),
882
+ lifetimeBaselineTokens: lifetime("baseline_tokens_total"),
883
+ lifetimeSentTokens: lifetime("sent_tokens_total"),
884
+ lifetimeOverheadTokens: lifetime("overhead_tokens_total"),
885
+ ...since !== void 0 && since > 0 && { countersSince: since },
769
886
  degraded: false,
770
887
  ...this.recoveredFrom && { recoveredFrom: this.recoveredFrom }
771
888
  };
@@ -778,6 +895,11 @@ var StashStore = class {
778
895
  sessionReads: 0,
779
896
  sessionBaselineTokens: 0,
780
897
  sessionSentTokens: 0,
898
+ lifetimeSessions: 0,
899
+ lifetimeReads: 0,
900
+ lifetimeBaselineTokens: 0,
901
+ lifetimeSentTokens: 0,
902
+ lifetimeOverheadTokens: 0,
781
903
  degraded: true,
782
904
  ...this.reason && { degradedReason: this.reason }
783
905
  };
@@ -790,6 +912,7 @@ var StashStore = class {
790
912
  db.prepare("DELETE FROM session_ranges").run();
791
913
  db.prepare("DELETE FROM session_stats").run();
792
914
  db.prepare("UPDATE stats SET value = 0").run();
915
+ db.prepare(`DELETE FROM stats WHERE key IN (${LIFETIME_KEYS.map(() => "?").join(", ")})`).run(...LIFETIME_KEYS);
793
916
  });
794
917
  }
795
918
  async resetReads() {
@@ -817,6 +940,19 @@ var StashStore = class {
817
940
  this.bumpSessionStat(db, "baseline_tokens", baseline);
818
941
  this.bumpSessionStat(db, "sent_tokens", sent);
819
942
  if (baseline > sent) this.addTokensSaved(db, baseline - sent);
943
+ this.bumpLifetime(db, { reads_total: 1, baseline_tokens_total: baseline, sent_tokens_total: sent });
944
+ }
945
+ bumpLifetime(db, counters) {
946
+ db.prepare("INSERT OR IGNORE INTO stats (key, value) VALUES ('counters_since', ?)").run(Date.now());
947
+ const bump = db.prepare("INSERT INTO stats (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = value + ?");
948
+ for (const [key, n] of Object.entries(counters)) bump.run(key, n, n);
949
+ }
950
+ async recordSessionStart(overheadTokens) {
951
+ await this.init();
952
+ if (this.sessionCounted) return;
953
+ this.sessionCounted = true;
954
+ const overhead = Number.isFinite(overheadTokens) && overheadTokens > 0 ? Math.round(overheadTokens) : 0;
955
+ this.guarded((db) => this.bumpLifetime(db, { sessions_total: 1, overhead_tokens_total: overhead }));
820
956
  }
821
957
  bumpSessionStat(db, key, n) {
822
958
  db.prepare(
@@ -901,6 +1037,23 @@ var FileWatcher = class {
901
1037
  }
902
1038
  };
903
1039
 
1040
+ // packages/sdk/src/lifetime.ts
1041
+ function lifetimeView(c) {
1042
+ const grossSaved = c.lifetimeBaselineTokens - c.lifetimeSentTokens;
1043
+ const netSaved = grossSaved - c.lifetimeOverheadTokens;
1044
+ return {
1045
+ empty: c.lifetimeSessions === 0 && c.lifetimeReads === 0,
1046
+ sessions: c.lifetimeSessions,
1047
+ reads: c.lifetimeReads,
1048
+ baselineTokens: c.lifetimeBaselineTokens,
1049
+ sentTokens: c.lifetimeSentTokens,
1050
+ overheadTokens: c.lifetimeOverheadTokens,
1051
+ grossSaved,
1052
+ netSaved,
1053
+ netPerSession: c.lifetimeSessions > 0 ? Math.round(netSaved / c.lifetimeSessions) || 0 : null
1054
+ };
1055
+ }
1056
+
904
1057
  // packages/sdk/src/index.ts
905
1058
  function createStash(config) {
906
1059
  const stash = new StashStore(config);
@@ -912,13 +1065,11 @@ function createStash(config) {
912
1065
  }
913
1066
 
914
1067
  // packages/cli/src/index.ts
915
- import { resolve as resolve4, join as join3 } from "node:path";
916
- import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2, copyFileSync, mkdirSync as mkdirSync2 } from "node:fs";
917
- import { homedir } from "node:os";
1068
+ import { resolve as resolve5, join as join5 } from "node:path";
1069
+ import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync2, copyFileSync, mkdirSync as mkdirSync2 } from "node:fs";
1070
+ import { homedir as homedir2 } from "node:os";
918
1071
 
919
1072
  // packages/cli/src/mcp.ts
920
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
921
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
922
1073
  import { z } from "zod";
923
1074
  import { resolve as resolve3, join, relative, isAbsolute } from "node:path";
924
1075
  import { readFileSync as readFileSync2, realpathSync } from "node:fs";
@@ -954,32 +1105,34 @@ function isPathAllowed(absPath, cwd) {
954
1105
  const rel = relative(cwd, realPath);
955
1106
  return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
956
1107
  }
957
- function formatReadResult(result) {
958
- if (result.stashed && result.diff) {
959
- return `[filestash: ${result.linesChanged} lines changed out of ${result.totalLines}]
960
- ${result.diff}`;
1108
+ function formatReadResult(result2) {
1109
+ if (result2.stashed && result2.diff) {
1110
+ return `[filestash: ${result2.linesChanged} lines changed out of ${result2.totalLines}]
1111
+ ${result2.diff}`;
961
1112
  }
962
- return result.content;
1113
+ return result2.content;
963
1114
  }
964
- function formatFileEntry(path, result) {
965
- if (result.stashed && result.diff) {
966
- return `=== ${path} [${result.linesChanged} lines changed out of ${result.totalLines}] ===
967
- ${result.diff}`;
1115
+ function formatFileEntry(path, result2) {
1116
+ if (result2.stashed && result2.diff) {
1117
+ return `=== ${path} [${result2.linesChanged} lines changed out of ${result2.totalLines}] ===
1118
+ ${result2.diff}`;
968
1119
  }
969
1120
  return `=== ${path} ===
970
- ${result.content}`;
1121
+ ${result2.content}`;
971
1122
  }
972
1123
  var readFileShape = {
973
1124
  path: z.string().describe("File path"),
974
1125
  offset: z.number().optional().describe("Start line (1-based)"),
975
1126
  limit: z.number().optional().describe("Max lines"),
976
- force: z.boolean().optional().describe("Bypass stash")
1127
+ force: z.boolean().optional().describe("Bypass stash"),
1128
+ agent: z.string().optional()
977
1129
  };
978
1130
  var readFilesShape = {
979
1131
  paths: z.preprocess(
980
1132
  (val) => typeof val === "string" ? JSON.parse(val) : val,
981
1133
  z.array(z.string())
982
- ).describe("File paths")
1134
+ ).describe("File paths"),
1135
+ agent: z.string().optional()
983
1136
  };
984
1137
  var TOOL_DEFS = {
985
1138
  read_file: {
@@ -1029,8 +1182,24 @@ function toolDefinitionTokens() {
1029
1182
  }));
1030
1183
  return Math.ceil(JSON.stringify(defs).length / 4);
1031
1184
  }
1032
- function degradedLine(reason = "unknown") {
1033
- return `Mode: DEGRADED (${reason}) - files are read normally, nothing is stashed`;
1185
+ function degradedLine(reason2 = "unknown") {
1186
+ return `Mode: DEGRADED (${reason2}) - files are read normally, nothing is stashed`;
1187
+ }
1188
+ function formatLifetime(c, since, saved) {
1189
+ const v = lifetimeView(c);
1190
+ if (v.empty) return [` Lifetime counters start with this version; ${saved} above includes older history.`];
1191
+ const fmt = (n) => `~${n.toLocaleString()}`;
1192
+ const date = since ? ` (since ${new Date(since).toISOString().slice(0, 10)})` : "";
1193
+ const perSession = v.netPerSession === null ? "" : `, ${fmt(v.netPerSession)} per session`;
1194
+ return [
1195
+ ` Lifetime${date}:`,
1196
+ ` Sessions: ${v.sessions}, reads: ${v.reads}`,
1197
+ ` Would have sent (plain reads): ${fmt(v.baselineTokens)} tokens`,
1198
+ ` Actually sent: ${fmt(v.sentTokens)} tokens`,
1199
+ ` Gross saved: ${fmt(v.grossSaved)} tokens`,
1200
+ ` Tool definitions overhead: ${fmt(v.overheadTokens)} tokens (est., ${v.sessions} sessions)`,
1201
+ ` Net saved: ${fmt(v.netSaved)} tokens (est.)${perSession}`
1202
+ ];
1034
1203
  }
1035
1204
  function formatStatus(stats, overheadTokens) {
1036
1205
  if (stats.degraded) return degradedLine(stats.degradedReason);
@@ -1046,7 +1215,8 @@ function formatStatus(stats, overheadTokens) {
1046
1215
  ` Gross saved: ${fmt(stats.sessionTokensSaved)} tokens`,
1047
1216
  ` Tool definitions overhead: ${fmt(overheadTokens)} tokens (est.)`,
1048
1217
  ` Net saved: ${fmt(net)} tokens (est.)`,
1049
- ` Gross saved (all sessions): ${fmt(stats.tokensSaved)} tokens`
1218
+ ` Gross saved (all sessions): ${fmt(stats.tokensSaved)} tokens`,
1219
+ ...formatLifetime(stats, stats.countersSince, `"Gross saved (all sessions)"`)
1050
1220
  ].join("\n");
1051
1221
  }
1052
1222
  function statsSuffix(grossSaved) {
@@ -1054,7 +1224,7 @@ function statsSuffix(grossSaved) {
1054
1224
 
1055
1225
  [filestash: net ~${(grossSaved - toolDefinitionTokens()).toLocaleString()} tokens this session (est.)]`;
1056
1226
  }
1057
- async function readSingleFile(path, cwd, stash) {
1227
+ async function readSingleFile(path, cwd, stash, scope) {
1058
1228
  const absPath = resolve3(path);
1059
1229
  if (!isPathAllowed(absPath, cwd)) {
1060
1230
  return {
@@ -1064,8 +1234,8 @@ Error: Path must be within the working directory (${cwd})`,
1064
1234
  };
1065
1235
  }
1066
1236
  try {
1067
- const result = await stash.readFile(path);
1068
- return { text: formatFileEntry(path, result), ok: true };
1237
+ const result2 = await stash.readFile(path, { ...scope !== void 0 && { scope } });
1238
+ return { text: formatFileEntry(path, result2), ok: true };
1069
1239
  } catch (e) {
1070
1240
  const message = e instanceof Error ? e.message : String(e);
1071
1241
  return { text: `=== ${path} ===
@@ -1093,6 +1263,9 @@ async function startMcpServer() {
1093
1263
  ...limitOptions()
1094
1264
  });
1095
1265
  await stash.init();
1266
+ await stash.recordSessionStart(toolDefinitionTokens());
1267
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
1268
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
1096
1269
  const server = new McpServer({
1097
1270
  name: "filestash",
1098
1271
  version: packageJson.version ?? "0.0.0"
@@ -1100,7 +1273,7 @@ async function startMcpServer() {
1100
1273
  server.registerTool(
1101
1274
  "read_file",
1102
1275
  TOOL_DEFS.read_file,
1103
- async ({ path, force, offset, limit }) => {
1276
+ async ({ path, force, offset, limit, agent }) => {
1104
1277
  const absPath = resolve3(path);
1105
1278
  if (!isPathAllowed(absPath, cwd)) {
1106
1279
  return {
@@ -1109,12 +1282,13 @@ async function startMcpServer() {
1109
1282
  };
1110
1283
  }
1111
1284
  try {
1112
- const result = force ? await stash.readFileFull(path) : await stash.readFile(path, {
1285
+ const result2 = force ? await stash.readFileFull(path, agent) : await stash.readFile(path, {
1113
1286
  ...offset !== void 0 && { offset },
1114
- ...limit !== void 0 && { limit }
1287
+ ...limit !== void 0 && { limit },
1288
+ ...agent !== void 0 && { scope: agent }
1115
1289
  });
1116
- let text = formatReadResult(result);
1117
- if (result.stashed) {
1290
+ let text = formatReadResult(result2);
1291
+ if (result2.stashed) {
1118
1292
  const stats = await stash.getStats();
1119
1293
  text += statsSuffix(stats.sessionTokensSaved);
1120
1294
  }
@@ -1134,8 +1308,8 @@ async function startMcpServer() {
1134
1308
  server.registerTool(
1135
1309
  "read_files",
1136
1310
  TOOL_DEFS.read_files,
1137
- async ({ paths }) => {
1138
- const results = await Promise.all(paths.map((p) => readSingleFile(p, cwd, stash)));
1311
+ async ({ paths, agent }) => {
1312
+ const results = await Promise.all(paths.map((p) => readSingleFile(p, cwd, stash, agent)));
1139
1313
  const successfulPaths = paths.filter((_, i) => results[i].ok);
1140
1314
  const combined = results.map((r) => r.text).join("\n\n");
1141
1315
  let footer = "";
@@ -1165,7 +1339,8 @@ async function startMcpServer() {
1165
1339
  [`${META_NAMESPACE}/stats`]: {
1166
1340
  ...stats,
1167
1341
  toolDefinitionTokens: overhead,
1168
- netTokensSaved: stats.sessionTokensSaved - overhead
1342
+ netTokensSaved: stats.sessionTokensSaved - overhead,
1343
+ lifetime: lifetimeView(stats)
1169
1344
  }
1170
1345
  }
1171
1346
  };
@@ -1223,6 +1398,449 @@ function findStashDatabases(root, depth = 0) {
1223
1398
  return found;
1224
1399
  }
1225
1400
 
1401
+ // packages/cli/src/editors.ts
1402
+ import { join as join3 } from "node:path";
1403
+ function editorTargets(home) {
1404
+ const mcpServersEntry = {
1405
+ command: "npx",
1406
+ args: ["agent-file-stash", "serve"]
1407
+ };
1408
+ const opencodeMcpEntry = {
1409
+ type: "local",
1410
+ command: ["npx", "agent-file-stash", "serve"]
1411
+ };
1412
+ const xdgConfig = process.env.XDG_CONFIG_HOME || join3(home, ".config");
1413
+ return [
1414
+ { name: "Claude Code", path: join3(home, ".claude.json"), key: "mcpServers", entry: mcpServersEntry },
1415
+ { name: "Cursor", path: join3(home, ".cursor", "mcp.json"), key: "mcpServers", entry: mcpServersEntry },
1416
+ { name: "OpenCode", path: join3(xdgConfig, "opencode", "opencode.json"), key: "mcp", entry: opencodeMcpEntry }
1417
+ ];
1418
+ }
1419
+
1420
+ // packages/cli/src/doctor.ts
1421
+ import { accessSync, constants, existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync2 } from "node:fs";
1422
+ import { homedir } from "node:os";
1423
+ import { delimiter, isAbsolute as isAbsolute2, join as join4, resolve as resolve4 } from "node:path";
1424
+ import { pathToFileURL } from "node:url";
1425
+ import { spawnSync } from "node:child_process";
1426
+ var PACKAGE_NAME = "agent-file-stash";
1427
+ var RECOVERY_LOCK_STALE_MS2 = 15e3;
1428
+ var DEFAULT_MIN_NODE = "24";
1429
+ var NPM_TIMEOUT_MS = 5e3;
1430
+ var MAX_SCRIPT_BYTES = 64 * 1024;
1431
+ var SESSION_START_TARGETS = ["clear", "compact"];
1432
+ var result = (id, status, message, hint) => ({
1433
+ id,
1434
+ status,
1435
+ message,
1436
+ ...hint !== void 0 && { hint }
1437
+ });
1438
+ var reason = (e) => e instanceof Error ? e.message : String(e);
1439
+ function readJson(path) {
1440
+ return JSON.parse(readFileSync3(path, "utf-8"));
1441
+ }
1442
+ function asRecord(v) {
1443
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
1444
+ }
1445
+ function ownPackageJson() {
1446
+ for (const rel of ["../package.json", "../../../package.json"]) {
1447
+ try {
1448
+ const pkg = readJson(join4(import.meta.dirname, rel));
1449
+ if (pkg.name === PACKAGE_NAME) return pkg;
1450
+ } catch {
1451
+ continue;
1452
+ }
1453
+ }
1454
+ return void 0;
1455
+ }
1456
+ function wide(mode, mask) {
1457
+ return (mode & mask) !== 0;
1458
+ }
1459
+ function octal(mode) {
1460
+ return (mode & 511).toString(8).padStart(3, "0");
1461
+ }
1462
+ function checkNode(o) {
1463
+ const range = ownPackageJson()?.engines?.node;
1464
+ const required = Number(/\d+/.exec(range ?? "")?.[0] ?? DEFAULT_MIN_NODE);
1465
+ const major = Number(o.nodeVersion.split(".")[0]);
1466
+ if (major >= required) return [result("node", "ok", `Node ${o.nodeVersion} (requires >=${required})`)];
1467
+ return [
1468
+ result("node", "error", `Node ${o.nodeVersion} is older than the required >=${required}`, `install Node ${required} or newer`)
1469
+ ];
1470
+ }
1471
+ function checkStashDir(o) {
1472
+ const dir = resolveStashDir(o.cwd);
1473
+ if (!existsSync4(dir)) return [result("stash-dir", "info", `stash directory ${dir} does not exist yet; it will be created on first read`)];
1474
+ const st = statSync2(dir);
1475
+ if (!st.isDirectory()) {
1476
+ return [result("stash-dir", "error", `${dir} exists but is not a directory`, `remove it or point FILESTASH_DIR elsewhere`)];
1477
+ }
1478
+ if (o.platform !== "win32" && wide(st.mode, 63)) {
1479
+ return [result("stash-dir", "warn", `stash directory ${dir} has mode ${octal(st.mode)}, expected 700`, `run: chmod 700 ${dir}`)];
1480
+ }
1481
+ return [result("stash-dir", "info", `stash directory ${dir}`)];
1482
+ }
1483
+ function isCorruptMessage(err) {
1484
+ const code = err.errcode;
1485
+ if (typeof code === "number" && [11, 26].includes(code & 255)) return true;
1486
+ return /not a database|malformed|corrupt/i.test(reason(err));
1487
+ }
1488
+ function pidAlive(pid) {
1489
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1490
+ try {
1491
+ process.kill(pid, 0);
1492
+ return true;
1493
+ } catch (e) {
1494
+ return e.code === "EPERM";
1495
+ }
1496
+ }
1497
+ function formatBytes(n) {
1498
+ return n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
1499
+ }
1500
+ async function inspectDatabase(dbPath) {
1501
+ const { DatabaseSync } = await import("node:sqlite");
1502
+ const url = pathToFileURL(dbPath);
1503
+ url.search = existsSync4(`${dbPath}-wal`) || existsSync4(`${dbPath}-shm`) ? "mode=ro" : "mode=ro&immutable=1";
1504
+ const db = new DatabaseSync(url.href, { readOnly: true });
1505
+ try {
1506
+ const userVersion = db.prepare("PRAGMA user_version").get().user_version;
1507
+ const first = db.prepare("PRAGMA quick_check").all()[0] ?? {};
1508
+ const integrity = String(Object.values(first)[0] ?? "no result");
1509
+ const facts = { userVersion, integrity };
1510
+ if (integrity === "ok" && userVersion >= 1 && userVersion <= SCHEMA_VERSION) {
1511
+ facts.filesTracked = db.prepare("SELECT COUNT(DISTINCT path) AS c FROM file_versions").get().c;
1512
+ facts.pids = db.prepare("SELECT pid FROM sessions").all().map((r) => r.pid);
1513
+ }
1514
+ return facts;
1515
+ } finally {
1516
+ db.close();
1517
+ }
1518
+ }
1519
+ function checkDatabaseFiles(dir, now) {
1520
+ const out = [];
1521
+ const names = readdirSync2(dir);
1522
+ const leftovers = names.filter((n) => /^stash\.db\.corrupt-\d+$/.test(n));
1523
+ if (leftovers.length > 0) {
1524
+ out.push(result("database-leftovers", "info", `${leftovers.length} moved-aside corrupt database file(s) in ${dir} (stash.db.corrupt-*); delete them when no longer needed`));
1525
+ }
1526
+ if (names.includes("stash.db.recover.lock")) {
1527
+ const lock = join4(dir, "stash.db.recover.lock");
1528
+ const age = now - statSync2(lock).mtimeMs;
1529
+ if (age > RECOVERY_LOCK_STALE_MS2) {
1530
+ out.push(result("database-lock", "warn", `stale recovery lock ${lock} (${Math.round(age / 1e3)}s old)`, `remove ${lock}; the next start also clears it`));
1531
+ } else {
1532
+ out.push(result("database-lock", "info", `a recovery is in progress (${lock})`));
1533
+ }
1534
+ }
1535
+ return out;
1536
+ }
1537
+ async function checkDatabase(o) {
1538
+ const dir = resolveStashDir(o.cwd);
1539
+ const dbPath = join4(dir, "stash.db");
1540
+ const out = [];
1541
+ if (existsSync4(dir)) out.push(...checkDatabaseFiles(dir, o.now));
1542
+ if (!existsSync4(dbPath)) return [result("database", "info", `no database at ${dbPath} yet; it will be created on first read`), ...out];
1543
+ const st = statSync2(dbPath);
1544
+ let facts;
1545
+ try {
1546
+ facts = await inspectDatabase(dbPath);
1547
+ } catch (e) {
1548
+ if (isCorruptMessage(e)) {
1549
+ return [
1550
+ result("database", "error", `${dbPath} is corrupt (${reason(e)})`, `it will be moved aside to stash.db.corrupt-<ms> on next start; or delete it`),
1551
+ ...out
1552
+ ];
1553
+ }
1554
+ return [result("database", "warn", `could not inspect ${dbPath} without writing (${reason(e)})`, `stop running servers and re-run doctor`), ...out];
1555
+ }
1556
+ if (facts.integrity !== "ok") {
1557
+ out.unshift(result("database", "error", `${dbPath} failed quick_check (${facts.integrity})`, `it will be moved aside to stash.db.corrupt-<ms> on next start; or delete it`));
1558
+ return out;
1559
+ }
1560
+ if (facts.userVersion > SCHEMA_VERSION) {
1561
+ out.unshift(result("database-schema", "error", new SchemaTooNewError(facts.userVersion, SCHEMA_VERSION).message, "upgrade agent-file-stash or point FILESTASH_DIR at another directory"));
1562
+ } else if (facts.userVersion < SCHEMA_VERSION) {
1563
+ out.unshift(result("database-schema", "info", `schema version ${facts.userVersion} will be upgraded to ${SCHEMA_VERSION} on next start`));
1564
+ } else {
1565
+ out.unshift(result("database-schema", "ok", `database schema version ${facts.userVersion}, quick_check passed`));
1566
+ }
1567
+ if (o.platform !== "win32" && wide(st.mode, 63)) {
1568
+ out.push(result("database-mode", "warn", `${dbPath} has mode ${octal(st.mode)}, expected 600`, `run: chmod 600 ${dbPath}`));
1569
+ }
1570
+ const parts = [formatBytes(st.size)];
1571
+ if (facts.filesTracked !== void 0) parts.push(`${facts.filesTracked} files tracked`);
1572
+ if (facts.pids) {
1573
+ const live = facts.pids.filter(pidAlive).length;
1574
+ parts.push(`${live} live and ${facts.pids.length - live} dead session(s)`);
1575
+ }
1576
+ out.push(result("database-stats", "info", `database ${parts.join(", ")}`));
1577
+ return out;
1578
+ }
1579
+ function isExecutable(path) {
1580
+ try {
1581
+ if (!statSync2(path).isFile()) return false;
1582
+ accessSync(path, constants.X_OK);
1583
+ return true;
1584
+ } catch {
1585
+ return false;
1586
+ }
1587
+ }
1588
+ function commandResolves(command2, platform) {
1589
+ if (isAbsolute2(command2) || command2.includes("/") || command2.includes("\\")) return isExecutable(command2);
1590
+ const exts = platform === "win32" ? ["", ...(process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
1591
+ return (process.env.PATH ?? "").split(delimiter).filter(Boolean).some((dir) => exts.some((ext) => isExecutable(join4(dir, command2 + ext))));
1592
+ }
1593
+ function splitCommand(entry) {
1594
+ const raw = entry.command;
1595
+ if (Array.isArray(raw) && typeof raw[0] === "string") return { command: raw[0], args: raw.slice(1).map(String) };
1596
+ if (typeof raw !== "string") return void 0;
1597
+ return { command: raw, args: Array.isArray(entry.args) ? entry.args.map(String) : [] };
1598
+ }
1599
+ function checkMcpRegistration(o) {
1600
+ const out = [];
1601
+ let found = 0;
1602
+ for (const t of editorTargets(o.home)) {
1603
+ if (!existsSync4(t.path)) continue;
1604
+ found++;
1605
+ const id = `mcp-${t.name.toLowerCase().replace(/\s+/g, "-")}`;
1606
+ let config;
1607
+ try {
1608
+ config = readJson(t.path);
1609
+ } catch {
1610
+ out.push(result(id, "warn", `${t.name}: ${t.path} is not valid JSON`, `fix the file, then run: agent-file-stash init`));
1611
+ continue;
1612
+ }
1613
+ const entry = asRecord(asRecord(asRecord(config)?.[t.key])?.filestash);
1614
+ if (!entry) {
1615
+ out.push(result(id, "warn", `${t.name}: no filestash entry in ${t.path}`, "run: agent-file-stash init"));
1616
+ continue;
1617
+ }
1618
+ const cmd = splitCommand(entry);
1619
+ if (!cmd) {
1620
+ out.push(result(id, "warn", `${t.name}: the filestash entry in ${t.path} has no command`, "run: agent-file-stash init"));
1621
+ } else if (!commandResolves(cmd.command, o.platform)) {
1622
+ out.push(result(id, "warn", `${t.name}: command "${cmd.command}" was not found or is not executable`, `install it or fix the command in ${t.path}`));
1623
+ } else if (!cmd.args.includes("serve")) {
1624
+ out.push(result(id, "warn", `${t.name}: the filestash entry does not run "serve"`, `set the arguments to: agent-file-stash serve`));
1625
+ } else {
1626
+ out.push(result(id, "ok", `${t.name}: filestash registered in ${t.path}`));
1627
+ }
1628
+ }
1629
+ if (found === 0) return [result("mcp", "info", "no editor configuration found (Claude Code, Cursor, OpenCode)")];
1630
+ return out;
1631
+ }
1632
+ function matcherCovers(matcher, event) {
1633
+ if (matcher === void 0 || matcher === "" || matcher === "*") return true;
1634
+ if (typeof matcher !== "string") return false;
1635
+ try {
1636
+ return new RegExp(`^(?:${matcher})$`).test(event);
1637
+ } catch {
1638
+ return matcher.split("|").includes(event);
1639
+ }
1640
+ }
1641
+ function commandRuns(command2, o, subcommand) {
1642
+ const mentions = (text) => text.includes(PACKAGE_NAME) && subcommand.test(text);
1643
+ if (mentions(command2)) return true;
1644
+ const target = command2.trim().replace(/^(?:ba|z)?sh\s+/, "").split(/\s+/)[0].replace(/^["']|["']$/g, "").replace(/^~(?=\/)/, o.home).replace(/\$\{?(?:HOME)\}?/, o.home).replace(/\$\{?CLAUDE_PROJECT_DIR\}?/, o.cwd);
1645
+ try {
1646
+ const path = resolve4(o.cwd, target);
1647
+ if (!statSync2(path).isFile() || statSync2(path).size > MAX_SCRIPT_BYTES) return false;
1648
+ return mentions(readFileSync3(path, "utf-8"));
1649
+ } catch {
1650
+ return false;
1651
+ }
1652
+ }
1653
+ function checkResetHook(o) {
1654
+ const out = [];
1655
+ const covered = /* @__PURE__ */ new Set();
1656
+ let hookFile;
1657
+ let hookFound = false;
1658
+ const files = [
1659
+ join4(o.home, ".claude", "settings.json"),
1660
+ join4(o.cwd, ".claude", "settings.json"),
1661
+ join4(o.cwd, ".claude", "settings.local.json")
1662
+ ];
1663
+ for (const file of files) {
1664
+ if (!existsSync4(file)) continue;
1665
+ let settings;
1666
+ try {
1667
+ settings = readJson(file);
1668
+ } catch {
1669
+ out.push(result("hook-settings", "warn", `${file} is not valid JSON`, "fix the file so hooks in it can be checked"));
1670
+ continue;
1671
+ }
1672
+ const sessionStart = asRecord(asRecord(settings)?.hooks)?.SessionStart;
1673
+ for (const entry of Array.isArray(sessionStart) ? sessionStart : []) {
1674
+ const e = asRecord(entry);
1675
+ const hooks = Array.isArray(e?.hooks) ? e.hooks : [];
1676
+ const runs = hooks.some((h) => typeof asRecord(h)?.command === "string" && commandRuns(asRecord(h).command, o, /\breset\b/));
1677
+ if (!runs) continue;
1678
+ hookFound = true;
1679
+ hookFile ??= file;
1680
+ for (const ev of SESSION_START_TARGETS) if (matcherCovers(e?.matcher, ev)) covered.add(ev);
1681
+ }
1682
+ }
1683
+ if (!hookFound) {
1684
+ out.push(result("hook", "warn", "no SessionStart hook runs 'agent-file-stash reset'", "run: agent-file-stash init --hooks (without it a read after /clear or /compact can come back as unchanged)"));
1685
+ return out;
1686
+ }
1687
+ const missing = SESSION_START_TARGETS.filter((ev) => !covered.has(ev));
1688
+ if (missing.length > 0) {
1689
+ out.push(result("hook", "warn", `reset hook in ${hookFile} does not match ${missing.join(" or ")}`, `set the matcher to "clear|compact"`));
1690
+ } else {
1691
+ out.push(result("hook", "ok", `reset hook configured for clear and compact (${hookFile})`));
1692
+ }
1693
+ return out;
1694
+ }
1695
+ function checkSubagentScopeHook(o) {
1696
+ const files = [
1697
+ join4(o.home, ".claude", "settings.json"),
1698
+ join4(o.cwd, ".claude", "settings.json"),
1699
+ join4(o.cwd, ".claude", "settings.local.json")
1700
+ ];
1701
+ for (const file of files) {
1702
+ if (!existsSync4(file)) continue;
1703
+ let settings;
1704
+ try {
1705
+ settings = readJson(file);
1706
+ } catch {
1707
+ continue;
1708
+ }
1709
+ const preToolUse = asRecord(asRecord(settings)?.hooks)?.PreToolUse;
1710
+ for (const entry of Array.isArray(preToolUse) ? preToolUse : []) {
1711
+ const e = asRecord(entry);
1712
+ const hooks = Array.isArray(e?.hooks) ? e.hooks : [];
1713
+ const runs = hooks.some((h) => typeof asRecord(h)?.command === "string" && commandRuns(asRecord(h).command, o, /\bhook\s+subagent-scope\b/));
1714
+ if (runs && matcherCovers(e?.matcher, "mcp__filestash__read_file")) {
1715
+ return [result("hook-subagent-scope", "ok", `subagent scope hook configured (${file})`)];
1716
+ }
1717
+ }
1718
+ }
1719
+ return [result("hook-subagent-scope", "warn", "no PreToolUse hook runs 'agent-file-stash hook subagent-scope' for the stash read tools", "run: agent-file-stash init --hooks (without it subagents share read tracking with the main agent)")];
1720
+ }
1721
+ function checkEnv() {
1722
+ const out = [];
1723
+ const exclude = process.env.FILESTASH_EXCLUDE;
1724
+ if (exclude !== void 0 && exclude !== "") {
1725
+ const n = exclude.split(",").filter((p) => p.trim()).length;
1726
+ out.push(result("env-exclude", "ok", `FILESTASH_EXCLUDE: ${n} pattern(s)`));
1727
+ }
1728
+ for (const name of ["FILESTASH_MAX_LINES", "FILESTASH_MAX_CHARS"]) {
1729
+ const raw = process.env[name];
1730
+ if (raw === void 0 || raw === "") continue;
1731
+ const valid = /^[1-9]\d*$/.test(raw) && Number.isSafeInteger(Number(raw));
1732
+ out.push(
1733
+ valid ? result(`env-${name.toLowerCase()}`, "ok", `${name}=${raw}`) : result(`env-${name.toLowerCase()}`, "warn", `${name}=${JSON.stringify(raw)} is not a positive integer and is ignored`, `set ${name} to a positive integer or unset it`)
1734
+ );
1735
+ }
1736
+ return out;
1737
+ }
1738
+ function npmLatestVersion() {
1739
+ const res = spawnSync("npm", ["view", PACKAGE_NAME, "version"], {
1740
+ encoding: "utf-8",
1741
+ timeout: NPM_TIMEOUT_MS,
1742
+ shell: process.platform === "win32"
1743
+ });
1744
+ const version = res.status === 0 ? res.stdout.trim() : "";
1745
+ return /^\d+\.\d+\.\d+/.test(version) ? version : void 0;
1746
+ }
1747
+ function newer(a, b) {
1748
+ const pa = a.split(/[.-]/).map(Number);
1749
+ const pb = b.split(/[.-]/).map(Number);
1750
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
1751
+ return false;
1752
+ }
1753
+ function checkUpdates(o) {
1754
+ if (!o.checkUpdates) return [];
1755
+ const installed = ownPackageJson()?.version;
1756
+ const latest = o.fetchLatest();
1757
+ if (!installed || !latest) return [result("updates", "info", "could not check for updates")];
1758
+ if (newer(latest, installed)) return [result("updates", "info", `update available: ${installed} -> ${latest} (npm install -g ${PACKAGE_NAME})`)];
1759
+ return [result("updates", "ok", `agent-file-stash ${installed} is up to date`)];
1760
+ }
1761
+ var CHECKS = [
1762
+ ["node", checkNode],
1763
+ ["stash-dir", checkStashDir],
1764
+ ["database", checkDatabase],
1765
+ ["mcp", checkMcpRegistration],
1766
+ ["hook", checkResetHook],
1767
+ ["hook-subagent-scope", checkSubagentScopeHook],
1768
+ ["env", checkEnv],
1769
+ ["updates", checkUpdates]
1770
+ ];
1771
+ function defaultOptions(argv = []) {
1772
+ return {
1773
+ nodeVersion: process.versions.node,
1774
+ cwd: process.cwd(),
1775
+ home: homedir(),
1776
+ platform: process.platform,
1777
+ now: Date.now(),
1778
+ checkUpdates: argv.includes("--check-updates"),
1779
+ fetchLatest: npmLatestVersion
1780
+ };
1781
+ }
1782
+ async function runChecks(o, checks = CHECKS) {
1783
+ const results = [];
1784
+ for (const [id, check] of checks) {
1785
+ try {
1786
+ results.push(...await check(o));
1787
+ } catch (e) {
1788
+ results.push(result(id, "warn", `check failed: ${reason(e)}`));
1789
+ }
1790
+ }
1791
+ return results;
1792
+ }
1793
+ function summarize(results) {
1794
+ const count = (s) => results.filter((r) => r.status === s).length;
1795
+ return { ok: count("ok"), warnings: count("warn"), errors: count("error") };
1796
+ }
1797
+ function formatHuman(results) {
1798
+ const lines = results.flatMap((r) => [`[${r.status}] ${r.message}`, ...r.hint ? [` ${r.hint}`] : []]);
1799
+ const s = summarize(results);
1800
+ return [...lines, "", `${s.ok} ok, ${s.warnings} warnings, ${s.errors} errors`].join("\n");
1801
+ }
1802
+ async function runDoctor(argv, overrides = {}) {
1803
+ const results = await runChecks({ ...defaultOptions(argv), ...overrides });
1804
+ console.log(argv.includes("--json") ? JSON.stringify(results, null, 2) : formatHuman(results));
1805
+ return summarize(results).errors > 0 ? 1 : 0;
1806
+ }
1807
+
1808
+ // packages/cli/src/hook.ts
1809
+ var HOOK_STDIN_TIMEOUT_MS = 1e3;
1810
+ var AGENT_ID_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
1811
+ var STASH_READ_TOOL = /__read_files?$/;
1812
+ async function readHookStdin() {
1813
+ if (process.stdin.isTTY) return "";
1814
+ const read = (async () => {
1815
+ let raw = "";
1816
+ for await (const chunk of process.stdin) raw += chunk;
1817
+ return raw;
1818
+ })();
1819
+ const timeout = new Promise((res) => setTimeout(() => res(""), HOOK_STDIN_TIMEOUT_MS).unref());
1820
+ return (await Promise.race([read, timeout])).trim();
1821
+ }
1822
+ function subagentScopeOutput(raw) {
1823
+ try {
1824
+ const input = JSON.parse(raw);
1825
+ const { agent_id: agentId, tool_name: toolName, tool_input: toolInput } = input;
1826
+ if (typeof agentId !== "string" || !AGENT_ID_PATTERN.test(agentId)) return void 0;
1827
+ if (typeof toolName !== "string" || !STASH_READ_TOOL.test(toolName)) return void 0;
1828
+ if (typeof toolInput !== "object" || toolInput === null || Array.isArray(toolInput)) return void 0;
1829
+ return JSON.stringify({
1830
+ hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: { ...toolInput, agent: agentId } }
1831
+ });
1832
+ } catch {
1833
+ return void 0;
1834
+ }
1835
+ }
1836
+ async function runSubagentScopeHook() {
1837
+ try {
1838
+ const out = subagentScopeOutput(await readHookStdin());
1839
+ if (out) process.stdout.write(out + "\n");
1840
+ } catch {
1841
+ }
1842
+ }
1843
+
1226
1844
  // packages/cli/src/index.ts
1227
1845
  var _origEmitWarning = process.emitWarning;
1228
1846
  process.emitWarning = function(msg, ...args) {
@@ -1235,19 +1853,16 @@ var RESET_HOOK_ENTRY = {
1235
1853
  matcher: "clear|compact",
1236
1854
  hooks: [{ type: "command", command: RESET_HOOK_COMMAND }]
1237
1855
  };
1238
- var HOOK_STDIN_TIMEOUT_MS = 1e3;
1856
+ var SCOPE_HOOK_COMMAND = "npx agent-file-stash hook subagent-scope";
1857
+ var SCOPE_HOOK_ENTRY = {
1858
+ matcher: "mcp__(filestash|agent-file-stash)__read_files?",
1859
+ hooks: [{ type: "command", command: SCOPE_HOOK_COMMAND }]
1860
+ };
1239
1861
  function assertHealthy(stash) {
1240
1862
  if (stash.isDegraded) throw new Error(stash.degradedReason);
1241
1863
  }
1242
1864
  async function readHookCwd() {
1243
- if (process.stdin.isTTY) return void 0;
1244
- const read = (async () => {
1245
- let raw2 = "";
1246
- for await (const chunk of process.stdin) raw2 += chunk;
1247
- return raw2;
1248
- })();
1249
- const timeout = new Promise((res) => setTimeout(() => res(""), HOOK_STDIN_TIMEOUT_MS).unref());
1250
- const raw = (await Promise.race([read, timeout])).trim();
1865
+ const raw = await readHookStdin();
1251
1866
  if (!raw) return void 0;
1252
1867
  try {
1253
1868
  const cwd = JSON.parse(raw)?.cwd;
@@ -1259,8 +1874,8 @@ async function readHookCwd() {
1259
1874
  async function runReset(fromHook) {
1260
1875
  try {
1261
1876
  const hookCwd = fromHook ? await readHookCwd() : void 0;
1262
- const dbPath = join3(resolveStashDir(hookCwd), "stash.db");
1263
- if (!existsSync4(dbPath)) {
1877
+ const dbPath = join5(resolveStashDir(hookCwd), "stash.db");
1878
+ if (!existsSync5(dbPath)) {
1264
1879
  if (!fromHook) console.log("No filestash database found. Nothing to reset.");
1265
1880
  return;
1266
1881
  }
@@ -1280,36 +1895,47 @@ async function runReset(fromHook) {
1280
1895
  }
1281
1896
  }
1282
1897
  function hooksSnippet() {
1283
- return JSON.stringify({ hooks: { SessionStart: [RESET_HOOK_ENTRY] } }, null, 2);
1898
+ return JSON.stringify({ hooks: { SessionStart: [RESET_HOOK_ENTRY], PreToolUse: [SCOPE_HOOK_ENTRY] } }, null, 2);
1899
+ }
1900
+ var HOOK_INSTALLS = [
1901
+ { event: "SessionStart", entry: RESET_HOOK_ENTRY },
1902
+ { event: "PreToolUse", entry: SCOPE_HOOK_ENTRY }
1903
+ ];
1904
+ function hasHookCommand(entries, command2) {
1905
+ return Array.isArray(entries) && entries.some((e) => e?.hooks?.some((h) => h?.command === command2));
1284
1906
  }
1285
1907
  function installClaudeHook(home) {
1286
- const settingsPath = join3(home, ".claude", "settings.json");
1908
+ const settingsPath = join5(home, ".claude", "settings.json");
1287
1909
  let settings = {};
1288
- const existed = existsSync4(settingsPath);
1910
+ const existed = existsSync5(settingsPath);
1289
1911
  if (existed) {
1290
1912
  try {
1291
- settings = JSON.parse(readFileSync3(settingsPath, "utf-8"));
1913
+ settings = JSON.parse(readFileSync4(settingsPath, "utf-8"));
1292
1914
  } catch {
1293
- console.error(` Claude Code hook: ${settingsPath} is not valid JSON, left untouched`);
1915
+ console.error(` Claude Code hooks: ${settingsPath} is not valid JSON, left untouched`);
1294
1916
  return;
1295
1917
  }
1296
1918
  }
1297
1919
  const hooks = settings.hooks ?? {};
1298
- const sessionStart = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : [];
1299
- if (sessionStart.some((e) => e?.hooks?.some((h) => h?.command === RESET_HOOK_COMMAND))) {
1300
- console.log(" Claude Code hook: already configured");
1920
+ const missing = HOOK_INSTALLS.filter(({ event, entry }) => !hasHookCommand(hooks[event], entry.hooks[0].command));
1921
+ if (missing.length === 0) {
1922
+ console.log(" Claude Code hooks: already configured");
1301
1923
  return;
1302
1924
  }
1303
1925
  if (existed) copyFileSync(settingsPath, `${settingsPath}.bak`);
1304
- else mkdirSync2(join3(home, ".claude"), { recursive: true });
1305
- settings.hooks = { ...hooks, SessionStart: [...sessionStart, RESET_HOOK_ENTRY] };
1926
+ else mkdirSync2(join5(home, ".claude"), { recursive: true });
1927
+ const merged = { ...hooks };
1928
+ for (const { event, entry } of missing) {
1929
+ merged[event] = [...Array.isArray(hooks[event]) ? hooks[event] : [], entry];
1930
+ }
1931
+ settings.hooks = merged;
1306
1932
  writeFileSync2(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1307
- console.log(` Claude Code hook: configured (${settingsPath})`);
1933
+ console.log(` Claude Code hooks: configured (${settingsPath})`);
1308
1934
  }
1309
1935
  async function runStatus() {
1310
- const stashDir = resolve4(process.env.FILESTASH_DIR ?? ".file-stash");
1311
- const dbPath = join3(stashDir, "stash.db");
1312
- if (!existsSync4(dbPath)) {
1936
+ const stashDir = resolve5(process.env.FILESTASH_DIR ?? ".file-stash");
1937
+ const dbPath = join5(stashDir, "stash.db");
1938
+ if (!existsSync5(dbPath)) {
1313
1939
  console.log("No filestash database found. Run 'agent-file-stash serve' to start stashing.");
1314
1940
  process.exit(0);
1315
1941
  }
@@ -1322,6 +1948,7 @@ async function runStatus() {
1322
1948
  console.log(`filestash status:`);
1323
1949
  console.log(` Files tracked: ${stats.filesTracked}`);
1324
1950
  console.log(` Tokens saved (total): ~${stats.tokensSaved.toLocaleString()}`);
1951
+ console.log(formatLifetime(stats, stats.countersSince, `"Tokens saved (total)"`).join("\n"));
1325
1952
  } catch (e) {
1326
1953
  console.error(`filestash status failed for ${dbPath}: ${e instanceof Error ? e.message : String(e)}`);
1327
1954
  process.exitCode = 1;
@@ -1330,19 +1957,27 @@ async function runStatus() {
1330
1957
  }
1331
1958
  }
1332
1959
  async function runStatusAll(root) {
1333
- const databases = findStashDatabases(resolve4(root));
1960
+ const databases = findStashDatabases(resolve5(root));
1334
1961
  if (databases.length === 0) {
1335
- console.log(`No filestash databases found under ${resolve4(root)}.`);
1962
+ console.log(`No filestash databases found under ${resolve5(root)}.`);
1336
1963
  return;
1337
1964
  }
1338
1965
  const rows = [];
1966
+ const lifetime = { lifetimeSessions: 0, lifetimeReads: 0, lifetimeBaselineTokens: 0, lifetimeSentTokens: 0, lifetimeOverheadTokens: 0 };
1967
+ let since;
1339
1968
  for (const dbPath of databases) {
1340
1969
  const { stash } = createStash({ dbPath, sessionId: CLI_STATUS_SESSION, recoverCorrupt: false, quiet: true });
1341
1970
  try {
1342
1971
  await stash.init();
1343
1972
  const stats = await stash.getStats();
1344
1973
  assertHealthy(stash);
1345
- rows.push({ project: resolve4(dbPath, "../..").replace(/\/\.vscode$/, ""), files: stats.filesTracked, tokens: stats.tokensSaved });
1974
+ lifetime.lifetimeSessions += stats.lifetimeSessions;
1975
+ lifetime.lifetimeReads += stats.lifetimeReads;
1976
+ lifetime.lifetimeBaselineTokens += stats.lifetimeBaselineTokens;
1977
+ lifetime.lifetimeSentTokens += stats.lifetimeSentTokens;
1978
+ lifetime.lifetimeOverheadTokens += stats.lifetimeOverheadTokens;
1979
+ if (stats.countersSince !== void 0) since = Math.min(since ?? Infinity, stats.countersSince);
1980
+ rows.push({ project: resolve5(dbPath, "../..").replace(/\/\.vscode$/, ""), files: stats.filesTracked, tokens: stats.tokensSaved });
1346
1981
  } catch (e) {
1347
1982
  console.error(` skipped ${dbPath}: ${e instanceof Error ? e.message : String(e)}`);
1348
1983
  } finally {
@@ -1352,53 +1987,26 @@ async function runStatusAll(root) {
1352
1987
  rows.sort((a, b) => b.tokens - a.tokens);
1353
1988
  const totalTokens = rows.reduce((sum, r) => sum + r.tokens, 0);
1354
1989
  const totalFiles = rows.reduce((sum, r) => sum + r.files, 0);
1355
- console.log(`filestash status (${rows.length} databases under ${resolve4(root)}):`);
1990
+ console.log(`filestash status (${rows.length} databases under ${resolve5(root)}):`);
1356
1991
  for (const r of rows) {
1357
1992
  console.log(` ~${r.tokens.toLocaleString().padStart(10)} tokens ${String(r.files).padStart(5)} files ${r.project}`);
1358
1993
  }
1359
1994
  console.log(` ~${totalTokens.toLocaleString().padStart(10)} tokens ${String(totalFiles).padStart(5)} files TOTAL`);
1995
+ console.log(formatLifetime(lifetime, since, "the per-project totals").join("\n"));
1360
1996
  console.log(`
1361
1997
  Savings count re-reads within a session (unchanged files and diffs); a new session always receives full content.`);
1362
1998
  }
1363
1999
  async function runInit(withHooks) {
1364
- const home = homedir();
1365
- const mcpServersEntry = {
1366
- command: "npx",
1367
- args: ["agent-file-stash", "serve"]
1368
- };
1369
- const opencodeMcpEntry = {
1370
- type: "local",
1371
- command: ["npx", "agent-file-stash", "serve"]
1372
- };
1373
- const xdgConfig = process.env.XDG_CONFIG_HOME || join3(home, ".config");
1374
- const targets = [
1375
- {
1376
- name: "Claude Code",
1377
- path: join3(home, ".claude.json"),
1378
- key: "mcpServers",
1379
- entry: mcpServersEntry
1380
- },
1381
- {
1382
- name: "Cursor",
1383
- path: join3(home, ".cursor", "mcp.json"),
1384
- key: "mcpServers",
1385
- entry: mcpServersEntry
1386
- },
1387
- {
1388
- name: "OpenCode",
1389
- path: join3(xdgConfig, "opencode", "opencode.json"),
1390
- key: "mcp",
1391
- entry: opencodeMcpEntry
1392
- }
1393
- ];
2000
+ const home = homedir2();
2001
+ const targets = editorTargets(home);
1394
2002
  let configured = 0;
1395
2003
  for (const target of targets) {
1396
- const dir = join3(target.path, "..");
1397
- if (!existsSync4(dir)) continue;
2004
+ const dir = join5(target.path, "..");
2005
+ if (!existsSync5(dir)) continue;
1398
2006
  let config = {};
1399
- if (existsSync4(target.path)) {
2007
+ if (existsSync5(target.path)) {
1400
2008
  try {
1401
- config = JSON.parse(readFileSync3(target.path, "utf-8"));
2009
+ config = JSON.parse(readFileSync4(target.path, "utf-8"));
1402
2010
  } catch {
1403
2011
  config = {};
1404
2012
  }
@@ -1416,7 +2024,7 @@ async function runInit(withHooks) {
1416
2024
  }
1417
2025
  if (configured === 0) {
1418
2026
  console.log("No supported tools detected. You can manually add agent-file-stash to your MCP config:");
1419
- console.log(JSON.stringify({ mcpServers: { "agent-file-stash": mcpServersEntry } }, null, 2));
2027
+ console.log(JSON.stringify({ mcpServers: { "agent-file-stash": targets[0].entry } }, null, 2));
1420
2028
  } else {
1421
2029
  console.log(`
1422
2030
  Done! Restart your editor to pick up agent-file-stash.`);
@@ -1445,13 +2053,17 @@ function runHelp() {
1445
2053
  Usage:
1446
2054
  agent-file-stash init Auto-configure for your editor
1447
2055
  agent-file-stash init --hooks
1448
- Also add the Claude Code context-reset hook to ~/.claude/settings.json
2056
+ Also add the Claude Code hooks (context reset, subagent read scope) to ~/.claude/settings.json
1449
2057
  agent-file-stash reset Forget what was read so the next read returns full content
1450
2058
  (--from-hook: quiet mode for Claude Code hooks)
2059
+ agent-file-stash hook subagent-scope
2060
+ PreToolUse hook that gives each subagent its own read tracking (no output otherwise)
1451
2061
  agent-file-stash serve Start the MCP server (default)
1452
2062
  agent-file-stash status Show stash statistics
1453
2063
  agent-file-stash status --all [dir]
1454
2064
  Sum statistics of every stash under dir (default: home)
2065
+ agent-file-stash doctor Check the install and report what to fix (read-only)
2066
+ (--json: machine-readable output, --check-updates: ask npm for the latest version)
1455
2067
  agent-file-stash help Show this help message
1456
2068
 
1457
2069
  Environment:
@@ -1464,13 +2076,17 @@ var command = process.argv[2];
1464
2076
  if (!command || command === "serve") {
1465
2077
  await startMcpServer();
1466
2078
  } else if (command === "status" && process.argv[3] === "--all") {
1467
- await runStatusAll(process.argv[4] ?? homedir());
2079
+ await runStatusAll(process.argv[4] ?? homedir2());
1468
2080
  } else if (command === "status") {
1469
2081
  await runStatus();
1470
2082
  } else if (command === "init") {
1471
2083
  await runInit(process.argv.includes("--hooks"));
1472
2084
  } else if (command === "reset") {
1473
2085
  await runReset(process.argv.includes("--from-hook"));
2086
+ } else if (command === "hook" && process.argv[3] === "subagent-scope") {
2087
+ await runSubagentScopeHook();
2088
+ } else if (command === "doctor") {
2089
+ process.exitCode = await runDoctor(process.argv.slice(3));
1474
2090
  } else if (command === "help" || command === "--help") {
1475
2091
  runHelp();
1476
2092
  } else {