agent-file-stash 0.5.0 → 0.6.0

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 +56 -8
  2. package/dist/cli.mjs +728 -194
  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,54 @@ 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;
229
307
  function positiveOr(value, fallback) {
230
308
  return Number.isInteger(value) && value > 0 ? value : fallback;
231
309
  }
@@ -330,6 +408,7 @@ async function waitForLockRelease(lockPath) {
330
408
  const deadline = Date.now() + RECOVERY_LOCK_WAIT_MS;
331
409
  while (existsSync(lockPath) && Date.now() < deadline) await sleep(RECOVERY_LOCK_POLL_MS);
332
410
  }
411
+ 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
412
  function queryOne(db, sql, params = []) {
334
413
  const rows = db.prepare(sql).all(...params);
335
414
  return rows[0];
@@ -350,7 +429,7 @@ var StashStore = class {
350
429
  reason;
351
430
  constructor(config) {
352
431
  this.dbPath = config.dbPath;
353
- this.sessionId = config.sessionId;
432
+ this.sessionId = config.sessionId.replace(/:{2,}/g, "_");
354
433
  this.exclude = config.exclude ?? [];
355
434
  this.recoverCorrupt = config.recoverCorrupt ?? true;
356
435
  this.quiet = config.quiet ?? false;
@@ -390,7 +469,7 @@ var StashStore = class {
390
469
  try {
391
470
  db.exec("PRAGMA busy_timeout=5000");
392
471
  db.exec("PRAGMA journal_mode=WAL");
393
- db.exec(SCHEMA);
472
+ runMigrations(db);
394
473
  this.registerSession(db);
395
474
  this.pruneClosedSessions(db);
396
475
  } catch (err) {
@@ -478,8 +557,9 @@ var StashStore = class {
478
557
  db.prepare("DELETE FROM sessions WHERE session_id = ?").run(s.session_id);
479
558
  }
480
559
  }
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)");
560
+ for (const table of ["session_reads", "session_ranges"]) {
561
+ db.exec(`DELETE FROM ${table} WHERE ${BASE_SESSION_SQL} NOT IN (SELECT session_id FROM sessions)`);
562
+ }
483
563
  db.exec("DELETE FROM session_stats WHERE session_id NOT IN (SELECT session_id FROM sessions)");
484
564
  db.exec(
485
565
  "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 +586,26 @@ var StashStore = class {
506
586
  const size = statSync(absPath).size;
507
587
  const now = Date.now();
508
588
  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 } };
589
+ const result2 = { stashed: false, content: `[filestash: file too large (${size} bytes); not read]`, hash: "" };
590
+ return { absPath, size, content: "", hash: "", lines: 0, now, early: { result: result2, countRead: false } };
511
591
  }
512
592
  const content = readFileSync(absPath, "utf-8");
513
593
  const hash = contentHash(content);
514
594
  const lines = content.split("\n").length;
515
595
  const snap = { absPath, size, content, hash, lines, now };
516
596
  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 };
597
+ const result2 = { stashed: false, content: `[filestash: binary file (${size} bytes), not shown]`, hash, totalLines: lines };
598
+ snap.early = { result: result2, countRead: true };
519
599
  }
520
600
  return snap;
521
601
  }
522
602
  earlyResult(snap) {
523
- const { result, countRead } = snap.early;
603
+ const { result: result2, countRead } = snap.early;
524
604
  if (countRead) {
525
- const tokens = estimateTokens(result.content);
605
+ const tokens = estimateTokens(result2.content);
526
606
  this.guarded((db) => this.recordRead(db, tokens, tokens));
527
607
  }
528
- return result;
608
+ return result2;
529
609
  }
530
610
  isStoreable(snap) {
531
611
  return snap.size <= this.maxStoreBytes && !isExcludedPath(snap.absPath, this.exclude);
@@ -551,7 +631,8 @@ var StashStore = class {
551
631
  now,
552
632
  size: snap.size,
553
633
  slice: slice.text,
554
- midLine: slice.midLine
634
+ midLine: slice.midLine,
635
+ tid: this.trackingId(options?.scope)
555
636
  };
556
637
  if (this.db) {
557
638
  try {
@@ -562,6 +643,22 @@ var StashStore = class {
562
643
  }
563
644
  return this.plainResult(state);
564
645
  }
646
+ trackingId(scope) {
647
+ return scope !== void 0 && SCOPE_PATTERN.test(scope) ? `${this.sessionId}${SCOPE_DELIMITER}${scope}` : this.sessionId;
648
+ }
649
+ ensureScopeCapacity(db, tid) {
650
+ if (tid === this.sessionId) return;
651
+ if (queryOne(db, "SELECT 1 FROM session_reads WHERE session_id = ? LIMIT 1", [tid])) return;
652
+ const prefix = `${this.sessionId}${SCOPE_DELIMITER}`;
653
+ const scopes = db.prepare(
654
+ "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"
655
+ ).all(prefix.length, prefix);
656
+ const excess = scopes.length - MAX_SCOPES_PER_SESSION + 1;
657
+ for (const { session_id } of scopes.slice(0, Math.max(0, excess))) {
658
+ db.prepare("DELETE FROM session_reads WHERE session_id = ?").run(session_id);
659
+ db.prepare("DELETE FROM session_ranges WHERE session_id = ?").run(session_id);
660
+ }
661
+ }
565
662
  plainResult(s) {
566
663
  return { stashed: false, content: s.slice, hash: s.currentHash, totalLines: s.currentLines };
567
664
  }
@@ -574,9 +671,10 @@ var StashStore = class {
574
671
  const lastRead = queryOne(
575
672
  db,
576
673
  "SELECT hash FROM session_reads WHERE session_id = ? AND path = ?",
577
- [this.sessionId, state.absPath]
674
+ [state.tid, state.absPath]
578
675
  );
579
676
  if (!lastRead) {
677
+ this.ensureScopeCapacity(db, state.tid);
580
678
  return this.handleFirstRead(db, state);
581
679
  }
582
680
  const lastHash = lastRead.hash;
@@ -589,43 +687,43 @@ var StashStore = class {
589
687
  this.storeVersion(db, s.absPath, s.currentHash, s.currentContent, s.currentLines, s.now);
590
688
  db.prepare(
591
689
  "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);
690
+ ).run(s.tid, s.absPath, s.currentHash, s.now);
691
+ this.clearRanges(db, s.tid, s.absPath);
594
692
  return this.fullSlice(db, s);
595
693
  }
596
694
  requestedEnd(s) {
597
695
  return Math.min(s.rangeEnd, s.currentLines);
598
696
  }
599
- getRanges(db, absPath, hash) {
697
+ getRanges(db, tid, absPath, hash) {
600
698
  const rows = db.prepare(
601
699
  "SELECT start_line, end_line FROM session_ranges WHERE session_id = ? AND path = ? AND hash = ?"
602
- ).all(this.sessionId, absPath, hash);
700
+ ).all(tid, absPath, hash);
603
701
  return rows.map((r) => [r.start_line, r.end_line]);
604
702
  }
605
703
  coversRequest(db, s, hash) {
606
- return coversRange(this.getRanges(db, s.absPath, hash), s.rangeStart, this.requestedEnd(s));
704
+ return coversRange(this.getRanges(db, s.tid, s.absPath, hash), s.rangeStart, this.requestedEnd(s));
607
705
  }
608
- clearRanges(db, absPath) {
609
- db.prepare("DELETE FROM session_ranges WHERE session_id = ? AND path = ?").run(this.sessionId, absPath);
706
+ clearRanges(db, tid, absPath) {
707
+ db.prepare("DELETE FROM session_ranges WHERE session_id = ? AND path = ?").run(tid, absPath);
610
708
  }
611
- addRange(db, absPath, hash, start, end) {
709
+ addRange(db, tid, absPath, hash, start, end) {
612
710
  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);
711
+ const merged = mergeIntervals([...this.getRanges(db, tid, absPath, hash), [start, end]]);
712
+ db.prepare("DELETE FROM session_ranges WHERE session_id = ? AND path = ? AND hash = ?").run(tid, absPath, hash);
615
713
  const insert = db.prepare(
616
714
  "INSERT INTO session_ranges (session_id, path, hash, start_line, end_line) VALUES (?, ?, ?, ?, ?)"
617
715
  );
618
- for (const [a, b] of merged) insert.run(this.sessionId, absPath, hash, a, b);
716
+ for (const [a, b] of merged) insert.run(tid, absPath, hash, a, b);
619
717
  }
620
718
  handleUncovered(db, s) {
621
719
  db.prepare(
622
720
  "UPDATE session_reads SET read_at = ? WHERE session_id = ? AND path = ?"
623
- ).run(s.now, this.sessionId, s.absPath);
721
+ ).run(s.now, s.tid, s.absPath);
624
722
  return this.fullSlice(db, s);
625
723
  }
626
724
  fullSlice(db, s) {
627
725
  const content = s.slice;
628
- this.addRange(db, s.absPath, s.currentHash, s.rangeStart, this.requestedEnd(s));
726
+ this.addRange(db, s.tid, s.absPath, s.currentHash, s.rangeStart, this.requestedEnd(s));
629
727
  this.recordRead(db, estimateTokens(content), estimateTokens(content));
630
728
  return { stashed: false, content, hash: s.currentHash, totalLines: s.currentLines };
631
729
  }
@@ -636,7 +734,7 @@ var StashStore = class {
636
734
  const text = label(saved);
637
735
  const sent = estimateTokens(text);
638
736
  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));
737
+ this.addRange(db, s.tid, s.absPath, s.currentHash, s.rangeStart, this.requestedEnd(s));
640
738
  if (sent >= baseline) {
641
739
  this.recordRead(db, baseline, baseline);
642
740
  return { ...base, content };
@@ -647,7 +745,7 @@ var StashStore = class {
647
745
  handleUnchanged(db, s) {
648
746
  db.prepare(
649
747
  "UPDATE session_reads SET read_at = ? WHERE session_id = ? AND path = ?"
650
- ).run(s.now, this.sessionId, s.absPath);
748
+ ).run(s.now, s.tid, s.absPath);
651
749
  return this.unchangedLabel(
652
750
  db,
653
751
  s,
@@ -660,12 +758,12 @@ var StashStore = class {
660
758
  "SELECT content, lines FROM file_versions WHERE path = ? AND hash = ?",
661
759
  [s.absPath, lastHash]
662
760
  );
663
- const oldFullyDelivered = !!oldVersion && coversRange(this.getRanges(db, s.absPath, lastHash), 1, oldVersion.lines);
761
+ const oldFullyDelivered = !!oldVersion && coversRange(this.getRanges(db, s.tid, s.absPath, lastHash), 1, oldVersion.lines);
664
762
  this.storeVersion(db, s.absPath, s.currentHash, s.currentContent, s.currentLines, s.now);
665
763
  db.prepare(
666
764
  "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);
765
+ ).run(s.currentHash, s.now, s.tid, s.absPath);
766
+ this.clearRanges(db, s.tid, s.absPath);
669
767
  if (oldVersion && oldFullyDelivered) {
670
768
  const diffResult = computeDiff(oldVersion.content, s.currentContent, s.absPath);
671
769
  if (diffResult.hasChanges) {
@@ -688,7 +786,7 @@ var StashStore = class {
688
786
  const contentTokens = estimateTokens(s.currentContent);
689
787
  const diffTokens = estimateTokens(diffResult.diff);
690
788
  if (diffTokens >= contentTokens) return this.fullSlice(db, s);
691
- this.addRange(db, s.absPath, s.currentHash, 1, s.currentLines);
789
+ this.addRange(db, s.tid, s.absPath, s.currentHash, 1, s.currentLines);
692
790
  this.recordRead(db, contentTokens, diffTokens);
693
791
  return {
694
792
  stashed: true,
@@ -711,29 +809,31 @@ var StashStore = class {
711
809
  ).run(absPath, hash, content, lines, now);
712
810
  }
713
811
  // 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) {
812
+ async readFileFull(filePath, scope) {
715
813
  await this.init();
716
814
  const snap = this.readFileSnapshot(filePath);
717
815
  if (snap.early) return this.earlyResult(snap);
718
816
  const { absPath, content, hash, lines, now } = snap;
719
817
  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;
818
+ const result2 = { stashed: false, content: slice.text, hash, totalLines: lines };
819
+ if (!this.db) return result2;
722
820
  try {
723
821
  const tokens = estimateTokens(slice.text);
724
822
  if (this.isStoreable(snap)) {
823
+ const tid = this.trackingId(scope);
824
+ this.ensureScopeCapacity(this.db, tid);
725
825
  this.storeVersion(this.db, absPath, hash, content, lines, now);
726
826
  this.db.prepare(
727
827
  "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);
828
+ ).run(tid, absPath, hash, now);
829
+ this.clearRanges(this.db, tid, absPath);
830
+ this.addRange(this.db, tid, absPath, hash, slice.start, slice.end);
731
831
  }
732
832
  this.recordRead(this.db, tokens, tokens);
733
833
  } catch (err) {
734
834
  this.degrade(err);
735
835
  }
736
- return result;
836
+ return result2;
737
837
  }
738
838
  async onFileDeleted(filePath) {
739
839
  await this.init();
@@ -912,13 +1012,11 @@ function createStash(config) {
912
1012
  }
913
1013
 
914
1014
  // 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";
1015
+ import { resolve as resolve5, join as join5 } from "node:path";
1016
+ import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync2, copyFileSync, mkdirSync as mkdirSync2 } from "node:fs";
1017
+ import { homedir as homedir2 } from "node:os";
918
1018
 
919
1019
  // packages/cli/src/mcp.ts
920
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
921
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
922
1020
  import { z } from "zod";
923
1021
  import { resolve as resolve3, join, relative, isAbsolute } from "node:path";
924
1022
  import { readFileSync as readFileSync2, realpathSync } from "node:fs";
@@ -954,32 +1052,34 @@ function isPathAllowed(absPath, cwd) {
954
1052
  const rel = relative(cwd, realPath);
955
1053
  return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
956
1054
  }
957
- function formatReadResult(result) {
958
- if (result.stashed && result.diff) {
959
- return `[filestash: ${result.linesChanged} lines changed out of ${result.totalLines}]
960
- ${result.diff}`;
1055
+ function formatReadResult(result2) {
1056
+ if (result2.stashed && result2.diff) {
1057
+ return `[filestash: ${result2.linesChanged} lines changed out of ${result2.totalLines}]
1058
+ ${result2.diff}`;
961
1059
  }
962
- return result.content;
1060
+ return result2.content;
963
1061
  }
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}`;
1062
+ function formatFileEntry(path, result2) {
1063
+ if (result2.stashed && result2.diff) {
1064
+ return `=== ${path} [${result2.linesChanged} lines changed out of ${result2.totalLines}] ===
1065
+ ${result2.diff}`;
968
1066
  }
969
1067
  return `=== ${path} ===
970
- ${result.content}`;
1068
+ ${result2.content}`;
971
1069
  }
972
1070
  var readFileShape = {
973
1071
  path: z.string().describe("File path"),
974
1072
  offset: z.number().optional().describe("Start line (1-based)"),
975
1073
  limit: z.number().optional().describe("Max lines"),
976
- force: z.boolean().optional().describe("Bypass stash")
1074
+ force: z.boolean().optional().describe("Bypass stash"),
1075
+ agent: z.string().optional()
977
1076
  };
978
1077
  var readFilesShape = {
979
1078
  paths: z.preprocess(
980
1079
  (val) => typeof val === "string" ? JSON.parse(val) : val,
981
1080
  z.array(z.string())
982
- ).describe("File paths")
1081
+ ).describe("File paths"),
1082
+ agent: z.string().optional()
983
1083
  };
984
1084
  var TOOL_DEFS = {
985
1085
  read_file: {
@@ -1029,8 +1129,8 @@ function toolDefinitionTokens() {
1029
1129
  }));
1030
1130
  return Math.ceil(JSON.stringify(defs).length / 4);
1031
1131
  }
1032
- function degradedLine(reason = "unknown") {
1033
- return `Mode: DEGRADED (${reason}) - files are read normally, nothing is stashed`;
1132
+ function degradedLine(reason2 = "unknown") {
1133
+ return `Mode: DEGRADED (${reason2}) - files are read normally, nothing is stashed`;
1034
1134
  }
1035
1135
  function formatStatus(stats, overheadTokens) {
1036
1136
  if (stats.degraded) return degradedLine(stats.degradedReason);
@@ -1054,7 +1154,7 @@ function statsSuffix(grossSaved) {
1054
1154
 
1055
1155
  [filestash: net ~${(grossSaved - toolDefinitionTokens()).toLocaleString()} tokens this session (est.)]`;
1056
1156
  }
1057
- async function readSingleFile(path, cwd, stash) {
1157
+ async function readSingleFile(path, cwd, stash, scope) {
1058
1158
  const absPath = resolve3(path);
1059
1159
  if (!isPathAllowed(absPath, cwd)) {
1060
1160
  return {
@@ -1064,8 +1164,8 @@ Error: Path must be within the working directory (${cwd})`,
1064
1164
  };
1065
1165
  }
1066
1166
  try {
1067
- const result = await stash.readFile(path);
1068
- return { text: formatFileEntry(path, result), ok: true };
1167
+ const result2 = await stash.readFile(path, { ...scope !== void 0 && { scope } });
1168
+ return { text: formatFileEntry(path, result2), ok: true };
1069
1169
  } catch (e) {
1070
1170
  const message = e instanceof Error ? e.message : String(e);
1071
1171
  return { text: `=== ${path} ===
@@ -1093,6 +1193,8 @@ async function startMcpServer() {
1093
1193
  ...limitOptions()
1094
1194
  });
1095
1195
  await stash.init();
1196
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
1197
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
1096
1198
  const server = new McpServer({
1097
1199
  name: "filestash",
1098
1200
  version: packageJson.version ?? "0.0.0"
@@ -1100,7 +1202,7 @@ async function startMcpServer() {
1100
1202
  server.registerTool(
1101
1203
  "read_file",
1102
1204
  TOOL_DEFS.read_file,
1103
- async ({ path, force, offset, limit }) => {
1205
+ async ({ path, force, offset, limit, agent }) => {
1104
1206
  const absPath = resolve3(path);
1105
1207
  if (!isPathAllowed(absPath, cwd)) {
1106
1208
  return {
@@ -1109,12 +1211,13 @@ async function startMcpServer() {
1109
1211
  };
1110
1212
  }
1111
1213
  try {
1112
- const result = force ? await stash.readFileFull(path) : await stash.readFile(path, {
1214
+ const result2 = force ? await stash.readFileFull(path, agent) : await stash.readFile(path, {
1113
1215
  ...offset !== void 0 && { offset },
1114
- ...limit !== void 0 && { limit }
1216
+ ...limit !== void 0 && { limit },
1217
+ ...agent !== void 0 && { scope: agent }
1115
1218
  });
1116
- let text = formatReadResult(result);
1117
- if (result.stashed) {
1219
+ let text = formatReadResult(result2);
1220
+ if (result2.stashed) {
1118
1221
  const stats = await stash.getStats();
1119
1222
  text += statsSuffix(stats.sessionTokensSaved);
1120
1223
  }
@@ -1134,8 +1237,8 @@ async function startMcpServer() {
1134
1237
  server.registerTool(
1135
1238
  "read_files",
1136
1239
  TOOL_DEFS.read_files,
1137
- async ({ paths }) => {
1138
- const results = await Promise.all(paths.map((p) => readSingleFile(p, cwd, stash)));
1240
+ async ({ paths, agent }) => {
1241
+ const results = await Promise.all(paths.map((p) => readSingleFile(p, cwd, stash, agent)));
1139
1242
  const successfulPaths = paths.filter((_, i) => results[i].ok);
1140
1243
  const combined = results.map((r) => r.text).join("\n\n");
1141
1244
  let footer = "";
@@ -1223,6 +1326,449 @@ function findStashDatabases(root, depth = 0) {
1223
1326
  return found;
1224
1327
  }
1225
1328
 
1329
+ // packages/cli/src/editors.ts
1330
+ import { join as join3 } from "node:path";
1331
+ function editorTargets(home) {
1332
+ const mcpServersEntry = {
1333
+ command: "npx",
1334
+ args: ["agent-file-stash", "serve"]
1335
+ };
1336
+ const opencodeMcpEntry = {
1337
+ type: "local",
1338
+ command: ["npx", "agent-file-stash", "serve"]
1339
+ };
1340
+ const xdgConfig = process.env.XDG_CONFIG_HOME || join3(home, ".config");
1341
+ return [
1342
+ { name: "Claude Code", path: join3(home, ".claude.json"), key: "mcpServers", entry: mcpServersEntry },
1343
+ { name: "Cursor", path: join3(home, ".cursor", "mcp.json"), key: "mcpServers", entry: mcpServersEntry },
1344
+ { name: "OpenCode", path: join3(xdgConfig, "opencode", "opencode.json"), key: "mcp", entry: opencodeMcpEntry }
1345
+ ];
1346
+ }
1347
+
1348
+ // packages/cli/src/doctor.ts
1349
+ import { accessSync, constants, existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync2 } from "node:fs";
1350
+ import { homedir } from "node:os";
1351
+ import { delimiter, isAbsolute as isAbsolute2, join as join4, resolve as resolve4 } from "node:path";
1352
+ import { pathToFileURL } from "node:url";
1353
+ import { spawnSync } from "node:child_process";
1354
+ var PACKAGE_NAME = "agent-file-stash";
1355
+ var RECOVERY_LOCK_STALE_MS2 = 15e3;
1356
+ var DEFAULT_MIN_NODE = "24";
1357
+ var NPM_TIMEOUT_MS = 5e3;
1358
+ var MAX_SCRIPT_BYTES = 64 * 1024;
1359
+ var SESSION_START_TARGETS = ["clear", "compact"];
1360
+ var result = (id, status, message, hint) => ({
1361
+ id,
1362
+ status,
1363
+ message,
1364
+ ...hint !== void 0 && { hint }
1365
+ });
1366
+ var reason = (e) => e instanceof Error ? e.message : String(e);
1367
+ function readJson(path) {
1368
+ return JSON.parse(readFileSync3(path, "utf-8"));
1369
+ }
1370
+ function asRecord(v) {
1371
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
1372
+ }
1373
+ function ownPackageJson() {
1374
+ for (const rel of ["../package.json", "../../../package.json"]) {
1375
+ try {
1376
+ const pkg = readJson(join4(import.meta.dirname, rel));
1377
+ if (pkg.name === PACKAGE_NAME) return pkg;
1378
+ } catch {
1379
+ continue;
1380
+ }
1381
+ }
1382
+ return void 0;
1383
+ }
1384
+ function wide(mode, mask) {
1385
+ return (mode & mask) !== 0;
1386
+ }
1387
+ function octal(mode) {
1388
+ return (mode & 511).toString(8).padStart(3, "0");
1389
+ }
1390
+ function checkNode(o) {
1391
+ const range = ownPackageJson()?.engines?.node;
1392
+ const required = Number(/\d+/.exec(range ?? "")?.[0] ?? DEFAULT_MIN_NODE);
1393
+ const major = Number(o.nodeVersion.split(".")[0]);
1394
+ if (major >= required) return [result("node", "ok", `Node ${o.nodeVersion} (requires >=${required})`)];
1395
+ return [
1396
+ result("node", "error", `Node ${o.nodeVersion} is older than the required >=${required}`, `install Node ${required} or newer`)
1397
+ ];
1398
+ }
1399
+ function checkStashDir(o) {
1400
+ const dir = resolveStashDir(o.cwd);
1401
+ if (!existsSync4(dir)) return [result("stash-dir", "info", `stash directory ${dir} does not exist yet; it will be created on first read`)];
1402
+ const st = statSync2(dir);
1403
+ if (!st.isDirectory()) {
1404
+ return [result("stash-dir", "error", `${dir} exists but is not a directory`, `remove it or point FILESTASH_DIR elsewhere`)];
1405
+ }
1406
+ if (o.platform !== "win32" && wide(st.mode, 63)) {
1407
+ return [result("stash-dir", "warn", `stash directory ${dir} has mode ${octal(st.mode)}, expected 700`, `run: chmod 700 ${dir}`)];
1408
+ }
1409
+ return [result("stash-dir", "info", `stash directory ${dir}`)];
1410
+ }
1411
+ function isCorruptMessage(err) {
1412
+ const code = err.errcode;
1413
+ if (typeof code === "number" && [11, 26].includes(code & 255)) return true;
1414
+ return /not a database|malformed|corrupt/i.test(reason(err));
1415
+ }
1416
+ function pidAlive(pid) {
1417
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1418
+ try {
1419
+ process.kill(pid, 0);
1420
+ return true;
1421
+ } catch (e) {
1422
+ return e.code === "EPERM";
1423
+ }
1424
+ }
1425
+ function formatBytes(n) {
1426
+ return n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
1427
+ }
1428
+ async function inspectDatabase(dbPath) {
1429
+ const { DatabaseSync } = await import("node:sqlite");
1430
+ const url = pathToFileURL(dbPath);
1431
+ url.search = existsSync4(`${dbPath}-wal`) || existsSync4(`${dbPath}-shm`) ? "mode=ro" : "mode=ro&immutable=1";
1432
+ const db = new DatabaseSync(url.href, { readOnly: true });
1433
+ try {
1434
+ const userVersion = db.prepare("PRAGMA user_version").get().user_version;
1435
+ const first = db.prepare("PRAGMA quick_check").all()[0] ?? {};
1436
+ const integrity = String(Object.values(first)[0] ?? "no result");
1437
+ const facts = { userVersion, integrity };
1438
+ if (integrity === "ok" && userVersion >= 1 && userVersion <= SCHEMA_VERSION) {
1439
+ facts.filesTracked = db.prepare("SELECT COUNT(DISTINCT path) AS c FROM file_versions").get().c;
1440
+ facts.pids = db.prepare("SELECT pid FROM sessions").all().map((r) => r.pid);
1441
+ }
1442
+ return facts;
1443
+ } finally {
1444
+ db.close();
1445
+ }
1446
+ }
1447
+ function checkDatabaseFiles(dir, now) {
1448
+ const out = [];
1449
+ const names = readdirSync2(dir);
1450
+ const leftovers = names.filter((n) => /^stash\.db\.corrupt-\d+$/.test(n));
1451
+ if (leftovers.length > 0) {
1452
+ 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`));
1453
+ }
1454
+ if (names.includes("stash.db.recover.lock")) {
1455
+ const lock = join4(dir, "stash.db.recover.lock");
1456
+ const age = now - statSync2(lock).mtimeMs;
1457
+ if (age > RECOVERY_LOCK_STALE_MS2) {
1458
+ out.push(result("database-lock", "warn", `stale recovery lock ${lock} (${Math.round(age / 1e3)}s old)`, `remove ${lock}; the next start also clears it`));
1459
+ } else {
1460
+ out.push(result("database-lock", "info", `a recovery is in progress (${lock})`));
1461
+ }
1462
+ }
1463
+ return out;
1464
+ }
1465
+ async function checkDatabase(o) {
1466
+ const dir = resolveStashDir(o.cwd);
1467
+ const dbPath = join4(dir, "stash.db");
1468
+ const out = [];
1469
+ if (existsSync4(dir)) out.push(...checkDatabaseFiles(dir, o.now));
1470
+ if (!existsSync4(dbPath)) return [result("database", "info", `no database at ${dbPath} yet; it will be created on first read`), ...out];
1471
+ const st = statSync2(dbPath);
1472
+ let facts;
1473
+ try {
1474
+ facts = await inspectDatabase(dbPath);
1475
+ } catch (e) {
1476
+ if (isCorruptMessage(e)) {
1477
+ return [
1478
+ result("database", "error", `${dbPath} is corrupt (${reason(e)})`, `it will be moved aside to stash.db.corrupt-<ms> on next start; or delete it`),
1479
+ ...out
1480
+ ];
1481
+ }
1482
+ return [result("database", "warn", `could not inspect ${dbPath} without writing (${reason(e)})`, `stop running servers and re-run doctor`), ...out];
1483
+ }
1484
+ if (facts.integrity !== "ok") {
1485
+ 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`));
1486
+ return out;
1487
+ }
1488
+ if (facts.userVersion > SCHEMA_VERSION) {
1489
+ out.unshift(result("database-schema", "error", new SchemaTooNewError(facts.userVersion, SCHEMA_VERSION).message, "upgrade agent-file-stash or point FILESTASH_DIR at another directory"));
1490
+ } else if (facts.userVersion < SCHEMA_VERSION) {
1491
+ out.unshift(result("database-schema", "info", `schema version ${facts.userVersion} will be upgraded to ${SCHEMA_VERSION} on next start`));
1492
+ } else {
1493
+ out.unshift(result("database-schema", "ok", `database schema version ${facts.userVersion}, quick_check passed`));
1494
+ }
1495
+ if (o.platform !== "win32" && wide(st.mode, 63)) {
1496
+ out.push(result("database-mode", "warn", `${dbPath} has mode ${octal(st.mode)}, expected 600`, `run: chmod 600 ${dbPath}`));
1497
+ }
1498
+ const parts = [formatBytes(st.size)];
1499
+ if (facts.filesTracked !== void 0) parts.push(`${facts.filesTracked} files tracked`);
1500
+ if (facts.pids) {
1501
+ const live = facts.pids.filter(pidAlive).length;
1502
+ parts.push(`${live} live and ${facts.pids.length - live} dead session(s)`);
1503
+ }
1504
+ out.push(result("database-stats", "info", `database ${parts.join(", ")}`));
1505
+ return out;
1506
+ }
1507
+ function isExecutable(path) {
1508
+ try {
1509
+ if (!statSync2(path).isFile()) return false;
1510
+ accessSync(path, constants.X_OK);
1511
+ return true;
1512
+ } catch {
1513
+ return false;
1514
+ }
1515
+ }
1516
+ function commandResolves(command2, platform) {
1517
+ if (isAbsolute2(command2) || command2.includes("/") || command2.includes("\\")) return isExecutable(command2);
1518
+ const exts = platform === "win32" ? ["", ...(process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
1519
+ return (process.env.PATH ?? "").split(delimiter).filter(Boolean).some((dir) => exts.some((ext) => isExecutable(join4(dir, command2 + ext))));
1520
+ }
1521
+ function splitCommand(entry) {
1522
+ const raw = entry.command;
1523
+ if (Array.isArray(raw) && typeof raw[0] === "string") return { command: raw[0], args: raw.slice(1).map(String) };
1524
+ if (typeof raw !== "string") return void 0;
1525
+ return { command: raw, args: Array.isArray(entry.args) ? entry.args.map(String) : [] };
1526
+ }
1527
+ function checkMcpRegistration(o) {
1528
+ const out = [];
1529
+ let found = 0;
1530
+ for (const t of editorTargets(o.home)) {
1531
+ if (!existsSync4(t.path)) continue;
1532
+ found++;
1533
+ const id = `mcp-${t.name.toLowerCase().replace(/\s+/g, "-")}`;
1534
+ let config;
1535
+ try {
1536
+ config = readJson(t.path);
1537
+ } catch {
1538
+ out.push(result(id, "warn", `${t.name}: ${t.path} is not valid JSON`, `fix the file, then run: agent-file-stash init`));
1539
+ continue;
1540
+ }
1541
+ const entry = asRecord(asRecord(asRecord(config)?.[t.key])?.filestash);
1542
+ if (!entry) {
1543
+ out.push(result(id, "warn", `${t.name}: no filestash entry in ${t.path}`, "run: agent-file-stash init"));
1544
+ continue;
1545
+ }
1546
+ const cmd = splitCommand(entry);
1547
+ if (!cmd) {
1548
+ out.push(result(id, "warn", `${t.name}: the filestash entry in ${t.path} has no command`, "run: agent-file-stash init"));
1549
+ } else if (!commandResolves(cmd.command, o.platform)) {
1550
+ 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}`));
1551
+ } else if (!cmd.args.includes("serve")) {
1552
+ out.push(result(id, "warn", `${t.name}: the filestash entry does not run "serve"`, `set the arguments to: agent-file-stash serve`));
1553
+ } else {
1554
+ out.push(result(id, "ok", `${t.name}: filestash registered in ${t.path}`));
1555
+ }
1556
+ }
1557
+ if (found === 0) return [result("mcp", "info", "no editor configuration found (Claude Code, Cursor, OpenCode)")];
1558
+ return out;
1559
+ }
1560
+ function matcherCovers(matcher, event) {
1561
+ if (matcher === void 0 || matcher === "" || matcher === "*") return true;
1562
+ if (typeof matcher !== "string") return false;
1563
+ try {
1564
+ return new RegExp(`^(?:${matcher})$`).test(event);
1565
+ } catch {
1566
+ return matcher.split("|").includes(event);
1567
+ }
1568
+ }
1569
+ function commandRuns(command2, o, subcommand) {
1570
+ const mentions = (text) => text.includes(PACKAGE_NAME) && subcommand.test(text);
1571
+ if (mentions(command2)) return true;
1572
+ 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);
1573
+ try {
1574
+ const path = resolve4(o.cwd, target);
1575
+ if (!statSync2(path).isFile() || statSync2(path).size > MAX_SCRIPT_BYTES) return false;
1576
+ return mentions(readFileSync3(path, "utf-8"));
1577
+ } catch {
1578
+ return false;
1579
+ }
1580
+ }
1581
+ function checkResetHook(o) {
1582
+ const out = [];
1583
+ const covered = /* @__PURE__ */ new Set();
1584
+ let hookFile;
1585
+ let hookFound = false;
1586
+ const files = [
1587
+ join4(o.home, ".claude", "settings.json"),
1588
+ join4(o.cwd, ".claude", "settings.json"),
1589
+ join4(o.cwd, ".claude", "settings.local.json")
1590
+ ];
1591
+ for (const file of files) {
1592
+ if (!existsSync4(file)) continue;
1593
+ let settings;
1594
+ try {
1595
+ settings = readJson(file);
1596
+ } catch {
1597
+ out.push(result("hook-settings", "warn", `${file} is not valid JSON`, "fix the file so hooks in it can be checked"));
1598
+ continue;
1599
+ }
1600
+ const sessionStart = asRecord(asRecord(settings)?.hooks)?.SessionStart;
1601
+ for (const entry of Array.isArray(sessionStart) ? sessionStart : []) {
1602
+ const e = asRecord(entry);
1603
+ const hooks = Array.isArray(e?.hooks) ? e.hooks : [];
1604
+ const runs = hooks.some((h) => typeof asRecord(h)?.command === "string" && commandRuns(asRecord(h).command, o, /\breset\b/));
1605
+ if (!runs) continue;
1606
+ hookFound = true;
1607
+ hookFile ??= file;
1608
+ for (const ev of SESSION_START_TARGETS) if (matcherCovers(e?.matcher, ev)) covered.add(ev);
1609
+ }
1610
+ }
1611
+ if (!hookFound) {
1612
+ 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)"));
1613
+ return out;
1614
+ }
1615
+ const missing = SESSION_START_TARGETS.filter((ev) => !covered.has(ev));
1616
+ if (missing.length > 0) {
1617
+ out.push(result("hook", "warn", `reset hook in ${hookFile} does not match ${missing.join(" or ")}`, `set the matcher to "clear|compact"`));
1618
+ } else {
1619
+ out.push(result("hook", "ok", `reset hook configured for clear and compact (${hookFile})`));
1620
+ }
1621
+ return out;
1622
+ }
1623
+ function checkSubagentScopeHook(o) {
1624
+ const files = [
1625
+ join4(o.home, ".claude", "settings.json"),
1626
+ join4(o.cwd, ".claude", "settings.json"),
1627
+ join4(o.cwd, ".claude", "settings.local.json")
1628
+ ];
1629
+ for (const file of files) {
1630
+ if (!existsSync4(file)) continue;
1631
+ let settings;
1632
+ try {
1633
+ settings = readJson(file);
1634
+ } catch {
1635
+ continue;
1636
+ }
1637
+ const preToolUse = asRecord(asRecord(settings)?.hooks)?.PreToolUse;
1638
+ for (const entry of Array.isArray(preToolUse) ? preToolUse : []) {
1639
+ const e = asRecord(entry);
1640
+ const hooks = Array.isArray(e?.hooks) ? e.hooks : [];
1641
+ const runs = hooks.some((h) => typeof asRecord(h)?.command === "string" && commandRuns(asRecord(h).command, o, /\bhook\s+subagent-scope\b/));
1642
+ if (runs && matcherCovers(e?.matcher, "mcp__filestash__read_file")) {
1643
+ return [result("hook-subagent-scope", "ok", `subagent scope hook configured (${file})`)];
1644
+ }
1645
+ }
1646
+ }
1647
+ 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)")];
1648
+ }
1649
+ function checkEnv() {
1650
+ const out = [];
1651
+ const exclude = process.env.FILESTASH_EXCLUDE;
1652
+ if (exclude !== void 0 && exclude !== "") {
1653
+ const n = exclude.split(",").filter((p) => p.trim()).length;
1654
+ out.push(result("env-exclude", "ok", `FILESTASH_EXCLUDE: ${n} pattern(s)`));
1655
+ }
1656
+ for (const name of ["FILESTASH_MAX_LINES", "FILESTASH_MAX_CHARS"]) {
1657
+ const raw = process.env[name];
1658
+ if (raw === void 0 || raw === "") continue;
1659
+ const valid = /^[1-9]\d*$/.test(raw) && Number.isSafeInteger(Number(raw));
1660
+ out.push(
1661
+ 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`)
1662
+ );
1663
+ }
1664
+ return out;
1665
+ }
1666
+ function npmLatestVersion() {
1667
+ const res = spawnSync("npm", ["view", PACKAGE_NAME, "version"], {
1668
+ encoding: "utf-8",
1669
+ timeout: NPM_TIMEOUT_MS,
1670
+ shell: process.platform === "win32"
1671
+ });
1672
+ const version = res.status === 0 ? res.stdout.trim() : "";
1673
+ return /^\d+\.\d+\.\d+/.test(version) ? version : void 0;
1674
+ }
1675
+ function newer(a, b) {
1676
+ const pa = a.split(/[.-]/).map(Number);
1677
+ const pb = b.split(/[.-]/).map(Number);
1678
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
1679
+ return false;
1680
+ }
1681
+ function checkUpdates(o) {
1682
+ if (!o.checkUpdates) return [];
1683
+ const installed = ownPackageJson()?.version;
1684
+ const latest = o.fetchLatest();
1685
+ if (!installed || !latest) return [result("updates", "info", "could not check for updates")];
1686
+ if (newer(latest, installed)) return [result("updates", "info", `update available: ${installed} -> ${latest} (npm install -g ${PACKAGE_NAME})`)];
1687
+ return [result("updates", "ok", `agent-file-stash ${installed} is up to date`)];
1688
+ }
1689
+ var CHECKS = [
1690
+ ["node", checkNode],
1691
+ ["stash-dir", checkStashDir],
1692
+ ["database", checkDatabase],
1693
+ ["mcp", checkMcpRegistration],
1694
+ ["hook", checkResetHook],
1695
+ ["hook-subagent-scope", checkSubagentScopeHook],
1696
+ ["env", checkEnv],
1697
+ ["updates", checkUpdates]
1698
+ ];
1699
+ function defaultOptions(argv = []) {
1700
+ return {
1701
+ nodeVersion: process.versions.node,
1702
+ cwd: process.cwd(),
1703
+ home: homedir(),
1704
+ platform: process.platform,
1705
+ now: Date.now(),
1706
+ checkUpdates: argv.includes("--check-updates"),
1707
+ fetchLatest: npmLatestVersion
1708
+ };
1709
+ }
1710
+ async function runChecks(o, checks = CHECKS) {
1711
+ const results = [];
1712
+ for (const [id, check] of checks) {
1713
+ try {
1714
+ results.push(...await check(o));
1715
+ } catch (e) {
1716
+ results.push(result(id, "warn", `check failed: ${reason(e)}`));
1717
+ }
1718
+ }
1719
+ return results;
1720
+ }
1721
+ function summarize(results) {
1722
+ const count = (s) => results.filter((r) => r.status === s).length;
1723
+ return { ok: count("ok"), warnings: count("warn"), errors: count("error") };
1724
+ }
1725
+ function formatHuman(results) {
1726
+ const lines = results.flatMap((r) => [`[${r.status}] ${r.message}`, ...r.hint ? [` ${r.hint}`] : []]);
1727
+ const s = summarize(results);
1728
+ return [...lines, "", `${s.ok} ok, ${s.warnings} warnings, ${s.errors} errors`].join("\n");
1729
+ }
1730
+ async function runDoctor(argv, overrides = {}) {
1731
+ const results = await runChecks({ ...defaultOptions(argv), ...overrides });
1732
+ console.log(argv.includes("--json") ? JSON.stringify(results, null, 2) : formatHuman(results));
1733
+ return summarize(results).errors > 0 ? 1 : 0;
1734
+ }
1735
+
1736
+ // packages/cli/src/hook.ts
1737
+ var HOOK_STDIN_TIMEOUT_MS = 1e3;
1738
+ var AGENT_ID_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
1739
+ var STASH_READ_TOOL = /__read_files?$/;
1740
+ async function readHookStdin() {
1741
+ if (process.stdin.isTTY) return "";
1742
+ const read = (async () => {
1743
+ let raw = "";
1744
+ for await (const chunk of process.stdin) raw += chunk;
1745
+ return raw;
1746
+ })();
1747
+ const timeout = new Promise((res) => setTimeout(() => res(""), HOOK_STDIN_TIMEOUT_MS).unref());
1748
+ return (await Promise.race([read, timeout])).trim();
1749
+ }
1750
+ function subagentScopeOutput(raw) {
1751
+ try {
1752
+ const input = JSON.parse(raw);
1753
+ const { agent_id: agentId, tool_name: toolName, tool_input: toolInput } = input;
1754
+ if (typeof agentId !== "string" || !AGENT_ID_PATTERN.test(agentId)) return void 0;
1755
+ if (typeof toolName !== "string" || !STASH_READ_TOOL.test(toolName)) return void 0;
1756
+ if (typeof toolInput !== "object" || toolInput === null || Array.isArray(toolInput)) return void 0;
1757
+ return JSON.stringify({
1758
+ hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: { ...toolInput, agent: agentId } }
1759
+ });
1760
+ } catch {
1761
+ return void 0;
1762
+ }
1763
+ }
1764
+ async function runSubagentScopeHook() {
1765
+ try {
1766
+ const out = subagentScopeOutput(await readHookStdin());
1767
+ if (out) process.stdout.write(out + "\n");
1768
+ } catch {
1769
+ }
1770
+ }
1771
+
1226
1772
  // packages/cli/src/index.ts
1227
1773
  var _origEmitWarning = process.emitWarning;
1228
1774
  process.emitWarning = function(msg, ...args) {
@@ -1235,19 +1781,16 @@ var RESET_HOOK_ENTRY = {
1235
1781
  matcher: "clear|compact",
1236
1782
  hooks: [{ type: "command", command: RESET_HOOK_COMMAND }]
1237
1783
  };
1238
- var HOOK_STDIN_TIMEOUT_MS = 1e3;
1784
+ var SCOPE_HOOK_COMMAND = "npx agent-file-stash hook subagent-scope";
1785
+ var SCOPE_HOOK_ENTRY = {
1786
+ matcher: "mcp__(filestash|agent-file-stash)__read_files?",
1787
+ hooks: [{ type: "command", command: SCOPE_HOOK_COMMAND }]
1788
+ };
1239
1789
  function assertHealthy(stash) {
1240
1790
  if (stash.isDegraded) throw new Error(stash.degradedReason);
1241
1791
  }
1242
1792
  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();
1793
+ const raw = await readHookStdin();
1251
1794
  if (!raw) return void 0;
1252
1795
  try {
1253
1796
  const cwd = JSON.parse(raw)?.cwd;
@@ -1259,8 +1802,8 @@ async function readHookCwd() {
1259
1802
  async function runReset(fromHook) {
1260
1803
  try {
1261
1804
  const hookCwd = fromHook ? await readHookCwd() : void 0;
1262
- const dbPath = join3(resolveStashDir(hookCwd), "stash.db");
1263
- if (!existsSync4(dbPath)) {
1805
+ const dbPath = join5(resolveStashDir(hookCwd), "stash.db");
1806
+ if (!existsSync5(dbPath)) {
1264
1807
  if (!fromHook) console.log("No filestash database found. Nothing to reset.");
1265
1808
  return;
1266
1809
  }
@@ -1280,36 +1823,47 @@ async function runReset(fromHook) {
1280
1823
  }
1281
1824
  }
1282
1825
  function hooksSnippet() {
1283
- return JSON.stringify({ hooks: { SessionStart: [RESET_HOOK_ENTRY] } }, null, 2);
1826
+ return JSON.stringify({ hooks: { SessionStart: [RESET_HOOK_ENTRY], PreToolUse: [SCOPE_HOOK_ENTRY] } }, null, 2);
1827
+ }
1828
+ var HOOK_INSTALLS = [
1829
+ { event: "SessionStart", entry: RESET_HOOK_ENTRY },
1830
+ { event: "PreToolUse", entry: SCOPE_HOOK_ENTRY }
1831
+ ];
1832
+ function hasHookCommand(entries, command2) {
1833
+ return Array.isArray(entries) && entries.some((e) => e?.hooks?.some((h) => h?.command === command2));
1284
1834
  }
1285
1835
  function installClaudeHook(home) {
1286
- const settingsPath = join3(home, ".claude", "settings.json");
1836
+ const settingsPath = join5(home, ".claude", "settings.json");
1287
1837
  let settings = {};
1288
- const existed = existsSync4(settingsPath);
1838
+ const existed = existsSync5(settingsPath);
1289
1839
  if (existed) {
1290
1840
  try {
1291
- settings = JSON.parse(readFileSync3(settingsPath, "utf-8"));
1841
+ settings = JSON.parse(readFileSync4(settingsPath, "utf-8"));
1292
1842
  } catch {
1293
- console.error(` Claude Code hook: ${settingsPath} is not valid JSON, left untouched`);
1843
+ console.error(` Claude Code hooks: ${settingsPath} is not valid JSON, left untouched`);
1294
1844
  return;
1295
1845
  }
1296
1846
  }
1297
1847
  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");
1848
+ const missing = HOOK_INSTALLS.filter(({ event, entry }) => !hasHookCommand(hooks[event], entry.hooks[0].command));
1849
+ if (missing.length === 0) {
1850
+ console.log(" Claude Code hooks: already configured");
1301
1851
  return;
1302
1852
  }
1303
1853
  if (existed) copyFileSync(settingsPath, `${settingsPath}.bak`);
1304
- else mkdirSync2(join3(home, ".claude"), { recursive: true });
1305
- settings.hooks = { ...hooks, SessionStart: [...sessionStart, RESET_HOOK_ENTRY] };
1854
+ else mkdirSync2(join5(home, ".claude"), { recursive: true });
1855
+ const merged = { ...hooks };
1856
+ for (const { event, entry } of missing) {
1857
+ merged[event] = [...Array.isArray(hooks[event]) ? hooks[event] : [], entry];
1858
+ }
1859
+ settings.hooks = merged;
1306
1860
  writeFileSync2(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1307
- console.log(` Claude Code hook: configured (${settingsPath})`);
1861
+ console.log(` Claude Code hooks: configured (${settingsPath})`);
1308
1862
  }
1309
1863
  async function runStatus() {
1310
- const stashDir = resolve4(process.env.FILESTASH_DIR ?? ".file-stash");
1311
- const dbPath = join3(stashDir, "stash.db");
1312
- if (!existsSync4(dbPath)) {
1864
+ const stashDir = resolve5(process.env.FILESTASH_DIR ?? ".file-stash");
1865
+ const dbPath = join5(stashDir, "stash.db");
1866
+ if (!existsSync5(dbPath)) {
1313
1867
  console.log("No filestash database found. Run 'agent-file-stash serve' to start stashing.");
1314
1868
  process.exit(0);
1315
1869
  }
@@ -1330,9 +1884,9 @@ async function runStatus() {
1330
1884
  }
1331
1885
  }
1332
1886
  async function runStatusAll(root) {
1333
- const databases = findStashDatabases(resolve4(root));
1887
+ const databases = findStashDatabases(resolve5(root));
1334
1888
  if (databases.length === 0) {
1335
- console.log(`No filestash databases found under ${resolve4(root)}.`);
1889
+ console.log(`No filestash databases found under ${resolve5(root)}.`);
1336
1890
  return;
1337
1891
  }
1338
1892
  const rows = [];
@@ -1342,7 +1896,7 @@ async function runStatusAll(root) {
1342
1896
  await stash.init();
1343
1897
  const stats = await stash.getStats();
1344
1898
  assertHealthy(stash);
1345
- rows.push({ project: resolve4(dbPath, "../..").replace(/\/\.vscode$/, ""), files: stats.filesTracked, tokens: stats.tokensSaved });
1899
+ rows.push({ project: resolve5(dbPath, "../..").replace(/\/\.vscode$/, ""), files: stats.filesTracked, tokens: stats.tokensSaved });
1346
1900
  } catch (e) {
1347
1901
  console.error(` skipped ${dbPath}: ${e instanceof Error ? e.message : String(e)}`);
1348
1902
  } finally {
@@ -1352,7 +1906,7 @@ async function runStatusAll(root) {
1352
1906
  rows.sort((a, b) => b.tokens - a.tokens);
1353
1907
  const totalTokens = rows.reduce((sum, r) => sum + r.tokens, 0);
1354
1908
  const totalFiles = rows.reduce((sum, r) => sum + r.files, 0);
1355
- console.log(`filestash status (${rows.length} databases under ${resolve4(root)}):`);
1909
+ console.log(`filestash status (${rows.length} databases under ${resolve5(root)}):`);
1356
1910
  for (const r of rows) {
1357
1911
  console.log(` ~${r.tokens.toLocaleString().padStart(10)} tokens ${String(r.files).padStart(5)} files ${r.project}`);
1358
1912
  }
@@ -1361,44 +1915,16 @@ async function runStatusAll(root) {
1361
1915
  Savings count re-reads within a session (unchanged files and diffs); a new session always receives full content.`);
1362
1916
  }
1363
1917
  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
- ];
1918
+ const home = homedir2();
1919
+ const targets = editorTargets(home);
1394
1920
  let configured = 0;
1395
1921
  for (const target of targets) {
1396
- const dir = join3(target.path, "..");
1397
- if (!existsSync4(dir)) continue;
1922
+ const dir = join5(target.path, "..");
1923
+ if (!existsSync5(dir)) continue;
1398
1924
  let config = {};
1399
- if (existsSync4(target.path)) {
1925
+ if (existsSync5(target.path)) {
1400
1926
  try {
1401
- config = JSON.parse(readFileSync3(target.path, "utf-8"));
1927
+ config = JSON.parse(readFileSync4(target.path, "utf-8"));
1402
1928
  } catch {
1403
1929
  config = {};
1404
1930
  }
@@ -1416,7 +1942,7 @@ async function runInit(withHooks) {
1416
1942
  }
1417
1943
  if (configured === 0) {
1418
1944
  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));
1945
+ console.log(JSON.stringify({ mcpServers: { "agent-file-stash": targets[0].entry } }, null, 2));
1420
1946
  } else {
1421
1947
  console.log(`
1422
1948
  Done! Restart your editor to pick up agent-file-stash.`);
@@ -1445,13 +1971,17 @@ function runHelp() {
1445
1971
  Usage:
1446
1972
  agent-file-stash init Auto-configure for your editor
1447
1973
  agent-file-stash init --hooks
1448
- Also add the Claude Code context-reset hook to ~/.claude/settings.json
1974
+ Also add the Claude Code hooks (context reset, subagent read scope) to ~/.claude/settings.json
1449
1975
  agent-file-stash reset Forget what was read so the next read returns full content
1450
1976
  (--from-hook: quiet mode for Claude Code hooks)
1977
+ agent-file-stash hook subagent-scope
1978
+ PreToolUse hook that gives each subagent its own read tracking (no output otherwise)
1451
1979
  agent-file-stash serve Start the MCP server (default)
1452
1980
  agent-file-stash status Show stash statistics
1453
1981
  agent-file-stash status --all [dir]
1454
1982
  Sum statistics of every stash under dir (default: home)
1983
+ agent-file-stash doctor Check the install and report what to fix (read-only)
1984
+ (--json: machine-readable output, --check-updates: ask npm for the latest version)
1455
1985
  agent-file-stash help Show this help message
1456
1986
 
1457
1987
  Environment:
@@ -1464,13 +1994,17 @@ var command = process.argv[2];
1464
1994
  if (!command || command === "serve") {
1465
1995
  await startMcpServer();
1466
1996
  } else if (command === "status" && process.argv[3] === "--all") {
1467
- await runStatusAll(process.argv[4] ?? homedir());
1997
+ await runStatusAll(process.argv[4] ?? homedir2());
1468
1998
  } else if (command === "status") {
1469
1999
  await runStatus();
1470
2000
  } else if (command === "init") {
1471
2001
  await runInit(process.argv.includes("--hooks"));
1472
2002
  } else if (command === "reset") {
1473
2003
  await runReset(process.argv.includes("--from-hook"));
2004
+ } else if (command === "hook" && process.argv[3] === "subagent-scope") {
2005
+ await runSubagentScopeHook();
2006
+ } else if (command === "doctor") {
2007
+ process.exitCode = await runDoctor(process.argv.slice(3));
1474
2008
  } else if (command === "help" || command === "--help") {
1475
2009
  runHelp();
1476
2010
  } else {