Skip to main content

ShareRouter Capture

sharerouter-capture is a local wrapper for capturing model CLI traffic while keeping the user's native auth on the same machine.

Install

The commands below assume sharerouter-capture is on your PATH. Editable install, so the shim follows the working tree and a git pull needs no reinstall:

uv tool install --editable /path/to/compressor-ai/sharerouter_capture

That puts the executable in ~/.local/bin. Verify with sharerouter-capture doctor, which prints the active profile without touching it. To upgrade after changing pyproject.toml, add --force; to remove, uv tool uninstall sharerouter-capture.

pip install -e . works too, but installs into whichever environment is active and so is easy to lose track of. Without any install, run it as a module — see the end of this file.

Disconnecting

sharerouter-capture logout

Removes the stored credential and then lists, with sizes, what it did NOT remove: the local CA's private key, the installed hooks, and any captured traffic — which is your own prompts and source code. logout means "stop being connected"; throwing that away is a different decision and needs --purge:

sharerouter-capture logout --purge

Nothing outside the capture home is touched either way, and the CA was never added to the system keychain, so there is no system trust to withdraw.

connect overwrites the whole profile, not just the key. --control-plane defaults to https://api.sharerouter.ai, so running plain connect against a local backend silently repoints it at production and re-enables upstream TLS verification. For a local backend pass both:

sharerouter-capture connect --workspace-key "sr_xxxxx" --control-plane http://127.0.0.1:8180

Check sharerouter-capture doctor afterwards — it prints the control plane in use.

Usage

The user-facing path stays short:

sharerouter-capture connect --workspace-key "sr_xxxxx"
sharerouter-capture run -- cursor-agent "Reply with exactly: cursor proxied native auth ok"
sharerouter-capture run -- codex exec "Reply with exactly: codex captured ok"
sharerouter-capture run -- claude "Reply with exactly: claude captured ok"
sharerouter-capture run -- grok -p "Reply with exactly: grok captured ok"

Desktop apps

One verb for both. --gui says you mean the desktop app of that name:

sharerouter-capture run --gui -- codex     # the Codex desktop app
sharerouter-capture run -- codex           # the codex CLI
sharerouter-capture run --gui -- "/Applications/Some App.app/Contents/MacOS/Some App"

The flag is not decoration. macOS filesystems are case-insensitive, so which("Codex") returns the codex CLI — capitalisation cannot carry the distinction, and something explicit has to. A path inside a .app is recognised as a desktop app on its own; --cli forces CLI handling for one that isn't.

Names are matched against each bundle's own Info.plist, not a table of ours: the Codex desktop app ships as ChatGPT.app with the identifier com.openai.codex, and --gui -- codex finds it through that identifier.

Codex desktop app — routed, not intercepted

Measured, not assumed: this app makes no model requests from Chromium. It spawns its bundled codex binary as an app-server over stdio (Contents/Resources/codex ... app-server), and that child does the model traffic. Proxying Chromium would therefore capture only telemetry — and would trip Chromium's certificate pinning on Google hosts along the way.

So it is routed the same way the codex CLI is, with no interception anywhere. The app has no argv of ours to add -c overrides to, and it writes its own settings into config.toml, so editing that file would fight the app. Instead CODEX_CLI_PATH — the app's own hook for choosing the binary — points at a small generated shim that execs the app's real binary with one override added. The user's CODEX_HOME is untouched and native ChatGPT auth is unchanged.

That override is openai_base_url, codex's own root-level setting for the model endpoint, and not a provider of ours. Declaring model_provider also worked, but it hid the entire chat history: threads are stored with the provider that created them (threads.model_provider in the state DB, indexed) and both the app and codex resume list only the active provider's. Measured on one machine: 25 threads listed under the built-in provider, 0 under ours. Keeping the built-in provider also keeps the user's configured model instead of one we pick, and needs no wire-API or auth flags of our own — the built-in provider already has the right ones.

Threads created while an older build declared a custom provider stay hidden until that value changes, in the state DB and in the session's rollout-*.jsonl (session_meta.model_provider), which thread/list scans to repair metadata.

Launching is quiet on purpose. The app's console does not reach the terminal, and only its failures reach ~/.sharerouter/capture/gui.log — unfiltered it was 101 KB after twelve seconds of one launch, which would fill a disk on an app left running. Measured on one launch: 26 of 808 lines kept, 5.8 KB of 291 KB. The filter is case-sensitive on purpose (errorCode=null appears on nearly every routed response, so matching "error" loosely keeps 244 lines of nothing) and it also keeps stdio_transport_spawned, the one line that records which binary the app spawned. The file rotates to gui.log.1 past 1 MB. On this path there is no proxy to keep alive, so the launch detaches and hands the shell straight back.

Quit the app before launching it here. It is single-instance: a second launch is handed to the process already running ("Opening in existing browser session") and the new one exits at once, so the environment run just set is discarded and the running instance decides whether anything is captured. run reads that state first and says which case you are in — already running under capture and it does nothing, or it refuses and asks you to quit. It never claims a capture it did not set up.

The bundle on disk is called ChatGPT.app even though the app is Codex; the identifier is com.openai.codex and OpenAI ships it under that file name. Nothing here renames anything — the only file this tool writes is the shim under ~/.sharerouter/capture/. Output names both, so a resolution never reads as a swap:

desktop app: codex → ChatGPT.app  ·  com.openai.codex

Verified end to end on com.openai.codex build 26.721.81911 (app-server 0.146.0-alpha.3.1): the app's own log reported stdio_transport_spawned executablePath=~/.sharerouter/capture/codex_app_server_shim.sh, the app-server ran with -c openai_base_url="…/token/…", its thread list returned the 25 existing threads, and a real turn through it produced one relay.request — agent codex, 26.0% input compression.

Other Chromium / Electron apps

A packaged Electron app is not a CLI. Measured: it ignores NODE_OPTIONS --require outright, so the preload hook that captures the CLI agents never loads, and its network goes through Chromium, which reads neither NODE_EXTRA_CA_CERTS nor SSL_CERT_FILE — it uses the system trust store.

So nothing is installed. Chromium gets two switches: --proxy-server pointing at the capture proxy, and --ignore-certificate-errors-spki-list carrying the SHA-256 of our CA's public key. Chromium then trusts exactly that one key, for that launch only, and keeps validating every other certificate normally. Nothing enters the system keychain and no other application is affected. This is deliberately not --ignore-certificate-errors, which turns validation off wholesale.

Verified with headless Chromium: every request, https://example.com included, was decrypted and read by the proxy, and the keychain was still clean afterwards.

Two things to know:

  • Never launch through open. open hands the launch to launchd, which starts the app in its own environment and drops both the switches and the environment. run refuses an open target for this reason.
  • The app must forward unknown arguments to Chromium. Most Electron apps do. One that parses argv strictly will ignore the switches, and then nothing is captured — the proxy will simply see no traffic rather than fail loudly. Google's own hosts are pinned and will refuse the local CA; that shows up in the log as not_captured, which is accurate.

Certificates: the CA stays on this machine

The signing key is never distributed and the backend holds no CA at all. ensure_ca() generates a CA per machine on first use, into ~/.sharerouter/capture/ca.key with mode 0600; it is referenced by path only and appears in no request payload. ensure_host_cert serves the leaf and the CA, so a client that pins our public key sees the full chain — an SPKI allowlist only matches certificates actually presented.

Signing centrally instead would be strictly worse, so it is deliberately not done:

  • One CA whose private key we hold, trusted on every user's machine, turns a single breach of ours into the ability to impersonate any site to all of them. A per-machine CA confines that blast radius to one laptop.
  • Minting leaves on demand would send us a CSR per visited hostname — a live browsing log for hosts we otherwise never learn about.
  • It would put a network round trip in the TLS handshake path, so run would stop working offline or on a flaky link.

In the agent's own status line (Claude Code)

While run -- claude is going, the bottom of Claude Code carries one figure: how much input this window's requests were compressed by, coloured on the same scale as the bars in stats, right-aligned on the row above the agent's own hints.

  input compression 46.0% of 23.1K sent
 ⏸ manual mode on · ← for agents

Left edge, in the same column as the hints under it: Claude Code gives us a row of our own there, and pushing the line to the right only made the two rows look unrelated. Cursor, which has no spare row, is the case that still needs the far edge — see below.

It is named the way the stats header names it, and it carries the count it was measured on. A bare ratio invites "of what?" — and 46% off a thousand tokens and 46% off a million are the same percentage and not the same result. Only the percentage is coloured; the words and the count stay grey.

Tokens are summed, percentages are not averaged. The figure is (input − compressed) / input over every model request this window made, so a large request counts for more than a small one. On one measured window — three requests of 576, 17 940 and 4 581 input tokens — that is 46.0%, while the mean of the three per-request percentages (0.0, 58.7, 1.9) would have said 20.2%. Same requests, and one of those two numbers is not a compression ratio.

Non-model traffic the proxy relayed — telemetry, plugin checks — is counted separately and kept out of the ratio; it has nothing compressible in it, and including it dragged the headline down for work the pipeline was never asked to do.

This window, not the workspace. Several agent windows can share one API key, so neither time nor key can tell them apart: each run mints a launch id, every report it makes carries it in a header, and the figure comes from /api/capture/stats?launch=<id>. A day total would be a different claim wearing the same percent sign. The ratio is all that is shown — token counts are in stats, and a second set of numbers on that row would compete with the agent's own.

A window that has not sent anything says no requests yet rather than 0.0%, which would read as "compression did nothing", and an unreachable backend says so. Both in grey, so nothing dim ever looks like a measurement.

An absence of data is cached for one second, a measurement for five. They shared a TTL once, and the window's first request landed inside it: the agent re-ran the command when the turn finished, got the cached "no requests yet", and had no reason to ask again — so a window that had just compressed a request went on claiming it had sent none. A measurement can be five seconds old without misleading anyone; the absence of one cannot. The row still only refreshes when the agent decides to re-render it, which is its call, not ours — the guarantee here is that the next render tells the truth.

Nothing of the user's is edited: Claude Code's statusLine is passed for that launch only, with --settings, so ~/.claude/settings.json is untouched and nothing is left behind afterwards. A --settings of their own is left alone, and a statusLine they already configured is run first and kept on the left — ours takes the right edge, or stands down if the row is too narrow for both. The command is re-run on every render, so the report is cached per launch: ~40 ms and no network per render.

The agent's own hints row cannot be shared. statusLine takes a type and a command and nothing else — no position — and the binary has no setting that hides or moves the ⏸ manual mode on row. Ours therefore sits directly above it.

Writing into that row from outside is possible and is not done, for reasons that are mechanical rather than aesthetic. The agent owns the terminal: it repaints with absolute cursor moves (ESC[41;1H and friends — 44 of them in 26 idle seconds of one measured session) and it repaints on every keystroke, spinner tick and stream chunk. A second writer on the same tty has no lock, so our bytes can land between two bytes of one of its escape sequences and corrupt the frame; and whatever survives is erased by the next repaint, which is the flicker. Doing it properly means owning the terminal instead — running the agent in a pty and reserving a row, which is what tmux does, and then rewriting the agent's absolute cursor moves so they cannot address the reserved row. That is a terminal emulator, and a fragile one for any agent that paints differently. If you want the number on a single reserved row today, run the agent inside tmux and put sharerouter-capture stats --json output in tmux's status line: tmux already owns the terminal and reserves that row correctly.

Three details of the row are measured, not documented, and pinned by tests: it is indented two columns, leading whitespace is trimmed (a no-break space counts as whitespace to JavaScript's trim(), so padding alone vanishes), and ANSI cursor-movement escapes are stripped while colour codes pass through. Right alignment therefore uses a zero-width space as an anchor, then no-break padding.

cursor-agent

Nothing to install. run -- cursor-agent puts the entry in place and takes it out again when the run ends, so the figure appears without anyone configuring anything and the config is as it was afterwards.

cursor-agent has a status line of its own kind — {type, command, padding, updateIntervalMs, timeoutMs} in cli-config.json — but, unlike Claude Code, no per-launch override, so that file is the only way into its footer. Which makes the restore the important part: cursor rewrites that file itself while it runs (model choice, approval mode, its own caches), so only the statusLine key is touched on the way out — writing back a copy taken before the launch would silently undo whatever changed during the session. A status line configured by someone else is left alone and ours is not added at all; one appearing mid-run wins too.

sharerouter-capture status-line prints where the figure comes from for each agent, and clears the entry if a run was killed before it could. Nobody needs to run it.

  Composer 2.5 Fast · 6.3%                              input compression 55.2% of 1.4K sent

Cursor's own footer is rebuilt, not taken. Measured: with a statusLine configured, cursor-agent stops painting Composer 2.5 Fast · 6.3% and paints ours in its place — trading the model and the context percentage for a compression figure would be a bad deal. Its payload carries both, so they go back on the left. That payload also states render_width_chars, which is what the right edge is measured against here, and what tells cursor's payload apart from Claude Code's: a status line the user configured for Claude is never run under cursor, where it would print one agent's state inside another.

Menu bar (macOS)

Every run also puts the figure in the menu bar, beside the clock, for as long as the agent is running:

                                                    ⌁ 23%   🔋  Wi-Fi  Thu 01:24
   ┌────────────────────────────────────────────────────────────────┐
   │  Running now · 2 windows · 22.7% together                      │
   │    codex      ██────────   22.7%  ·   91.2K sent  ·     5 req  │
   │    claude     ··········  no requests yet                      │
   │  Today                                                         │
   │    all agents ████──────   35.6%  ·    2.5M sent  ·   181 req  │
   │    cursor     ███████───   66.2%  ·   44.3K sent  ·    13 req  │
   │  Open ShareRouter console                                      │
   └────────────────────────────────────────────────────────────────┘

The request count is there because a total without one is misread. That 91.2K was five model calls in a single desktop turn — 4.4K, 13.1K, 25.2K, 15.3K and 33.2K input tokens — since each call resends the agent's instructions, its tool definitions and the history so far. Shown as "91.2K sent" alone it looks like one impossibly large request.

Colour belongs to the bar, on the scale stats already uses — green over 50%, blue over 25%, amber below, grey for nothing — muted a quarter towards grey, because at full saturation in a small menu a scale reads as a row of alerts. The figures themselves stay in the label colour: they are readings, and a coloured number competes with the bar for the same job. A window that has sent nothing gets a dotted track rather than an empty bar, which a measured zero would look like.

Getting colour into a menu took one non-obvious thing: AppKit dims a disabled item's title even when the attributed string names a colour, which is why every figure first came out grey as though switched off. The rows are enabled instead, with autoenablesItems off so nothing is clickable, and they draw at full strength. System colours rather than fixed ones, since the menu is drawn in whichever appearance the user runs.

Two agents at once: the title is the windows together, summed over tokens like every other figure here. Picking one would make it depend on which agent started first, and averaging the percentages would let a 1K window count as much as a 100K one — 10% of 100K and 90% of 1K is 10.8% together, not 50%. The header says how many windows and the menu lists each one, so the aggregate is never the only thing on offer.

This is the answer for the desktop apps, whose own UI has no place for us: Codex's message list is its own React tree, reachable only by attaching a debugger and rewriting its DOM — fragile, a security downgrade, and not ours to edit. A status item sits beside them instead, needs no permissions, and serves every agent the same way. The title carries the running window when there is one, because a menu bar has room for one number and the one worth having is "is it working right now".

Nothing to install and nothing to run: run starts it and it follows the agent's own pid, which is what makes it work for a desktop launch — the wrapper hands the shell back immediately, so the indicator cannot be a child that dies with it.

It is a ~110-line Swift file compiled on first use into ~/.sharerouter/capture/indicator (~110 KB, no Python dependencies; a prebuilt binary in a wheel would arrive unsigned and Gatekeeper-quarantined). Without Xcode command line tools there is simply no indicator and nothing is said about it — the same number is in stats --watch and the console, so this is a missing convenience, not a failure. The Swift process draws strings and nothing else: it holds no key, opens no socket and parses no protocol, and is fed one JSON line per refresh over stdin, so an update is a write rather than a restart and closing the pipe is how it knows to go.

codex and grok: at the end of the run, or live in a second pane

Neither has a status line to write into. codex has a tui.status_line, but it selects from its own fixed items (account, model, reasoning_effort, usage) and takes no text of ours. grok has none at all — its whole [ui] config is one option, max_thoughts_width; its hooks are lifecycle scripts whose output is not a footer, and its announcements channel carries xAI's own notices through an undocumented internal shape, which is not somewhere our numbers belong. Painting into either TUI from outside would mean two writers on one tty with no lock, and every frame they paint erasing ours.

A one-shot run therefore reports as it finishes, which is where -p and exec users were already looking. An interactive session shows nothing until it exits, so for that there is a second pane:

sharerouter-capture stats --watch     # redraws every 2s; --watch 5 for slower
  RUNNING NOW
    grok     ██████████████────────   64.1%     16.4K of   25.6K  ·  3 req

  Past 24 hours ...

RUNNING NOW is the window that is actually on screen, not the day's total: each run leaves a marker naming its launch, which is the only way a second terminal can tell one window from another. Several windows are several rows, and two of the same agent are told apart by the directory each was started in — claude · compressor-ai beside claude · grigory — 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 killed wrapper is pruned on sight — its pid is checked, not trusted, because a stale row would describe a window nobody is looking at.

So they get the same figure once, after the agent exits and the terminal is ours again:

sharerouter · input compression 48.6% of 19.1K sent · 1 request

For codex exec and grok -p that lands directly under the answer, and every agent gets it — cursor and claude included, so a finished run always ends the same way. It goes to stderr and only to a terminal, so piping an agent's output into a file or a test gains nothing of ours. A run that captured nothing says so — silence there is indistinguishable from capture quietly not working. Claude Code shows the figure live and gets this line too, so every run ends with the same sentence.

How one window is told from another

Per-window scoping needs the launch identified, and the agents reach ShareRouter in three different ways, so it is carried three ways:

Path Agent How the launch travels
Local proxy claude x-sharerouter-capture-launch on the report
Node hook cursor the same header, from the hook's own posts
Base URL, no interception codex, grok /token/<key>/l/<launch>/… in the URL

codex and grok dial the relay themselves, so there is no request of ours to put a header on — the id has to be in the URL they were configured with. The backend serves both URL shapes, so an older capture keeps working; the id is bounded and alphanumeric, stored on the event and compared, never executed.

Statistics

sharerouter-capture stats          # or: statistics
sharerouter-capture stats --json   # the raw report

Measured input compression for the key in your profile, over the past day, week and month, broken down by agent. Counts and percentages together: a percentage alone hides whether it acted on a thousand tokens or a million, and a token count alone says nothing about how much of the request was compressed away.

  Past 24 hours  ███████████·············  45.9%   216.3K saved of 470.8K in 255 requests
    claude     ███████·········  45.2%    133.2K of  294.9K · 142 req
    grok       ██████████······  61.8%     39.6K of   64.0K · 8 req

Everything is summed from the relay's own relay.request events for that key — nothing modelled, nothing extrapolated. A window with no traffic says "no requests" rather than showing 0%, which would read as "compressed nothing". Output is not compressed and is not counted.

What happens under the hood:

  • connect stores a ShareRouter workspace profile in ~/.sharerouter/capture/config.json.
  • run starts a local proxy on a random loopback port and injects proxy/CA environment variables only into the child process.
  • HTTPS CONNECT traffic to model endpoints is terminated locally with a generated local CA, forwarded upstream, and mirrored to ShareRouter as redacted metadata. Every other host is tunnelled byte-for-byte and never decrypted: interception used to be attempted on every connection, and github.com refusing the local CA took the agent's request down with the handshake. The set is anthropic.com, api.openai.com, chatgpt.com, api.x.ai, cursor.sh; add to it with SHAREROUTER_CAPTURE_MITM_HOSTS=host.example for a model provider we do not know, since a model endpoint that is merely tunnelled would never be compressed.
  • Sensitive request and response headers are redacted before events are sent.
  • Native model traffic is classified as cursor, codex, claude, grok, openai, or unknown.
  • Generation requests are tagged with provider_protocol, for example cursor_connect_proto, openai_responses, anthropic_messages, or xai_responses.
  • 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.
  • If the control plane is unavailable, events are spooled locally in events_spool.jsonl; the user command still runs.

Fallback capture without changing base_url:

  • 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.
  • 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.
  • 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.
  • 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.

For local development without installing:

cd sharerouter_capture
python3 -m sharerouter_capture connect --workspace-key "sr_xxxxx"
python3 -m sharerouter_capture run -- python3 -c "print('wrapped')"

Run the smoke test:

cd sharerouter_capture
python3 tests/smoke_test.py

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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sharerouter_capture-0.1.0.tar.gz (137.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sharerouter_capture-0.1.0-py3-none-any.whl (132.9 kB view details)

Uploaded Python 3

File details

Details for the file sharerouter_capture-0.1.0.tar.gz.

File metadata

  • Download URL: sharerouter_capture-0.1.0.tar.gz
  • Upload date:
  • Size: 137.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.12.4

File hashes

Hashes for sharerouter_capture-0.1.0.tar.gz
Algorithm Hash digest
SHA256 54d234fd0c7c14b73b609650f5a6f768de29fef0bc2eede08f5561bf5c576cbc
MD5 53e0dbe7ae689cd0da1082ab4ef0db27
BLAKE2b-256 1032e4f9be4da2fc178790ac602626601b589bd2ffb633920ddd2f8385267107

See more details on using hashes here.

File details

Details for the file sharerouter_capture-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for sharerouter_capture-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c0094864ca2357a0c4e1bdc0392f30747f25e8665073cbd0908cfed6514d5354
MD5 061541070e4b095db8228ce0f38bd4e1
BLAKE2b-256 19810cfbe0a19aa5696114b02fcf0da80b3a375a8503cb102857714b73e78de9

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page