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 +21 -0
- package/README.md +408 -0
- package/dist/claudep.js +207 -0
- package/dist/evict.js +416 -0
- package/dist/launch.js +176 -0
- package/dist/log.js +53 -0
- package/dist/main.js +62 -0
- package/dist/recall.js +190 -0
- package/dist/report.js +137 -0
- package/dist/server.js +340 -0
- package/dist/session.js +100 -0
- package/dist/speed.js +77 -0
- package/dist/transcript.js +79 -0
- package/package.json +48 -0
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*`.
|
package/dist/claudep.js
ADDED
|
@@ -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();
|