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.
- package/README.md +70 -8
- package/dist/cli.mjs +812 -196
- 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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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`).
|
|
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
|
|