@rikcodes/teamclaude 1.1.20-rik.1 → 1.1.20-rik.3

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
@@ -150,7 +150,7 @@ At launch, `teamclaude run` — and the `claude` alias, which passes through `ru
150
150
  | Row field | Where it ends up |
151
151
  | --- | --- |
152
152
  | `model`, `label`, `description` | A `/model` picker row under the **real** model id (`--settings`), so `/model gpt-5.6-sol` works picked or typed |
153
- | `model` | A dispatchable subagent named after the model (`--agents`), so "dispatch a `gpt-5.6-terra` subagent" works from a Claude parent |
153
+ | `model` | A dispatchable subagent named after the model (`--agents`), so "dispatch a `gpt-5.6-terra` subagent" works from a Claude parent. Set `"customModelAgents": false` to skip these if you define your own agents in `~/.claude/agents/` |
154
154
  | `contextTokens` | `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, set to the largest value across rows, so Claude Code compacts at the real window instead of assuming 200k |
155
155
 
156
156
  For tools that spawn `claude` themselves, `teamclaude env` can set only environment variables. It carries the window and `ANTHROPIC_CUSTOM_MODEL_OPTION` for the **first** row. For GPT subagents under `env`, create `~/.claude/agents/<name>.md` with `model: gpt-5.6-terra` in its frontmatter.
@@ -163,9 +163,67 @@ Each request is routed by the model name in its body, so one session can freely
163
163
  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
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.
165
165
 
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, and use **one** ChatGPT subscription for each person. Pooling several subscriptions is the pattern that OpenAI's fraud systems target ([terms of service](docs/openai.md#terms-of-service)).
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.
167
167
 
168
- Full details: [docs/openai.md](docs/openai.md).
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`).
169
+
170
+ #### Several ChatGPT accounts
171
+
172
+ > **Read the [terms of service](docs/openai.md#terms-of-service) before setting this up.** OpenAI's Terms of Use prohibit rotating ChatGPT subscriptions past a spent window, and account suspension is a plausible consequence. This is a sharper trade-off than pooling Claude subscriptions because the first-party client lets you switch Claude subscriptions by hand.
173
+
174
+ One sidecar holds one ChatGPT login, so GPT requests do not rotate and its quota belongs to a login that TeamClaude does not own. Point the sidecar's **back leg** at TeamClaude so native Codex accounts serve it instead:
175
+
176
+ ```
177
+ Claude Code ──▶ TC /v1/messages (gpt-*) ──▶ sidecar account ──▶ sidecar translates
178
+ ──▶ TC /backend-api/codex/responses ──▶ ChatGPT account pool ──▶ chatgpt.com
179
+ ```
180
+
181
+ Each hop is classified by its path, and the subscription partition keeps the pools apart: on the way in only the sidecar account is eligible, on the way back only the ChatGPT accounts. One route lists both.
182
+
183
+ **1. Add the accounts** — run `teamclaude login --codex` once for each one. A Codex login takes its email as its name. Your Anthropic account probably uses the same name, so the Codex name gets a prefix to keep it unambiguous:
184
+
185
+ ```
186
+ $ teamclaude login --codex
187
+ Named "codex:you@example.com" — "you@example.com" is already an account on another provider.
188
+ ```
189
+
190
+ **2. Redirect the sidecar** and stub its own login, so TeamClaude supplies the credential instead:
191
+
192
+ ```json
193
+ { "name": "codex",
194
+ "command": ["claude-code-proxy", "serve", "--no-monitor", "--port", "18765"],
195
+ "env": {
196
+ "CCP_CODEX_BASE_URL": "http://127.0.0.1:3456/backend-api/codex/responses",
197
+ "CCP_CODEX_TRANSPORT": "http"
198
+ } }
199
+ ```
200
+
201
+ ```bash
202
+ cd ~/.config/claude-code-proxy/codex
203
+ cp auth.json auth.json.bak # the real login — keep it
204
+ echo '{ "access": "delegated-to-teamclaude", "refresh": "", "expires": 4102444800000 }' > auth.json
205
+ ```
206
+
207
+ The sidecar refuses to start with an empty store but never refreshes a far-future token, and TeamClaude replaces both the bearer and the account header on the way out. Leave `accountId` unset so none of the sidecar's own identity can leak.
208
+
209
+ **3. Put them all on the `gpt-*` route**, sidecar included, and give each account a `headersTimeoutMs` — the 120s fleet default is shorter than a long reasoning turn:
210
+
211
+ ```json
212
+ { "name": "codex", "match": ["gpt-*"],
213
+ "accounts": ["codex", "codex:you@example.com", "codex:you@work.example"] }
214
+ ```
215
+
216
+ **4. Restart the server.** A `sidecars[].env` change is read once at startup, so a reload is not enough.
217
+
218
+ Three details matter:
219
+
220
+ - **Leave the sidecar account on the route.** It can look removable because it is not a subscription or an account row, but it is the routing target for the way *in*. Without it, every `gpt-*` request fails to find an account while `teamclaude status` shows two healthy ChatGPT accounts on the route.
221
+ - **`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
+ - **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
+
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.
225
+
226
+ 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).
169
227
 
170
228
  ### Burn-rate projection
171
229
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rikcodes/teamclaude",
3
- "version": "1.1.20-rik.1",
3
+ "version": "1.1.20-rik.3",
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",
@@ -1,5 +1,5 @@
1
1
  import { refreshAccessToken, isTokenExpiringSoon, isTokenExpired, formatMoney } from './oauth.js';
2
- import { providerOf, DEFAULT_PROVIDER, isSubscriptionAccount } from './provider.js';
2
+ import { providerOf, DEFAULT_PROVIDER, isSubscriptionAccount, isLocalUpstream } from './provider.js';
3
3
  import { refreshCodexToken } from './codex-auth.js';
4
4
  import { parseCodexQuota, parseCodexPlanType } from './codex-quota.js';
5
5
  import { sameIdentity } from './identity.js';
@@ -2699,14 +2699,27 @@ export class AccountManager {
2699
2699
  }
2700
2700
 
2701
2701
  /** Accounts a configured route can use (all accounts when it lists none), each
2702
- * with a live eligibility flag for a representative model of the route. */
2702
+ * with a live eligibility flag for a representative model of the route and the
2703
+ * provider that will serve it.
2704
+ *
2705
+ * A route that NAMES its accounts is shown in full, whatever provider each one
2706
+ * belongs to. One route legitimately spans two: a translating sidecar reached
2707
+ * on the Anthropic wire, plus the subscription pool its own back leg re-enters
2708
+ * on the provider's path. Both hops are that route's traffic. Filtering the
2709
+ * view by a single provider hid the second set completely — the route looked
2710
+ * like it listed one account, and a newly added subscription that nobody had
2711
+ * added to the list was invisible rather than merely idle, which is the exact
2712
+ * shape of the diagnosis this view exists to prevent.
2713
+ *
2714
+ * A route that lists NOBODY is different: it constrains models, not accounts,
2715
+ * so only the asking provider's own pool can serve it and the partition still
2716
+ * applies. */
2703
2717
  _routeAccountsView(route, provider = DEFAULT_PROVIDER) {
2704
2718
  const sample = sampleModelFor(route);
2705
- const excluded = this._excludeOtherProviders(null, provider);
2706
- const inRoute = a => !route.accounts.length
2707
- || route.accounts.includes(a.name) || route.accounts.includes(String(a.index));
2708
- return this.accounts.filter(a => inRoute(a) && !excluded?.has(a.index))
2709
- .map(a => ({ name: a.name, eligible: this._isAvailable(a, sample) }));
2719
+ const listed = route.accounts.length
2720
+ ? this.accounts.filter(a => route.accounts.includes(a.name) || route.accounts.includes(String(a.index)))
2721
+ : this.accounts.filter(a => !this._excludeOtherProviders(null, provider)?.has(a.index));
2722
+ return listed.map(a => ({ name: a.name, provider: providerOf(a), eligible: this._isAvailable(a, sample) }));
2710
2723
  }
2711
2724
 
2712
2725
  /** A representative model id for a route name (configured or auto fable/sonnet),
@@ -3384,14 +3397,26 @@ export class AccountManager {
3384
3397
  // display, projection and switch-threshold logic apply unchanged. A window
3385
3398
  // with no length is a bucket the plan does not have, not one at 0% used.
3386
3399
  // used-percent is 0-100 (not the 0-1 fraction Anthropic reports).
3387
- for (const window of ['primary', 'secondary']) {
3388
- const used = parseFloat(headers[`x-codex-${window}-used-percent`]);
3389
- const minutes = parseInt(headers[`x-codex-${window}-window-minutes`], 10);
3390
- if (isNaN(used) || !(minutes > 0)) continue;
3391
- const reset = parseResetAt(headers[`x-codex-${window}-reset-at`]);
3392
- const weekly = minutes > CODEX_WEEKLY_MIN_MINUTES;
3393
- account.quota[weekly ? 'unified7d' : 'unified5h'] = used / 100;
3394
- if (reset != null) account.quota[weekly ? 'unified7dReset' : 'unified5hReset'] = reset;
3400
+ //
3401
+ // Unless this account is a CONDUIT: a local proxy whose own back leg draws
3402
+ // on the Codex accounts in this same fleet. Then the numbers it forwards
3403
+ // belong to whichever of them served, and filing them here makes the
3404
+ // conduit's bars a copy of the last one to answer. That is not merely a
3405
+ // wrong readout — the conduit is the only account its route can use on the
3406
+ // way in, so borrowing a spent account's number takes it below the switch
3407
+ // threshold and every request fails, while a sibling sits at 0%.
3408
+ // A standalone sidecar (no Codex accounts here) is NOT a conduit: it holds
3409
+ // its own login, the forwarded numbers are its own, and they still apply.
3410
+ if (!(isLocalUpstream(account) && this.accounts.some(a => providerOf(a) === 'codex'))) {
3411
+ for (const window of ['primary', 'secondary']) {
3412
+ const used = parseFloat(headers[`x-codex-${window}-used-percent`]);
3413
+ const minutes = parseInt(headers[`x-codex-${window}-window-minutes`], 10);
3414
+ if (isNaN(used) || !(minutes > 0)) continue;
3415
+ const reset = parseResetAt(headers[`x-codex-${window}-reset-at`]);
3416
+ const weekly = minutes > CODEX_WEEKLY_MIN_MINUTES;
3417
+ account.quota[weekly ? 'unified7d' : 'unified5h'] = used / 100;
3418
+ if (reset != null) account.quota[weekly ? 'unified7dReset' : 'unified5hReset'] = reset;
3419
+ }
3395
3420
  }
3396
3421
 
3397
3422
  // Standard rate limits (API key accounts)
package/src/dashboard.js CHANGED
@@ -5,8 +5,9 @@
5
5
  // /teamclaude/status (same origin) with the proxy key and re-renders every few
6
6
  // seconds. That split is what lets the asset be served without the key (a
7
7
  // browser address bar cannot send x-api-key) while every byte of actual status
8
- // stays behind the existing gate. The key is asked for once and kept in
9
- // localStorage; a 401 (wrong or rotated key) brings the prompt back.
8
+ // stays behind the existing gate. The key is asked for only when the server
9
+ // refuses the page without one (401/403; loopback browsers are exempt), and is
10
+ // kept in localStorage; a later refusal (wrong or rotated key) asks again.
10
11
  //
11
12
  // Self-contained on purpose: no external scripts, styles, or fonts, so the
12
13
  // page works on air-gapped deployments and adds no third-party surface. All
@@ -894,7 +895,10 @@ ${SHARED_HELPERS}
894
895
  function poll() {
895
896
  fetch('/teamclaude/status', { headers: { 'x-api-key': localStorage.getItem(KEY) || '' } })
896
897
  .then(function (res) {
897
- if (res.status === 401) { localStorage.removeItem(KEY); showKeybox(); return null; }
898
+ // 403 is the loopback exemption refusing a key-less request (a Host
899
+ // that does not name this machine, e.g. behind a local reverse proxy
900
+ // that adds no forwarding headers). A valid key clears that gate too.
901
+ if (res.status === 401 || res.status === 403) { localStorage.removeItem(KEY); showKeybox(); return null; }
898
902
  if (!res.ok) throw new Error('status ' + res.status);
899
903
  return res.json();
900
904
  })
@@ -909,6 +913,9 @@ ${SHARED_HELPERS}
909
913
  var err = document.getElementById('err');
910
914
  err.style.display = 'block';
911
915
  err.textContent = 'Cannot reach the proxy: ' + e.message;
916
+ // The banner lives inside #app, which stays hidden until a first
917
+ // status lands; without this a first poll that fails is a blank page.
918
+ if (document.getElementById('keybox').style.display !== 'block') document.getElementById('app').style.display = '';
912
919
  });
913
920
  }
914
921
 
@@ -936,7 +943,9 @@ ${SHARED_HELPERS}
936
943
  });
937
944
  });
938
945
 
939
- if (localStorage.getItem(KEY)) start(); else showKeybox();
946
+ // Poll before asking: a loopback browser is key-exempt, so the prompt is
947
+ // shown only once the server refuses the request without a valid key.
948
+ start();
940
949
  })();
941
950
  </script>
942
951
  </body>
package/src/identity.js CHANGED
@@ -188,3 +188,48 @@ export function oauthIdentityFields(profile) {
188
188
  .map(key => [key, profile[key]])
189
189
  );
190
190
  }
191
+
192
+ /**
193
+ * Names held by more than one account, with the providers that hold each.
194
+ *
195
+ * `name` is this proxy's ADDRESSING key — routes name accounts by it
196
+ * (`_routeAllows`), so do `TC_ACCT`, the `/tc-acct/` pin, `teamclaude disable`
197
+ * and the TUI pickers. Identity, by contrast, is deliberately provider-aware:
198
+ * `sameIdentity` treats two providers as separate plans, so signing the same
199
+ * person's email into Anthropic and into Codex correctly yields two accounts.
200
+ *
201
+ * Those two rules meet badly. Two distinct accounts sharing a name make every
202
+ * name-addressed lookup ambiguous, and the lookups do not agree with each other
203
+ * about which one they mean: `_routeAllows` admits BOTH, while
204
+ * `resolveAccountPin` takes the first. Listing a shared name in a route
205
+ * therefore quietly admits an account that cannot serve the request — and when
206
+ * that account outranks the intended one on priority, it wins.
207
+ *
208
+ * Detection only. Nothing here refuses a config: an operator who wants two rows
209
+ * called the same thing may have a reason, and a proxy that will not start is
210
+ * worse than one that says what is wrong.
211
+ *
212
+ * @param {Array<{name?: string}>} accounts
213
+ * @returns {Array<{name: string, providers: string[]}>}
214
+ */
215
+ export function duplicateAccountNames(accounts = []) {
216
+ const byName = new Map();
217
+ for (const a of accounts) {
218
+ const name = a?.name;
219
+ if (typeof name !== 'string' || !name) continue;
220
+ if (!byName.has(name)) byName.set(name, []);
221
+ byName.get(name).push(providerOf(a));
222
+ }
223
+ return [...byName.entries()]
224
+ .filter(([, providers]) => providers.length > 1)
225
+ .map(([name, providers]) => ({ name, providers }));
226
+ }
227
+
228
+ /** The duplicate-name warning lines for `accounts`, or [] when there are none. */
229
+ export function duplicateNameWarnings(accounts = []) {
230
+ return duplicateAccountNames(accounts).map(({ name, providers }) =>
231
+ `[TeamClaude] Two accounts are named "${name}" (${providers.join(', ')}). `
232
+ + 'Routes, TC_ACCT and `teamclaude disable` address accounts by name, so this one is '
233
+ + 'ambiguous: a route listing it admits both, including the one that cannot serve the '
234
+ + 'request. Rename one (e.g. "codex:' + name + '").');
235
+ }
package/src/index.js CHANGED
@@ -18,8 +18,7 @@ import {
18
18
  findUpsertTarget,
19
19
  updateAccountEntry,
20
20
  canUpsertOAuthAccount,
21
- oauthIdentityFields,
22
- } from './identity.js';
21
+ oauthIdentityFields, duplicateNameWarnings } from './identity.js';
23
22
  import { resolveAccounts } from './resolve-accounts.js';
24
23
  import { loginCodex } from './codex-auth.js';
25
24
  import { syncAccountsFromDisk } from './sync-accounts.js';
@@ -286,6 +285,11 @@ async function serverCommand() {
286
285
  console.error(`[TeamClaude] Bad adaptiveDistribution setting in ${getConfigPath()}: ${err.message}`);
287
286
  process.exit(1);
288
287
  }
288
+ // Name is the addressing key for routes, TC_ACCT and the CLI, while identity
289
+ // is provider-aware — so the same email on two providers is two accounts with
290
+ // one name, and every name lookup becomes ambiguous. Said once at startup and
291
+ // again after a reload, never fatal.
292
+ for (const line of duplicateNameWarnings(accounts)) console.error(line);
289
293
  const accountManager = new AccountManager(accounts, threshold, { routes: config.routes, ramp: config.stormRamp, distributeSessions: config.distributeSessions, projection: config.projection, expiryRouting: config.expiryRouting, adaptive });
290
294
  // Names the activity log's session column from Claude Code's own on-disk
291
295
  // session titles. Built whether or not the TUI runs, so a reload has one
@@ -392,6 +396,7 @@ async function serverCommand() {
392
396
  const diskConfig = await loadConfig();
393
397
  if (!diskConfig) return 0;
394
398
  const added = await syncAccountsFromDisk(diskConfig, config, accountManager);
399
+ for (const line of duplicateNameWarnings(accountManager.accounts)) console.error(line);
395
400
  // Pick up client-key edits (proxy.clientKeys is read live by both auth
396
401
  // gates through the shared config object, so refreshing it here is all a
397
402
  // key add/rotate/revoke needs — no restart).
@@ -472,7 +477,7 @@ async function serverCommand() {
472
477
 
473
478
  if (useTUI) {
474
479
  tui = new TUI({
475
- accountManager, config, sx, activityLogPath, sessionTitles,
480
+ accountManager, config, sx, activityLogPath, sessionTitles, version: serverVersion,
476
481
  saveConfig: () => atomicConfigUpdate(async diskConfig => {
477
482
  diskConfig.accounts = mergeAccountsForSave(
478
483
  config.accounts, accountManager.accounts, diskConfig.accounts, removedAccountIds(config),
@@ -497,6 +502,9 @@ async function serverCommand() {
497
502
  if (config.routes != null) diskConfig.routes = config.routes;
498
503
  }),
499
504
  syncAccounts: reloadAccounts,
505
+ // Read through to the live supervisor rather than snapshotting: it
506
+ // respawns on its own schedule and the TUI redraws on a timer.
507
+ getSidecars: () => sidecar?.getStatus() || [],
500
508
  // `p` key: on-demand fleet-wide quota refresh. The prober is constructed
501
509
  // after the TUI, so this is a thunk over the closure variable.
502
510
  probeQuota: () => prober?.probeAll(),
@@ -804,8 +812,21 @@ async function loginCodexCommand() {
804
812
  // account would fail on its next restart. So the upsert runs against a fresh
805
813
  // read of the file, and only this account's row is touched.
806
814
  await atomicConfigUpdate(config => {
807
- const name = argValue('--name') || creds.email
815
+ // A Codex login is named for its email, and the same person's Anthropic
816
+ // account is named for the same email — so the default collides by default.
817
+ // Name is the addressing key (routes, TC_ACCT, `disable`), and a route
818
+ // listing an ambiguous name admits BOTH accounts, including the one that
819
+ // cannot serve the request. Prefix rather than refuse: the operator asked
820
+ // for this login, and a name they did not choose is a smaller surprise than
821
+ // a failed command. An explicit --name is theirs and is left alone.
822
+ const preferred = creds.email
808
823
  || `codex-${config.accounts.filter(a => a.provider === 'codex').length + 1}`;
824
+ const takenByOther = (n) => config.accounts.some(a => a.name === n && a.provider !== 'codex');
825
+ const name = argValue('--name')
826
+ || (takenByOther(preferred) ? `codex:${preferred}` : preferred);
827
+ if (!argValue('--name') && name !== preferred) {
828
+ console.log(`Named "${name}" — "${preferred}" is already an account on another provider.`);
829
+ }
809
830
 
810
831
  const account = {
811
832
  name,
@@ -1092,8 +1113,11 @@ async function runCommand() {
1092
1113
  if (settings && !claudeArgs.includes('--settings')) claudeArgs.push('--settings', settings);
1093
1114
  // Dispatchable subagents per custom model — the Agent tool's `model`
1094
1115
  // parameter is an alias enum, so only a named agent definition can carry a
1095
- // custom model id into a subagent.
1096
- const agents = buildCustomModelAgents(config.customModels);
1116
+ // custom model id into a subagent. `customModelAgents: false` skips them for
1117
+ // operators with their own ~/.claude/agents definitions: the plain agents
1118
+ // invite an effort-less dispatch, and a file-based agent's `model:` reaches
1119
+ // the proxy without them.
1120
+ const agents = config.customModelAgents === false ? null : buildCustomModelAgents(config.customModels);
1097
1121
  if (agents && !claudeArgs.includes('--agents')) claudeArgs.push('--agents', agents);
1098
1122
  }
1099
1123
 
package/src/provider.js CHANGED
@@ -169,6 +169,12 @@ export function applyAuthHeaders(headers, account) {
169
169
  const provider = providerOf(account);
170
170
  if (provider === 'codex') {
171
171
  headers['authorization'] = `Bearer ${account.credential}`;
172
+ // Cleared before it is set, not merely overwritten. `authorization` is
173
+ // stripped from every inbound request, but this header is not — so a
174
+ // caller that sends one of its own (a translating sidecar does, from its
175
+ // own local login) would have it survive for an account that carries no
176
+ // accountId, pairing THIS account's token with THAT caller's account id.
177
+ delete headers['chatgpt-account-id'];
172
178
  if (account.accountId) headers['chatgpt-account-id'] = account.accountId;
173
179
  return;
174
180
  }
package/src/server.js CHANGED
@@ -801,13 +801,15 @@ const SESSION_ID_SHAPE = /^[A-Za-z0-9._-]{1,128}$/;
801
801
  /** The session id a request carries, or null when the header is absent or
802
802
  * malformed — a malformed one is treated as no session, not rejected.
803
803
  *
804
- * Claude Code sends `x-claude-code-session-id`, the Codex CLI `session-id`.
805
- * Reading only the first left every Codex request untagged, so
806
- * `distributeSessions` had nothing to place and a Codex pool stayed on one
807
- * account until the switch threshold. The specific header wins when both are
808
- * present: `session-id` is generic enough for a proxy in front to set. */
804
+ * Claude Code sends `x-claude-code-session-id`, the Codex CLI `session-id`,
805
+ * and a translating sidecar re-emits the session it was given as `session_id`
806
+ * (the spelling the Codex backend itself uses). Reading only the first left
807
+ * every Codex request untagged, so `distributeSessions` had nothing to place
808
+ * and a Codex pool stayed on one account until the switch threshold. Order is
809
+ * most-specific first: the underscore form is the one a sidecar writes on our
810
+ * behalf, and `session-id` is generic enough for a proxy in front to set. */
809
811
  export function clientSessionId(headers) {
810
- const raw = headers['x-claude-code-session-id'] ?? headers['session-id'];
812
+ const raw = headers['x-claude-code-session-id'] ?? headers['session-id'] ?? headers['session_id'];
811
813
  return typeof raw === 'string' && SESSION_ID_SHAPE.test(raw) ? raw : null;
812
814
  }
813
815
 
@@ -211,10 +211,19 @@ function routingLines(routes, blocked, paint) {
211
211
  // say so, rather than listing eligible accounts it will never reach.
212
212
  const routeBlocked = globs.length > 0
213
213
  && globs.every(g => blocked.some(p => modelGlobOverlaps(p, g)));
214
+ // A route may list accounts from two providers — a local translating sidecar
215
+ // on this route's own wire, plus the subscription pool its back leg reaches.
216
+ // Tag the ones that are not this route's own provider, so a mixed row says
217
+ // which hop each account serves instead of reading as one flat pool.
218
+ const routeProvider = route.provider || 'anthropic';
219
+ const accountText = (a) => {
220
+ const tag = a.provider && a.provider !== routeProvider ? `:${nameText(a.provider)}` : '';
221
+ return nameText(a.name) + tag;
222
+ };
214
223
  const accounts = routeBlocked
215
224
  ? paint.red('blocked')
216
225
  : (route.accounts || [])
217
- .map(a => (a.eligible ? paint.green(nameText(a.name)) : paint.red(nameText(a.name)))).join(' ') || paint.gray('(none)');
226
+ .map(a => (a.eligible ? paint.green(accountText(a)) : paint.red(accountText(a)))).join(' ') || paint.gray('(none)');
218
227
  const tag = route.autocreated ? paint.dim(' (auto)') : route.bucket ? paint.dim(` [${nameText(route.bucket)}]`) : '';
219
228
  const pin = route.pinned ? paint.dim(` [pinned: ${nameText(route.pinned)}]`) : '';
220
229
  // padEnd on the raw text, color after, so ANSI codes don't throw off alignment.
package/src/tui-remote.js CHANGED
@@ -264,7 +264,21 @@ export class RemoteAccountManager {
264
264
  target: r?.target == null ? r?.target : text(r.target, NAME_MAX),
265
265
  match: (Array.isArray(r?.match) ? r.match : []).map(g => text(g, 64)).filter(Boolean),
266
266
  accounts: (Array.isArray(r?.accounts) ? r.accounts : [])
267
- .map(a => ({ ...a, name: text(a?.name, NAME_MAX, '?'), eligible: !!a?.eligible })),
267
+ .map(a => ({
268
+ ...a,
269
+ name: text(a?.name, NAME_MAX, '?'),
270
+ provider: a?.provider == null ? a?.provider : text(a.provider, 16),
271
+ eligible: !!a?.eligible,
272
+ })),
273
+ }));
274
+ // Conduit lines read this. Clamped like every other remote field: the
275
+ // payload is a server's word, not ours, and it reaches a rendered line.
276
+ this.sidecars = (Array.isArray(status?.sidecars) ? status.sidecars : []).map(sc => ({
277
+ name: text(sc?.name, NAME_MAX, '?'),
278
+ running: !!sc?.running,
279
+ pid: Number.isFinite(sc?.pid) ? sc.pid : null,
280
+ restarts: Number.isFinite(sc?.restarts) ? sc.restarts : 0,
281
+ lastExit: sc?.lastExit == null ? null : text(sc.lastExit, 48),
268
282
  }));
269
283
  this.status = status;
270
284
  this.connected = true;
package/src/tui.js CHANGED
@@ -405,6 +405,9 @@ function timestamp() {
405
405
 
406
406
  export class TUI {
407
407
  constructor({ accountManager, config, saveConfig, syncAccounts, onQuit, sx = null, probeQuota = null, activityLogPath = null,
408
+ // Supervised sidecar state for the conduit lines. A getter, not a snapshot:
409
+ // the supervisor respawns on its own schedule and the TUI redraws on a timer.
410
+ getSidecars = null,
408
411
  // Attach mode: the accounts belong to a server in another process, reached
409
412
  // over its control plane. Everything that would mutate local state is off,
410
413
  // and a switch becomes a request (applySwitch) instead of an assignment.
@@ -414,7 +417,11 @@ export class TUI {
414
417
  readCredentials = importCredentials, readProfile = fetchProfile,
415
418
  // Names the activity column against the session id the client sent. Absent
416
419
  // or disabled leaves every row showing the short id.
417
- sessionTitles = null }) {
420
+ sessionTitles = null,
421
+ // Shown faint beside the title. Null in attach mode, where the dashboard is
422
+ // built before the first poll and the server's version is not yet known,
423
+ // and in tests — both render the title alone rather than a stray `null`.
424
+ version = null }) {
418
425
  this.am = accountManager;
419
426
  this.remote = remote;
420
427
  this.applySwitch = applySwitch;
@@ -425,11 +432,13 @@ export class TUI {
425
432
  this.sx = sx; // sx.org proxy manager (may be null)
426
433
  this.sxBalance = null; // last fetched sx.org balance, for the settings screen
427
434
  this.probeQuota = probeQuota; // on-demand fleet-wide quota refresh (may be null)
435
+ this.getSidecars = getSidecars; // supervised sidecar state (may be null)
428
436
  this.activityLogPath = activityLogPath;
429
437
  this._readCredentials = readCredentials;
430
438
  this._readProfile = readProfile;
431
439
  this._activityStream = null;
432
440
  this.sessionTitles = sessionTitles;
441
+ /** @type {string|null} */ this.version = version;
433
442
 
434
443
  this.log = []; // completed activity entries
435
444
  this.active = new Map(); // in-flight requests
@@ -1372,7 +1381,7 @@ export class TUI {
1372
1381
  const lines = [];
1373
1382
 
1374
1383
  // ── Header
1375
- const left = bold(' TeamClaude');
1384
+ const left = bold(' RikClaude Harness') + (this.version ? dim(` ${this.version}`) : '');
1376
1385
  const port = this.config.proxy?.port || 3456;
1377
1386
  const sess = this.am.sessionStats();
1378
1387
  const sessStr = (sess.active || sess.known)
@@ -1514,6 +1523,8 @@ export class TUI {
1514
1523
  const b = budgets.get(categoryOf(this.am.accounts[i]));
1515
1524
  lines.push(this._renderAcct(i, b.bw, b.showBoth, routes, genRoutes, familyTarget, b.showFamily, nameW));
1516
1525
  }
1526
+ // Local backends sit under the seats, as a readout rather than rows.
1527
+ lines.push(...this._conduitLines());
1517
1528
  }
1518
1529
 
1519
1530
  // Routing is surfaced inline on each account row (see _renderAcct): a colored
@@ -1565,14 +1576,15 @@ export class TUI {
1565
1576
  this._paint(buf, force);
1566
1577
  }
1567
1578
 
1568
- /** Manager indices in the order the rows are drawn: accounts served by a
1569
- * local process last, every other account left where it is.
1579
+ /** Manager indices of the accounts drawn as rows: the seats that rotate.
1570
1580
  *
1571
- * A local backend (a translating proxy in front of another vendor, say) is
1572
- * infrastructure rather than a seat to rotate between, so it reads as noise
1573
- * wedged among the accounts that do rotate. Config order cannot keep it out
1574
- * of the way on its own, because a newly added account is appended AFTER it
1575
- * and puts it back in the middle.
1581
+ * A local backend — a translating proxy in front of another vendor — is
1582
+ * infrastructure, not a seat. It holds no subscription (its token is a
1583
+ * placeholder), it is the only candidate its route has, so it never rotates,
1584
+ * and it has no quota of its own to show. Drawn among the accounts it was a
1585
+ * row of dashes and borrowed numbers in a table whose whole purpose is which
1586
+ * account is being spent. It gets its own line below instead — see
1587
+ * _conduitLines. Sorting it last was the first half of this thought.
1576
1588
  *
1577
1589
  * Display only. `selIdx`, `currentIndex`, session pins and route entries all
1578
1590
  * stay manager indices, so nothing about selection or routing moves with the
@@ -1581,11 +1593,44 @@ export class TUI {
1581
1593
  _displayOrder() {
1582
1594
  return this.am.accounts
1583
1595
  .map((_, i) => i)
1584
- .sort((x, y) => {
1585
- const sx = isLocalUpstream(this.am.accounts[x]) ? 1 : 0;
1586
- const sy = isLocalUpstream(this.am.accounts[y]) ? 1 : 0;
1587
- return sx - sy || x - y; // ties keep list order, so the sort is stable
1588
- });
1596
+ .filter(i => !isLocalUpstream(this.am.accounts[i]));
1597
+ }
1598
+
1599
+ /** Manager indices of the local backends, in config order. */
1600
+ _conduitOrder() {
1601
+ return this.am.accounts.map((_, i) => i).filter(i => isLocalUpstream(this.am.accounts[i]));
1602
+ }
1603
+
1604
+ /** One line per local backend: what it is, where it sends, and whether it can
1605
+ * serve. Its supervised process's state is folded in when this TUI has it
1606
+ * (the server passes a getter; the remote TUI reads the status payload), so
1607
+ * a crash-looping sidecar says so here rather than only in `status --json`.
1608
+ *
1609
+ * Deliberately terse. There is nothing to choose between, so this is a
1610
+ * readout, not a row: the operator needs "is it up" and nothing else. */
1611
+ _conduitLines() {
1612
+ const sidecars = this._sidecars();
1613
+ return this._conduitOrder().map(i => {
1614
+ const a = this.am.accounts[i];
1615
+ let host = a.upstream;
1616
+ try { host = new URL(a.upstream).host; } catch { /* keep the raw string */ }
1617
+ // Matched by name: a sidecars[] entry and the account that routes to it
1618
+ // are named by the same operator, and nothing else pairs them.
1619
+ const proc = sidecars.find(sc => sc.name === a.name) || null;
1620
+ const state = a.disabled ? red('disabled')
1621
+ : a.rateLimitedUntil > Date.now() ? yellow('throttled')
1622
+ : proc && !proc.running ? red(`down (${proc.lastExit || 'restarting'})`)
1623
+ : proc ? green('up') : green('ok');
1624
+ const pid = proc?.running ? dim(` pid ${proc.pid}`) : '';
1625
+ const restarts = proc?.restarts ? yellow(` ${proc.restarts} restarts`) : '';
1626
+ return ` ${dim('⚙')} ${a.name} ${dim('→')} ${dim(host)} ${state}${pid}${restarts}`;
1627
+ });
1628
+ }
1629
+
1630
+ /** Supervised sidecar state, or [] when this TUI has no view of it. */
1631
+ _sidecars() {
1632
+ const list = this.getSidecars ? this.getSidecars() : this.am.sidecars;
1633
+ return Array.isArray(list) ? list : [];
1589
1634
  }
1590
1635
 
1591
1636
  _renderAcct(idx, bw, showBoth, routes = this.am.getRoutes(), genRoutes = routes.filter(r => routeFamily(r) === null), familyTarget = {}, showFamily = true, nameW = NAME_MIN) {