@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 +60 -2
- package/package.json +1 -1
- package/src/account-manager.js +40 -15
- package/src/identity.js +45 -0
- package/src/index.js +24 -3
- package/src/provider.js +6 -0
- package/src/server.js +8 -6
- package/src/status-renderer.js +10 -1
- package/src/tui-remote.js +15 -1
- package/src/tui.js +52 -12
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
|
|
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
|
-
|
|
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.
|
|
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",
|
package/src/account-manager.js
CHANGED
|
@@ -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
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
return
|
|
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
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
*
|
|
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
|
|
package/src/status-renderer.js
CHANGED
|
@@ -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(
|
|
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 => ({
|
|
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
|
|
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
|
|
1577
|
-
* infrastructure
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1580
|
-
* and
|
|
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
|
-
.
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
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) {
|