onepass-proxy 0.3.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Julian-Win-Stack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,408 @@
1
+ # Onepass
2
+
3
+ Run a long Claude Code session to the end without it compacting.
4
+
5
+ Claude Code resends the whole conversation on every turn, so a long session grows until the
6
+ client summarises it away and you lose the detail. Onepass sits between Claude Code and the
7
+ Anthropic API on your own machine and replaces old, large, **recoverable** context — tool
8
+ results, the calls that made them, files the agent read, background-task output — with short
9
+ stubs, so the conversation stops growing. Nothing is lost: the transcript on disk is untouched,
10
+ and a bundled MCP server (`recall`) fetches any of it back verbatim when the agent asks.
11
+
12
+ Measured on real sessions: **~1.49M tokens of raw conversation in one sitting, 289 turns, zero
13
+ compactions**, with the task finished correctly. Numbers and caveats under
14
+ [Verification](#verification).
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install -g onepass-proxy
20
+ ```
21
+
22
+ Needs Node 20+ and the `claude` CLI already on your PATH (2.1.241 or newer).
23
+
24
+ ## Run
25
+
26
+ ```bash
27
+ claudep
28
+ ```
29
+
30
+ That is the whole thing. `claudep` starts a proxy for this session on a port the operating
31
+ system picks, registers `recall` so the agent can fetch evicted content back, runs `claude`
32
+ against it, and shuts the proxy down when you quit. Every argument you pass goes straight to
33
+ `claude`, so `claudep --resume`, `claudep -p "..."` and the rest all work.
34
+
35
+ When the session ends it prints one line saying what happened:
36
+
37
+ ```
38
+ onepass: evicted 143 segments (~102,318 tokens), recalled 0, compactions 0 (peak ~96,412 tokens)
39
+ ```
40
+
41
+ Run as many sessions as you like at once — each `claudep` gets its own proxy, its own evicted
42
+ set and its own log, so two sessions can never see each other's history.
43
+
44
+ ## Turning it off
45
+
46
+ Run `claude` instead of `claudep`. Nothing is installed into your shell, no background service
47
+ is left running, and no Claude Code setting is changed — a plain `claude` run has never been
48
+ through the proxy.
49
+
50
+ ## Sharp edges
51
+
52
+ - **Your credentials pass through untouched and go nowhere else.** The proxy forwards to
53
+ `api.anthropic.com` and talks to nothing on the internet but that. It logs sizes, ids and
54
+ paths — **never request or response bodies**.
55
+ - **It listens on `127.0.0.1` only.** Anything that can reach the port can spend your Claude
56
+ subscription, so it is not reachable from your network. `ONEPASS_HOST` can move it; think
57
+ twice before you do.
58
+ - **Eviction is not free.** Each trip rewrites Anthropic's prompt cache, and a cache write
59
+ costs 12.5× a cache read. The target is a session that *finishes* at roughly what an
60
+ unproxied session costs — not a cheaper session. See
61
+ [`ONEPASS_BATCH_MIN_TOKENS`](#configuration-env-vars--this-is-all-of-it).
62
+ - **`CLAUDE_CODE_GZIP_REQUEST_BODIES` disables it silently.** A compressed body is forwarded
63
+ untouched. `claudep` unsets the variable for you; a hand-started proxy cannot.
64
+ - **Not a background service.** Nothing should reach the proxy unless a session opts in.
65
+
66
+ ---
67
+
68
+ # How it works
69
+
70
+ Four segment kinds are evictable, a fixed whitelist (measured against real sessions in
71
+ docs/findings.md §13 — tool results alone are only ~6% of a real request body):
72
+
73
+ - `tool_result` blocks
74
+ - **`tool_use` inputs** — the calls themselves. `Edit` and `Write` carry the whole text they
75
+ wrote; the edit already landed on disk, so the input is as recoverable as the result
76
+ - **attached file content** Claude Code injects as `<system-reminder>` user text after a Read
77
+ — the biggest single mass in real sessions (~20%)
78
+ - **task notifications** (`<task-notification>` user messages carrying background-task output)
79
+
80
+ Everything else is protected by omission: CLAUDE.md instructions, skill/agent listings,
81
+ compaction summaries, and thinking blocks (the client already manages those via the API's
82
+ `context_management` thinking-clearing). **Text the user typed is never touched** — there is no
83
+ file to re-read and no command to re-run, so a stub in its place would be the one loss recall
84
+ could not undo. Assistant text has no exception either.
85
+
86
+ Stubs are pointers, never summaries — and they name the target once, not three times. A tool
87
+ block stubs to `[onepass: evicted 1,481 chars]` and nothing else: what the block was is
88
+ already beside it in the request. A stubbed `tool_use` keeps its `id`, `name` and `type`, and
89
+ its `input` becomes `{}` — the smallest object the API will accept, and the only stub shape
90
+ with nothing in it the model can copy. Where its result is stubbed too, that result's stub
91
+ gains the path the call named (`[onepass: evicted 4,000 chars; call evicted, /repo/x.ts]`), so
92
+ the pair still says which file it was; where the result is still live, it names the file
93
+ itself. An attached file stubs to `[onepass: evicted
94
+ attached file, N chars]`, its path left in the `Called the Read tool` reminder next to it,
95
+ which is never evicted. Only a task notification still names its target (`[onepass: evicted
96
+ task notification abc123, N chars; output at /tmp/out/task.log]`), because nothing else in
97
+ the request carries the task id or its output path. How to get any of it back is one
98
+ paragraph in the recall tool's own description, where it is prompt-cached and costs nothing
99
+ per turn — so it is not repeated in every stub. Naming the target in every stub is what made
100
+ stubs 12% of the measured peak request (docs/findings.md §15). A result now stubs to about 30
101
+ chars whatever it held, and a call to 2 — plus the ~30-char suffix its result's stub gains,
102
+ which is charged to the call whether the result takes it or not.
103
+
104
+ A stub is only applied when it saves at least `ONEPASS_MIN_SAVED_CHARS`. The saving is
105
+ measured on the finished stub, not the raw segment, so a segment that stubs to no less than
106
+ it holds is left alone and never enters the evicted-id set. A `Read` call is the case that
107
+ needs this: emptying its input saves almost nothing, and most of that comes straight back as
108
+ the path appended to its result's stub.
109
+
110
+ ## Recall
111
+
112
+ `recall` is an MCP server over the session transcript, shipped in this package and registered
113
+ by `claudep` for the session it starts. Two tools — `recall_search` over the session's own
114
+ history and `recall_get` for one entry by id — so anything a stub replaced can be fetched back
115
+ character-for-character. Its `recall_search` description is where the agent is told what a stub
116
+ is and how to get the content back.
117
+
118
+ It reads the transcript named by `ONEPASS_SESSION_ID`, which `claudep` sets, and never any
119
+ other. Without it — a hand-started proxy — it falls back to the newest transcript for the
120
+ current directory, which with two sessions open in one repository can be the other session's
121
+ history.
122
+
123
+ The transcript is read-only to Onepass. Nothing here ever writes to one.
124
+
125
+ ## Running the proxy by hand
126
+
127
+ You do not need this unless you are working on Onepass itself, or driving Claude Code from
128
+ something that cannot go through `claudep`.
129
+
130
+ ```bash
131
+ onepass-proxy
132
+ ```
133
+
134
+ It runs in the foreground until you stop it, and prints the line to launch a session against
135
+ it. Auth passes straight through: `ANTHROPIC_API_KEY` and subscription OAuth both work
136
+ (verified live on CLI 2.1.243 — the CLI does send OAuth credentials to a custom
137
+ `ANTHROPIC_BASE_URL`, whatever the docs say):
138
+
139
+ ```bash
140
+ ANTHROPIC_BASE_URL=http://127.0.0.1:3777 _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=1 claude
141
+ ```
142
+
143
+ The second variable is load-bearing. Claude Code decides the context window client-side, and
144
+ a base URL whose host is not `api.anthropic.com` makes it cap native-1M models (`opus`,
145
+ `fable`) at 200k unless the model name ends in `[1m]`. The flag says the upstream really is
146
+ first-party — it is, the proxy forwards to `api.anthropic.com`. Details under "Known Claude
147
+ Code interactions".
148
+
149
+ One proxy serves one session. Its evicted-id set, its chars-per-token calibration and its log
150
+ are per-process, so two sessions sharing a proxy put one session's stubs into the other's
151
+ request. `claudep` exists so you never have to think about this.
152
+
153
+ To register recall by hand, add it to your MCP config with the session id in its environment:
154
+
155
+ ```json
156
+ {
157
+ "mcpServers": {
158
+ "onepass": {
159
+ "command": "onepass-recall",
160
+ "env": { "ONEPASS_SESSION_ID": "<the session uuid>" }
161
+ }
162
+ }
163
+ }
164
+ ```
165
+
166
+ ## Configuration (env vars — this is all of it)
167
+
168
+ | Variable | Default | Meaning |
169
+ |---|---|---|
170
+ | `ONEPASS_PORT` | `3777` | Port the proxy listens on. `0` asks the operating system for a free one, and the startup banner reports what it got |
171
+ | `ONEPASS_HOST` | `127.0.0.1` | Interface the proxy binds. Every request through it carries your Claude Code credentials upstream, so the default is loopback-only; `0.0.0.0` makes it an open relay for anyone who can reach the port. `claudep` pins this to the loopback for its own child whatever the shell says |
172
+ | `ONEPASS_UPSTREAM` | `https://api.anthropic.com` | Where requests are forwarded |
173
+ | `ONEPASS_EVICT_AFTER_TURNS` | `8` | N: a tool result is eligible once ≥ N assistant messages follow it |
174
+ | `ONEPASS_PROTECT_LAST_TURNS` | `4` | K: results inside the last K assistant turns are never touched |
175
+ | `ONEPASS_TRIP_TOKENS` | `80000` | T: new ids are evicted only when the projected request size, in **real tokens**, exceeds this (measured after re-applying existing stubs). Mid-session, peaks run well over T: at T=110k the measured peak was 140,253 across 588 assistant turns, with no turn above 150k (docs/findings.md §17). The default moved 110k → 80k once the batch minimum existed, because a lower T then costs a handful of larger trips instead of a swarm of small ones (§21). The un-evictable floor (system + last-K turns + small results) still grows with the session and is what eventually bounds it — once the floor is above T, no value of T brings the peak down: one recorded session peaked at 150,811 tokens at T=110k and at T=80k alike. Size T so `T + 60k` clears your effective compact line (`window − 13k`; the window is 1M with `_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=1` in the launch command, 200k without it) |
176
+ | `ONEPASS_BATCH_MIN_TOKENS` | `20000` | The least a trip may newly evict. A smaller batch is held back, and that content waits for a later request where the batch has grown past the minimum; `0` turns it off. Every trip rewrites the prompt cache, and a cache write costs 1.25× base input against a cache read's 0.1×, so trips are the proxy's main running cost. Without a minimum, a session whose floor has passed T trips on nearly every request for a few hundred tokens each: on one recording at T=80k, **50 trips in 68 requests, against 1** with the default minimum (§21) |
177
+ | `ONEPASS_MIN_SAVED_CHARS` | `50` | A segment is stubbed only when its finished stub is at least this many chars smaller than the content. The stub's own cost decides, so no fixed size floor is needed |
178
+ | `ONEPASS_SESSION_ID` | unset | Which session's transcript `recall` reads. `claudep` sets it; without it recall falls back to the newest transcript for the current directory |
179
+ | `ONEPASS_DUMP_DIR` | unset | Debug only: when set, every `/v1/messages` and `/v1/messages/count_tokens` body is written to this directory before eviction — other paths are never dumped. Bodies contain the full conversation — never leave it on |
180
+
181
+ ## How eviction behaves
182
+
183
+ - `POST /v1/messages` and `POST /v1/messages/count_tokens` bodies get the same transform —
184
+ the client's context bookkeeping may consume the count, so it must describe the evicted
185
+ request that will actually be sent, not the raw conversation. Everything else is forwarded
186
+ verbatim. Responses stream straight through (SSE included), never buffered.
187
+ - Eviction replaces only the content of whitelisted segments: a `tool_result` block's
188
+ `content`, a `tool_use` block's `input`, an attached-file text block's `text`, or a
189
+ task-notification user message's string content. Block structure, `tool_use_id`, a call's
190
+ `id`/`name`/`type`, `is_error`, user text, assistant text, thinking blocks, system prompt,
191
+ and tool definitions are never
192
+ touched — and injected text is matched by exact prefix, so CLAUDE.md/skill-listing
193
+ `<system-reminder>` blocks (same envelope, different prefix) are never candidates.
194
+ `is_error` results are evicted like any other. An attached-file stub names no path — the
195
+ `Called the Read tool` reminder beside it does, and that reminder is never a candidate;
196
+ task-notification stubs name the task id and output file, which nothing else carries.
197
+ - Eviction is **monotonic and batched** to protect prompt caching: the proxy keeps an
198
+ in-memory set of evicted segment ids (`tool_use_id` for results, `call:<tool_use_id>` for
199
+ calls — a distinct id, so stubbing a big call never drags its small result along — and a
200
+ sha1 content hash for text, which re-matches because the client resends originals), re-stubs those on
201
+ every request (except inside the protected last-K window: content re-attached by a fresh
202
+ Read of an evicted file has the same hash, and stubbing the young copy would break the
203
+ stub's own recovery path), and adds new ids only when the size threshold T trips — all
204
+ currently eligible ids at once. The message prefix therefore changes once per trip, not every turn.
205
+ A proxy restart loses the set; originals reappear and the cache rebuilds once. Nothing
206
+ breaks.
207
+ - T is denominated in **real tokens**, not chars ÷ 4. The proxy reads the `usage` object out
208
+ of every API response it forwards (stripping `accept-encoding` on those requests so the
209
+ body is scannable) and calibrates a live chars-per-token ratio. Measured on real traffic:
210
+ 2.1–2.7 chars per token for `.d.ts`-heavy content and ~3.2 for mixed code, so a fixed ÷ 4
211
+ under-counts by 25–40% — enough to cross Claude Code's compaction threshold while the
212
+ estimate still looks safe. Until the first sample the fallback is a deliberately
213
+ conservative 3.2.
214
+ - **Pressure pass**: a burst of large reads in quick succession is younger than N and
215
+ normally un-evictable. If the normal pass leaves the request over T, the age gate relaxes
216
+ down to K for that trip — only the last K turns are ever untouchable. Without this, a
217
+ chunked file sweep outruns the age gate and the client compacts anyway.
218
+ - Malformed or non-JSON bodies are forwarded byte-for-byte untouched. A parse failure never
219
+ fails a request.
220
+ - **Why a stubbed call keeps nothing.** The stub used to be `{ file_path | command, evicted }`,
221
+ and the model copied it into calls it meant to make — sending `evicted` where
222
+ `old_string`/`new_string` belong, and truncating its own Bash commands at exactly the 80 chars
223
+ this file used to truncate at, on commands the session had never run. 11 occurrences in 588
224
+ turns against **zero** unproxied; the rate tracked the share of the agent's own visible calls
225
+ that were stubbed, and the identical marker text in 361 tool *results* was copied zero times,
226
+ because a result is not written in the agent's voice (docs/findings.md §17–18). Emptying the
227
+ input removes the thing being copied, and an imitated `{}` cannot become a valid call the way
228
+ a kept `{ command }` could.
229
+
230
+ ## Known Claude Code interactions (measured against 2.1.241–2.1.258)
231
+
232
+ - **Compaction really does key off API-reported usage.** From the shipped binary: auto-compact
233
+ fires when `input_tokens + cache_creation_input_tokens + cache_read_input_tokens (+ output)`
234
+ from the last assistant message crosses `effective_window − 13,000` (or
235
+ `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE % × window` when set). Shrinking the request shrinks that
236
+ number; nothing client-side re-measures the original conversation.
237
+ - **A non-`api.anthropic.com` base URL downgrades the 1M window** (measured on
238
+ 2.1.250–2.1.252). The window is decided client-side: 1M if the model name ends in `[1m]`;
239
+ else 1M only if the model is natively 1M *and* the base URL host is exactly
240
+ `api.anthropic.com` or `_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=1` is set; else 200k. So
241
+ `opus` through `localhost:3777` reports 200,000 while `opus[1m]` reports 1,000,000. `claudep`
242
+ sets the flag for you; set it yourself if you launch by hand, so the window does not depend
243
+ on which `/model` entry was last picked — choosing "Opus 5" there persists plain `opus`.
244
+ `CLAUDE_CODE_MAX_CONTEXT_TOKENS` does not help; it is ignored for known model names.
245
+ - **Gzipped request bodies bypass eviction.** With `CLAUDE_CODE_GZIP_REQUEST_BODIES=1` the
246
+ client compresses `/v1/messages` bodies and the proxy forwards `content-encoding` bodies
247
+ untouched by design. `claudep` unsets it; unset it yourself for a hand-launched session
248
+ (local desktop sessions don't set it).
249
+ - **Compaction thrash is fatal, not just slow.** If context refills within 3 turns of a
250
+ compact 3 times in a row, the client's `rapid_refill_breaker` aborts the session
251
+ (docs/findings.md §10). Each compact also stalls the session ~90–100s. This is what the
252
+ proxy prevents by keeping reported usage far below the threshold.
253
+
254
+ ## Log
255
+
256
+ `~/.onepass/proxy.log.<start-time>.jsonl` — one file per proxy run, so reports never mix
257
+ metrics from unrelated runs. One JSON object per line: per-request entries
258
+ (path, status, sizes, timings, estimated tokens before/after eviction) and per-trip entries
259
+ (ids added, chars removed). **Request and response bodies are never logged** — sizes, ids,
260
+ and URL paths only. Human-readable mirror lines go to stdout.
261
+
262
+ ### The speed gauge
263
+
264
+ The proxy can only make a session slower in two ways: its own per-request work, and cache
265
+ rebuilds it causes. Four numbers per request show both, in the log and on the stdout line:
266
+
267
+ | field | what it measures |
268
+ |---|---|
269
+ | `proxyMs` | the proxy's own work — request body fully read to upstream request sent. Parse + evict + serialize. |
270
+ | `upstreamFirstByteMs` | upstream request sent to its first response byte: the wait on Anthropic. Not the headers event — for SSE the headers arrive before `message_start`. |
271
+ | `cacheReadInputTokens` | context Anthropic served from cache (from the response `usage`). |
272
+ | `cacheCreationInputTokens` | context Anthropic had to process fresh (from the response `usage`). |
273
+
274
+ `durationMs` is request received to upstream response ended — the whole time the client
275
+ waited. (It used to be measured from *after* the eviction work to the response *headers*, so
276
+ it under-reported both ends.) `inputTokens` is the uncached remainder, usually small.
277
+
278
+ A **rebuild** is a request where more than 20% of the context was `cache_creation`: Anthropic
279
+ re-read the conversation instead of serving it from cache, costing a few seconds on that one
280
+ turn. `rebuild` is set only on those, and says why:
281
+
282
+ | value | expected? |
283
+ |---|---|
284
+ | `first` | yes — the session's first request; nothing was cached yet |
285
+ | `after-trip` | yes — the proxy swapped segments for stubs, so the conversation changed. One rebuild per trip, by design |
286
+ | `after-idle` | yes — over 5 minutes since the previous request, so the cache entry expired |
287
+ | `unexpected` | **no** — something is changing the request every turn. A bug in the proxy or the client |
288
+
289
+ Stdout, one line per request — expected rebuilds are noted in lower case, the unexpected one
290
+ shouts:
291
+
292
+ ```
293
+ [onepass] 12:01:03 POST /v1/messages 200 | proxy 41ms | first-byte 1.8s | total 9.2s | cache read 141.2k / new 2.1k | est 140k -> 96k tok, 12 stubbed (0 new)
294
+ [onepass] 12:01:31 POST /v1/messages 200 | proxy 45ms | first-byte 19.2s | total 27.0s | cache read 0 / new 143.0k | est 140k -> 96k tok, 12 stubbed (0 new) <- REBUILD (unexpected)
295
+ ```
296
+
297
+ Only `/v1/messages` requests over **20,000 estimated tokens** are classified. Claude Code
298
+ makes several kinds of call on that path — the conversation itself, plus small side calls
299
+ (title generation, warm-ups) that carry their own separate cache prefix. Counting those made
300
+ the session's real first request look like an unexplained rebuild, and a rebuild that small
301
+ costs no measurable time. `count_tokens` is timed but never classified — it carries no cache
302
+ numbers worth reading — though a trip on one is still counted as the cause of the
303
+ `/v1/messages` rebuild that follows it. Every request is still timed and logged; the floor
304
+ only decides what gets a rebuild verdict.
305
+
306
+ ## Report
307
+
308
+ ```bash
309
+ onepass-report ~/.claude/projects/<cwd-slug>/<session-uuid>.jsonl [proxy-log-path]
310
+ ```
311
+
312
+ Reads the session transcript (read-only) plus the proxy log and prints: compaction count
313
+ (target zero), tokens evicted, tokens recalled via `recall_search`/`recall_get`, the
314
+ evicted:recalled ratio (read it as how much the agent had to pay back for eviction, not as
315
+ proof recall is carrying the session — on real workloads it is rarely called at all, see
316
+ Verification), a speed summary (rebuilds by cause, median and max `proxyMs`, median first-byte
317
+ on cached requests versus rebuilt ones), and a per-request table carrying those numbers next to
318
+ the estimated tokens sent over time (flat is good). The proxy log path defaults to the newest
319
+ `proxy.log.*.jsonl` under `~/.onepass/`.
320
+
321
+ ## Verification
322
+
323
+ Automated (`npm test`, no network): unit tests for the eviction transform (including the
324
+ pressure pass), plus integration tests that run the proxy against a **recorded stub
325
+ upstream** — a local HTTP server that captures exactly what was forwarded. Covered: verbatim
326
+ forwarding of non-messages paths, byte-identical `/v1/messages` bodies when nothing is
327
+ stubbed, stubbing + monotonic re-stub across requests with a single trip logged,
328
+ `count_tokens` evicted identically, chars-per-token calibration from response usage, a user's
329
+ paste going upstream untouched however far past the threshold it is,
330
+ malformed bodies passed through, SSE streamed without buffering (the test deadlocks if the
331
+ proxy buffers), and a 502 API-shaped error when the upstream is unreachable. For `claudep`:
332
+ an end-to-end launch against a fake `claude` and a fake upstream, asserting the exit code is
333
+ passed through, recall is registered for that session id, and the proxy is dead afterwards.
334
+ For recall: a real MCP handshake against the built server, answering out of its own session's
335
+ transcript with a newer decoy transcript planted beside it.
336
+
337
+ ### Verified against the real API
338
+
339
+ Newest evidence first; full numbers in `docs/findings.md`.
340
+
341
+ **On cost, the target is parity, not a saving.** Each trip rewrites the prompt cache, and a
342
+ rewrite is charged at 1.25× base input where a cache read is 0.1× — so the tokens a trip saves on
343
+ later turns are paid for up front. What the proxy buys is a session that runs to the end without
344
+ compacting, at roughly what an unproxied session costs; it is not a way to spend less. A build
345
+ that tripped on nearly every request cost about 4× control, which is what the batch minimum
346
+ (`ONEPASS_BATCH_MIN_TOKENS`) exists to prevent — docs/findings.md §21.
347
+
348
+ - **The A/B run against an unproxied control** (§17, CLI 2.1.258, `opus[1m]`, same task, same
349
+ base commit, byte-identical prompt). The current build peaked at **140,253 tokens** over
350
+ **588 assistant turns** with **zero compactions, zero turns above 150k, and zero unexpected
351
+ rebuilds**; p90 context 118,633, and the median climbed only 1.47× from the session's first
352
+ quarter to its last. The previous stub design, same task: 194,659 peak and 96 turns above
353
+ 150k. Proxy overhead 9ms median, 17ms max. **Quality held** — 63/65 against the ground-truth
354
+ tests for the third proxied run running, versus 64/65 for the unproxied control.
355
+ - **The long run** (§11, cloud container, CLI 2.1.241, OAuth): a 3.3MB four-file
356
+ TypeScript-declaration audit. Raw conversation reached **~1.49M tokens**; sent requests
357
+ peaked at **146,947**; **289 assistant turns, zero compactions**; the audit completed
358
+ correctly. An unproxied 200k-window session hard-stops near 187k — this is ~8× that in one
359
+ sitting, with the client's own context gauge staying flat. §11 also has the ~130–150
360
+ tokens/turn growth of the un-evictable floor that eventually bounds session length.
361
+ - **Real debugging session through the proxy** (§11): two planted bugs in a copy of this
362
+ codebase, fixed character-exact with 22/22 tests green while the proxy evicted the session's
363
+ early context mid-task. The agent re-read files instead of trusting stubs; no confabulation.
364
+ OAuth/subscription auth passes through untouched — an API key is not required after all.
365
+
366
+ **What is still unproven: the recovery path.** Across the three real proxied runs the agent
367
+ called `recall_search`/`recall_get` **zero** times — evicted:recalled is 178,594 : 0. It never
368
+ needed to: it re-read from disk instead, and never once mentioned eviction, missing context, or
369
+ recall. The earlier build put an explicit `recall_search("<path>")` hint in every stub and it
370
+ was still never followed. So the eviction half is measured on real work and the recall half is
371
+ not; §12's deliberate probe — an unannounced question answerable only from evicted content,
372
+ answered exactly — remains the only direct evidence that recall works.
373
+
374
+ If the repo (or `~/.claude/settings.json`) pins `autoCompactWindow`, remember the proxy
375
+ makes that stopgap unnecessary for proxied sessions — a low window like 160k puts the
376
+ compact line at ~144–147k, inside the proxy's own peak range. Drop the setting or lower
377
+ `ONEPASS_TRIP_TOKENS` so peaks clear it.
378
+
379
+ ### Verified locally (2026-08-25, CLI 2.1.243, subscription OAuth, macOS)
380
+
381
+ The local pass-through and recall loop are confirmed too — measurements in `docs/findings.md`
382
+ §12, in the repository:
383
+
384
+ 1. **Pass-through parity**: `ANTHROPIC_BASE_URL=http://localhost:3777 claude -p …` behaves
385
+ identically to a direct run, on real subscription OAuth from a local machine.
386
+ 2. **Recall closes the loop**: a 5-turn session whose raw request size grew to 2.3× the
387
+ armed window ran with **zero compactions**, a flat sent curve, and **evicted:recalled =
388
+ 99:1**; an unannounced probe for evicted content was answered exactly, via
389
+ `recall_search`/`recall_get` — disk first, recall second, no confabulation.
390
+
391
+ ## Working on Onepass itself
392
+
393
+ From a clone of this repo:
394
+
395
+ ```bash
396
+ cd proxy
397
+ npm install
398
+ npm run build
399
+ npm test
400
+ ```
401
+
402
+ `npm i -g .` symlinks the four bins (`claudep`, `onepass-proxy`, `onepass-recall`,
403
+ `onepass-report`) to this
404
+ working tree, so a rebuild is all a deploy needs. The proxy runs compiled `dist/`, not `src/`,
405
+ and reads no git: uncommitted edits go live once built, and switching branches changes what
406
+ runs.
407
+
408
+ Publishing is a tag push — `.github/workflows/publish.yml` publishes this directory on `v*`.
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ // `claudep` — Claude Code with the eviction proxy in front of it.
3
+ //
4
+ // claudep instead of `claude`
5
+ // claude when you want it off; nothing about a plain session changes
6
+ //
7
+ // One proxy per session, on a port the operating system picks. That is what keeps sessions
8
+ // apart: the proxy remembers what it has evicted, its calibration and its timings in memory,
9
+ // and two sessions sharing one proxy would share all three — one session's stubs landing in
10
+ // another's request, and one log holding both. A process each is the cheapest way to have
11
+ // neither. The proxy is started before Claude Code and killed after it, whatever ends first.
12
+ import { spawn } from "node:child_process";
13
+ import { createRequire } from "node:module";
14
+ import { existsSync } from "node:fs";
15
+ import { fileURLToPath } from "node:url";
16
+ import { randomUUID } from "node:crypto";
17
+ import { claudeArgs, claudeEnv, isPassthrough, parseBanner, proxyEnv, reusesExistingSession, sessionIdFromArgs, summaryLine, upstreamWarning, withRecallMcp, } from "./launch.js";
18
+ import { parseProxyLog, scanTranscript } from "./session.js";
19
+ import { findTranscript } from "./transcript.js";
20
+ /** Long enough for a cold `node` start on a loaded machine, short enough to fail a launch. */
21
+ const PROXY_START_TIMEOUT_MS = 20_000;
22
+ const PROXY_STOP_TIMEOUT_MS = 5_000;
23
+ const PROXY_ENTRY = fileURLToPath(new URL("./main.js", import.meta.url));
24
+ function version() {
25
+ return createRequire(import.meta.url)("../package.json").version;
26
+ }
27
+ function note(message) {
28
+ // stderr, not stdout: `claudep -p "..."` is piped into other things, and its output is the
29
+ // agent's answer. Nothing here belongs in that stream.
30
+ process.stderr.write(`${message}\n`);
31
+ }
32
+ /** Runs Claude Code with no proxy at all — for `claudep mcp list`, `--help`, `--version`. */
33
+ function runClaudeAlone(args) {
34
+ const child = spawn("claude", args, { stdio: "inherit" });
35
+ child.on("error", (err) => exitOnClaudeError(err));
36
+ child.on("exit", (code, signal) => process.exit(exitCode(code, signal)));
37
+ }
38
+ function exitOnClaudeError(err) {
39
+ if (err.code === "ENOENT") {
40
+ note("claudep: `claude` is not on your PATH — install Claude Code first (https://claude.com/claude-code)");
41
+ process.exit(127);
42
+ }
43
+ note(`claudep: could not start claude: ${err.message}`);
44
+ process.exit(1);
45
+ }
46
+ /** A child's exit as an exit code of our own: the shell convention for a signal is 128 + n. */
47
+ function exitCode(code, signal) {
48
+ if (code !== null)
49
+ return code;
50
+ const numbers = { SIGINT: 2, SIGTERM: 15, SIGHUP: 1, SIGKILL: 9 };
51
+ return 128 + (numbers[signal ?? "SIGTERM"] ?? 15);
52
+ }
53
+ /**
54
+ * Starts the proxy and waits for it to say which port it bound and where its log is.
55
+ *
56
+ * `detached` puts it in its own process group. Without that, the Ctrl-C that interrupts a turn
57
+ * in Claude Code goes to every process in the terminal's foreground group, and the proxy would
58
+ * die in the middle of the session it is serving.
59
+ */
60
+ async function startProxy() {
61
+ if (!existsSync(PROXY_ENTRY)) {
62
+ note(`claudep: the proxy is not built — no ${PROXY_ENTRY}. Run \`npm run build\` in proxy/.`);
63
+ process.exit(1);
64
+ }
65
+ const child = spawn(process.execPath, [PROXY_ENTRY], {
66
+ env: proxyEnv(process.env),
67
+ stdio: ["ignore", "pipe", "pipe"],
68
+ detached: true,
69
+ });
70
+ const output = [];
71
+ child.stdout?.setEncoding("utf8");
72
+ child.stderr?.setEncoding("utf8");
73
+ child.stderr?.on("data", (chunk) => output.push(chunk));
74
+ const banner = await new Promise((resolve, reject) => {
75
+ let settled = false;
76
+ const finish = (err, value) => {
77
+ if (settled)
78
+ return;
79
+ settled = true;
80
+ clearTimeout(timer);
81
+ if (err !== null)
82
+ reject(err);
83
+ else
84
+ resolve(value);
85
+ };
86
+ const timer = setTimeout(() => finish(new Error(`the proxy said nothing usable in ${PROXY_START_TIMEOUT_MS}ms:\n${output.join("")}`)), PROXY_START_TIMEOUT_MS);
87
+ child.stdout?.on("data", (chunk) => {
88
+ output.push(chunk);
89
+ const parsed = parseBanner(output.join(""));
90
+ if (parsed !== null)
91
+ finish(null, parsed);
92
+ });
93
+ child.on("error", (err) => finish(new Error(`the proxy would not start: ${err.message}`)));
94
+ child.on("exit", (code, signal) => finish(new Error(`the proxy exited (code ${code}, signal ${signal}):\n${output.join("").trim()}`)));
95
+ }).catch((err) => {
96
+ child.kill("SIGKILL");
97
+ note(`claudep: ${err instanceof Error ? err.message : String(err)}`);
98
+ process.exit(1);
99
+ });
100
+ // Past the banner its per-request lines would land on top of the session's own display, but a
101
+ // pipe nobody reads fills up and blocks the writer, so they are read and dropped. Everything
102
+ // they carry is in the JSONL log, which `onepass-report` reads.
103
+ child.stdout?.removeAllListeners("data");
104
+ child.stdout?.resume();
105
+ child.stderr?.removeAllListeners("data");
106
+ child.stderr?.resume();
107
+ let stopping = false;
108
+ child.on("exit", (code) => {
109
+ if (!stopping) {
110
+ note(`claudep: the proxy exited early (code ${code}) — this session can no longer reach the API.`);
111
+ }
112
+ });
113
+ return {
114
+ banner,
115
+ killNow: () => {
116
+ stopping = true;
117
+ if (child.exitCode === null && child.signalCode === null)
118
+ child.kill("SIGKILL");
119
+ },
120
+ stop: async () => {
121
+ stopping = true;
122
+ if (child.exitCode !== null || child.signalCode !== null)
123
+ return;
124
+ await new Promise((resolve) => {
125
+ const giveUp = setTimeout(() => child.kill("SIGKILL"), PROXY_STOP_TIMEOUT_MS);
126
+ child.once("exit", () => {
127
+ clearTimeout(giveUp);
128
+ resolve();
129
+ });
130
+ child.kill("SIGTERM");
131
+ });
132
+ },
133
+ };
134
+ }
135
+ /** What the session did, as one line. Never throws: the session has already ended well. */
136
+ async function summarize(logFilePath, sessionId) {
137
+ try {
138
+ const { requests, trips } = existsSync(logFilePath)
139
+ ? parseProxyLog(logFilePath)
140
+ : { requests: [], trips: [] };
141
+ const transcriptPath = sessionId === null ? null : findTranscript(sessionId);
142
+ const stats = transcriptPath === null ? null : await scanTranscript(transcriptPath);
143
+ return summaryLine({
144
+ transcript: stats === null
145
+ ? null
146
+ : {
147
+ compactions: stats.compactionCount,
148
+ recallResults: stats.recallResultCount,
149
+ peakContextTokens: stats.realUsagePeak,
150
+ },
151
+ segmentsEvicted: trips.reduce((total, trip) => total + trip.addedToolUseIds.length, 0),
152
+ tokensEvicted: Math.round(trips.reduce((total, trip) => total + trip.charsRemoved, 0) / 4),
153
+ peakSentTokens: Math.max(0, ...requests.map((request) => request.estimatedTokensSent ?? 0)),
154
+ });
155
+ }
156
+ catch (err) {
157
+ return `onepass: could not read this session's numbers (${err instanceof Error ? err.message : String(err)})`;
158
+ }
159
+ }
160
+ async function main() {
161
+ const args = process.argv.slice(2);
162
+ if (args[0] === "--version" || args[0] === "-v")
163
+ note(`claudep (onepass-proxy ${version()})`);
164
+ if (isPassthrough(args)) {
165
+ runClaudeAlone(args);
166
+ return;
167
+ }
168
+ const warning = upstreamWarning(process.env);
169
+ if (warning !== null)
170
+ note(warning);
171
+ const proxy = await startProxy();
172
+ // A resumed conversation already has an id, and Claude Code rejects a second one. Its own id
173
+ // is used for the exit line when the user named it; `--continue` names nothing, so that line
174
+ // says what the log alone can say.
175
+ const resuming = reusesExistingSession(args);
176
+ const sessionId = resuming ? sessionIdFromArgs(args) : randomUUID();
177
+ const withRecall = withRecallMcp(claudeArgs(args, resuming ? null : sessionId), {
178
+ node: process.execPath,
179
+ entry: fileURLToPath(new URL("./recall.js", import.meta.url)),
180
+ sessionId,
181
+ });
182
+ if (withRecall.warning !== null)
183
+ note(withRecall.warning);
184
+ const claude = spawn("claude", withRecall.args, {
185
+ stdio: "inherit",
186
+ env: claudeEnv(process.env, proxy.banner.port),
187
+ });
188
+ // Ctrl-C belongs to the session: Claude Code interrupts the turn, and `claudep` must not take
189
+ // the terminal down around it. Ending is Claude Code's to decide, and we follow it out.
190
+ process.on("SIGINT", () => { });
191
+ process.on("SIGTERM", () => claude.kill("SIGTERM"));
192
+ process.on("SIGHUP", () => claude.kill("SIGHUP"));
193
+ // Whatever else happens, the proxy does not outlive this process.
194
+ process.on("exit", () => proxy.killNow());
195
+ claude.on("error", (err) => {
196
+ proxy.killNow();
197
+ exitOnClaudeError(err);
198
+ });
199
+ claude.on("exit", (code, signal) => {
200
+ void (async () => {
201
+ await proxy.stop();
202
+ note(await summarize(proxy.banner.logFilePath, sessionId));
203
+ process.exit(exitCode(code, signal));
204
+ })();
205
+ });
206
+ }
207
+ await main();