sharerouter-capture 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. sharerouter_capture-0.1.0/LICENSE +21 -0
  2. sharerouter_capture-0.1.0/PKG-INFO +516 -0
  3. sharerouter_capture-0.1.0/README.md +488 -0
  4. sharerouter_capture-0.1.0/pyproject.toml +63 -0
  5. sharerouter_capture-0.1.0/setup.cfg +4 -0
  6. sharerouter_capture-0.1.0/sharerouter_capture/__init__.py +3 -0
  7. sharerouter_capture-0.1.0/sharerouter_capture/__main__.py +5 -0
  8. sharerouter_capture-0.1.0/sharerouter_capture/certs.py +162 -0
  9. sharerouter_capture-0.1.0/sharerouter_capture/cli.py +1788 -0
  10. sharerouter_capture-0.1.0/sharerouter_capture/config.py +68 -0
  11. sharerouter_capture-0.1.0/sharerouter_capture/cursor_h2.py +1386 -0
  12. sharerouter_capture-0.1.0/sharerouter_capture/cursor_inspect.py +724 -0
  13. sharerouter_capture-0.1.0/sharerouter_capture/events.py +365 -0
  14. sharerouter_capture-0.1.0/sharerouter_capture/indicator/ShareRouterIndicator.swift +221 -0
  15. sharerouter_capture-0.1.0/sharerouter_capture/indicator.py +348 -0
  16. sharerouter_capture-0.1.0/sharerouter_capture/mcp_load_tools_server.py +130 -0
  17. sharerouter_capture-0.1.0/sharerouter_capture/node_preload/sharerouter_capture_hook.cjs +2015 -0
  18. sharerouter_capture-0.1.0/sharerouter_capture/proxy.py +888 -0
  19. sharerouter_capture-0.1.0/sharerouter_capture/status_line.py +609 -0
  20. sharerouter_capture-0.1.0/sharerouter_capture.egg-info/PKG-INFO +516 -0
  21. sharerouter_capture-0.1.0/sharerouter_capture.egg-info/SOURCES.txt +23 -0
  22. sharerouter_capture-0.1.0/sharerouter_capture.egg-info/dependency_links.txt +1 -0
  23. sharerouter_capture-0.1.0/sharerouter_capture.egg-info/entry_points.txt +2 -0
  24. sharerouter_capture-0.1.0/sharerouter_capture.egg-info/requires.txt +2 -0
  25. sharerouter_capture-0.1.0/sharerouter_capture.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ShareRouter
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.
@@ -0,0 +1,516 @@
1
+ Metadata-Version: 2.4
2
+ Name: sharerouter-capture
3
+ Version: 0.1.0
4
+ Summary: Measure and compress what your coding agent sends to an LLM, without handing over its credentials
5
+ Author: ShareRouter
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://gitlab.com/sharerouter/sharerouter_capture
8
+ Project-URL: Repository, https://gitlab.com/sharerouter/sharerouter_capture
9
+ Project-URL: Issues, https://gitlab.com/sharerouter/sharerouter_capture/-/issues
10
+ Keywords: llm,proxy,compression,tokens,codex,claude-code,cursor,mitm
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: MacOS :: MacOS X
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Testing
20
+ Classifier: Topic :: System :: Networking :: Monitoring
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: h2>=4
26
+ Requires-Dist: websockets>=12
27
+ Dynamic: license-file
28
+
29
+ # ShareRouter Capture
30
+
31
+ `sharerouter-capture` is a local wrapper for capturing model CLI traffic while keeping the user's native auth on the same machine.
32
+
33
+ ## Install
34
+
35
+ The commands below assume `sharerouter-capture` is on your `PATH`. Editable install,
36
+ so the shim follows the working tree and a `git pull` needs no reinstall:
37
+
38
+ ```bash
39
+ uv tool install --editable /path/to/compressor-ai/sharerouter_capture
40
+ ```
41
+
42
+ That puts the executable in `~/.local/bin`. Verify with `sharerouter-capture doctor`,
43
+ which prints the active profile without touching it. To upgrade after changing
44
+ `pyproject.toml`, add `--force`; to remove, `uv tool uninstall sharerouter-capture`.
45
+
46
+ `pip install -e .` works too, but installs into whichever environment is active and
47
+ so is easy to lose track of. Without any install, run it as a module — see the end
48
+ of this file.
49
+
50
+ ## Disconnecting
51
+
52
+ ```bash
53
+ sharerouter-capture logout
54
+ ```
55
+
56
+ Removes the stored credential and then lists, with sizes, what it did NOT remove: the
57
+ local CA's private key, the installed hooks, and any captured traffic — which is your own
58
+ prompts and source code. `logout` means "stop being connected"; throwing that away is a
59
+ different decision and needs `--purge`:
60
+
61
+ ```bash
62
+ sharerouter-capture logout --purge
63
+ ```
64
+
65
+ Nothing outside the capture home is touched either way, and the CA was never added to the
66
+ system keychain, so there is no system trust to withdraw.
67
+
68
+ **`connect` overwrites the whole profile, not just the key.** `--control-plane`
69
+ defaults to `https://api.sharerouter.ai`, so running plain `connect` against a local
70
+ backend silently repoints it at production and re-enables upstream TLS verification.
71
+ For a local backend pass both:
72
+
73
+ ```bash
74
+ sharerouter-capture connect --workspace-key "sr_xxxxx" --control-plane http://127.0.0.1:8180
75
+ ```
76
+
77
+ Check `sharerouter-capture doctor` afterwards — it prints the control plane in use.
78
+
79
+ ## Usage
80
+
81
+ The user-facing path stays short:
82
+
83
+ ```bash
84
+ sharerouter-capture connect --workspace-key "sr_xxxxx"
85
+ sharerouter-capture run -- cursor-agent "Reply with exactly: cursor proxied native auth ok"
86
+ sharerouter-capture run -- codex exec "Reply with exactly: codex captured ok"
87
+ sharerouter-capture run -- claude "Reply with exactly: claude captured ok"
88
+ sharerouter-capture run -- grok -p "Reply with exactly: grok captured ok"
89
+ ```
90
+
91
+ ## Desktop apps
92
+
93
+ One verb for both. `--gui` says you mean the desktop app of that name:
94
+
95
+ ```bash
96
+ sharerouter-capture run --gui -- codex # the Codex desktop app
97
+ sharerouter-capture run -- codex # the codex CLI
98
+ sharerouter-capture run --gui -- "/Applications/Some App.app/Contents/MacOS/Some App"
99
+ ```
100
+
101
+ The flag is not decoration. macOS filesystems are case-insensitive, so
102
+ `which("Codex")` returns the `codex` CLI — capitalisation cannot carry the
103
+ distinction, and something explicit has to. A path inside a `.app` is recognised as a
104
+ desktop app on its own; `--cli` forces CLI handling for one that isn't.
105
+
106
+ Names are matched against each bundle's own `Info.plist`, not a table of ours: the
107
+ Codex desktop app ships as `ChatGPT.app` with the identifier `com.openai.codex`, and
108
+ `--gui -- codex` finds it through that identifier.
109
+
110
+ ### Codex desktop app — routed, not intercepted
111
+
112
+ Measured, not assumed: this app makes **no** model requests from Chromium. It spawns
113
+ its bundled `codex` binary as an app-server over stdio
114
+ (`Contents/Resources/codex ... app-server`), and that child does the model traffic.
115
+ Proxying Chromium would therefore capture only telemetry — and would trip Chromium's
116
+ certificate pinning on Google hosts along the way.
117
+
118
+ So it is routed the same way the codex CLI is, with no interception anywhere. The app
119
+ has no argv of ours to add `-c` overrides to, and it writes its own settings into
120
+ `config.toml`, so editing that file would fight the app. Instead `CODEX_CLI_PATH` —
121
+ the app's own hook for choosing the binary — points at a small generated shim that
122
+ execs the app's real binary with one override added. The user's `CODEX_HOME` is
123
+ untouched and native ChatGPT auth is unchanged.
124
+
125
+ That override is `openai_base_url`, codex's own root-level setting for the model
126
+ endpoint, and **not** a provider of ours. Declaring `model_provider` also worked, but
127
+ it hid the entire chat history: threads are stored with the provider that created
128
+ them (`threads.model_provider` in the state DB, indexed) and both the app and `codex
129
+ resume` list only the active provider's. Measured on one machine: 25 threads listed
130
+ under the built-in provider, 0 under ours. Keeping the built-in provider also keeps
131
+ the user's configured model instead of one we pick, and needs no wire-API or auth
132
+ flags of our own — the built-in provider already has the right ones.
133
+
134
+ Threads created while an older build declared a custom provider stay hidden until
135
+ that value changes, in the state DB **and** in the session's `rollout-*.jsonl`
136
+ (`session_meta.model_provider`), which `thread/list` scans to repair metadata.
137
+
138
+ Launching is quiet on purpose. The app's console does not reach the terminal, and
139
+ only its failures reach `~/.sharerouter/capture/gui.log` — unfiltered it was 101 KB
140
+ after twelve seconds of one launch, which would fill a disk on an app left running.
141
+ Measured on one launch: 26 of 808 lines kept, 5.8 KB of 291 KB. The filter is
142
+ case-sensitive on purpose (`errorCode=null` appears on nearly every routed response,
143
+ so matching "error" loosely keeps 244 lines of nothing) and it also keeps
144
+ `stdio_transport_spawned`, the one line that records which binary the app spawned.
145
+ The file rotates to `gui.log.1` past 1 MB. On this path there is no proxy to keep
146
+ alive, so the launch detaches and hands the shell straight back.
147
+
148
+ **Quit the app before launching it here.** It is single-instance: a second launch is
149
+ handed to the process already running ("Opening in existing browser session") and the
150
+ new one exits at once, so the environment `run` just set is discarded and the running
151
+ instance decides whether anything is captured. `run` reads that state first and says
152
+ which case you are in — `already running under capture` and it does nothing, or it
153
+ refuses and asks you to quit. It never claims a capture it did not set up.
154
+
155
+ The bundle on disk is called **ChatGPT.app** even though the app is Codex; the
156
+ identifier is `com.openai.codex` and OpenAI ships it under that file name. Nothing
157
+ here renames anything — the only file this tool writes is the shim under
158
+ `~/.sharerouter/capture/`. Output names both, so a resolution never reads as a swap:
159
+
160
+ ```
161
+ desktop app: codex → ChatGPT.app · com.openai.codex
162
+ ```
163
+
164
+ Verified end to end on `com.openai.codex` build 26.721.81911 (app-server
165
+ 0.146.0-alpha.3.1): the app's own log reported
166
+ `stdio_transport_spawned executablePath=~/.sharerouter/capture/codex_app_server_shim.sh`,
167
+ the app-server ran with `-c openai_base_url="…/token/…"`, its thread list returned the
168
+ 25 existing threads, and a real turn through it produced one `relay.request` — agent
169
+ `codex`, 26.0% input compression.
170
+
171
+ ### Other Chromium / Electron apps
172
+
173
+ A packaged Electron app is not a CLI. Measured: it ignores
174
+ `NODE_OPTIONS --require` outright, so the preload hook that captures the CLI agents
175
+ never loads, and its network goes through Chromium, which reads neither
176
+ `NODE_EXTRA_CA_CERTS` nor `SSL_CERT_FILE` — it uses the system trust store.
177
+
178
+ So nothing is installed. Chromium gets two switches: `--proxy-server` pointing at the
179
+ capture proxy, and `--ignore-certificate-errors-spki-list` carrying the SHA-256 of our
180
+ CA's public key. Chromium then trusts **exactly that one key**, for **that launch
181
+ only**, and keeps validating every other certificate normally. Nothing enters the
182
+ system keychain and no other application is affected. This is deliberately not
183
+ `--ignore-certificate-errors`, which turns validation off wholesale.
184
+
185
+ Verified with headless Chromium: every request, `https://example.com` included, was
186
+ decrypted and read by the proxy, and the keychain was still clean afterwards.
187
+
188
+ Two things to know:
189
+
190
+ - **Never launch through `open`.** `open` hands the launch to launchd, which starts
191
+ the app in its own environment and drops both the switches and the environment.
192
+ `run` refuses an `open` target for this reason.
193
+ - **The app must forward unknown arguments to Chromium.** Most Electron apps do.
194
+ One that parses argv strictly will ignore the switches, and then nothing is
195
+ captured — the proxy will simply see no traffic rather than fail loudly. Google's
196
+ own hosts are pinned and will refuse the local CA; that shows up in the log as
197
+ `not_captured`, which is accurate.
198
+
199
+ ## Certificates: the CA stays on this machine
200
+
201
+ The signing key is **never** distributed and the backend holds no CA at all.
202
+ `ensure_ca()` generates a CA per machine on first use, into
203
+ `~/.sharerouter/capture/ca.key` with mode `0600`; it is referenced by path only and
204
+ appears in no request payload. `ensure_host_cert` serves the leaf **and** the CA, so a
205
+ client that pins our public key sees the full chain — an SPKI allowlist only matches
206
+ certificates actually presented.
207
+
208
+ Signing centrally instead would be strictly worse, so it is deliberately not done:
209
+
210
+ - One CA whose private key we hold, trusted on every user's machine, turns a single
211
+ breach of ours into the ability to impersonate any site to all of them. A
212
+ per-machine CA confines that blast radius to one laptop.
213
+ - Minting leaves on demand would send us a CSR per visited hostname — a live browsing
214
+ log for hosts we otherwise never learn about.
215
+ - It would put a network round trip in the TLS handshake path, so `run` would stop
216
+ working offline or on a flaky link.
217
+
218
+ ## In the agent's own status line (Claude Code)
219
+
220
+ While `run -- claude` is going, the bottom of Claude Code carries one figure: how much
221
+ input this window's requests were compressed by, coloured on the same scale as the
222
+ bars in `stats`, right-aligned on the row above the agent's own hints.
223
+
224
+ ```
225
+ input compression 46.0% of 23.1K sent
226
+ ⏸ manual mode on · ← for agents
227
+ ```
228
+
229
+ Left edge, in the same column as the hints under it: Claude Code gives us a row of our
230
+ own there, and pushing the line to the right only made the two rows look unrelated.
231
+ Cursor, which has no spare row, is the case that still needs the far edge — see below.
232
+
233
+ It is named the way the `stats` header names it, and it carries the count it was
234
+ measured on. A bare ratio invites "of what?" — and 46% off a thousand tokens and 46%
235
+ off a million are the same percentage and not the same result. Only the percentage is
236
+ coloured; the words and the count stay grey.
237
+
238
+ **Tokens are summed, percentages are not averaged.** The figure is
239
+ `(input − compressed) / input` over every model request this window made, so a large
240
+ request counts for more than a small one. On one measured window — three requests of
241
+ 576, 17 940 and 4 581 input tokens — that is 46.0%, while the mean of the three
242
+ per-request percentages (0.0, 58.7, 1.9) would have said 20.2%. Same requests, and one
243
+ of those two numbers is not a compression ratio.
244
+
245
+ Non-model traffic the proxy relayed — telemetry, plugin checks — is counted separately
246
+ and kept out of the ratio; it has nothing compressible in it, and including it dragged
247
+ the headline down for work the pipeline was never asked to do.
248
+
249
+ **This window, not the workspace.** Several agent windows can share one API key, so
250
+ neither time nor key can tell them apart: each `run` mints a launch id, every report it
251
+ makes carries it in a header, and the figure comes from
252
+ `/api/capture/stats?launch=<id>`. A day total would be a different claim wearing the
253
+ same percent sign. The ratio is all that is shown — token counts are in `stats`, and a
254
+ second set of numbers on that row would compete with the agent's own.
255
+
256
+ A window that has not sent anything says `no requests yet` rather than `0.0%`, which
257
+ would read as "compression did nothing", and an unreachable backend says so. Both in
258
+ grey, so nothing dim ever looks like a measurement.
259
+
260
+ **An absence of data is cached for one second, a measurement for five.** They shared a
261
+ TTL once, and the window's first request landed inside it: the agent re-ran the command
262
+ when the turn finished, got the cached "no requests yet", and had no reason to ask
263
+ again — so a window that had just compressed a request went on claiming it had sent
264
+ none. A measurement can be five seconds old without misleading anyone; the absence of
265
+ one cannot. The row still only refreshes when the agent decides to re-render it, which
266
+ is its call, not ours — the guarantee here is that the next render tells the truth.
267
+
268
+ Nothing of the user's is edited: Claude Code's `statusLine` is passed for that launch
269
+ only, with `--settings`, so `~/.claude/settings.json` is untouched and nothing is left
270
+ behind afterwards. A `--settings` of their own is left alone, and a `statusLine` they
271
+ already configured is **run first** and kept on the left — ours takes the right edge,
272
+ or stands down if the row is too narrow for both. The command is re-run on every
273
+ render, so the report is cached per launch: ~40 ms and no network per render.
274
+
275
+ **The agent's own hints row cannot be shared.** `statusLine` takes a `type` and a
276
+ `command` and nothing else — no position — and the binary has no setting that hides or
277
+ moves the `⏸ manual mode on` row. Ours therefore sits directly above it.
278
+
279
+ Writing into that row from outside is possible and is not done, for reasons that are
280
+ mechanical rather than aesthetic. The agent owns the terminal: it repaints with
281
+ absolute cursor moves (`ESC[41;1H` and friends — 44 of them in 26 idle seconds of one
282
+ measured session) and it repaints on every keystroke, spinner tick and stream chunk. A
283
+ second writer on the same tty has no lock, so our bytes can land between two bytes of
284
+ one of its escape sequences and corrupt the frame; and whatever survives is erased by
285
+ the next repaint, which is the flicker. Doing it properly means owning the terminal
286
+ instead — running the agent in a pty and reserving a row, which is what tmux does, and
287
+ then rewriting the agent's absolute cursor moves so they cannot address the reserved
288
+ row. That is a terminal emulator, and a fragile one for any agent that paints
289
+ differently. If you want the number on a single reserved row today, run the agent
290
+ inside tmux and put `sharerouter-capture stats --json` output in tmux's status line:
291
+ tmux already owns the terminal and reserves that row correctly.
292
+
293
+ Three details of the row are measured, not documented, and pinned by tests: it is
294
+ indented two columns, leading whitespace is trimmed (a no-break space counts as
295
+ whitespace to JavaScript's `trim()`, so padding alone vanishes), and ANSI
296
+ cursor-movement escapes are stripped while colour codes pass through. Right alignment
297
+ therefore uses a zero-width space as an anchor, then no-break padding.
298
+
299
+ ### cursor-agent
300
+
301
+ Nothing to install. `run -- cursor-agent` puts the entry in place and takes it out again
302
+ when the run ends, so the figure appears without anyone configuring anything and the
303
+ config is as it was afterwards.
304
+
305
+ cursor-agent has a status line of its own kind — `{type, command, padding,
306
+ updateIntervalMs, timeoutMs}` in `cli-config.json` — but, unlike Claude Code, no
307
+ per-launch override, so that file is the only way into its footer. Which makes the
308
+ restore the important part: cursor rewrites that file itself while it runs (model choice,
309
+ approval mode, its own caches), so only the `statusLine` key is touched on the way out —
310
+ writing back a copy taken before the launch would silently undo whatever changed during
311
+ the session. A status line configured by someone else is left alone and ours is not added
312
+ at all; one appearing mid-run wins too.
313
+
314
+ `sharerouter-capture status-line` prints where the figure comes from for each agent, and
315
+ clears the entry if a run was killed before it could. Nobody needs to run it.
316
+
317
+ ```
318
+ Composer 2.5 Fast · 6.3% input compression 55.2% of 1.4K sent
319
+ ```
320
+
321
+ **Cursor's own footer is rebuilt, not taken.** Measured: with a `statusLine` configured,
322
+ cursor-agent stops painting `Composer 2.5 Fast · 6.3%` and paints ours in its place —
323
+ trading the model and the context percentage for a compression figure would be a bad
324
+ deal. Its payload carries both, so they go back on the left. That payload also states
325
+ `render_width_chars`, which is what the right edge is measured against here, and what
326
+ tells cursor's payload apart from Claude Code's: a status line the user configured for
327
+ Claude is never run under cursor, where it would print one agent's state inside another.
328
+
329
+ ### Menu bar (macOS)
330
+
331
+ Every `run` also puts the figure in the menu bar, beside the clock, for as long as the
332
+ agent is running:
333
+
334
+ ```
335
+ ⌁ 23% 🔋 Wi-Fi Thu 01:24
336
+ ┌────────────────────────────────────────────────────────────────┐
337
+ │ Running now · 2 windows · 22.7% together │
338
+ │ codex ██──────── 22.7% · 91.2K sent · 5 req │
339
+ │ claude ·········· no requests yet │
340
+ │ Today │
341
+ │ all agents ████────── 35.6% · 2.5M sent · 181 req │
342
+ │ cursor ███████─── 66.2% · 44.3K sent · 13 req │
343
+ │ Open ShareRouter console │
344
+ └────────────────────────────────────────────────────────────────┘
345
+ ```
346
+
347
+ The request count is there because a total without one is misread. That 91.2K was five
348
+ model calls in a single desktop turn — 4.4K, 13.1K, 25.2K, 15.3K and 33.2K input tokens —
349
+ since each call resends the agent's instructions, its tool definitions and the history so
350
+ far. Shown as "91.2K sent" alone it looks like one impossibly large request.
351
+
352
+ Colour belongs to the bar, on the scale `stats` already uses — green over 50%, blue over
353
+ 25%, amber below, grey for nothing — muted a quarter towards grey, because at full
354
+ saturation in a small menu a scale reads as a row of alerts. The figures themselves stay in
355
+ the label colour: they are readings, and a coloured number competes with the bar for the
356
+ same job. A window that has sent nothing gets a dotted track rather than an empty bar, which
357
+ a measured zero would look like.
358
+
359
+ Getting colour into a menu took one non-obvious thing: AppKit dims a **disabled** item's
360
+ title even when the attributed string names a colour, which is why every figure first came
361
+ out grey as though switched off. The rows are enabled instead, with `autoenablesItems` off
362
+ so nothing is clickable, and they draw at full strength. System colours rather than fixed
363
+ ones, since the menu is drawn in whichever appearance the user runs.
364
+
365
+ **Two agents at once:** the title is the windows **together**, summed over tokens like every
366
+ other figure here. Picking one would make it depend on which agent started first, and
367
+ averaging the percentages would let a 1K window count as much as a 100K one — 10% of 100K
368
+ and 90% of 1K is 10.8% together, not 50%. The header says how many windows and the menu
369
+ lists each one, so the aggregate is never the only thing on offer.
370
+
371
+ This is the answer for the desktop apps, whose own UI has no place for us: Codex's message
372
+ list is its own React tree, reachable only by attaching a debugger and rewriting its DOM —
373
+ fragile, a security downgrade, and not ours to edit. A status item sits beside them
374
+ instead, needs no permissions, and serves every agent the same way. The title carries the
375
+ running window when there is one, because a menu bar has room for one number and the one
376
+ worth having is "is it working right now".
377
+
378
+ Nothing to install and nothing to run: `run` starts it and it follows the agent's own
379
+ pid, which is what makes it work for a desktop launch — the wrapper hands the shell back
380
+ immediately, so the indicator cannot be a child that dies with it.
381
+
382
+ It is a ~110-line Swift file compiled on first use into `~/.sharerouter/capture/indicator`
383
+ (~110 KB, no Python dependencies; a prebuilt binary in a wheel would arrive unsigned and
384
+ Gatekeeper-quarantined). Without Xcode command line tools there is simply no indicator and
385
+ nothing is said about it — the same number is in `stats --watch` and the console, so this
386
+ is a missing convenience, not a failure. The Swift process draws strings and nothing else:
387
+ it holds no key, opens no socket and parses no protocol, and is fed one JSON line per
388
+ refresh over stdin, so an update is a write rather than a restart and closing the pipe is
389
+ how it knows to go.
390
+
391
+ ### codex and grok: at the end of the run, or live in a second pane
392
+
393
+ Neither has a status line to write into. codex has a `tui.status_line`, but it selects
394
+ from its own fixed items (`account`, `model`, `reasoning_effort`, `usage`) and takes no
395
+ text of ours. grok has none at all — its whole `[ui]` config is one option,
396
+ `max_thoughts_width`; its hooks are lifecycle scripts whose output is not a footer, and
397
+ its announcements channel carries xAI's own notices through an undocumented internal
398
+ shape, which is not somewhere our numbers belong. Painting into either TUI from outside
399
+ would mean two writers on one tty with no lock, and every frame they paint erasing ours.
400
+
401
+ A one-shot run therefore reports as it finishes, which is where `-p` and `exec` users
402
+ were already looking. An **interactive** session shows nothing until it exits, so for that
403
+ there is a second pane:
404
+
405
+ ```bash
406
+ sharerouter-capture stats --watch # redraws every 2s; --watch 5 for slower
407
+ ```
408
+
409
+ ```
410
+ RUNNING NOW
411
+ grok ██████████████──────── 64.1% 16.4K of 25.6K · 3 req
412
+
413
+ Past 24 hours ...
414
+ ```
415
+
416
+ `RUNNING NOW` is the window that is actually on screen, not the day's total: each `run`
417
+ leaves a marker naming its launch, which is the only way a second terminal can tell one
418
+ window from another. Several windows are several rows, and two of the same agent are told apart
419
+ by the directory each was started in — `claude · compressor-ai` beside `claude · grigory` —
420
+ added only when it disambiguates, since one window needs no explaining. The marker is removed when the run ends, and one left behind by a
421
+ killed wrapper is pruned on sight — its pid is checked, not trusted, because a stale row
422
+ would describe a window nobody is looking at.
423
+
424
+ So they get the same figure once, after the agent exits and the terminal is ours again:
425
+
426
+ ```
427
+ sharerouter · input compression 48.6% of 19.1K sent · 1 request
428
+ ```
429
+
430
+ For `codex exec` and `grok -p` that lands directly under the answer, and every agent
431
+ gets it — cursor and claude included, so a finished run always ends the same way.
432
+ It goes to stderr and only to a terminal, so piping an agent's output into a file or a
433
+ test gains nothing of ours. A run that captured nothing says so — silence there is
434
+ indistinguishable from capture quietly not working. Claude Code shows the figure live
435
+ and gets this line too, so every run ends with the same sentence.
436
+
437
+ ### How one window is told from another
438
+
439
+ Per-window scoping needs the launch identified, and the agents reach ShareRouter in
440
+ three different ways, so it is carried three ways:
441
+
442
+ | Path | Agent | How the launch travels |
443
+ |---|---|---|
444
+ | Local proxy | claude | `x-sharerouter-capture-launch` on the report |
445
+ | Node hook | cursor | the same header, from the hook's own posts |
446
+ | Base URL, no interception | codex, grok | `/token/<key>/l/<launch>/…` in the URL |
447
+
448
+ codex and grok dial the relay themselves, so there is no request of ours to put a header
449
+ on — the id has to be in the URL they were configured with. The backend serves both URL
450
+ shapes, so an older capture keeps working; the id is bounded and alphanumeric, stored on
451
+ the event and compared, never executed.
452
+
453
+ ## Statistics
454
+
455
+ ```bash
456
+ sharerouter-capture stats # or: statistics
457
+ sharerouter-capture stats --json # the raw report
458
+ ```
459
+
460
+ Measured input compression for the key in your profile, over the past day, week and
461
+ month, broken down by agent. Counts and percentages together: a percentage alone
462
+ hides whether it acted on a thousand tokens or a million, and a token count alone
463
+ says nothing about how much of the request was compressed away.
464
+
465
+ ```
466
+ Past 24 hours ███████████············· 45.9% 216.3K saved of 470.8K in 255 requests
467
+ claude ███████········· 45.2% 133.2K of 294.9K · 142 req
468
+ grok ██████████······ 61.8% 39.6K of 64.0K · 8 req
469
+ ```
470
+
471
+ Everything is summed from the relay's own `relay.request` events for that key —
472
+ nothing modelled, nothing extrapolated. A window with no traffic says "no requests"
473
+ rather than showing 0%, which would read as "compressed nothing". Output is not
474
+ compressed and is not counted.
475
+
476
+ What happens under the hood:
477
+
478
+ - `connect` stores a ShareRouter workspace profile in `~/.sharerouter/capture/config.json`.
479
+ - `run` starts a local proxy on a random loopback port and injects proxy/CA environment variables only into the child process.
480
+ - HTTPS `CONNECT` traffic **to model endpoints** is terminated locally with a generated
481
+ local CA, forwarded upstream, and mirrored to ShareRouter as redacted metadata. Every
482
+ other host is tunnelled byte-for-byte and never decrypted: interception used to be
483
+ attempted on every connection, and `github.com` refusing the local CA took the agent's
484
+ request down with the handshake. The set is
485
+ `anthropic.com`, `api.openai.com`, `chatgpt.com`, `api.x.ai`, `cursor.sh`; add to it
486
+ with `SHAREROUTER_CAPTURE_MITM_HOSTS=host.example` for a model provider we do not know,
487
+ since a model endpoint that is merely tunnelled would never be compressed.
488
+ - Sensitive request and response headers are redacted before events are sent.
489
+ - Native model traffic is classified as `cursor`, `codex`, `claude`, `grok`, `openai`, or `unknown`.
490
+ - Generation requests are tagged with `provider_protocol`, for example `cursor_connect_proto`, `openai_responses`, `anthropic_messages`, or `xai_responses`.
491
+ - Request and response bodies are not persisted raw by default in control-plane events, but SHA-256 fingerprints and redacted JSON previews are included when possible. That lets ShareRouter verify the intercepted body is byte-identical to the upstream body.
492
+ - If the control plane is unavailable, events are spooled locally in `events_spool.jsonl`; the user command still runs.
493
+
494
+ Fallback capture without changing `base_url`:
495
+
496
+ - `codex`: run the Codex command through `sharerouter-capture run -- ...`. Codex keeps its native auth/config; ShareRouter sees the OpenAI Responses request shape when the client honors proxy/CA environment variables.
497
+ - `claude`: run Claude Code through `sharerouter-capture run -- ...`. Claude keeps its native auth/config; ShareRouter sees Anthropic Messages traffic when the client honors proxy/CA environment variables.
498
+ - `grok`: run Grok Build through `sharerouter-capture run -- ...`. Grok keeps browser-auth or `XAI_API_KEY` auth; ShareRouter sees xAI Responses or chat-completions traffic when the client honors proxy/CA environment variables.
499
+ - Node-based clients also get a preload hook through `NODE_OPTIONS`, so direct `http`, `https`, `http2`, and `fetch` calls are mirrored even when proxy-only capture misses details.
500
+
501
+ For local development without installing:
502
+
503
+ ```bash
504
+ cd sharerouter_capture
505
+ python3 -m sharerouter_capture connect --workspace-key "sr_xxxxx"
506
+ python3 -m sharerouter_capture run -- python3 -c "print('wrapped')"
507
+ ```
508
+
509
+ Run the smoke test:
510
+
511
+ ```bash
512
+ cd sharerouter_capture
513
+ python3 tests/smoke_test.py
514
+ ```
515
+
516
+ The smoke test starts a local HTTPS upstream and a local mock control plane, sends real HTTPS requests through the capture proxy, verifies the upstream response, verifies that the captured event redacts secrets, and verifies that Codex-style `/v1/responses`, Claude-style `/v1/messages`, and Grok/xAI-style `/v1/responses` request body hashes match the upstream body hashes.