@rikcodes/teamclaude 1.1.20-rik.14 → 1.1.20-rik.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -107,10 +107,12 @@ A Claude Code session can use OpenAI models alongside the Claude accounts. They
107
107
  **1. Install the sidecar and log it into your ChatGPT account** (one time):
108
108
 
109
109
  ```bash
110
- brew install raine/claude-code-proxy/claude-code-proxy
110
+ curl -fsSL https://raw.githubusercontent.com/rikbrown/claude-code-proxy/rik/main/scripts/install.sh | bash
111
111
  claude-code-proxy codex auth login
112
112
  ```
113
113
 
114
+ This installs [rikbrown/claude-code-proxy](https://github.com/rikbrown/claude-code-proxy), a fork of the reference sidecar. Take the fork rather than upstream's Homebrew build: it forwards Codex's quota headers, without which every bar on the account reads `unknown`, and it lets you raise the 60-second header timeout that otherwise kills a long reasoning turn. See [Timeouts](docs/openai.md#timeouts).
115
+
114
116
  **2. Connect it** — add four pieces to `~/.config/teamclaude.json`:
115
117
 
116
118
  ```json
@@ -161,11 +163,11 @@ Each request is routed by the model name in its body, so one session can freely
161
163
 
162
164
  1. Check that your sidecar build lists it: `curl -s http://127.0.0.1:18765/v1/models`. The sidecar has its own allow-list and rejects any id that it does not know, regardless of the TeamClaude configuration. Upgrade the sidecar if the id is missing.
163
165
  2. Add a `customModels` row. Codex publishes the window for each model as `context_window` in `~/.codex/models_cache.json`; copy it to `contextTokens`.
164
- 3. Start a new `teamclaude run` session. The rows are read at launch, so you do not need to restart the server. If you upgraded the sidecar binary, restart the server — or send `SIGTERM` to the sidecar process and let the supervisor restart it with the new binary.
166
+ 3. Start a new `teamclaude run` session. The rows are read at launch, so you do not need to restart the server. If you upgraded the sidecar binary, restart the server — or send `SIGTERM` to the sidecar process and let the supervisor restart it with the new binary. The first `SIGTERM` only starts a graceful shutdown, which a request in flight holds open; send it a second time to force the exit.
165
167
 
166
- Claude Code prints one `[claude-code:unrecognized_model]` line to stderr for each custom model. This is expected; suppressing it would lose the correct context window. The quota bars for the sidecar account show `unknown` unless the sidecar forwards Codex's rate-limit headers — see [Quota](docs/openai.md#quota). Keep the sidecar on loopback.
168
+ Claude Code prints one `[claude-code:unrecognized_model]` line to stderr for each custom model. This is expected; suppressing it would lose the correct context window. The quota bars for the sidecar account show `unknown` unless the sidecar forwards Codex's rate-limit headers, which the fork build in step 1 does and upstream's does not — see [Quota](docs/openai.md#quota). Keep the sidecar on loopback.
167
169
 
168
- The sidecar appears under the account table as a `⚙` line rather than a row because it holds no subscription, is the only account its route can use, and never rotates. The line also shows its supervised process state (`up pid 98018`, or `down (code 1) 3 restarts`).
170
+ The sidecar appears under the account table as a `⚙` line rather than a row because it holds no subscription, is the only account its route can use, and never rotates. The line also shows its supervised process state (`up pid 98018`, or `down (code 1) 3 restarts`), followed by what the sidecar reports about itself while it is up (`2 active 3 errors`, read from its own `/monitor` endpoint; each is omitted at zero, and both are omitted if that endpoint does not answer).
169
171
 
170
172
  #### Several ChatGPT accounts
171
173
 
@@ -221,7 +223,7 @@ Three details matter:
221
223
  - **`CCP_CODEX_TRANSPORT=http` is required.** A WebSocket upgrade is relayed with the caller's own headers and draws no account, so the WebSocket transport cannot be pooled.
222
224
  - **Do not reuse a name across providers.** Routes address accounts by name, so a shared name admits both — including the Claude account that cannot serve `gpt-*`, which outranks the sidecar on priority and wins. TeamClaude warns at startup when it sees one.
223
225
 
224
- Two things differ from the single-account setup: each turn appears **twice** in the activity list, once per hop, and tokens are booked against the sidecar account, so a ChatGPT account reads `N req · 0 tok`. Its quota bars are unaffected because they come from the `x-codex-*` headers on the second hop, where the subscription is.
226
+ One thing differs from the single-account setup: each turn appears **twice** in the activity list, once per hop. Tokens and quota bars both come from the second hop, where the subscription is, so they are booked against the ChatGPT account that served. The sidecar account holds a stub login and spends nothing of its own, so it reads `N req · 0 tok` — booking it as well would double every figure.
225
227
 
226
228
  Full details, including what happens to quota on each hop: [Several ChatGPT accounts behind one sidecar](docs/openai.md#several-chatgpt-accounts-behind-one-sidecar).
227
229
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rikcodes/teamclaude",
3
- "version": "1.1.20-rik.14",
3
+ "version": "1.1.20-rik.15",
4
4
  "description": "Multi-account proxy for Claude Code and Codex: pools Claude Max, ChatGPT/Codex, API-key and third-party backend accounts, and rotates on quota",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -3492,7 +3492,9 @@ export class AccountManager {
3492
3492
  // threshold and every request fails, while a sibling sits at 0%.
3493
3493
  // A standalone sidecar (no Codex accounts here) is NOT a conduit: it holds
3494
3494
  // its own login, the forwarded numbers are its own, and they still apply.
3495
- if (!(isLocalUpstream(account) && this.accounts.some(a => providerOf(a) === 'codex'))) {
3495
+ // Shares its definition with the token counters, which drop a conduit hop
3496
+ // for the same reason: what it reports belongs to whoever served.
3497
+ if (!this._isCodexConduit(account)) {
3496
3498
  for (const window of ['primary', 'secondary']) {
3497
3499
  const used = parseFloat(headers[`x-codex-${window}-used-percent`]);
3498
3500
  const minutes = parseInt(headers[`x-codex-${window}-window-minutes`], 10);
@@ -3563,12 +3565,49 @@ export class AccountManager {
3563
3565
  }
3564
3566
  }
3565
3567
 
3568
+ /**
3569
+ * Whether `account` merely RELAYS to this fleet's own Codex pool instead of
3570
+ * holding a subscription of its own: a translating sidecar whose back leg is
3571
+ * pointed back at this proxy, so each turn crosses this process twice — once
3572
+ * inbound on `/v1/messages` and once outbound on `/backend-api/codex/*`
3573
+ * (docs/openai.md, "Several ChatGPT accounts behind one sidecar").
3574
+ *
3575
+ * Keyed on three things, all of which the documented setup has and no other
3576
+ * account does:
3577
+ *
3578
+ * a loopback upstream the sidecar runs on this machine.
3579
+ * the Anthropic wire a conduit is reached on `/v1/messages`, so it
3580
+ * carries no `provider` field — docs/openai.md warns
3581
+ * that giving one `"provider": "codex"` puts it in the
3582
+ * foreign-subscription partition and every `gpt-*`
3583
+ * request then fails to find an account. A pooled
3584
+ * ChatGPT account is therefore never a conduit,
3585
+ * whatever its upstream says; it IS the pool.
3586
+ * a pool to relay to a standalone sidecar holding its own ChatGPT login
3587
+ * is NOT a conduit: everything it reports is its own,
3588
+ * and it is the only hop there is.
3589
+ *
3590
+ * Still imprecise in one direction, deliberately: a fleet running BOTH a
3591
+ * self-hosting local Anthropic backend and Codex accounts reads the former as
3592
+ * a conduit. Narrowing that would mean correlating the two hops of one turn,
3593
+ * which nothing here can do — they are separate requests sharing no id — and
3594
+ * the cost of the false positive is a row that under-reports rather than one
3595
+ * that misroutes.
3596
+ *
3597
+ * @param {any} account
3598
+ */
3599
+ _isCodexConduit(account) {
3600
+ return isLocalUpstream(account)
3601
+ && providerOf(account) === DEFAULT_PROVIDER
3602
+ && this.accounts.some(a => providerOf(a) === 'codex');
3603
+ }
3604
+
3566
3605
  /**
3567
3606
  * Update cumulative token usage from response body data.
3568
3607
  */
3569
3608
  updateUsage(accountIndex, inputTokens, outputTokens) {
3570
3609
  const account = this.accounts[accountIndex];
3571
- if (!account) return;
3610
+ if (!account || this._isCodexConduit(account)) return;
3572
3611
  if (inputTokens) account.usage.totalInputTokens += inputTokens;
3573
3612
  if (outputTokens) account.usage.totalOutputTokens += outputTokens;
3574
3613
  }
@@ -3588,6 +3627,22 @@ export class AccountManager {
3588
3627
  */
3589
3628
  recordTokenUsage(accountIndex, sessionId, model, usage) {
3590
3629
  if (!usage) return;
3630
+ // A conduit hop is the SAME tokens, translated: the sidecar rebuilt this
3631
+ // report out of the Responses usage the pool sent it, and the pool's own hop
3632
+ // records the original a moment later. Both scopes would double — the two
3633
+ // account rows are distinct, but a session is one row and carries the same
3634
+ // id on both hops, so its context and spend would read twice the truth.
3635
+ //
3636
+ // Dropped rather than deduplicated because there is nothing to deduplicate
3637
+ // against: the two hops are separate requests that share no id, and the
3638
+ // conduit spends nothing of its own anyway (its login is a stub). Booking at
3639
+ // the hop that really spent is what keeps one turn one record.
3640
+ //
3641
+ // Only the ACCOUNT-scoped and SESSION-scoped counters stop here. Per-client
3642
+ // attribution still runs on the inbound hop, in the caller, because that is
3643
+ // the only hop that can see who asked: the outbound one comes from the
3644
+ // sidecar on loopback and carries no client identity at all.
3645
+ if (this._isCodexConduit(this.accounts[accountIndex])) return;
3591
3646
  // The same resolver routing uses, so a token total and a routing decision
3592
3647
  // agree about which family a request belonged to. Resolved here rather than
3593
3648
  // at the call sites: they parse a wire format and have no business knowing
package/src/claude-env.js CHANGED
@@ -76,6 +76,66 @@ export function mergeNoProxy(...inherited) {
76
76
  return out.join(',');
77
77
  }
78
78
 
79
+ /** The two ways a launched client can reach the proxy. */
80
+ export const CLIENT_MODES = ['mitm', 'base-url'];
81
+
82
+ /**
83
+ * Which mode `run` and `env` use: a `--mitm` or `--no-mitm` flag decides, else
84
+ * the config's `defaultClientMode`, else MITM.
85
+ *
86
+ * MITM routes every request of the launched client through the proxy, so the
87
+ * hard-coded api.anthropic.com endpoints and the Codex CLI (which honours only
88
+ * proxy variables) are covered. It is also the whole point of the setting: with
89
+ * `eval "$(teamclaude env)"` the proxy variables are shell-wide, and every other
90
+ * tool in that shell — gh, git, a package manager — follows them to a listener
91
+ * that only speaks to two hosts (#382). An operator who lives in such a shell
92
+ * sets `defaultClientMode: "base-url"` once and opts back in per launch.
93
+ *
94
+ * @param {{ defaultClientMode?: string }|null|undefined} config
95
+ * @param {string[]} flags the invocation's own arguments
96
+ * @returns {'mitm'|'base-url'}
97
+ */
98
+ export function resolveClientMode(config, flags) {
99
+ const mitm = flags.includes('--mitm');
100
+ const noMitm = flags.includes('--no-mitm');
101
+ if (mitm && noMitm) throw new Error('choose either --mitm or --no-mitm');
102
+ if (mitm) return 'mitm';
103
+ if (noMitm) return 'base-url';
104
+ return config?.defaultClientMode === 'base-url' ? 'base-url' : 'mitm';
105
+ }
106
+
107
+ const SHELL_PROXY_VARS = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy'];
108
+
109
+ /**
110
+ * @param {unknown} value
111
+ * @param {number} port
112
+ */
113
+ function pointsAtLoopback(value, port) {
114
+ if (!value) return false;
115
+ try {
116
+ const url = new URL(String(value));
117
+ const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
118
+ return ['127.0.0.1', 'localhost', '::1'].includes(host) && Number(url.port || 80) === port;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * `unset` lines for proxy variables a previous MITM-mode eval left in the
126
+ * shell, so re-evaluating in base-URL mode takes the proxy back out of it. Only
127
+ * a value naming THIS proxy's loopback port is touched: a real corporate proxy
128
+ * in the same variables is the operator's and stays.
129
+ *
130
+ * @param {unknown} port
131
+ * @param {NodeJS.ProcessEnv} [env]
132
+ * @returns {string[]}
133
+ */
134
+ export function clearSelfProxyEnvLines(port, env = process.env) {
135
+ const checkedPort = validPort(port);
136
+ return SHELL_PROXY_VARS.filter(name => pointsAtLoopback(env[name], checkedPort)).map(name => `unset ${name}`);
137
+ }
138
+
79
139
  // Build the shell `export` lines that point Claude Code — or any tool that
80
140
  // spawns it, e.g. an agent multiplexer — at the proxy. This is the same
81
141
  // environment `teamclaude run` sets up, but emitted for `eval "$(teamclaude
package/src/config.js CHANGED
@@ -101,6 +101,7 @@ export function createDefaultConfig() {
101
101
  sessionTitles: { enabled: false, width: 18 },
102
102
  projection: { enabled: true, windowMinutes: 90, wasteFloor: 0.1 },
103
103
  eventLogging: 'hide',
104
+ defaultClientMode: 'mitm',
104
105
  activityLog: null,
105
106
  autoRestart: false,
106
107
  blockedModels: [],
package/src/index.js CHANGED
@@ -31,7 +31,7 @@ import { ensureCerts, mitmHosts } from './mitm.js';
31
31
  import { Prober } from './prober.js';
32
32
  import { Warmer } from './warmer.js';
33
33
  import { createRollingWarmupSchedule, formatWarmupScheduleConfirmation, resolveWarmupConfig, resolveWarmupSchedule } from './warmup-schedule.js';
34
- import { Sidecar } from './sidecar.js';
34
+ import { Sidecar, SIDECAR_DRAIN_GRACE_MS, SIDECAR_SHUTDOWN_GRACE_MS } from './sidecar.js';
35
35
  import { TUI } from './tui.js';
36
36
  import { SessionTitles } from './session-titles.js';
37
37
  import { RemoteControl, createAttachSession } from './tui-remote.js';
@@ -42,7 +42,7 @@ import { createVersionSource, UpdateWatcher } from './update-watch.js';
42
42
  import { renderStatus, formatPercent } from './status-renderer.js';
43
43
  import { sanitizeText } from './safe-text.js';
44
44
  import { ClientUsageTracker, UsageDimensionTracker } from './client-usage.js';
45
- import { buildClaudeEnvLines, buildCustomModelAgents, buildCustomModelSettings, buildCustomModelVars, bypassesAllHosts, encodePinComponent, mergeNoProxy } from './claude-env.js';
45
+ import { buildClaudeEnvLines, buildCustomModelAgents, buildCustomModelSettings, buildCustomModelVars, bypassesAllHosts, clearSelfProxyEnvLines, encodePinComponent, mergeNoProxy, resolveClientMode } from './claude-env.js';
46
46
  import { serviceKind, installService, uninstallService, serviceStatus, renderService, logPath } from './service.js';
47
47
  import { formatTerminalTitle, titleSequence, TITLE_STACK_PUSH, TITLE_STACK_POP } from './terminal-title.js';
48
48
  import { getUpstreamProxy, describeProxy, describeSelfProxy } from './upstream-proxy.js';
@@ -474,6 +474,8 @@ async function serverCommand() {
474
474
  // Both are read per request off this object (server.js) and the TUI already
475
475
  // persists them; without this a hand edit or another writer waited for a restart.
476
476
  config.eventLogging = diskConfig.eventLogging || 'hide';
477
+ // Read by `run`/`env` from disk, but the TUI settings screen shows it live.
478
+ config.defaultClientMode = diskConfig.defaultClientMode === 'base-url' ? 'base-url' : 'mitm';
477
479
  config.blockedModels = Array.isArray(diskConfig.blockedModels) ? diskConfig.blockedModels : [];
478
480
  config.projection = diskConfig.projection;
479
481
  accountManager.setProjection(config.projection);
@@ -537,6 +539,7 @@ async function serverCommand() {
537
539
  // screen too; the server reads them live from `config`, but without this
538
540
  // the edit never reached disk and was silently undone by the next start.
539
541
  if (config.eventLogging != null) diskConfig.eventLogging = config.eventLogging;
542
+ if (config.defaultClientMode != null) diskConfig.defaultClientMode = config.defaultClientMode;
540
543
  if (config.blockedModels != null) diskConfig.blockedModels = config.blockedModels;
541
544
  if (config.sessionTitles != null) diskConfig.sessionTitles = config.sessionTitles;
542
545
  // Persist the route table (edited from the TUI routes screen).
@@ -800,7 +803,20 @@ async function serverCommand() {
800
803
  prober?.stop();
801
804
  warmer?.stop();
802
805
  eventLoopMonitor.stop();
803
- sidecar?.stop();
806
+ // Started here, awaited at the end, so the sidecar's grace runs BESIDE the
807
+ // rest of this teardown instead of after it.
808
+ //
809
+ // Forcing, because this path already made that choice for everything else:
810
+ // it destroys live streaming connections a few lines down and hard-exits on
811
+ // a timer. The sidecar's first SIGTERM only BEGINS its shutdown, which an
812
+ // in-flight request holds open, so asking politely and leaving is how a
813
+ // copy of it outlived the server and kept its port — the exact orphan
814
+ // Sidecar._reapOrphan then had to clean up on the next start.
815
+ //
816
+ // Never rejects by construction; caught anyway, because an unhandled one
817
+ // between here and the await below would be this process's last word.
818
+ const sidecarStopped = Promise.resolve(
819
+ sidecar?.stop({ graceMs: SIDECAR_SHUTDOWN_GRACE_MS, force: true })).catch(() => {});
804
820
  if (quotaSaveInterval) clearInterval(quotaSaveInterval);
805
821
  await persistQuotaState();
806
822
  // Don't linger waiting on keep-alive / streaming connections: actively
@@ -808,7 +824,12 @@ async function serverCommand() {
808
824
  // short grace period in case anything still hangs.
809
825
  setTimeout(() => process.exit(0), 2000).unref?.();
810
826
  server.closeAllConnections?.();
811
- server.close(() => process.exit(0));
827
+ const closed = new Promise(resolve => server.close(() => resolve(undefined)));
828
+ // Both, not the first of the two. Exiting the moment the listener closed is
829
+ // what walked out on the sidecar mid-kill; the timer above is still what
830
+ // guarantees this ends.
831
+ await Promise.all([closed, sidecarStopped]);
832
+ process.exit(0);
812
833
  }
813
834
  process.on('SIGINT', shutdown);
814
835
  process.on('SIGTERM', shutdown);
@@ -865,7 +886,13 @@ async function serverCommand() {
865
886
  console.log(drained
866
887
  ? `[TeamClaude] Drained in ${(waitedMs / 1000).toFixed(1)}s. Restarting on the new build.`
867
888
  : `[TeamClaude] ${inFlight} request(s) still in flight after ${(waitedMs / 1000).toFixed(0)}s — restarting anyway.`);
868
- sidecar?.stop();
889
+ // Patient, and awaited: the drain above already proved nothing is in
890
+ // flight, so one SIGTERM is all this takes and the wait is only there to
891
+ // bound it. Awaited because the relaunch wants the port a moment later
892
+ // and this is the only place that can hand it over cleanly. No forcing
893
+ // signal — the whole point of this path is to cost the fleet nothing, and
894
+ // a sidecar that somehow outlasts the grace is what _reapOrphan is for.
895
+ await sidecar?.stop({ graceMs: SIDECAR_DRAIN_GRACE_MS });
869
896
  if (quotaSaveInterval) clearInterval(quotaSaveInterval);
870
897
  await persistQuotaState();
871
898
  } catch (err) {
@@ -1147,7 +1174,13 @@ async function envCommand() {
1147
1174
  process.exit(1);
1148
1175
  }
1149
1176
  const port = config.proxy.port;
1150
- const useMitm = !args.slice(1).includes('--no-mitm');
1177
+ let useMitm;
1178
+ try {
1179
+ useMitm = resolveClientMode(config, args.slice(1)) === 'mitm';
1180
+ } catch (/** @type {any} */ err) {
1181
+ process.stderr.write(`teamclaude env: ${err.message}\n`);
1182
+ process.exit(1);
1183
+ }
1151
1184
 
1152
1185
  let caPath = null;
1153
1186
  // The leaf has to name every host MITM will intercept, or the CONNECT for
@@ -1166,6 +1199,9 @@ async function envCommand() {
1166
1199
  inheritedNoProxy: [process.env.NO_PROXY, process.env.no_proxy].filter(Boolean).join(','),
1167
1200
  customModels: config.customModels,
1168
1201
  });
1202
+ // Base-URL mode takes the proxy back OUT of a shell an earlier MITM eval
1203
+ // put it into; only this proxy's own loopback address is unset.
1204
+ if (!useMitm) lines.push(...clearSelfProxyEnvLines(port));
1169
1205
  } catch (err) {
1170
1206
  // A bad proxy.port. Nothing reaches stdout: the shell is eval'ing it.
1171
1207
  process.stderr.write(`teamclaude env: ${err.message} (in ${getConfigPath()})\n`);
@@ -1183,7 +1219,9 @@ async function envCommand() {
1183
1219
  process.stderr.write(`# warning: no account named "${account}" in the config — the proxy will refuse this pin\n`);
1184
1220
  }
1185
1221
  }
1186
- process.stderr.write(`# apply to this shell: eval "$(teamclaude env${useMitm ? '' : ' --no-mitm'})"\n`);
1222
+ // The flag that reproduces this mode whatever the config's default says.
1223
+ process.stderr.write(`# apply to this shell: eval "$(teamclaude env${useMitm ? ' --mitm' : ' --no-mitm'})"\n`);
1224
+ process.stderr.write(`# default mode is ${config.defaultClientMode === 'base-url' ? 'base-URL' : 'MITM'} (config defaultClientMode; the TUI settings screen toggles it)\n`);
1187
1225
  if (!(await isProxyUp(port))) {
1188
1226
  process.stderr.write(`# note: proxy not running on port ${port} — start it with: teamclaude server\n`);
1189
1227
  }
@@ -1200,12 +1238,18 @@ async function runCommand() {
1200
1238
  // Args after 'run'. teamclaude flags (e.g. --no-mitm) are recognized only
1201
1239
  // before an optional `--` separator; everything after `--` goes verbatim to
1202
1240
  // claude. MITM forward-proxy mode is the default so hardcoded api.anthropic.com
1203
- // endpoints are intercepted too; --no-mitm opts back into base-URL-only routing.
1204
- // --mitm is still accepted (now a no-op) for backward compatibility.
1241
+ // endpoints are intercepted too; the config's `defaultClientMode` can make
1242
+ // base-URL the default instead, and --mitm / --no-mitm decide per launch.
1205
1243
  const rest = args.slice(1);
1206
1244
  const sep = rest.indexOf('--');
1207
1245
  const tcFlags = sep >= 0 ? rest.slice(0, sep) : rest;
1208
- const useMitm = !tcFlags.includes('--no-mitm');
1246
+ let useMitm;
1247
+ try {
1248
+ useMitm = resolveClientMode(config, tcFlags) === 'mitm';
1249
+ } catch (/** @type {any} */ err) {
1250
+ console.error(`[TeamClaude] ${err.message}`);
1251
+ process.exit(1);
1252
+ }
1209
1253
  const autoFallback = tcFlags.includes('--auto-fallback');
1210
1254
  const claudeArgs = sep >= 0
1211
1255
  ? rest.slice(sep + 1)
@@ -2283,10 +2327,12 @@ Commands:
2283
2327
  login OAuth login via browser
2284
2328
  login --token OAuth login via copy/paste (no local callback; for headless/remote)
2285
2329
  login --api Add an API key account
2286
- env [--no-mitm] Print export lines to point Claude Code at the proxy, for
2287
- 'eval "$(teamclaude env)"' (MITM forward-proxy by default;
2288
- --no-mitm for base-URL only). Handy for agent multiplexers
2289
- that spawn claude themselves instead of via 'teamclaude run'
2330
+ env [--mitm|--no-mitm]
2331
+ Print export lines to point Claude Code at the proxy, for
2332
+ 'eval "$(teamclaude env)"'. MITM forward-proxy unless the
2333
+ config's defaultClientMode is "base-url"; a flag decides
2334
+ per call. Handy for agent multiplexers that spawn claude
2335
+ themselves instead of via 'teamclaude run'
2290
2336
  run [--no-mitm] [--auto-fallback] [-- args...]
2291
2337
  Run Claude Code through the proxy (errors if it's down,
2292
2338
  unless --auto-fallback launches claude directly instead).
@@ -0,0 +1,131 @@
1
+ // Token usage as OpenAI's Responses API reports it, rewritten into the shape
2
+ // the rest of this proxy books.
3
+ //
4
+ // Every counter here — the account totals, the per-session ones, the per-client
5
+ // ones — speaks Anthropic's usage vocabulary, because that is the only shape
6
+ // this proxy had to read for its first year. Codex traffic is a passthrough, so
7
+ // it arrives in OpenAI's vocabulary instead, and the two disagree in a way that
8
+ // is easy to miss because they share field NAMES:
9
+ //
10
+ // Anthropic `input_tokens` is the UNCACHED input. The cached part is
11
+ // reported separately (`cache_read_input_tokens`,
12
+ // `cache_creation_input_tokens`) and the three are disjoint — the
13
+ // readers here add them up to get a context size or a total.
14
+ //
15
+ // Responses `input_tokens` is the WHOLE prompt, with the cached part named
16
+ // again underneath it as `input_tokens_details.cached_tokens`.
17
+ // Booking it as Anthropic's would count the cached prefix twice
18
+ // once the cache field is also read, and on Codex traffic that
19
+ // prefix is most of the prompt.
20
+ //
21
+ // So the subtraction below is the entire point of this file: the cached tokens
22
+ // move out of `input_tokens` and into `cache_read_input_tokens`, leaving three
23
+ // disjoint figures that every existing reader already knows how to add.
24
+ //
25
+ // `output_tokens_details.reasoning_tokens` is NOT added to the output side —
26
+ // it is a breakdown of `output_tokens`, not a second quantity beside it (a real
27
+ // turn: input 54904, output 132 of which 18 reasoning, total_tokens 55036 =
28
+ // 54904 + 132). `input_tokens_details.cache_write_tokens` is likewise left
29
+ // folded into the uncached remainder rather than mapped onto Anthropic's
30
+ // `cache_creation_input_tokens`: a cache write IS fresh input being processed,
31
+ // OpenAI does not bill it at a premium the way Anthropic does, and inventing a
32
+ // fourth disjoint bucket out of a field whose subset relationship is not stated
33
+ // anywhere would risk a total that no longer adds up.
34
+ //
35
+ // Shape confirmed against OpenAI's generated SDK types (`ResponseUsage`), the
36
+ // Codex CLI's own per-turn records, and the sidecar binary that translates this
37
+ // same body for the hop before us.
38
+
39
+ /** Terminal stream events, the only ones that carry a settled usage object.
40
+ *
41
+ * Named rather than inferred from "the event has a usage object": EVERY
42
+ * `response.*` event carries the whole response envelope, so `response.created`
43
+ * and `response.in_progress` have a `usage` key too — null today. Reading the
44
+ * key would make this proxy's accounting depend on that staying null, and an
45
+ * upstream that started reporting progress figures would be counted twice. A
46
+ * fixed set of terminal names makes a second report unrepresentable instead.
47
+ *
48
+ * `response.failed` is in the set because a failure that got far enough to
49
+ * report figures still spent them; when it carries none — the usual case — the
50
+ * normaliser below returns null and nothing is recorded.
51
+ */
52
+ const TERMINAL_EVENTS = new Set(['response.completed', 'response.incomplete', 'response.failed']);
53
+
54
+ /**
55
+ * A wire figure as a non-negative integer. Everything here is untrusted JSON:
56
+ * a string, a null, a NaN or a negative count must read as "nothing reported"
57
+ * rather than poison a cumulative total that is never recomputed.
58
+ *
59
+ * @param {unknown} v
60
+ * @returns {number}
61
+ */
62
+ function count(v) {
63
+ return typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0;
64
+ }
65
+
66
+ /**
67
+ * A Responses `usage` object in Anthropic's disjoint shape, or null when there
68
+ * is nothing to record.
69
+ *
70
+ * Null for an absent, non-object or all-zero usage, because the recorders
71
+ * downstream treat a written record as an observation that happened: an
72
+ * all-zero one would claim a report arrived and count against `reports`, which
73
+ * is the one thing that distinguishes "upstream told us nothing" from "upstream
74
+ * told us zero".
75
+ *
76
+ * @param {any} usage
77
+ * @returns {{input_tokens: number, output_tokens: number, cache_read_input_tokens: number}|null}
78
+ */
79
+ export function normalizeResponsesUsage(usage) {
80
+ if (!usage || typeof usage !== 'object') return null;
81
+ const prompt = count(usage.input_tokens);
82
+ const output = count(usage.output_tokens);
83
+ // Clamped to the prompt it is a part of. Upstream cannot report more cached
84
+ // tokens than input tokens, but the uncached remainder is a subtraction, and
85
+ // an unclamped one would go negative and silently shrink a lifetime total.
86
+ const cached = Math.min(count(usage.input_tokens_details?.cached_tokens), prompt);
87
+ if (!prompt && !output) return null;
88
+ return {
89
+ input_tokens: prompt - cached,
90
+ output_tokens: output,
91
+ cache_read_input_tokens: cached,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Usage from one parsed SSE event of a Responses stream, or null when this
97
+ * event is not a terminal one (the common case — a stream is mostly deltas).
98
+ *
99
+ * @param {any} event
100
+ */
101
+ export function responsesEventUsage(event) {
102
+ if (!event || !TERMINAL_EVENTS.has(event.type)) return null;
103
+ return normalizeResponsesUsage(event.response?.usage);
104
+ }
105
+
106
+ /**
107
+ * Usage from a NON-streaming Responses body, or null when the body is not one.
108
+ *
109
+ * Two independent tells, either of which is enough, and neither of which an
110
+ * Anthropic message body can produce:
111
+ *
112
+ * `object: "response"` what the Responses envelope calls itself.
113
+ * `input_tokens_details` present exactly when a prompt-cache breakdown is
114
+ * reported, which is the case that actually needs
115
+ * rewriting.
116
+ *
117
+ * Both, rather than either alone, because the first is the honest discriminator
118
+ * while the second is the one that matters: a Responses body that reports no
119
+ * cache breakdown at all needs no rewriting — its `input_tokens` is already
120
+ * uncached input — so a body that somehow shows neither tell is read as
121
+ * Anthropic's and lands on exactly the same numbers it would have anyway.
122
+ *
123
+ * @param {any} json
124
+ */
125
+ export function responsesBodyUsage(json) {
126
+ if (!json || typeof json !== 'object') return null;
127
+ const usage = json.usage;
128
+ const tagged = json.object === 'response'
129
+ || (usage != null && typeof usage === 'object' && usage.input_tokens_details != null);
130
+ return tagged ? normalizeResponsesUsage(usage) : null;
131
+ }
package/src/server.js CHANGED
@@ -19,6 +19,7 @@ import { safeLine } from './safe-text.js';
19
19
  import { forwardRefusal, guardedLookup, FORBIDDEN_FORWARD } from './forward-target.js';
20
20
  import { renderDashboardHtml, dashboardCsp } from './dashboard.js';
21
21
  import { createUsageRecorder, resolveUsageDimensions, usageDimensionHeaderNames } from './client-usage.js';
22
+ import { responsesEventUsage, responsesBodyUsage } from './responses-usage.js';
22
23
  import { classificationPath } from './classification-path.js';
23
24
  /** @typedef {import('./types.js').CodedError} CodedError */
24
25
 
@@ -3041,8 +3042,24 @@ export function createSseLineScanner(onLine, maxChars = SSE_MAX_LINE_CHARS) {
3041
3042
  // over. One record per message is what makes double counting unrepresentable
3042
3043
  // rather than merely avoided.
3043
3044
  //
3044
- // Reads one `data:` line. Anthropic's events carry exactly one, so a line is an
3045
- // event for this purpose, and the scanner above never has to hold more.
3045
+ // A Responses stream (the Codex path) instead reports once, at the end, and in
3046
+ // OpenAI's own vocabulary — so it is rewritten into Anthropic's disjoint shape
3047
+ // before it reaches either counter (src/responses-usage.js explains why the two
3048
+ // disagree). It rides this function rather than a parser of its own because the
3049
+ // line is ALREADY parsed here: the branch costs a Set lookup on a string, not a
3050
+ // second pass over the stream. Nothing else would be cheap — a Responses stream
3051
+ // is mostly text deltas, and the settled figures arrive on one event near the end
3052
+ // with no header or marker to find it by.
3053
+ //
3054
+ // Reads one `data:` line. Both dialects carry exactly one per event, so a line
3055
+ // is an event for this purpose, and the scanner above never has to hold more.
3056
+ /**
3057
+ * @param {string} line
3058
+ * @param {number} accountIndex
3059
+ * @param {any} accountManager
3060
+ * @param {((inputTokens: number, outputTokens: number) => void)|null} [onUsage]
3061
+ * @param {Record<string, any>|null} [merged]
3062
+ */
3046
3063
  function parseSSEDataLine(line, accountIndex, accountManager, onUsage = null, merged = null) {
3047
3064
  if (!line.startsWith('data: ')) return;
3048
3065
 
@@ -3056,6 +3073,15 @@ function parseSSEDataLine(line, accountIndex, accountManager, onUsage = null, me
3056
3073
  accountManager.updateUsage(accountIndex, 0, data.usage.output_tokens);
3057
3074
  onUsage?.(0, data.usage.output_tokens || 0);
3058
3075
  if (merged) Object.assign(merged, data.usage);
3076
+ } else {
3077
+ // Both sides settle at once here, so unlike the Anthropic branches above
3078
+ // this is a single incremental update rather than one per side.
3079
+ const usage = responsesEventUsage(data);
3080
+ if (usage) {
3081
+ accountManager.updateUsage(accountIndex, usage.input_tokens, usage.output_tokens);
3082
+ onUsage?.(usage.input_tokens, usage.output_tokens);
3083
+ if (merged) Object.assign(merged, usage);
3084
+ }
3059
3085
  }
3060
3086
  } catch {
3061
3087
  // not valid JSON, skip
@@ -3066,9 +3092,16 @@ function extractUsageFromBody(buffer, accountIndex, accountManager, onUsage = nu
3066
3092
  try {
3067
3093
  const json = JSON.parse(buffer.toString());
3068
3094
  if (json.usage) {
3069
- accountManager.updateUsage(accountIndex, json.usage.input_tokens, json.usage.output_tokens);
3070
- onUsage?.(json.usage.input_tokens || 0, json.usage.output_tokens || 0);
3071
- accountManager.recordTokenUsage(accountIndex, sessionId, model, json.usage);
3095
+ // A buffered Responses body reports under the same two field NAMES with a
3096
+ // different meaning, so reading it as Anthropic's would book the cached
3097
+ // prefix as fresh input and never book it as a cache read at all. Only a
3098
+ // body that says it is one is rewritten; anything else — including a
3099
+ // Responses body carrying no figures to rewrite — falls through to the
3100
+ // reading this had before, unchanged.
3101
+ const usage = responsesBodyUsage(json) || json.usage;
3102
+ accountManager.updateUsage(accountIndex, usage.input_tokens, usage.output_tokens);
3103
+ onUsage?.(usage.input_tokens || 0, usage.output_tokens || 0);
3104
+ accountManager.recordTokenUsage(accountIndex, sessionId, model, usage);
3072
3105
  }
3073
3106
  } catch {
3074
3107
  // not JSON or no usage
package/src/sidecar.js CHANGED
@@ -10,7 +10,9 @@
10
10
  //
11
11
  // stdout is ignored (sidecars keep their own log files); stderr's last few
12
12
  // lines are kept in a ring buffer so `getStatus()` can say WHY a sidecar is
13
- // crash-looping without anyone hunting for its logs.
13
+ // crash-looping without anyone hunting for its logs. What the sidecar has to
14
+ // say about its own health rides along beside that, polled off its endpoint
15
+ // rather than inferred from ours — see the readout section below.
14
16
  //
15
17
  // One failure mode earned its own handling. A sidecar binds a fixed port, so a
16
18
  // copy that outlives its server keeps that port and every later server fails to
@@ -20,6 +22,35 @@
20
22
 
21
23
  import { spawn, spawnSync } from 'node:child_process';
22
24
 
25
+ // How long each caller of stop() waits for a child to go, and why the two
26
+ // differ. The sidecar (claude-code-proxy v0.1.40 and later) reads the FIRST
27
+ // SIGTERM as "begin a graceful shutdown", which an in-flight request holds
28
+ // open, and only forces exit on a second signal. Measured against that build:
29
+ // idle it exits in ~30ms, even with an idle keep-alive socket still attached;
30
+ // with one request in flight it was still serving 3s after the first signal and
31
+ // died 4ms after the second.
32
+ //
33
+ // So the wait is not about the sidecar being slow — it is about whether the
34
+ // caller has already decided to interrupt work in flight.
35
+ //
36
+ // SHUTDOWN — ctrl-c. The caller destroys live client connections and
37
+ // hard-exits 2s later, so the sidecar's remaining work has nowhere to go
38
+ // anyway. Long enough for the idle exit by a wide margin, short enough that
39
+ // the escalation below still completes inside that 2s budget.
40
+ //
41
+ // DRAIN — the restart path, which waits for the fleet to go idle first. There
42
+ // is nothing left in flight to interrupt and nothing is watching, so the
43
+ // number only has to be larger than an unhurried exit; it exists to bound the
44
+ // wait, not to ration it. No escalation goes with it (see stop()).
45
+ export const SIDECAR_SHUTDOWN_GRACE_MS = 500;
46
+ export const SIDECAR_DRAIN_GRACE_MS = 5_000;
47
+
48
+ // Allowed to the forcing signal, and again to SIGKILL. Both end the process
49
+ // where it stands — upstream's second signal is a bare process::exit(130) — so
50
+ // this is a scheduling allowance for the exit to be observed, not a shutdown
51
+ // budget of its own.
52
+ const FORCE_STEP_MS = 250;
53
+
23
54
  /** Delay before restart attempt N (0-based): base, doubled per consecutive
24
55
  * crash, capped. Pure so the schedule is testable without timers. */
25
56
  export function restartDelayMs(restarts, { baseRestartMs, maxRestartMs }) {
@@ -33,6 +64,71 @@ export function isBindConflict(line) {
33
64
  return /address already in use|address in use|EADDRINUSE/i.test(String(line));
34
65
  }
35
66
 
67
+ // The sidecar's health readout, polled off its own /monitor endpoint.
68
+ //
69
+ // `--no-monitor` picks the sidecar's plain serve mode, which still builds a
70
+ // monitor handle and still registers /monitor on the same router, so this needs
71
+ // nothing changed about how the process is spawned — verified against
72
+ // claude-code-proxy 0.1.40 with today's `sidecars[].command`.
73
+ //
74
+ // It is a READOUT, not a dependency. The endpoint belongs to upstream and its
75
+ // shape can move under any rebase, so every field here is optional and every
76
+ // failure — no listener, a timeout, a 404, a schema that no longer matches —
77
+ // means "no numbers this time" and leaves the process line exactly as it was
78
+ // before this existed. Nothing in here may throw at the supervisor or the TUI.
79
+
80
+ /** How often to re-read it. The screen it feeds redraws every 500ms at its
81
+ * busiest and every 5s when idle, so polling faster buys nothing an operator
82
+ * could see; the sidecar's own dashboard uses 250ms because it draws per-
83
+ * request progress, which this line does not. */
84
+ const MONITOR_POLL_MS = 2_000;
85
+ /** A local process answering from memory. Anything slower than this is a
86
+ * sidecar with worse problems than a missing status line. */
87
+ const MONITOR_TIMEOUT_MS = 500;
88
+ /** Ceiling on the reply. The body carries the whole recent ring (200 requests,
89
+ * each with an upstream error string of no fixed length), and when the sidecar
90
+ * is blocked the thing answering on its port is by definition NOT the sidecar. */
91
+ const MONITOR_MAX_BYTES = 1024 * 1024;
92
+
93
+ /** The port this sidecar was told to listen on, or null when the command does
94
+ * not say. Read from the entry's own args rather than assuming 18765: the port
95
+ * is the operator's choice, and a readout aimed at the wrong one would report
96
+ * a stranger's numbers.
97
+ * @param {string[]|undefined} command
98
+ * @returns {number|null}
99
+ */
100
+ export function monitorPort(command) {
101
+ const args = Array.isArray(command) ? command : [];
102
+ for (let i = 0; i < args.length; i++) {
103
+ const arg = String(args[i]);
104
+ const value = arg === '--port' ? args[i + 1] : arg.startsWith('--port=') ? arg.slice(7) : null;
105
+ if (value == null) continue;
106
+ const port = Number(value);
107
+ if (Number.isInteger(port) && port > 0 && port < 65_536) return port;
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /** The two numbers the ⚙ line wants, out of whatever /monitor answered.
113
+ *
114
+ * `active` is the requests the sidecar has in flight right now. Errors are
115
+ * counted over `recent`, its ring of finished requests, which makes the number
116
+ * "failing lately" rather than "failed ever" — the question an operator
117
+ * glancing at a status line is actually asking, and the only one a bounded
118
+ * ring can answer honestly. null means the payload did not say.
119
+ * @param {any} payload
120
+ * @returns {{activeRequests: number|null, recentErrors: number|null}|null}
121
+ */
122
+ export function monitorCounts(payload) {
123
+ const snapshot = payload?.snapshot;
124
+ const active = Array.isArray(snapshot?.active) ? snapshot.active.length : null;
125
+ const recent = Array.isArray(snapshot?.recent)
126
+ ? snapshot.recent.filter((/** @type {any} */ r) => r?.status === 'failed').length
127
+ : null;
128
+ if (active == null && recent == null) return null; // not a shape we know
129
+ return { activeRequests: active, recentErrors: recent };
130
+ }
131
+
36
132
  export class Sidecar {
37
133
  constructor(entries, {
38
134
  spawnFn = defaultSpawn,
@@ -45,6 +141,8 @@ export class Sidecar {
45
141
  onPids = null,
46
142
  readProcess = defaultReadProcess,
47
143
  killFn = (pid, signal) => process.kill(pid, signal),
144
+ fetchFn = fetch,
145
+ monitorPollMs = MONITOR_POLL_MS,
48
146
  } = {}) {
49
147
  this.entries = Array.isArray(entries) ? entries : [];
50
148
  this.spawnFn = spawnFn;
@@ -59,6 +157,8 @@ export class Sidecar {
59
157
  this.onPids = onPids;
60
158
  this.readProcess = readProcess;
61
159
  this.killFn = killFn;
160
+ this.fetchFn = fetchFn;
161
+ this.monitorPollMs = monitorPollMs;
62
162
  this.stopping = false;
63
163
  // Per-entry runtime state, keyed by entry (parallel array to this.entries).
64
164
  this.states = this.entries.map(entry => ({
@@ -70,6 +170,12 @@ export class Sidecar {
70
170
  timer: null,
71
171
  stderrTail: [],
72
172
  blocked: null, // stderr line proving the port is held, else null
173
+ // The health readout: where to ask, when to ask again, and the last
174
+ // answer (null until one arrives, and again whenever one stops arriving).
175
+ monitorPort: monitorPort(entry.command),
176
+ monitorTimer: null,
177
+ /** @type {{activeRequests: number|null, recentErrors: number|null}|null} */
178
+ monitor: null,
73
179
  }));
74
180
  }
75
181
 
@@ -80,12 +186,53 @@ export class Sidecar {
80
186
  }
81
187
  }
82
188
 
83
- stop() {
189
+ /** Stop every supervised child, and resolve once they are gone or the grace
190
+ * runs out — whichever happens first.
191
+ *
192
+ * A caller that does not await this gets what stop() always did: the signal
193
+ * is sent synchronously and the promise is nobody's business. A caller that
194
+ * DOES await it is saying the thing it is about to do next — exit — must not
195
+ * happen while a child is still holding its port.
196
+ *
197
+ * `force` is for a caller that has already chosen to be abrupt with
198
+ * everything else. It escalates when the grace expires: a second SIGTERM,
199
+ * which is the forcing signal for this sidecar, then SIGKILL, so this keeps
200
+ * working if that ever changes again. Anything that wants the sidecar to
201
+ * finish its work leaves it off and simply waits.
202
+ *
203
+ * @param {{graceMs?: number, force?: boolean}} [opts]
204
+ * @returns {Promise<void>}
205
+ */
206
+ async stop({ graceMs = 0, force = false } = {}) {
207
+ // Both of these before a single signal goes out: a child killed while its
208
+ // respawn timer is pending would be replaced by the very timer it was
209
+ // killed to cancel.
84
210
  this.stopping = true;
211
+ const gone = [];
85
212
  for (const state of this.states) {
86
213
  if (state.timer) { clearTimeout(state.timer); state.timer = null; }
87
- state.child?.kill('SIGTERM');
214
+ this._stopMonitor(state);
215
+ if (state.child) gone.push(this._stopChild(state.child, graceMs, force));
88
216
  }
217
+ await Promise.all(gone);
218
+ }
219
+
220
+ /** Signal one child and wait it out, escalating when asked.
221
+ * @param {any} child
222
+ * @param {number} graceMs
223
+ * @param {boolean} force
224
+ */
225
+ async _stopChild(child, graceMs, force) {
226
+ // Subscribed BEFORE the signal, or a child that dies instantly resolves
227
+ // nothing and every wait below runs to its full length.
228
+ const gone = childGone(child);
229
+ signalChild(child, 'SIGTERM');
230
+ if (await settledWithin(gone, graceMs)) return;
231
+ if (!force) return;
232
+ signalChild(child, 'SIGTERM');
233
+ if (await settledWithin(gone, FORCE_STEP_MS)) return;
234
+ signalChild(child, 'SIGKILL');
235
+ await settledWithin(gone, FORCE_STEP_MS);
89
236
  }
90
237
 
91
238
  getStatus() {
@@ -100,6 +247,10 @@ export class Sidecar {
100
247
  blocked: !!state.blocked,
101
248
  blockedReason: state.blocked,
102
249
  stderrTail: [...state.stderrTail],
250
+ // Straight off the sidecar's /monitor, and null whenever it did not
251
+ // answer — which a consumer must be able to tell apart from a zero.
252
+ activeRequests: state.monitor?.activeRequests ?? null,
253
+ recentErrors: state.monitor?.recentErrors ?? null,
103
254
  }));
104
255
  }
105
256
 
@@ -124,6 +275,12 @@ export class Sidecar {
124
275
  * last test, and a sidecar belonging to another live server fails the second,
125
276
  * so neither is touched. The port may take a moment to free after this; the
126
277
  * ordinary restart backoff covers that.
278
+ *
279
+ * One SIGTERM is still the right signal, and now for two reasons rather than
280
+ * one. An orphan of a SIGKILLed server was never signalled, so this is its
281
+ * first: idle, it goes at once. An orphan that outran stop()'s escalation
282
+ * already has one, so this is its SECOND — the forcing signal — which is
283
+ * precisely what a leftover still clinging to the port has earned.
127
284
  */
128
285
  _reapOrphan(state) {
129
286
  const pid = Number(this.savedPids[state.entry.name]?.pid);
@@ -172,6 +329,7 @@ export class Sidecar {
172
329
  if (state.child !== child) return;
173
330
  this._onDown(state, signal ? `signal ${signal}` : `code ${code}`);
174
331
  });
332
+ this._scheduleMonitor(state);
175
333
  }
176
334
 
177
335
  _onDown(state, lastExit) {
@@ -180,6 +338,9 @@ export class Sidecar {
180
338
  if (state.startedAt && Date.now() - state.startedAt >= this.stableMs) state.restarts = 0;
181
339
  state.child = null;
182
340
  state.lastExit = lastExit;
341
+ // Nothing to poll and nothing true left to say: "2 active" printed beside
342
+ // "down (code 1)" describes a process that no longer exists.
343
+ this._stopMonitor(state);
183
344
  if (this.stopping) return;
184
345
  const delay = restartDelayMs(state.restarts, this);
185
346
  // Retrying still makes sense while blocked — a port held by a process we
@@ -196,6 +357,65 @@ export class Sidecar {
196
357
  state.timer.unref?.();
197
358
  }
198
359
 
360
+ /** Arm the next health poll for this entry, if there is anything to poll.
361
+ * @param {any} state */
362
+ _scheduleMonitor(state) {
363
+ if (this.stopping || !state.child || !state.monitorPort || state.monitorTimer) return;
364
+ const child = state.child;
365
+ state.monitorTimer = setTimeout(() => {
366
+ state.monitorTimer = null;
367
+ this._readMonitor(state.monitorPort).then(counts => {
368
+ // Everything below is conditional on the poll still being about the
369
+ // same running child. It was in flight for up to a timeout, and in that
370
+ // window the sidecar can have died, been respawned, or been stopped —
371
+ // after which these numbers describe a process that is not there, and
372
+ // a second loop would be left polling beside the first.
373
+ if (this.stopping || state.child !== child) return;
374
+ state.monitor = counts;
375
+ this._scheduleMonitor(state);
376
+ });
377
+ }, this.monitorPollMs);
378
+ // A status line may not be the reason this process is still alive.
379
+ state.monitorTimer.unref?.();
380
+ }
381
+
382
+ /** @param {any} state */
383
+ _stopMonitor(state) {
384
+ if (state.monitorTimer) { clearTimeout(state.monitorTimer); state.monitorTimer = null; }
385
+ state.monitor = null;
386
+ }
387
+
388
+ /** Ask the endpoint once. Never rejects and never throws: a readout that
389
+ * failed is a readout with nothing in it.
390
+ * @param {number} port
391
+ * @returns {Promise<{activeRequests: number|null, recentErrors: number|null}|null>}
392
+ */
393
+ async _readMonitor(port) {
394
+ try {
395
+ // Loopback only. The sidecar's own handler refuses a non-local peer, and
396
+ // asking some other host about "our" process would mean nothing anyway.
397
+ const res = await this.fetchFn(`http://127.0.0.1:${port}/monitor`, {
398
+ signal: AbortSignal.timeout(MONITOR_TIMEOUT_MS),
399
+ });
400
+ if (!res?.ok) return null;
401
+ // A declared length is how this reply is bounded BEFORE a byte of it is
402
+ // buffered. The sidecar always sends one; whatever else may be holding
403
+ // that port — the case `blocked` exists for — is under no such
404
+ // obligation, and an unbounded body read on a timer is a memory leak with
405
+ // a schedule. Matched rather than coerced: Number(null) and Number('')
406
+ // are both 0, which would wave an undeclared body past as an empty one.
407
+ const declared = res.headers?.get?.('content-length');
408
+ const bytes = /^\d+$/.test(String(declared)) ? Number(declared) : NaN;
409
+ if (!(bytes <= MONITOR_MAX_BYTES)) return null;
410
+ return monitorCounts(JSON.parse(await res.text()));
411
+ } catch {
412
+ // No listener, a timeout, a body that is not JSON, an endpoint upstream
413
+ // has moved: one answer covers them all, and none of them is this
414
+ // supervisor's to report.
415
+ return null;
416
+ }
417
+ }
418
+
199
419
  _publishPids() {
200
420
  if (!this.onPids) return;
201
421
  // Persistence is the owner's business and best-effort: failing to record a
@@ -213,6 +433,49 @@ export class Sidecar {
213
433
  }
214
434
  }
215
435
 
436
+ /** A promise for "this child is no longer running".
437
+ *
438
+ * 'exit' is the event that answers the question stop() is really asking — the
439
+ * port is free from that moment — but a child whose stdio outlives it emits
440
+ * only 'close' afterwards, so both are taken and the first one wins.
441
+ * @param {any} child
442
+ * @returns {Promise<void>}
443
+ */
444
+ function childGone(child) {
445
+ return new Promise(resolve => {
446
+ const done = () => resolve();
447
+ child.once?.('exit', done);
448
+ child.once?.('close', done);
449
+ });
450
+ }
451
+
452
+ /** True when `promise` settles within `ms`, false when the wait runs out.
453
+ *
454
+ * The timer is deliberately NOT unref'd. Every caller here is on its way to a
455
+ * specific exit code, and an unref'd wait would let an emptied event loop end
456
+ * the process at 0 instead — silently turning a restart (75) into a stop. It
457
+ * is bounded and cleared on the fast path, so it holds nothing open.
458
+ * @param {Promise<void>} promise
459
+ * @param {number} ms
460
+ * @returns {Promise<boolean>}
461
+ */
462
+ function settledWithin(promise, ms) {
463
+ if (!(ms > 0)) return Promise.resolve(false);
464
+ return new Promise(resolve => {
465
+ const timer = setTimeout(() => resolve(false), ms);
466
+ promise.then(() => { clearTimeout(timer); resolve(true); });
467
+ });
468
+ }
469
+
470
+ /** Signal a child, never throwing. A child that has already exited, or whose
471
+ * handle no longer accepts the signal, IS the outcome being asked for.
472
+ * @param {any} child
473
+ * @param {NodeJS.Signals} signal
474
+ */
475
+ function signalChild(child, signal) {
476
+ try { child.kill(signal); } catch { /* already gone, which is the point */ }
477
+ }
478
+
216
479
  // Real spawner: stdout ignored (sidecars log to their own files), stderr piped
217
480
  // for the ring buffer.
218
481
  //
package/src/tui-remote.js CHANGED
@@ -288,6 +288,11 @@ export class RemoteAccountManager {
288
288
  pid: Number.isFinite(sc?.pid) ? sc.pid : null,
289
289
  restarts: Number.isFinite(sc?.restarts) ? sc.restarts : 0,
290
290
  lastExit: sc?.lastExit == null ? null : text(sc.lastExit, 48),
291
+ // The health readout. A server too old to send these omits them, which is
292
+ // already what "the sidecar did not answer" looks like, so attach mode
293
+ // needs no version check — it simply draws the line it always drew.
294
+ activeRequests: Number.isFinite(sc?.activeRequests) ? sc.activeRequests : null,
295
+ recentErrors: Number.isFinite(sc?.recentErrors) ? sc.recentErrors : null,
291
296
  }));
292
297
  this.status = status;
293
298
  this.connected = true;
package/src/tui.js CHANGED
@@ -857,6 +857,16 @@ export class TUI {
857
857
  enter: () => this._cycleEventLogging(+1),
858
858
  });
859
859
 
860
+ fields.push({
861
+ id: 'clientMode',
862
+ label: 'Client mode',
863
+ hint: '←→ toggle',
864
+ value: () => (this.config.defaultClientMode === 'base-url' ? yellow('base-url') : green('mitm')),
865
+ left: () => this._toggleClientMode(),
866
+ right: () => this._toggleClientMode(),
867
+ enter: () => this._toggleClientMode(),
868
+ });
869
+
860
870
  if (this.sessionTitles) {
861
871
  fields.push({
862
872
  id: 'sessionTitles',
@@ -1360,6 +1370,19 @@ export class TUI {
1360
1370
  if (this.running) this.render();
1361
1371
  }
1362
1372
 
1373
+ async _toggleClientMode() {
1374
+ // What `teamclaude run` and `env` do when no --mitm/--no-mitm flag is given.
1375
+ // Base-URL keeps a shell's other tools off the proxy (#382); MITM covers the
1376
+ // hard-coded endpoints and the Codex CLI. Read from disk by those commands,
1377
+ // so the save is the whole application.
1378
+ const next = this.config.defaultClientMode === 'base-url' ? 'mitm' : 'base-url';
1379
+ this.config.defaultClientMode = next;
1380
+ try { await this.saveConfig(this.config); }
1381
+ catch (/** @type {any} */ e) { this._addLog(`Failed to save: ${e.message}`); }
1382
+ this._addLog(`Default client mode: ${next} (run/env without a flag)`);
1383
+ if (this.running) this.render();
1384
+ }
1385
+
1363
1386
  async _doClearSxKey() {
1364
1387
  this.config.sx = null;
1365
1388
  try { await this.saveConfig(this.config); }
@@ -1895,7 +1918,13 @@ export class TUI {
1895
1918
  : proc ? green('up') : green('ok');
1896
1919
  const pid = proc?.running ? dim(` pid ${proc.pid}`) : '';
1897
1920
  const restarts = proc?.restarts ? yellow(` ${proc.restarts} restarts`) : '';
1898
- return ` ${dim('⚙')} ${a.name} ${dim('→')} ${dim(host)} ${state}${pid}${restarts}`;
1921
+ // What the sidecar says about itself, when it says anything: it is polled
1922
+ // off its own /monitor and the numbers are null whenever that did not
1923
+ // answer. Shown only when non-zero — an idle sidecar has nothing to add,
1924
+ // and "0 active 0 errors" on every line is how a readout becomes noise.
1925
+ const active = proc?.activeRequests ? dim(` ${proc.activeRequests} active`) : '';
1926
+ const errors = proc?.recentErrors ? yellow(` ${proc.recentErrors} errors`) : '';
1927
+ return ` ${dim('⚙')} ${a.name} ${dim('→')} ${dim(host)} ${state}${pid}${restarts}${active}${errors}`;
1899
1928
  });
1900
1929
  }
1901
1930
 
@@ -2106,6 +2135,10 @@ export class TUI {
2106
2135
  lines.push(row(byId('eventlog')));
2107
2136
  if (byId('sessionTitles')) lines.push(row(byId('sessionTitles')));
2108
2137
  lines.push('');
2138
+ // ── Launch
2139
+ lines.push(bold(' Launch') + dim(' — how `teamclaude run` and `env` reach the proxy when no flag says'));
2140
+ lines.push(row(byId('clientMode')));
2141
+ lines.push('');
2109
2142
  // ── Routing
2110
2143
  lines.push(bold(' Routing') + dim(' — pin model families to specific accounts, or block them outright'));
2111
2144
  lines.push(row(byId('routes')));