@rikcodes/teamclaude 1.1.20-rik.2 → 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
@@ -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.2",
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/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).
@@ -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,
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.
@@ -429,6 +432,7 @@ export class TUI {
429
432
  this.sx = sx; // sx.org proxy manager (may be null)
430
433
  this.sxBalance = null; // last fetched sx.org balance, for the settings screen
431
434
  this.probeQuota = probeQuota; // on-demand fleet-wide quota refresh (may be null)
435
+ this.getSidecars = getSidecars; // supervised sidecar state (may be null)
432
436
  this.activityLogPath = activityLogPath;
433
437
  this._readCredentials = readCredentials;
434
438
  this._readProfile = readProfile;
@@ -1519,6 +1523,8 @@ export class TUI {
1519
1523
  const b = budgets.get(categoryOf(this.am.accounts[i]));
1520
1524
  lines.push(this._renderAcct(i, b.bw, b.showBoth, routes, genRoutes, familyTarget, b.showFamily, nameW));
1521
1525
  }
1526
+ // Local backends sit under the seats, as a readout rather than rows.
1527
+ lines.push(...this._conduitLines());
1522
1528
  }
1523
1529
 
1524
1530
  // Routing is surfaced inline on each account row (see _renderAcct): a colored
@@ -1570,14 +1576,15 @@ export class TUI {
1570
1576
  this._paint(buf, force);
1571
1577
  }
1572
1578
 
1573
- /** Manager indices in the order the rows are drawn: accounts served by a
1574
- * local process last, every other account left where it is.
1579
+ /** Manager indices of the accounts drawn as rows: the seats that rotate.
1575
1580
  *
1576
- * A local backend (a translating proxy in front of another vendor, say) is
1577
- * infrastructure rather than a seat to rotate between, so it reads as noise
1578
- * wedged among the accounts that do rotate. Config order cannot keep it out
1579
- * of the way on its own, because a newly added account is appended AFTER it
1580
- * 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.
1581
1588
  *
1582
1589
  * Display only. `selIdx`, `currentIndex`, session pins and route entries all
1583
1590
  * stay manager indices, so nothing about selection or routing moves with the
@@ -1586,11 +1593,44 @@ export class TUI {
1586
1593
  _displayOrder() {
1587
1594
  return this.am.accounts
1588
1595
  .map((_, i) => i)
1589
- .sort((x, y) => {
1590
- const sx = isLocalUpstream(this.am.accounts[x]) ? 1 : 0;
1591
- const sy = isLocalUpstream(this.am.accounts[y]) ? 1 : 0;
1592
- return sx - sy || x - y; // ties keep list order, so the sort is stable
1593
- });
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 : [];
1594
1634
  }
1595
1635
 
1596
1636
  _renderAcct(idx, bw, showBoth, routes = this.am.getRoutes(), genRoutes = routes.filter(r => routeFamily(r) === null), familyTarget = {}, showFamily = true, nameW = NAME_MIN) {