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/README.md CHANGED
@@ -57,7 +57,7 @@ The MCP server exposes 4 tools that agents discover and use automatically:
57
57
 
58
58
  | Tool | Description |
59
59
  |------|-------------|
60
- | `read_file` | Read a file with stashing. Returns full content on first read, an "unchanged" label or diff on subsequent reads. Parameters: `path` (required), `offset` (1-based start line), `limit` (max lines), `force` (bypass the stash and return full content). |
60
+ | `read_file` | Read a file with stashing. Returns full content on first read, an "unchanged" label or diff on subsequent reads. Parameters: `path` (required), `offset` (1-based start line), `limit` (max lines), `force` (bypass the stash and return full content), `agent` (read-tracking scope, normally set by the [subagent hook](#subagents)). |
61
61
  | `read_files` | Batch read multiple files at once with stashing. Parameter: `paths` (array of file paths). |
62
62
  | `stash_status` | Show files tracked and this session's accounting: reads, tokens a plain read would have sent, tokens actually sent, gross saved, tool-definition overhead (est.) and net saved (est.), plus lifetime gross saved. |
63
63
  | `stash_clear` | Clear all stashed content, read tracking and stats. |
@@ -91,7 +91,7 @@ Done! Restart your editor to pick up agent-file-stash.
91
91
 
92
92
  When no supported editor is detected, `init` prints the manual MCP snippet instead.
93
93
 
94
- `init --hooks` also merges the context-reset hook (see [Context resets](#context-resets)) into `~/.claude/settings.json`. It is idempotent, keeps all existing settings, and saves the previous file as `settings.json.bak`. Without the flag, `init` only prints the snippet.
94
+ `init --hooks` also merges the context-reset hook (see [Context resets](#context-resets)) and the subagent scope hook (see [Subagents](#subagents)) into `~/.claude/settings.json`. It is idempotent, keeps all existing settings, and saves the previous file as `settings.json.bak`. Without the flag, `init` only prints the snippet.
95
95
 
96
96
  #### `serve`
97
97
 
@@ -142,6 +142,24 @@ npx agent-file-stash reset
142
142
 
143
143
  Forgets what each session has read, so the next read of every file returns the full content. Stats and stashed versions are kept. It resolves `FILESTASH_DIR` like the server does and exits 0 if no database exists. Meant to be run by a Claude Code hook (see [Context resets](#context-resets)); `--from-hook` reads the hook JSON from stdin, resolves a relative `FILESTASH_DIR` against its `cwd`, and stays silent.
144
144
 
145
+ #### `hook`
146
+
147
+ ```bash
148
+ npx agent-file-stash hook subagent-scope
149
+ ```
150
+
151
+ A Claude Code `PreToolUse` hook, not meant to be run by hand (see [Subagents](#subagents)). It reads the hook JSON from stdin and, when the call comes from a subagent to `read_file` or `read_files`, prints the same arguments plus an `agent` argument so the server tracks that subagent separately. For every other call it prints nothing and exits 0. It never opens the database and never grants or changes permissions.
152
+
153
+ #### `doctor`
154
+
155
+ ```bash
156
+ npx agent-file-stash doctor
157
+ npx agent-file-stash doctor --json
158
+ npx agent-file-stash doctor --check-updates
159
+ ```
160
+
161
+ Read-only diagnostics that tell you whether the install works and what to fix. It checks the Node version, the stash directory and database (schema version, integrity, permissions, leftover recovery files), the MCP registration in Claude Code, Cursor and OpenCode, the context-reset hook, the subagent scope hook, and the `FILESTASH_*` limits. It never creates, changes or deletes any file or setting (when a running server holds the database, SQLite may refresh the timestamp of its shared-memory index file `stash.db-shm`, as any reader does) and makes no network call unless you pass `--check-updates`, which compares the installed version with `npm view`. Each line is `[ok]`, `[warn]`, `[error]` or `[info]`, followed by a fix hint for warnings and errors; `--json` prints the same results as a JSON array and nothing else. The exit code is 1 when any check reports an error and 0 otherwise.
162
+
145
163
  #### `help`
146
164
 
147
165
  ```bash
@@ -195,7 +213,30 @@ The server answers a repeat read of an unchanged file with a short "unchanged" n
195
213
  }
196
214
  ```
197
215
 
198
- Limitation: subagents that use the MCP server by name share the parent's server process, so they share its read tracking. A subagent can be told "unchanged" for a file only the parent has seen; have it pass `force=true` on its first read of each file.
216
+ ### Subagents
217
+
218
+ A subagent that uses the MCP server by name shares the parent's server process. Without help, the parent and every subagent share one read tracking: a subagent can be told "unchanged" for a file only the parent has seen, the parent can be told that for a file only a subagent read, and parallel subagents can do it to each other.
219
+
220
+ `init --hooks` also registers a `PreToolUse` hook that fixes this (or paste it into `~/.claude/settings.json`):
221
+
222
+ ```json
223
+ {
224
+ "hooks": {
225
+ "PreToolUse": [
226
+ {
227
+ "matcher": "mcp__(filestash|agent-file-stash)__read_files?",
228
+ "hooks": [{ "type": "command", "command": "npx agent-file-stash hook subagent-scope" }]
229
+ }
230
+ ]
231
+ }
232
+ }
233
+ ```
234
+
235
+ For a call made by a subagent, Claude Code gives the hook the subagent's `agent_id`, and the hook passes it to the tool as the optional `agent` argument. The server then tracks delivered line ranges per `agent` value: a subagent's first read of a file returns real content, a repeat read by the same subagent is "unchanged", and nothing a subagent reads is counted as delivered to the parent or to another subagent. Calls by the main agent carry no `agent_id` and are left untouched. Token accounting (`stash_status`, the read footer) stays per session.
236
+
237
+ The hook only adds an argument. It emits no permission decision, so it never grants or bypasses a permission, and a `permissions.deny` rule still blocks the tool. Without the hook, subagents share the parent's tracking as before, and `force=true` on a subagent's first read of each file is the workaround. `doctor` reports a missing hook.
238
+
239
+ Each session keeps at most 32 `agent` scopes; a read from a new scope beyond that deletes the tracking of the least recently used one, which then gets real content on its next read. Scoped tracking is removed together with its session when that session closes. SDK users pass `scope` to `readFile` and `readFileFull`; a value that is not 1-64 characters of `A-Za-z0-9_.-` is ignored, and session ids must not contain `::`.
199
240
 
200
241
  ### How sessions work
201
242
 
@@ -216,7 +257,7 @@ When a server starts, it prunes closed sessions: any other session whose process
216
257
  ### Known limitations
217
258
 
218
259
  - Clients do not send a per-conversation id, so the server cannot tell that you ran `/clear` or `/compact`. Without the [reset hook](#context-resets) it can answer "unchanged" for content the model no longer has; `force=true` always returns full content.
219
- - Subagents that use the MCP server by name share the parent's server process and therefore its read tracking.
260
+ - Subagents only get their own read tracking when the [subagent hook](#subagents) is installed; without it they share the parent's.
220
261
  - The secret denylist matches on file names only: symlinks are not resolved, so a link with an innocent name pointing at a secret file is stashed, and secrets in files with ordinary names are not detected.
221
262
  - Extra patterns in `FILESTASH_EXCLUDE` match basenames only, not directories or full paths.
222
263
  - Node.js 24 or later is required (`node:sqlite`).
@@ -228,6 +269,7 @@ The stash never prevents a file from being read. If the database or the stash di
228
269
 
229
270
  - A database that is corrupt (not a database, malformed) is treated as a disposable cache: it is renamed to `stash.db.corrupt-<unix-ms>` next to the original (with any `-wal`/`-shm` files given the same suffix), a fresh database is created, and one line is written to stderr. `stash_status` shows `Recovered: corrupt database moved to <path>`. The renamed file can be deleted. Recovery is attempted once per process and is serialised across processes with a `stash.db.recover.lock` file next to the database, so servers starting together on the same corrupt file recover it once.
230
271
  - If the stash directory cannot be created or written, or the database cannot be opened or locked, or any database call fails during a session, the stash switches to degraded mode for the rest of the process: reads return the plain file content, nothing is stashed, and one line is written to stderr with the reason. `stash_status` starts with `Mode: DEGRADED (<reason>) - files are read normally, nothing is stashed`, its metadata carries `degraded` and `degradedReason`, and `stash_clear` reports that there is nothing to clear.
272
+ - A database created by a newer release (its `user_version` is higher than this release knows) is not treated as corrupt and is left untouched: the stash degrades with `database schema version N is newer than this release supports (M); upgrade agent-file-stash`.
231
273
  - Errors about the file being read (missing, unreadable, a directory) are still reported as errors.
232
274
  - If file watching cannot start (for example the OS watcher limit is reached) or fails later, one line is written to stderr and the server continues without it.
233
275
  - `status` and `reset` print a one-line error and exit 1 on an unreadable database (`reset --from-hook` exits 0); `status --all` skips it and continues.
@@ -314,6 +356,20 @@ filestash status:
314
356
  Gross saved (all sessions): ~53,851 tokens
315
357
  ```
316
358
 
359
+ Session counters are discarded when a closed session is pruned, so the status also keeps lifetime counters in the database: sessions, reads, what plain reads would have returned, what the stash returned, and the estimated tool-definition overhead of every session. `agent-file-stash status`, `status --all` (summed over every database found) and `stash_status` print them as a block:
360
+
361
+ ```
362
+ Lifetime (since 2026-10-09):
363
+ Sessions: 14, reads: 312
364
+ Would have sent (plain reads): ~410,200 tokens
365
+ Actually sent: ~188,900 tokens
366
+ Gross saved: ~221,300 tokens
367
+ Tool definitions overhead: ~3,878 tokens (est., 14 sessions)
368
+ Net saved: ~217,422 tokens (est.), ~15,530 per session
369
+ ```
370
+
371
+ Net is gross saved minus the overhead of every counted session, so it shows whether the stash pays for its tool definitions on average; it can be negative. A session is counted once, when the MCP server starts; `status`, `reset` and `doctor` never count one. Counting starts when you upgrade to the release that introduced it, so on an older database `Gross saved (all sessions)` includes history the lifetime block does not, and the block is replaced by a note until the first session is counted. The overhead is an estimate (`ceil(characters / 4)` of the tool definitions), not a measurement. `stash_clear` resets the lifetime counters together with the other totals.
372
+
317
373
  ## Benchmark
318
374
 
319
375
  Tested on a real 268-file TypeScript codebase ([opencode](https://github.com/sst/opencode)) — same agent, same prompt, only the stash toggled:
@@ -355,24 +411,27 @@ _Run `pnpm benchmark` to reproduce._
355
411
  packages/
356
412
  ├── sdk/src/
357
413
  │ ├── index.ts Exports: createStash, StashStore, FileWatcher, computeDiff, isExcludedPath, types
414
+ │ ├── migrations.ts SCHEMA_VERSION and the ordered, transactional schema migrations
358
415
  │ ├── stash.ts StashStore — SQLite-backed content-addressed stash with per-session read tracking and pruning
359
- │ ├── differ.ts computeDiff — line-based LCS diff (unified format, LCS capped at 5 000 lines)
416
+ │ ├── differ.ts computeDiff — line-based Myers diff (unified format, edit distance capped at 2 500 lines)
360
417
  │ ├── exclude.ts isExcludedPath — secret-file denylist and FILESTASH_EXCLUDE patterns
361
418
  │ ├── watcher.ts FileWatcher — debounced fs.watch wrapper that evicts deleted files from the stash
362
419
  │ └── types.ts StashConfig, FileReadResult, StashStats type definitions
363
420
  │
364
421
  └── cli/src/
365
422
  ├── index.ts CLI entry point — init, serve, status, reset, help commands
423
+ ├── hook.ts The hook subagent-scope command (PreToolUse hook for per-subagent read tracking)
366
424
  ├── mcp.ts MCP server — registers read_file, read_files, stash_status, stash_clear tools
367
425
  └── scan.ts findStashDatabases — locates stash databases for status --all
368
426
 
369
427
  test/
370
428
  ├── smoke.test.ts End-to-end flows: first read, stash hit, diff on change, partial reads, multi-session isolation
371
- ├── differ.test.ts Unit tests for computeDiff: add/remove/mixed edits, context lines, LCS size limit
429
+ ├── differ.test.ts Unit tests for computeDiff: add/remove/mixed edits, context lines, edit distance cap
372
430
  ├── stash-errors.test.ts Error paths: missing file, clear(), onFileDeleted(), post-close re-init
373
431
  ├── watcher.test.ts FileWatcher: deletion detection, debounce coalescence, close() cancellation
374
432
  ├── mcp-tools.test.ts Unit tests for isPathAllowed (path traversal guard) and formatReadResult
375
433
  ├── mcp-meta.test.ts Validates the _meta field format and reverse-DNS namespace convention
434
+ ├── differ-myers.test.ts computeDiff against a reference LCS, patch round-trips, edge cases, edit distance cap, speed bounds
376
435
  ├── diff-guard.test.ts Full content is returned when a diff is not smaller than the file
377
436
  ├── prune.test.ts Pruning of closed sessions and their data
378
437
  ├── scan.test.ts findStashDatabases (status --all)
@@ -380,6 +439,7 @@ test/
380
439
  ├── session-reset.test.ts resetReads, the reset command, init --hooks, MCP integration
381
440
  ├── savings-regression.test.ts Session accounting identity, savings workload, tool definition overhead
382
441
  ├── e2e.test.ts End-to-end suite with real servers: concurrent servers, crash safety, secrets, path restriction, shutdown
442
+ ├── subagent-scope.test.ts Per-agent read tracking in the SDK, the MCP server, the hook command, init --hooks and doctor
383
443
  ├── docs.test.ts README mentions every CLI command, FILESTASH_* variable and MCP tool
384
444
  └── benchmark.ts Reproducible two-pass simulation across generated TypeScript files (pnpm benchmark)
385
445
  ```
@@ -405,11 +465,13 @@ The SDK has no external dependencies — it uses only Node.js built-ins (`node:s
405
465
 
406
466
  WAL mode is enabled with a 5-second busy timeout so several servers can share one database and readers do not block the writer.
407
467
 
468
+ **Schema versioning:** the schema version is stored in SQLite's `PRAGMA user_version` (currently 1; a database with version 0 is a legacy one created before versioning and is completed with any missing table or index). On open, each pending migration runs in its own `BEGIN IMMEDIATE` transaction that also bumps `user_version`, and servers opening the same database at once re-read the version inside the transaction, so each migration is applied once. Migrations are forward-only and additive when possible; there is no downgrade. A database whose version is newer than the release supports is never modified: the server enters [degraded mode](#degraded-mode) with `database schema version N is newer than this release supports (M); upgrade agent-file-stash`.
469
+
408
470
  **Pruning:** on startup each server deletes sessions whose pid is no longer alive, their read pointers, delivered ranges and counters, and any `file_versions` row no remaining session points at. Rows for paths matching the secret denylist are removed at the same time. See [How sessions work](#how-sessions-work).
409
471
 
410
472
  **Change detection:** On every read, the current file content is hashed (SHA-256, truncated to 16 hex chars). Same hash = unchanged. Different hash = compute diff, update stash. No polling or watchers required for correctness — the hash is the source of truth. File watchers are optional and only used to proactively evict deleted files.
411
473
 
412
- **Diff algorithm:** Line-based unified diff (`computeDiff`). Groups changed lines into hunks with context lines, in unified format with 3 lines of context. The diff is returned verbatim to the agent, unless it is not smaller than the file, in which case the full content is returned.
474
+ **Diff algorithm:** Line-based unified diff (`computeDiff`). The common leading and trailing lines are trimmed and the rest is diffed with Myers' O(ND) algorithm, so the cost depends on how much changed rather than on file size. Changed lines are grouped into hunks in unified format with 3 lines of context. The diff is returned verbatim to the agent, unless it is not smaller than the file, in which case the full content is returned. When the edit distance exceeds 2 500 changed lines (or the work budget is spent), the differing middle is reported as fully removed and re-added, which in practice makes the guard return the full file; this keeps the diff within a fixed time bound on the single-threaded server.
413
475
 
414
476
  **Token estimation:** `ceil(characters / 4)`. Rough but directionally correct for code. Used for the token metrics and to decide whether a label or diff is actually shorter than the plain content; it never changes what the file contains.
415
477
 
@@ -429,7 +491,7 @@ Delete the `"filestash"` key (the one `init` adds; also `"agent-file-stash"` if
429
491
 
430
492
  **2. Remove the Claude Code hook**
431
493
 
432
- If you ran `init --hooks` (or pasted the snippet from [Context resets](#context-resets)), remove the `SessionStart` entry whose command is `npx agent-file-stash reset --from-hook` from `~/.claude/settings.json`. `init --hooks` also left a backup of the previous file at `~/.claude/settings.json.bak`, which you can delete.
494
+ If you ran `init --hooks` (or pasted the snippet from [Context resets](#context-resets)), remove the `SessionStart` entry whose command is `npx agent-file-stash reset --from-hook` and the `PreToolUse` entry whose command is `npx agent-file-stash hook subagent-scope` from `~/.claude/settings.json`. `init --hooks` also left a backup of the previous file at `~/.claude/settings.json.bak`, which you can delete.
433
495
 
434
496
  **3. Remove the stash database**
435
497