@rikcodes/teamclaude 1.1.13-rik.1 → 1.1.13-rik.4

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
@@ -1,13 +1,24 @@
1
1
  # TeamClaude
2
2
 
3
- > **Fork notice (rikbrown).** This fork adds one feature and two reload fixes on top of
3
+ > **Fork notice (rikbrown).** This fork adds four features and two reload fixes on top of
4
4
  > [KarpelesLab/teamclaude](https://github.com/KarpelesLab/teamclaude):
5
5
  >
6
+ > - **[OpenAI models via a Codex sidecar](docs/openai.md)** (`sidecars` + `customModels`, opt-in):
7
+ > route `gpt-*` requests through a supervised local translating proxy to a ChatGPT subscription,
8
+ > under real model names — `/model gpt-5.6-sol` in the picker and typed, correct 272k context
9
+ > sizing, and dispatchable GPT subagents — while Claude traffic stays on the Claude accounts.
6
10
  > - **[Soonest-weekly rotation](docs/routing.md#soonest-weekly-rotation)** (`soonestWeekly`, opt-in): rank
7
11
  > equal-priority accounts by the weekly window that governs the requested model, continuously — preempt the
8
12
  > current account when another resets more than `poolHours` sooner, and balance `distributeSessions` within
9
13
  > that pool instead of across all accounts. Spends the quota closest to refreshing first, so a window no
10
14
  > longer rolls over with quota unspent.
15
+ > - **[Burn-rate projection](docs/quota.md#burn-rate-projection)** (`projection`, on by default): sample each
16
+ > bucket's consumption over a rolling window and tag every account row with whichever window binds
17
+ > first — `Ses TTL 38m` when it runs out before it resets, `Wk 22% unspent` when the reset arrives
18
+ > first and that much expires. A readout only: no selection code reads it.
19
+ > - **[Session titles](docs/usage.md#session-titles-in-the-activity-log)** (`sessionTitles`, on by default):
20
+ > name each activity row after the Claude Code session that sent the request, reading the title
21
+ > `/rename` writes and the one Claude Code generates. A session with neither keeps its short id.
11
22
  > - `soonestWeekly` and `distributeSessions` changes now apply on config reload; upstream applies
12
23
  > `distributeSessions` only at startup.
13
24
  >
@@ -18,11 +29,14 @@
18
29
  > npm install -g @rikcodes/teamclaude
19
30
  > ```
20
31
  >
32
+ > Already have upstream installed globally? Run `npm uninstall -g @karpeleslab/teamclaude` first —
33
+ > both packages provide the `teamclaude` command.
34
+ >
21
35
  > Branch: `rik/soonest-weekly-pool`. Everything else matches upstream.
22
36
 
23
- [![CI](https://github.com/KarpelesLab/teamclaude/actions/workflows/ci.yml/badge.svg)](https://github.com/KarpelesLab/teamclaude/actions/workflows/ci.yml)
24
- [![npm version](https://img.shields.io/npm/v/@karpeleslab/teamclaude.svg)](https://www.npmjs.com/package/@karpeleslab/teamclaude)
25
- [![node](https://img.shields.io/node/v/@karpeleslab/teamclaude.svg)](https://nodejs.org)
37
+ [![CI](https://github.com/rikbrown/teamclaude/actions/workflows/ci.yml/badge.svg?branch=rik/soonest-weekly-pool)](https://github.com/rikbrown/teamclaude/actions/workflows/ci.yml)
38
+ [![npm version](https://img.shields.io/npm/v/@rikcodes/teamclaude.svg)](https://www.npmjs.com/package/@rikcodes/teamclaude)
39
+ [![node](https://img.shields.io/node/v/@rikcodes/teamclaude.svg)](https://nodejs.org)
26
40
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
27
41
 
28
42
  Multi-account Claude proxy with automatic quota-based rotation for [Claude Code](https://claude.ai/claude-code).
@@ -48,6 +62,8 @@ Already logged into Claude Code? `teamclaude import` takes its credentials inste
48
62
  ## What it does
49
63
 
50
64
  - Rotates to the next account when the 5h session or 7d weekly bucket reaches the threshold (98% by default), preferring the account whose weekly quota resets soonest.
65
+ - Optionally spends the account whose weekly window resets soonest **first**, preempting the current account when another resets more than `poolHours` sooner, so a window stops rolling over with quota unspent (`soonestWeekly`, this fork).
66
+ - Projects each quota window's burn rate against its reset, so a row says `Ses TTL 38m · Wk 22% unspent` instead of leaving you to read it off a bar (`projection`, this fork).
51
67
  - Tracks the per-model weekly cap separately, so an account out of Fable quota is skipped for Fable requests and still serves Opus and Sonnet.
52
68
  - Tells a spent quota bucket apart from a per-minute rate limit and only rotates on the first one. Rotating on a rate limit would just move the burst to the next account and drop the warm cache, so it paces the same account instead.
53
69
  - Paces requests onto a freshly switched account, so a herd of agents failing over at the same instant doesn't throttle it and cascade down the fleet.
@@ -56,6 +72,7 @@ Already logged into Claude Code? `teamclaude import` takes its credentials inste
56
72
  - Holds the request open until quota resets instead of returning 429 when every account is spent, so an unattended run finishes on its own (`holdSeconds`, off by default).
57
73
  - Refreshes OAuth tokens before they expire and writes them back to config. Client refreshes pass through untouched.
58
74
  - Takes any Anthropic-compatible API (DeepSeek, GLM) as a low-priority fallback for when the Claude accounts are done.
75
+ - Serves OpenAI models next to Claude ones — a supervised local sidecar translates `gpt-*` requests onto a ChatGPT subscription, with real model names in `/model` and GPT subagents dispatchable from a Claude parent (`sidecars` + `customModels`, this fork).
59
76
  - No dependencies. Node built-ins only.
60
77
 
61
78
  ## Everyday commands
@@ -95,27 +112,38 @@ Step-by-step lifecycle: [docs/routing.md](docs/routing.md#request-lifecycle).
95
112
  | [Usage](docs/usage.md) | Server and TUI, running Claude Code, shell alias, command reference, logging |
96
113
  | [Routing](docs/routing.md) | Rotation, the two kinds of 429, storm control, model routes, session spreading, pinning, prompt cache |
97
114
  | [Quota](docs/quota.md) | Quota probe, keep-warm, holding on exhaustion |
115
+ | [OpenAI models](docs/openai.md) | Codex sidecar setup, custom model registration, GPT subagents, limitations |
98
116
  | [Configuration](docs/configuration.md) | Config format, every field, environment variables, network tuning |
99
117
  | [Proxy modes](docs/proxy-modes.md) | MITM forward proxy, sx.org residential egress |
100
118
  | [Compliance](docs/compliance.md) | Terms of service notes |
101
119
 
120
+ ## Releasing this fork
121
+
122
+ Versions are `<upstream base>-rik.<n>`, e.g. `1.1.13-rik.1`. The self-updater orders that tail, so every publish reaches existing installs within a day.
123
+
124
+ 1. Rebase onto the upstream release you want as the base, if any.
125
+ 2. Bump `version` in `package.json` and commit.
126
+ 3. Push to `rik/soonest-weekly-pool` — the Publish workflow runs the tests, publishes to npm, and cuts a GitHub release.
127
+
128
+ The workflow authenticates with npm Trusted Publishing (OIDC), which needs a one-time setup on npmjs.com: `@rikcodes/teamclaude` → Settings → Trusted Publisher → GitHub Actions, owner `rikbrown`, repo `teamclaude`, workflow `publish.yml`. Until that exists, publish by hand:
129
+
130
+ ```bash
131
+ pnpm publish --publish-branch rik/soonest-weekly-pool --tag latest --otp=<code>
132
+ ```
133
+
134
+ A prerelease version always needs an explicit `--tag`, and `latest` is the tag the self-updater reads.
135
+
102
136
  ## Security
103
137
 
104
- The only canonical sources for TeamClaude are this repository (https://github.com/KarpelesLab/teamclaude) and the [`@karpeleslab/teamclaude`](https://www.npmjs.com/package/@karpeleslab/teamclaude) npm package. TeamClaude is **never** distributed as a downloadable binary archive, so be wary of soft-forks that bundle a `.zip` and tell you to extract and run it. See [SECURITY.md](SECURITY.md) for details and how to report issues.
138
+ This repository is a personal fork and is **not** the canonical project. Upstream's canonical sources are unchanged: the [KarpelesLab repository](https://github.com/KarpelesLab/teamclaude) and the [`@karpeleslab/teamclaude`](https://www.npmjs.com/package/@karpeleslab/teamclaude) npm package.
105
139
 
106
- ## Compliance
140
+ This fork is distributed as this repository and the [`@rikcodes/teamclaude`](https://www.npmjs.com/package/@rikcodes/teamclaude) npm package, published by `rikbrown`. The separate package name means it can never be installed over the canonical one.
107
141
 
108
- TeamClaude is a local proxy holding your own credentials and driving your own Claude Code CLI. How that lines up with Anthropic's terms, including the multi-subscription question people ask most, is written up in [docs/compliance.md](docs/compliance.md). Not legal advice.
142
+ Neither is **ever** distributed as a downloadable binary archive, so be wary of any copy that bundles a `.zip` and tells you to extract and run it. See [SECURITY.md](SECURITY.md) for details and how to report issues.
109
143
 
110
- ## Star history
144
+ ## Compliance
111
145
 
112
- <a href="https://www.star-history.com/?repos=KarpelesLab%2Fteamclaude&type=date&legend=top-left">
113
- <picture>
114
- <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=KarpelesLab/teamclaude&type=date&theme=dark&legend=top-left" />
115
- <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=KarpelesLab/teamclaude&type=date&legend=top-left" />
116
- <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=KarpelesLab/teamclaude&type=date&legend=top-left" />
117
- </picture>
118
- </a>
146
+ TeamClaude is a local proxy holding your own credentials and driving your own Claude Code CLI. How that lines up with Anthropic's terms, including the multi-subscription question people ask most, is written up in [docs/compliance.md](docs/compliance.md). Not legal advice.
119
147
 
120
148
  ## License
121
149
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rikcodes/teamclaude",
3
- "version": "1.1.13-rik.1",
3
+ "version": "1.1.13-rik.4",
4
4
  "description": "Multi-account Claude proxy with automatic quota-based rotation",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -10,6 +10,11 @@
10
10
  "files": [
11
11
  "src/"
12
12
  ],
13
+ "scripts": {
14
+ "start": "node src/index.js",
15
+ "test": "node --test --test-timeout=120000",
16
+ "lint": "eslint ."
17
+ },
13
18
  "keywords": [
14
19
  "claude",
15
20
  "anthropic",
@@ -34,10 +39,5 @@
34
39
  },
35
40
  "publishConfig": {
36
41
  "access": "public"
37
- },
38
- "scripts": {
39
- "start": "node src/index.js",
40
- "test": "node --test --test-timeout=120000",
41
- "lint": "eslint ."
42
42
  }
43
- }
43
+ }
@@ -2,6 +2,7 @@ import { refreshAccessToken, isTokenExpiringSoon, isTokenExpired } from './oauth
2
2
  import { sameIdentity } from './identity.js';
3
3
  import { weeklyBucketForModel, modelGlobMatches } from './model.js';
4
4
  import { SessionTracker } from './session-tracker.js';
5
+ import { QuotaProjection, PROJECTED_BUCKETS } from './quota-projection.js';
5
6
 
6
7
  // Re-exported for callers that import these model helpers from here.
7
8
  export { isFableModel, parseRequestModel, parseAdvisorModel } from './model.js';
@@ -21,6 +22,22 @@ const PERSISTED_QUOTA_FIELDS = [
21
22
  'tokensLimit', 'tokensRemaining', 'requestsLimit', 'requestsRemaining', 'resetsAt',
22
23
  ];
23
24
 
25
+ // Longest Codex window still treated as a session bucket. The two lengths seen
26
+ // in practice are 300 minutes (5h) and 10080 (weekly), so a day sits safely
27
+ // between them.
28
+ const CODEX_WEEKLY_MIN_MINUTES = 1440;
29
+
30
+ // A Codex `*-reset-at` header as a ms timestamp. The wire format isn't pinned
31
+ // down (the sidecar forwards it opaquely), so accept epoch seconds, epoch ms,
32
+ // or an ISO-8601 date; anything else is null.
33
+ function parseResetAt(value) {
34
+ if (value == null || value === '') return null;
35
+ const n = Number(value);
36
+ if (Number.isFinite(n)) return n > 1e12 ? n : n * 1000;
37
+ const parsed = Date.parse(value);
38
+ return Number.isNaN(parsed) ? null : parsed;
39
+ }
40
+
24
41
  function emptyQuota() {
25
42
  return {
26
43
  // Standard API rate limits (API key accounts)
@@ -108,7 +125,7 @@ function sampleModelFor(route) {
108
125
  }
109
126
 
110
127
  export class AccountManager {
111
- constructor(accounts, switchThreshold = 0.98, { refreshFn = refreshAccessToken, throttleProbeFloorMs, forcedRefreshFloorMs = FORCED_REFRESH_FLOOR_MS, routes, ramp, distributeSessions = false, soonestWeekly, sessionTracker } = {}) {
128
+ constructor(accounts, switchThreshold = 0.98, { refreshFn = refreshAccessToken, throttleProbeFloorMs, forcedRefreshFloorMs = FORCED_REFRESH_FLOOR_MS, routes, ramp, distributeSessions = false, soonestWeekly, projection, sessionTracker } = {}) {
112
129
  // How long a just-minted token is trusted against a forced refresh.
113
130
  this._forcedRefreshFloorMs = forcedRefreshFloorMs;
114
131
  // Injectable for tests (mirrors Prober's probeFn); defaults to the real
@@ -131,6 +148,7 @@ export class AccountManager {
131
148
  this.switchThreshold = switchThreshold;
132
149
  this.setRoutes(routes);
133
150
  this.setSoonestWeekly(soonestWeekly);
151
+ this.setProjection(projection);
134
152
  // Storm control: when rotation switches to a fresh account, a burst of
135
153
  // in-flight requests (e.g. dozens of agents failing over together) would all
136
154
  // hit it at once and instantly throttle it — cascading down the fleet
@@ -693,6 +711,47 @@ export class AccountManager {
693
711
  };
694
712
  }
695
713
 
714
+ /**
715
+ * Burn-rate projection settings, applied live on config reload. Enabled by
716
+ * default: the projection is a readout and no selection code consults it, so
717
+ * turning it on cannot change which account serves a request.
718
+ */
719
+ setProjection(cfg) {
720
+ const c = cfg || {};
721
+ this.projection = new QuotaProjection({
722
+ enabled: c.enabled !== false,
723
+ windowMinutes: c.windowMinutes,
724
+ wasteFloor: c.wasteFloor ?? 0.1,
725
+ });
726
+ }
727
+
728
+ /** Sample every reported bucket. Both quota write paths call this: response
729
+ * headers (updateQuota) and the usage probe (applyUsageData). */
730
+ _recordQuotaSamples(account, now = Date.now()) {
731
+ const q = account.quota;
732
+ for (const bucket of PROJECTED_BUCKETS) {
733
+ if (q[bucket] !== undefined) this.projection.record(account.index, bucket, q[bucket], now);
734
+ }
735
+ }
736
+
737
+ /** Every bucket's projection for one account, keyed by bucket name. Buckets
738
+ * without a usable rate are absent rather than null. */
739
+ projectionsFor(accountIndex, now = Date.now()) {
740
+ const account = this.accounts[accountIndex];
741
+ if (!account) return {};
742
+ const q = account.quota;
743
+ const out = {};
744
+ for (const bucket of PROJECTED_BUCKETS) {
745
+ const projected = this.projection.project(accountIndex, bucket, {
746
+ utilization: q[bucket],
747
+ resetAt: q[`${bucket}Reset`],
748
+ now,
749
+ });
750
+ if (projected) out[bucket] = projected;
751
+ }
752
+ return out;
753
+ }
754
+
696
755
  /**
697
756
  * Normalize and store the configurable routing table. A route pins a set of
698
757
  * model globs to an exclusive set of accounts (and may override the governing
@@ -1161,6 +1220,25 @@ export class AccountManager {
1161
1220
  const uStatus = headers['anthropic-ratelimit-unified-status'];
1162
1221
  if (uStatus) account.quota.unifiedStatus = uStatus;
1163
1222
 
1223
+ // OpenAI/Codex windows (`x-codex-*`, forwarded by a translating sidecar for
1224
+ // a ChatGPT-subscription account). Codex reports two windows, `primary` and
1225
+ // `secondary`, whose meaning comes from the declared length rather than the
1226
+ // position: a ChatGPT Pro plan reports its weekly limit as the primary one
1227
+ // and meters no secondary window at all. So each window is filed by length,
1228
+ // which lands it in the same 5h/weekly slots the rest of the code reads —
1229
+ // display, projection and switch-threshold logic apply unchanged. A window
1230
+ // with no length is a bucket the plan does not have, not one at 0% used.
1231
+ // used-percent is 0-100 (not the 0-1 fraction Anthropic reports).
1232
+ for (const window of ['primary', 'secondary']) {
1233
+ const used = parseFloat(headers[`x-codex-${window}-used-percent`]);
1234
+ const minutes = parseInt(headers[`x-codex-${window}-window-minutes`], 10);
1235
+ if (isNaN(used) || !(minutes > 0)) continue;
1236
+ const reset = parseResetAt(headers[`x-codex-${window}-reset-at`]);
1237
+ const weekly = minutes > CODEX_WEEKLY_MIN_MINUTES;
1238
+ account.quota[weekly ? 'unified7d' : 'unified5h'] = used / 100;
1239
+ if (reset != null) account.quota[weekly ? 'unified7dReset' : 'unified5hReset'] = reset;
1240
+ }
1241
+
1164
1242
  // Standard rate limits (API key accounts)
1165
1243
  const tokensLimit = parseInt(headers['anthropic-ratelimit-tokens-limit'], 10);
1166
1244
  const tokensRemaining = parseInt(headers['anthropic-ratelimit-tokens-remaining'], 10);
@@ -1177,6 +1255,8 @@ export class AccountManager {
1177
1255
  if (tokensReset) account.quota.resetsAt = tokensReset;
1178
1256
  else if (requestsReset) account.quota.resetsAt = requestsReset;
1179
1257
 
1258
+ this._recordQuotaSamples(account);
1259
+
1180
1260
  account.usage.totalRequests++;
1181
1261
  account.usage.lastUsed = new Date().toISOString();
1182
1262
 
@@ -1244,6 +1324,8 @@ export class AccountManager {
1244
1324
  if (usage.sevenDayFable.resetAt != null) q.unified7dFableReset = usage.sevenDayFable.resetAt;
1245
1325
  }
1246
1326
 
1327
+ this._recordQuotaSamples(account);
1328
+
1247
1329
  // If we just learned this account's weekly window while probing, re-evaluate
1248
1330
  // selection (same path as learning it from a live response).
1249
1331
  if (account.probing && q.unified7dReset != null) {
@@ -1437,6 +1519,7 @@ export class AccountManager {
1437
1519
  routes: this.getRoutes(),
1438
1520
  sessions: { ...sessions, distribute: this.distributeSessions },
1439
1521
  soonestWeekly: { ...this.soonestWeekly },
1522
+ projection: this.projection.settings(),
1440
1523
  accounts: this.accounts.map(a => ({
1441
1524
  name: a.name,
1442
1525
  type: a.type,
@@ -1447,6 +1530,10 @@ export class AccountManager {
1447
1530
  sessions: sessions.perAccount[a.index] || 0,
1448
1531
  quota: { ...a.quota },
1449
1532
  usage: { ...a.usage },
1533
+ projection: (() => {
1534
+ const buckets = this.projectionsFor(a.index);
1535
+ return { headline: this.projection.headline(Object.values(buckets)), buckets };
1536
+ })(),
1450
1537
  rateLimitedUntil: a.rateLimitedUntil
1451
1538
  ? new Date(a.rateLimitedUntil).toISOString()
1452
1539
  : null,
package/src/claude-env.js CHANGED
@@ -30,7 +30,66 @@ export function encodePinComponent(s) {
30
30
  // `/tc-acct/` prefix. TC_ACCT itself is then unset, so the pin does not leak
31
31
  // into claude or anything it spawns — same reasoning as `run` deleting it from
32
32
  // the child environment.
33
- export function buildClaudeEnvLines({ port, useMitm = true, caPath = null, holdSeconds = 0, account = null, proxyApiKey = '' }) {
33
+ // `config.customModels` → the `--settings` JSON that puts each model in the
34
+ // /model picker under its REAL id ({model, label?, description?} rows; typed
35
+ // `/model <id>` also accepts picker rows). contextTokens is ours, not Claude
36
+ // Code's — it feeds CLAUDE_CODE_MAX_CONTEXT_TOKENS below. Null when empty so
37
+ // callers can skip the flag entirely.
38
+ export function buildCustomModelSettings(customModels) {
39
+ if (!customModels?.length) return null;
40
+ const options = customModels.map(({ model, label, description }) => ({
41
+ model,
42
+ ...(label ? { label } : {}),
43
+ ...(description ? { description } : {}),
44
+ }));
45
+ return JSON.stringify({ modelPicker: { options } });
46
+ }
47
+
48
+ // `config.customModels` → the `--agents` JSON that makes each model
49
+ // dispatchable as a subagent. The Agent tool's per-invocation `model`
50
+ // parameter is an alias enum (sonnet|opus|haiku|fable) and rejects custom ids;
51
+ // an agent DEFINITION's `model:` field accepts any id, so each custom model
52
+ // gets a general-purpose agent named after it ("dispatch a gpt-5.6-terra
53
+ // subagent" then works out of the box). Null when empty.
54
+ export function buildCustomModelAgents(customModels) {
55
+ if (!customModels?.length) return null;
56
+ const agents = {};
57
+ for (const { model, label } of customModels) {
58
+ agents[model] = {
59
+ description: `General-purpose subagent running on ${label || model} (via TeamClaude). `
60
+ + `Use when asked to run a task on ${model}.`,
61
+ prompt: `You are a general-purpose subagent running on the ${model} model. `
62
+ + 'Complete the task you are given and report the results concisely.',
63
+ model,
64
+ };
65
+ }
66
+ return JSON.stringify(agents);
67
+ }
68
+
69
+ // The env-only registration for launchers we can't pass flags to (`teamclaude
70
+ // env`). ANTHROPIC_CUSTOM_MODEL_OPTION registers ONE model (env can't express a
71
+ // list — the picker rows need `--settings`, i.e. `teamclaude run`), so the
72
+ // first entry is the one that gets a picker row and typed-/model acceptance.
73
+ // CLAUDE_CODE_MAX_CONTEXT_TOKENS is global for all unknown model ids: use the
74
+ // largest declared window so no custom model is compacted early; deliberately
75
+ // NOT modelOverrides, which would pin the window to the mapped Claude model's.
76
+ export function buildCustomModelVars(customModels) {
77
+ if (!customModels?.length) return {};
78
+ const vars = { ANTHROPIC_CUSTOM_MODEL_OPTION: customModels[0].model };
79
+ if (customModels[0].label) vars.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME = customModels[0].label;
80
+ if (customModels[0].description) vars.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION = customModels[0].description;
81
+ const windows = customModels.map(m => m.contextTokens).filter(n => Number.isFinite(n));
82
+ if (windows.length) vars.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(Math.max(...windows));
83
+ return vars;
84
+ }
85
+
86
+ // Single-quote a value for an unquoted-context shell `export` line (labels and
87
+ // descriptions contain spaces). POSIX: close, escaped quote, reopen.
88
+ function shellQuote(value) {
89
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
90
+ }
91
+
92
+ export function buildClaudeEnvLines({ port, useMitm = true, caPath = null, holdSeconds = 0, account = null, proxyApiKey = '', customModels = null }) {
34
93
  const lines = [];
35
94
  const pin = (account || '').trim();
36
95
 
@@ -61,5 +120,10 @@ export function buildClaudeEnvLines({ port, useMitm = true, caPath = null, holdS
61
120
  const holdMs = (holdSeconds || 0) * 1000;
62
121
  if (holdMs > 0) lines.push(`export API_TIMEOUT_MS=${holdMs + 60_000}`);
63
122
 
123
+ // Custom (third-party) model registration — see buildCustomModelVars.
124
+ for (const [key, value] of Object.entries(buildCustomModelVars(customModels))) {
125
+ lines.push(`export ${key}=${shellQuote(value)}`);
126
+ }
127
+
64
128
  return lines;
65
129
  }
package/src/config.js CHANGED
@@ -58,6 +58,8 @@ export function createDefaultConfig() {
58
58
  switchThreshold: 0.98,
59
59
  holdSeconds: 0,
60
60
  distributeSessions: false,
61
+ projection: { enabled: true, windowMinutes: 90, wasteFloor: 0.1 },
62
+ sessionTitles: { enabled: true, width: 18 },
61
63
  eventLogging: 'hide',
62
64
  blockedModels: [],
63
65
  accounts: [],
package/src/index.js CHANGED
@@ -15,12 +15,14 @@ import * as alias from './alias.js';
15
15
  import { ensureCerts } from './mitm.js';
16
16
  import { Prober } from './prober.js';
17
17
  import { Warmer } from './warmer.js';
18
+ import { Sidecar } from './sidecar.js';
18
19
  import { TUI } from './tui.js';
20
+ import { SessionTitles } from './session-titles.js';
19
21
  import { RemoteControl, createAttachSession } from './tui-remote.js';
20
22
  import { SxManager } from './sx.js';
21
23
  import { autoUpdate, checkForUpdate, currentVersion, runUpdate, installKind, PKG_NAME } from './updater.js';
22
24
  import { renderStatus } from './status-renderer.js';
23
- import { buildClaudeEnvLines, encodePinComponent } from './claude-env.js';
25
+ import { buildClaudeEnvLines, buildCustomModelAgents, buildCustomModelSettings, buildCustomModelVars, encodePinComponent } from './claude-env.js';
24
26
  import { serviceKind, installService, uninstallService, serviceStatus, renderService, logPath } from './service.js';
25
27
  import { formatTerminalTitle, titleSequence, TITLE_STACK_PUSH, TITLE_STACK_POP } from './terminal-title.js';
26
28
  import { getUpstreamProxy, describeProxy } from './upstream-proxy.js';
@@ -178,7 +180,12 @@ async function serverCommand() {
178
180
  }
179
181
 
180
182
  const threshold = config.switchThreshold || 0.98;
181
- const accountManager = new AccountManager(accounts, threshold, { routes: config.routes, ramp: config.stormRamp, distributeSessions: config.distributeSessions, soonestWeekly: config.soonestWeekly });
183
+ const accountManager = new AccountManager(accounts, threshold, { routes: config.routes, ramp: config.stormRamp, distributeSessions: config.distributeSessions, soonestWeekly: config.soonestWeekly, projection: config.projection });
184
+ // Names the activity log's session column from Claude Code's own on-disk
185
+ // session titles. Built whether or not the TUI runs, so a reload has one
186
+ // object to reconfigure.
187
+ const sessionTitles = new SessionTitles(config.sessionTitles);
188
+
182
189
 
183
190
  // Restore quota observed in a previous run so a restart doesn't lose rotation
184
191
  // state (passive — we never call the API to re-learn it). Stale windows are
@@ -242,6 +249,9 @@ async function serverCommand() {
242
249
  let prober = null;
243
250
  // Opt-in keep-warm scheduler (config.warmupSeconds, default 0 = off).
244
251
  let warmer = null;
252
+ // Supervised sidecar processes (config.sidecars, default none) — e.g. a local
253
+ // Anthropic→OpenAI translating proxy that a third-party account routes to.
254
+ let sidecar = null;
245
255
  const serverStartedAt = Date.now();
246
256
 
247
257
  // sx.org proxy (IP-based-429 workaround). Dormant unless an API key is set in
@@ -269,6 +279,10 @@ async function serverCommand() {
269
279
  accountManager.setDistributeSessions(config.distributeSessions);
270
280
  config.soonestWeekly = diskConfig.soonestWeekly;
271
281
  accountManager.setSoonestWeekly(config.soonestWeekly);
282
+ config.projection = diskConfig.projection;
283
+ accountManager.setProjection(config.projection);
284
+ config.sessionTitles = diskConfig.sessionTitles;
285
+ sessionTitles.configure(config.sessionTitles);
272
286
  // Apply an sx.org key/mode change made on disk (e.g. via POST /teamclaude/reload).
273
287
  const diskSxKey = diskConfig.sx?.apiKey || null;
274
288
  const diskSxMode = diskConfig.sx?.mode || 'always';
@@ -299,7 +313,7 @@ async function serverCommand() {
299
313
 
300
314
  if (useTUI) {
301
315
  tui = new TUI({
302
- accountManager, config, sx, activityLogPath,
316
+ accountManager, config, sx, activityLogPath, sessionTitles,
303
317
  saveConfig: () => atomicConfigUpdate(async diskConfig => {
304
318
  // Write in-memory accounts as the authoritative state, preserving
305
319
  // extra disk-only fields (e.g. importFrom) where the account still exists.
@@ -418,6 +432,7 @@ async function serverCommand() {
418
432
  error: null,
419
433
  })),
420
434
  },
435
+ sidecars: sidecar?.getStatus() || [],
421
436
  });
422
437
 
423
438
  const server = createProxyServer(accountManager, config, hooks, sx);
@@ -487,6 +502,10 @@ async function serverCommand() {
487
502
  });
488
503
  warmer.start();
489
504
 
505
+ // Launch supervised sidecars (no-op when config.sidecars is empty).
506
+ sidecar = new Sidecar(config.sidecars);
507
+ sidecar.start();
508
+
490
509
  // Background self-update for a backgrounded (headless) server. Skipped under
491
510
  // the TUI, where npm's install output would corrupt the display — interactive
492
511
  // users update via `teamclaude run` (post-session) or `teamclaude update`.
@@ -507,6 +526,7 @@ async function serverCommand() {
507
526
  if (!tui) console.log('\n[TeamClaude] Shutting down...');
508
527
  prober?.stop();
509
528
  warmer?.stop();
529
+ sidecar?.stop();
510
530
  if (quotaSaveInterval) clearInterval(quotaSaveInterval);
511
531
  await persistQuotaState();
512
532
  // Don't linger waiting on keep-alive / streaming connections: actively
@@ -669,6 +689,7 @@ async function envCommand() {
669
689
  const lines = buildClaudeEnvLines({
670
690
  port, useMitm, caPath, holdSeconds: config.holdSeconds,
671
691
  account, proxyApiKey: config.proxy?.apiKey || '',
692
+ customModels: config.customModels,
672
693
  });
673
694
  process.stdout.write(`${lines.join('\n')}\n`);
674
695
 
@@ -726,7 +747,8 @@ async function runCommand() {
726
747
  // also pins (shipped in 1.1.10). TC_ACCT is the supported way now — it works in
727
748
  // MITM mode too, and keeps the pin out of the API path.
728
749
  const pinnedBase = isLocalAccountPin(process.env.ANTHROPIC_BASE_URL, port);
729
- if (await isProxyUp(port)) {
750
+ const proxyUp = await isProxyUp(port);
751
+ if (proxyUp) {
730
752
  if (useMitm) {
731
753
  // Route ALL of claude's traffic through us as an HTTPS forward proxy, so
732
754
  // even hardcoded api.anthropic.com endpoints (e.g. the design MCP) get the
@@ -774,6 +796,21 @@ async function runCommand() {
774
796
  process.exit(1);
775
797
  }
776
798
 
799
+ // Register custom (third-party) models with Claude Code — /model picker rows
800
+ // via --settings, typed-/model + window sizing via env — but only when routed
801
+ // through the proxy: launched directly, those models aren't reachable. A
802
+ // caller-supplied --settings wins; merging two would silently drop keys.
803
+ if (proxyUp && config.customModels?.length) {
804
+ Object.assign(env, buildCustomModelVars(config.customModels));
805
+ const settings = buildCustomModelSettings(config.customModels);
806
+ if (settings && !claudeArgs.includes('--settings')) claudeArgs.push('--settings', settings);
807
+ // Dispatchable subagents per custom model — the Agent tool's `model`
808
+ // parameter is an alias enum, so only a named agent definition can carry a
809
+ // custom model id into a subagent.
810
+ const agents = buildCustomModelAgents(config.customModels);
811
+ if (agents && !claudeArgs.includes('--agents')) claudeArgs.push('--agents', agents);
812
+ }
813
+
777
814
  // If holdSeconds is set, ensure API_TIMEOUT_MS on the Claude Code side is
778
815
  // large enough for the hold to complete. Add 60s padding (one extra poll
779
816
  // cycle) so the client doesn't time out while we're still waiting.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Burn-rate projection for quota buckets.
3
+ *
4
+ * Each bucket reports utilization as a 0-1 fraction. Sampling that over a
5
+ * rolling window gives a consumption rate, and the rate against the bucket's
6
+ * known reset answers the question the bars cannot: will this window stop you
7
+ * before it resets, or expire with quota unspent?
8
+ *
9
+ * The rate is a least-squares slope rather than a first-to-last delta, so one
10
+ * large response does not swing the figure. The window is a trade: consumption
11
+ * is bursty and a recent-rate estimate is meant to track it, but utilization
12
+ * arrives quantised to whole percent, so too narrow a window contains no step
13
+ * to measure. Nothing is reported rather than a fabricated rate when the
14
+ * samples cannot support one.
15
+ */
16
+
17
+ /** Every bucket that reports utilization, in the order the TUI shows them. */
18
+ export const PROJECTED_BUCKETS = ['unified5h', 'unified7d', 'unified7dSonnet', 'unified7dFable'];
19
+
20
+ /** Buckets whose leftovers are worth reporting. A 5h window refills the same
21
+ * day, so its unspent tail costs nothing and is never reported as a surplus. */
22
+ const WEEKLY_BUCKETS = new Set(['unified7d', 'unified7dSonnet', 'unified7dFable']);
23
+
24
+ /** Row labels, matching the TUI's bar labels so one tag reads against them. */
25
+ const BUCKET_LABELS = {
26
+ unified5h: 'Ses',
27
+ unified7d: 'Wk',
28
+ unified7dSonnet: 'S7',
29
+ unified7dFable: 'F7',
30
+ };
31
+
32
+ /** Two samples one second apart would extrapolate a burst across a whole week.
33
+ * Below this span the samples are kept but no rate is reported. */
34
+ const MIN_SPAN_MS = 5 * 60_000;
35
+
36
+ /** Utilization is reported as whole percent, so the signal is a staircase with
37
+ * 1% steps. A window narrower than this can contain no step at all: measured
38
+ * against a 1%/h burn, a 30-minute window reports nothing half the time and
39
+ * ranges 0.4-2.9%/h when it does speak, while 90 minutes holds 0.9-1.1%/h. */
40
+ const DEFAULT_WINDOW_MINUTES = 90;
41
+
42
+ export class QuotaProjection {
43
+ constructor({ enabled = true, windowMinutes = DEFAULT_WINDOW_MINUTES, wasteFloor = 0.1 } = {}) {
44
+ this.enabled = enabled !== false;
45
+ this.windowMs = Math.max(1, windowMinutes) * 60_000;
46
+ this.wasteFloor = Math.max(0, Math.min(1, wasteFloor));
47
+ /** @type {Map<string, Array<{t: number, u: number}>>} */
48
+ this.samples = new Map();
49
+ }
50
+
51
+ /** The settings in force, for the status readout. */
52
+ settings() {
53
+ return {
54
+ enabled: this.enabled,
55
+ windowMinutes: this.windowMs / 60_000,
56
+ wasteFloor: this.wasteFloor,
57
+ };
58
+ }
59
+
60
+ /** Record one utilization reading. A null reading means the window rolled
61
+ * (_clearExpiredQuotas nulls the bucket at its reset), so the history is
62
+ * dropped: without this the roll reads as a large negative burn. */
63
+ record(accountIndex, bucket, utilization, at = Date.now()) {
64
+ if (!this.enabled) return;
65
+ const key = `${accountIndex}:${bucket}`;
66
+ if (utilization == null || isNaN(utilization)) {
67
+ this.samples.delete(key);
68
+ return;
69
+ }
70
+ let list = this.samples.get(key);
71
+ if (!list) {
72
+ list = [];
73
+ this.samples.set(key, list);
74
+ }
75
+ // A window can also roll as a decrease: a probe reports the fresh window
76
+ // before _clearExpiredQuotas nulls the bucket. Utilization never falls
77
+ // within a window, so a drop means the same restart a null does.
78
+ if (list.length && utilization < list[list.length - 1].u) list.length = 0;
79
+ list.push({ t: at, u: utilization });
80
+ const cutoff = at - this.windowMs;
81
+ while (list.length && list[0].t < cutoff) list.shift();
82
+ }
83
+
84
+ /** Consumption in utilization per millisecond, or null when the samples in
85
+ * the window cannot support an estimate (too few, too short a span, or no
86
+ * measurable consumption). */
87
+ rate(accountIndex, bucket) {
88
+ if (!this.enabled) return null;
89
+ const list = this.samples.get(`${accountIndex}:${bucket}`);
90
+ if (!list || list.length < 2) return null;
91
+ const span = list[list.length - 1].t - list[0].t;
92
+ if (span < MIN_SPAN_MS) return null;
93
+
94
+ // Times are relative to the first sample: epoch milliseconds squared loses
95
+ // precision in the sums below.
96
+ const t0 = list[0].t;
97
+ let sumT = 0, sumU = 0, sumTT = 0, sumTU = 0;
98
+ for (const { t, u } of list) {
99
+ const x = t - t0;
100
+ sumT += x;
101
+ sumU += u;
102
+ sumTT += x * x;
103
+ sumTU += x * u;
104
+ }
105
+ const n = list.length;
106
+ const denom = n * sumTT - sumT * sumT;
107
+ if (denom === 0) return null;
108
+ const slope = (n * sumTU - sumT * sumU) / denom;
109
+ return slope > 0 ? slope : null;
110
+ }
111
+
112
+ /**
113
+ * Project one bucket against its reset.
114
+ * @returns {{bucket: string, kind: 'deficit'|'surplus', exhaustsInMs?: number,
115
+ * unspent?: number, resetInMs: number} | null}
116
+ */
117
+ project(accountIndex, bucket, { utilization, resetAt, now = Date.now() } = {}) {
118
+ if (!this.enabled) return null;
119
+ if (utilization == null || resetAt == null) return null;
120
+ const rate = this.rate(accountIndex, bucket);
121
+ if (rate == null) return null;
122
+
123
+ const resetInMs = resetAt - now;
124
+ const remaining = 1 - utilization;
125
+ if (remaining <= 0) return { bucket, kind: 'deficit', exhaustsInMs: 0, resetInMs };
126
+
127
+ const exhaustsInMs = remaining / rate;
128
+ if (exhaustsInMs <= resetInMs) return { bucket, kind: 'deficit', exhaustsInMs, resetInMs };
129
+
130
+ if (!WEEKLY_BUCKETS.has(bucket)) return null;
131
+ const unspent = remaining - rate * resetInMs;
132
+ if (unspent < this.wasteFloor) return null;
133
+ return { bucket, kind: 'surplus', unspent, resetInMs };
134
+ }
135
+
136
+ /** Projections in display order: anything that will stop you comes before
137
+ * anything that will merely expire, soonest and largest first. */
138
+ rank(projections) {
139
+ const rank = p => (p.kind === 'deficit' ? 0 : 1);
140
+ return (projections || []).filter(Boolean).sort((a, b) => {
141
+ if (rank(a) !== rank(b)) return rank(a) - rank(b);
142
+ return a.kind === 'deficit' ? a.exhaustsInMs - b.exhaustsInMs : b.unspent - a.unspent;
143
+ });
144
+ }
145
+
146
+ /** The most urgent projection, or null when there is nothing to report. */
147
+ headline(projections) {
148
+ return this.rank(projections)[0] || null;
149
+ }
150
+ }
151
+
152
+ /** Render a projection as a row tag, e.g. "Ses TTL 38m" or "Wk 22% unspent". */
153
+ export function formatProjection(projection) {
154
+ if (!projection) return null;
155
+ const label = BUCKET_LABELS[projection.bucket] || projection.bucket;
156
+ if (projection.kind === 'deficit') return `${label} TTL ${formatDuration(projection.exhaustsInMs)}`;
157
+ return `${label} ${Math.round(projection.unspent * 100)}% unspent`;
158
+ }
159
+
160
+ function formatDuration(ms) {
161
+ const minutes = Math.max(0, Math.round(ms / 60_000));
162
+ if (minutes < 60) return `${minutes}m`;
163
+ const hours = Math.floor(minutes / 60);
164
+ if (hours < 24) return `${hours}h${minutes % 60}m`;
165
+ return `${Math.floor(hours / 24)}d${hours % 24}h`;
166
+ }
package/src/server.js CHANGED
@@ -918,12 +918,7 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
918
918
  }
919
919
 
920
920
  // Extract rate limit headers
921
- const rateLimitHeaders = {};
922
- for (const [key, value] of upstreamRes.headers.entries()) {
923
- if (key.startsWith('anthropic-ratelimit-')) {
924
- rateLimitHeaders[key] = value;
925
- }
926
- }
921
+ const rateLimitHeaders = collectRateLimitHeaders(upstreamRes.headers);
927
922
  accountManager.updateQuota(account.index, rateLimitHeaders);
928
923
 
929
924
  // Any non-429 response is live proof a rate-limit hold no longer binds —
@@ -950,7 +945,10 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
950
945
  // already recorded the spent bucket's utilization from the headers).
951
946
  const rl = rateLimitHeaders;
952
947
  const generalRejected = rl['anthropic-ratelimit-unified-5h-status'] === 'rejected'
953
- || rl['anthropic-ratelimit-unified-7d-status'] === 'rejected';
948
+ || rl['anthropic-ratelimit-unified-7d-status'] === 'rejected'
949
+ // A spent Codex window on a sidecar-backed account is the same shape:
950
+ // a durable quota rejection, not a transient throttle.
951
+ || codexQuotaRejected(rl);
954
952
  const fableRejected = rl['anthropic-ratelimit-unified-7d_oi-status'] === 'rejected' && !generalRejected;
955
953
  if ((generalRejected || fableRejected) && retryCount < maxRetries) {
956
954
  // A Fable-only rejection leaves the account fine for other models, so we
@@ -1306,6 +1304,25 @@ export function rewriteModel(body, modelMap) {
1306
1304
  return body;
1307
1305
  }
1308
1306
 
1307
+ // Rate-limit telemetry we pass to AccountManager.updateQuota: Anthropic's
1308
+ // `anthropic-ratelimit-*` family, plus the OpenAI/Codex `x-codex-*` family a
1309
+ // translating sidecar may forward from the ChatGPT backend. Exported for tests.
1310
+ export function collectRateLimitHeaders(headers) {
1311
+ const out = {};
1312
+ for (const [key, value] of headers.entries()) {
1313
+ if (key.startsWith('anthropic-ratelimit-') || key.startsWith('x-codex-')) out[key] = value;
1314
+ }
1315
+ return out;
1316
+ }
1317
+
1318
+ // Durable Codex quota exhaustion: either subscription window (primary ≈ 5h,
1319
+ // secondary ≈ weekly) reports fully spent. Like a unified "rejected" status,
1320
+ // retrying the same account is futile until the window resets. Exported for tests.
1321
+ export function codexQuotaRejected(rl) {
1322
+ return parseFloat(rl['x-codex-primary-used-percent']) >= 100
1323
+ || parseFloat(rl['x-codex-secondary-used-percent']) >= 100;
1324
+ }
1325
+
1309
1326
  function computeRetryAfter(accounts) {
1310
1327
  let soonest = Infinity;
1311
1328
  for (const acct of accounts) {
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Session titles for the activity log.
3
+ *
4
+ * The proxy already knows which Claude Code session a request belongs to: the
5
+ * client sends `x-claude-code-session-id`, and the TUI prints its first six hex
6
+ * characters. That distinguishes concurrent sessions but does not name them.
7
+ *
8
+ * Claude Code stores the name on disk under `~/.claude/projects/<slug>/`. A
9
+ * `/rename` writes `<session-id>/custom-title.json`; it also appends a
10
+ * `custom-title` record to the transcript, which is the only copy for a session
11
+ * renamed before the sidecar existed. Claude Code generates its own title as
12
+ * well and appends it as an `ai-title` record, so most sessions have a usable
13
+ * name without a rename.
14
+ *
15
+ * Lookups are cached and run off the render path: `get` returns what is cached
16
+ * and schedules the read, so a frame never waits on the disk.
17
+ */
18
+
19
+ import { readdir, readFile, open } from 'node:fs/promises';
20
+ import { homedir } from 'node:os';
21
+ import { join } from 'node:path';
22
+
23
+ const DEFAULT_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
24
+
25
+ /** A rename must reach the TUI without a restart, and a re-read costs one
26
+ * directory scan and one 64 KB read. */
27
+ const DEFAULT_TTL_MS = 30_000;
28
+
29
+ /** Measured across 218 transcripts: the last title record sits within 32.7 KB
30
+ * of EOF at p99 and 46.3 KB at the maximum. Reading more would only reach
31
+ * sessions that have written megabytes since their last title. */
32
+ const DEFAULT_TAIL_BYTES = 64 * 1024;
33
+
34
+ /** Columns the activity log gives the session label. Every row pays this width,
35
+ * named or not, so the columns after it stay aligned. A typed name fits: the
36
+ * longest in use is "adv-review-rewrite" at 18. A generated title is a sentence
37
+ * and is cut. */
38
+ const DEFAULT_WIDTH = 18;
39
+
40
+ /** The activity log falls back to this many hex characters of the session id,
41
+ * so a label can never be narrower. */
42
+ const SHORT_ID_LEN = 6;
43
+
44
+ export class SessionTitles {
45
+ constructor({ projectsDir = DEFAULT_PROJECTS_DIR, ttlMs = DEFAULT_TTL_MS, tailBytes = DEFAULT_TAIL_BYTES, ...cfg } = {}) {
46
+ this.projectsDir = projectsDir;
47
+ this.ttlMs = ttlMs;
48
+ this.tailBytes = tailBytes;
49
+ /** @type {Map<string, {title: string|null, at: number}>} */
50
+ this.cache = new Map();
51
+ /** @type {Map<string, Promise<string|null>>} */
52
+ this.inflight = new Map();
53
+ this.configure(cfg);
54
+ }
55
+
56
+ /** Apply a config change in place, so a reload takes effect without a
57
+ * restart. An absent key returns to its default. */
58
+ configure(cfg) {
59
+ const { enabled = true, width = DEFAULT_WIDTH } = cfg || {};
60
+ this.enabled = enabled !== false;
61
+ // A label narrower than the short id it falls back to would cut the id.
62
+ this.width = Math.max(SHORT_ID_LEN, Math.trunc(width) || DEFAULT_WIDTH);
63
+ }
64
+
65
+ /** The settings in force, for the status readout. */
66
+ settings() {
67
+ return { enabled: this.enabled, width: this.width };
68
+ }
69
+
70
+ /** The cached title, or null. Never touches the disk: a miss or a stale entry
71
+ * schedules the read and the caller gets the previous answer until it lands. */
72
+ get(sessionId, now = Date.now()) {
73
+ if (!this.enabled || !sessionId) return null;
74
+ const hit = this.cache.get(sessionId);
75
+ if (!hit || now - hit.at >= this.ttlMs) this._schedule(sessionId, now);
76
+ return hit ? hit.title : null;
77
+ }
78
+
79
+ /** Read the title now. Resolves to null when the session has none. */
80
+ async resolve(sessionId, now = Date.now()) {
81
+ if (!this.enabled || !sessionId) return null;
82
+ return this._schedule(sessionId, now);
83
+ }
84
+
85
+ /** Number of reads in flight. */
86
+ pending() {
87
+ return this.inflight.size;
88
+ }
89
+
90
+ /** Settle every scheduled read. A resolve can schedule another, so this loops. */
91
+ async idle() {
92
+ while (this.inflight.size) await Promise.all([...this.inflight.values()]);
93
+ }
94
+
95
+ _schedule(sessionId, now) {
96
+ const existing = this.inflight.get(sessionId);
97
+ if (existing) return existing;
98
+ // A missing title is cached like a found one: without that, every frame
99
+ // would rescan the projects directory for a session that has no name.
100
+ const read = this._read(sessionId)
101
+ .catch(() => null)
102
+ .then((title) => {
103
+ this.cache.set(sessionId, { title, at: now });
104
+ return title;
105
+ })
106
+ .finally(() => this.inflight.delete(sessionId));
107
+ this.inflight.set(sessionId, read);
108
+ return read;
109
+ }
110
+
111
+ /** The project directory is keyed by the session's cwd, which the proxy does
112
+ * not know, so each directory is tried until one holds the session. */
113
+ async _read(sessionId) {
114
+ const entries = await readdir(this.projectsDir, { withFileTypes: true }).catch(() => []);
115
+ for (const entry of entries) {
116
+ if (!entry.isDirectory()) continue;
117
+ const project = join(this.projectsDir, entry.name);
118
+ const renamed = await this._fromSidecar(join(project, sessionId, 'custom-title.json'));
119
+ if (renamed) return renamed;
120
+ const recorded = await this._fromTranscript(join(project, `${sessionId}.jsonl`));
121
+ if (recorded) return recorded;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ async _fromSidecar(path) {
127
+ const raw = await readFile(path, 'utf8').catch(() => null);
128
+ if (raw == null) return null;
129
+ try {
130
+ return cleanTitle(JSON.parse(raw).customTitle);
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ async _fromTranscript(path) {
137
+ const file = await open(path, 'r').catch(() => null);
138
+ if (!file) return null;
139
+ try {
140
+ const { size } = await file.stat();
141
+ const start = Math.max(0, size - this.tailBytes);
142
+ const buffer = Buffer.alloc(Math.min(size, this.tailBytes));
143
+ if (buffer.length) await file.read(buffer, 0, buffer.length, start);
144
+ const lines = buffer.toString('utf8').split('\n');
145
+ // A window that does not start at byte 0 opens mid-line; that fragment is
146
+ // not a JSON record.
147
+ if (start > 0) lines.shift();
148
+ return lastTitle(lines);
149
+ } finally {
150
+ await file.close();
151
+ }
152
+ }
153
+ }
154
+
155
+ /** A typed name wins wherever it sits in the file; otherwise the most recent
156
+ * generated one. Both are re-appended as a session runs, so the scan reads
157
+ * backwards and stops at the first match. */
158
+ function lastTitle(lines) {
159
+ let generated = null;
160
+ for (let i = lines.length - 1; i >= 0; i--) {
161
+ const line = lines[i];
162
+ // Transcript lines are mostly multi-kilobyte messages; reject those on a
163
+ // substring before paying for JSON.parse.
164
+ if (!line || line[0] !== '{' || !line.includes('-title"')) continue;
165
+ let record;
166
+ try {
167
+ record = JSON.parse(line);
168
+ } catch {
169
+ continue;
170
+ }
171
+ if (record.type === 'custom-title') {
172
+ const typed = cleanTitle(record.customTitle);
173
+ if (typed) return typed;
174
+ }
175
+ if (record.type === 'ai-title' && !generated) generated = cleanTitle(record.aiTitle);
176
+ }
177
+ return generated;
178
+ }
179
+
180
+ function cleanTitle(value) {
181
+ if (typeof value !== 'string') return null;
182
+ const trimmed = value.trim();
183
+ return trimmed || null;
184
+ }
package/src/sidecar.js ADDED
@@ -0,0 +1,133 @@
1
+ // Sidecar supervisor (codex-proxy feature).
2
+ //
3
+ // TeamClaude is the controller: when a third-party backend account points at a
4
+ // local translating proxy (e.g. raine/claude-code-proxy for the ChatGPT/Codex
5
+ // backend), the server owns that process rather than asking the user to run it
6
+ // by hand or via brew services. Each `config.sidecars[]` entry is spawned on
7
+ // server start, respawned with exponential backoff when it dies, and killed on
8
+ // shutdown. Supervision is process-level only — routing to the sidecar is
9
+ // unchanged (a normal `accounts[].upstream` + route).
10
+ //
11
+ // stdout is ignored (sidecars keep their own log files); stderr's last few
12
+ // lines are kept in a ring buffer so `getStatus()` can say WHY a sidecar is
13
+ // crash-looping without anyone hunting for its logs.
14
+
15
+ import { spawn } from 'node:child_process';
16
+
17
+ /** Delay before restart attempt N (0-based): base, doubled per consecutive
18
+ * crash, capped. Pure so the schedule is testable without timers. */
19
+ export function restartDelayMs(restarts, { baseRestartMs, maxRestartMs }) {
20
+ return Math.min(baseRestartMs * 2 ** restarts, maxRestartMs);
21
+ }
22
+
23
+ export class Sidecar {
24
+ constructor(entries, {
25
+ spawnFn = defaultSpawn,
26
+ baseRestartMs = 1000,
27
+ maxRestartMs = 30_000,
28
+ stableMs = 30_000,
29
+ stderrTailLines = 20,
30
+ log = console.log,
31
+ } = {}) {
32
+ this.entries = Array.isArray(entries) ? entries : [];
33
+ this.spawnFn = spawnFn;
34
+ this.baseRestartMs = baseRestartMs;
35
+ this.maxRestartMs = maxRestartMs;
36
+ this.stableMs = stableMs;
37
+ this.stderrTailLines = stderrTailLines;
38
+ this.log = log;
39
+ this.stopping = false;
40
+ // Per-entry runtime state, keyed by entry (parallel array to this.entries).
41
+ this.states = this.entries.map(entry => ({
42
+ entry,
43
+ child: null,
44
+ startedAt: null,
45
+ restarts: 0,
46
+ lastExit: null,
47
+ timer: null,
48
+ stderrTail: [],
49
+ }));
50
+ }
51
+
52
+ start() {
53
+ for (const state of this.states) this._spawn(state);
54
+ }
55
+
56
+ stop() {
57
+ this.stopping = true;
58
+ for (const state of this.states) {
59
+ if (state.timer) { clearTimeout(state.timer); state.timer = null; }
60
+ state.child?.kill('SIGTERM');
61
+ }
62
+ }
63
+
64
+ getStatus() {
65
+ return this.states.map(state => ({
66
+ name: state.entry.name,
67
+ running: !!state.child,
68
+ pid: state.child?.pid ?? null,
69
+ restarts: state.restarts,
70
+ lastExit: state.lastExit,
71
+ stderrTail: [...state.stderrTail],
72
+ }));
73
+ }
74
+
75
+ _spawn(state) {
76
+ const { entry } = state;
77
+ const [command, ...args] = entry.command;
78
+ let child;
79
+ try {
80
+ child = this.spawnFn({
81
+ name: entry.name,
82
+ command,
83
+ args,
84
+ env: { ...process.env, ...(entry.env || {}) },
85
+ });
86
+ } catch (err) {
87
+ this._onDown(state, `spawn failed: ${err?.message || err}`);
88
+ return;
89
+ }
90
+ state.child = child;
91
+ state.startedAt = Date.now();
92
+ child.stderr?.on('data', (chunk) => this._recordStderr(state, chunk));
93
+ child.once('error', (err) => {
94
+ if (state.child !== child) return;
95
+ this._onDown(state, `spawn error: ${err?.message || err}`);
96
+ });
97
+ child.once('exit', (code, signal) => {
98
+ if (state.child !== child) return;
99
+ this._onDown(state, signal ? `signal ${signal}` : `code ${code}`);
100
+ });
101
+ }
102
+
103
+ _onDown(state, lastExit) {
104
+ // A run that survived long enough resets the backoff: the next crash is a
105
+ // fresh incident, not a continuation of a crash loop.
106
+ if (state.startedAt && Date.now() - state.startedAt >= this.stableMs) state.restarts = 0;
107
+ state.child = null;
108
+ state.lastExit = lastExit;
109
+ if (this.stopping) return;
110
+ const delay = restartDelayMs(state.restarts, this);
111
+ this.log(`[TeamClaude] Sidecar "${state.entry.name}" down (${lastExit}); restarting in ${Math.round(delay / 1000)}s`);
112
+ state.restarts += 1;
113
+ state.timer = setTimeout(() => {
114
+ state.timer = null;
115
+ this._spawn(state);
116
+ }, delay);
117
+ state.timer.unref?.();
118
+ }
119
+
120
+ _recordStderr(state, chunk) {
121
+ const lines = String(chunk).split('\n').map(s => s.trim()).filter(Boolean);
122
+ state.stderrTail.push(...lines);
123
+ if (state.stderrTail.length > this.stderrTailLines) {
124
+ state.stderrTail.splice(0, state.stderrTail.length - this.stderrTailLines);
125
+ }
126
+ }
127
+ }
128
+
129
+ // Real spawner: stdout ignored (sidecars log to their own files), stderr piped
130
+ // for the ring buffer. detached:false so the child dies with us as a backstop.
131
+ function defaultSpawn({ command, args, env }) {
132
+ return spawn(command, args, { env, stdio: ['ignore', 'ignore', 'pipe'] });
133
+ }
@@ -276,9 +276,12 @@ function formatServerSummary(server, now) {
276
276
  return started ? `up ${formatDuration(now - started)}` : 'unknown';
277
277
  }
278
278
 
279
- function formatPercent(value) {
279
+ export function formatPercent(value) {
280
280
  if (value == null || Number.isNaN(Number(value))) return '-';
281
- return `${Math.round(Number(value) * 100)}%`;
281
+ // The switch threshold can be set to a tenth of a percent, so rounding to a
282
+ // whole one would print a value that was never stored. Reported utilization
283
+ // arrives on a whole-percent grid, so bars are unaffected.
284
+ return `${Math.round(Number(value) * 1000) / 10}%`;
282
285
  }
283
286
 
284
287
  function formatNumber(value) {
package/src/tui-remote.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { TUI } from './tui.js';
2
+ import { SessionTitles } from './session-titles.js';
2
3
  import { modelGlobMatches } from './model.js';
3
4
 
4
5
  // Attach mode — the dashboard against a server running somewhere else (a
@@ -234,6 +235,10 @@ export function createAttachSession({ control, config, onQuit, pollMs = DEFAULT_
234
235
  accountManager: am,
235
236
  config,
236
237
  remote: true,
238
+ // Titles come from this machine's Claude Code sessions. Attaching to a
239
+ // server on another host leaves the ids unresolved, which shows the short
240
+ // id rather than a wrong name.
241
+ sessionTitles: new SessionTitles(config?.sessionTitles),
237
242
  // Every screen that writes config is unreachable in attach mode; if one ever
238
243
  // becomes reachable, this fails loudly instead of silently dropping a save.
239
244
  saveConfig: async () => { throw new Error('attach mode cannot write config'); },
package/src/tui.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { createWriteStream } from 'node:fs';
2
2
  import { importCredentials, fetchProfile } from './oauth.js';
3
3
  import { sameIdentity, findUpsertTarget } from './identity.js';
4
+ import { formatProjection } from './quota-projection.js';
5
+ import { formatPercent } from './status-renderer.js';
4
6
  import { parseProxyUrl, proxyToUrl, describeProxy, resolveUpstreamProxy, setUpstreamProxy, getUpstreamProxy } from './upstream-proxy.js';
5
7
 
6
8
  // ── ANSI helpers ─────────────────────────────────────────────
@@ -62,10 +64,12 @@ function sessionColorCode(sid) {
62
64
  for (let i = 0; i < sid.length; i++) h = (h * 31 + sid.charCodeAt(i)) >>> 0;
63
65
  return SESSION_FG[h % SESSION_FG.length];
64
66
  }
65
- // Fixed-width colored short id (blank-padded when there's no session, e.g. a
66
- // telemetry request), so the activity column stays aligned.
67
- const sessionTag = sid =>
68
- sid ? fg(sessionColorCode(sid), sid.slice(0, SESSION_ID_LEN)) : ' '.repeat(SESSION_ID_LEN);
67
+ // Fixed-width colored session label: the name Claude Code holds on disk for the
68
+ // session (see session-titles.js), else the short id. Blank-padded when there's
69
+ // no session (e.g. a telemetry request). One width for every row, named or not,
70
+ // keeps the columns after it aligned.
71
+ const sessionTag = (sid, title = null, width = SESSION_ID_LEN) =>
72
+ sid ? fg(sessionColorCode(sid), (title || sid.slice(0, SESSION_ID_LEN)).slice(0, width).padEnd(width)) : ' '.repeat(width);
69
73
 
70
74
  // Which quota-family bar (F7/S7) a route binds to, or null for a general route.
71
75
  // Auto routes are named 'fable'/'sonnet'; a configured route is classified by its
@@ -198,7 +202,10 @@ export class TUI {
198
202
  remote = false, applySwitch = null,
199
203
  // Injectable so the import path can be exercised without a real credentials
200
204
  // file or a live profile call.
201
- readCredentials = importCredentials, readProfile = fetchProfile }) {
205
+ readCredentials = importCredentials, readProfile = fetchProfile,
206
+ // Names the activity column against the session id the client sent. Absent
207
+ // or disabled leaves every row showing the short id.
208
+ sessionTitles = null }) {
202
209
  this.am = accountManager;
203
210
  this.remote = remote;
204
211
  this.applySwitch = applySwitch;
@@ -213,6 +220,7 @@ export class TUI {
213
220
  this._readCredentials = readCredentials;
214
221
  this._readProfile = readProfile;
215
222
  this._activityStream = null;
223
+ this.sessionTitles = sessionTitles;
216
224
 
217
225
  this.log = []; // completed activity entries
218
226
  this.active = new Map(); // in-flight requests
@@ -312,9 +320,20 @@ export class TUI {
312
320
  process.stdin.pause();
313
321
  }
314
322
 
323
+ // A title lookup costs a directory scan and a file read, so it stays off the
324
+ // render path: this returns what is cached and schedules the rest.
325
+ _sessionTag(sid) {
326
+ const titles = this.sessionTitles;
327
+ if (!titles?.enabled) return sessionTag(sid);
328
+ return sessionTag(sid, titles.get(sid), titles.width);
329
+ }
330
+
315
331
  // ── server hooks ───────────────────────────────────
316
332
 
317
333
  onRequestStart(id, info) {
334
+ // Start the lookup now, so the title is cached by the time the request ends
335
+ // and its log line is composed.
336
+ this._sessionTag(info.sessionId);
318
337
  this.active.set(id, { ...info, t: timestamp(), started: Date.now(), account: null });
319
338
  this.render();
320
339
  if (this.active.size === 1) this._retick(); // idle → animating
@@ -338,7 +357,7 @@ export class TUI {
338
357
  const model = info.model ? ` (${info.model})` : ''; // shown when the request named a model
339
358
  const sid = info.sessionId || r?.sessionId || null;
340
359
  const pin = (info.pinned || r?.pinned) ? dim(' [pin]') : '';
341
- this._addLog(`${sessionTag(sid)} ${info.method} ${info.path}${model} → ${acct}${pin} (${info.status}, ${dur}s)`);
360
+ this._addLog(`${this._sessionTag(sid)} ${info.method} ${info.path}${model} → ${acct}${pin} (${info.status}, ${dur}s)`);
342
361
  if (this.active.size === 0) this._retick(); // animating → idle
343
362
  }
344
363
 
@@ -411,13 +430,10 @@ export class TUI {
411
430
  id: 'threshold',
412
431
  label: 'Switch threshold',
413
432
  hint: '←→ ±1%',
414
- value: () => {
415
- const thr = this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98;
416
- return green(`${Math.round(thr * 100)}%`);
417
- },
433
+ value: () => green(formatPercent(this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98)),
418
434
  left: () => this._nudgeThreshold(-1),
419
435
  right: () => this._nudgeThreshold(+1),
420
- enter: () => this._promptInput('Switch threshold % (1-100)', v => this._doSetThreshold(v.trim())),
436
+ enter: () => this._promptInput('Switch threshold % (1-100, tenths allowed)', v => this._doSetThreshold(v.trim())),
421
437
  });
422
438
 
423
439
  fields.push({
@@ -448,6 +464,18 @@ export class TUI {
448
464
  enter: () => this._cycleEventLogging(+1),
449
465
  });
450
466
 
467
+ if (this.sessionTitles) {
468
+ fields.push({
469
+ id: 'sessionTitles',
470
+ label: 'Session titles',
471
+ hint: '←→ toggle',
472
+ value: () => (this.sessionTitles.enabled ? green('on') : gray('off')),
473
+ left: () => this._toggleSessionTitles(),
474
+ right: () => this._toggleSessionTitles(),
475
+ enter: () => this._toggleSessionTitles(),
476
+ });
477
+ }
478
+
451
479
  fields.push({
452
480
  id: 'routes',
453
481
  label: 'Manage routing',
@@ -572,9 +600,11 @@ export class TUI {
572
600
  }
573
601
 
574
602
  _nudgeThreshold(deltaPct) {
575
- const cur = Math.round((this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98) * 100);
603
+ // Stepping from the exact percent, not a rounded one, so a threshold set to
604
+ // a tenth keeps its fraction instead of snapping to the nearest whole.
605
+ const cur = (this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98) * 100;
576
606
  const next = Math.max(1, Math.min(100, cur + deltaPct));
577
- if (next !== cur) this._doSetThreshold(String(next));
607
+ if (next !== cur) return this._doSetThreshold(String(next));
578
608
  }
579
609
 
580
610
  _nudgeProbe(deltaSec) {
@@ -588,7 +618,9 @@ export class TUI {
588
618
  if (!Number.isFinite(pct) || pct < 1 || pct > 100) {
589
619
  this._addLog('Invalid threshold — enter 1–100'); this.mode = 'settings'; if (this.running) this.render(); return;
590
620
  }
591
- const v = Math.round(pct) / 100;
621
+ // Tenths of a percent are kept; anything finer is quantised so the stored
622
+ // value is the one the screen shows.
623
+ const v = Math.round(pct * 10) / 1000;
592
624
  this.config.switchThreshold = v;
593
625
  this.am.switchThreshold = v; // apply to the running rotation immediately
594
626
  try { await this.saveConfig(this.config); }
@@ -848,6 +880,17 @@ export class TUI {
848
880
  if (this.running) this.render();
849
881
  }
850
882
 
883
+ async _toggleSessionTitles() {
884
+ const next = { ...this.sessionTitles.settings(), enabled: !this.sessionTitles.enabled };
885
+ this.sessionTitles.configure(next);
886
+ // The shared config object is what a save writes and a reload re-applies.
887
+ this.config.sessionTitles = { ...this.config.sessionTitles, ...next };
888
+ try { await this.saveConfig(this.config); }
889
+ catch (e) { this._addLog(`Failed to save: ${e.message}`); }
890
+ this._addLog(`Session titles: ${next.enabled ? 'on' : 'off'}`);
891
+ if (this.running) this.render();
892
+ }
893
+
851
894
  async _cycleEventLogging(dir = 1) {
852
895
  // Claude Code telemetry display/handling: show → hide → block → show.
853
896
  const order = ['show', 'hide', 'block'];
@@ -1107,7 +1150,7 @@ export class TUI {
1107
1150
  const m = r.model ? dim(` (${r.model})`) : ''; // filled in as soon as the model is peeked from the stream
1108
1151
  const pin = r.pinned ? dim(' [pin]') : '';
1109
1152
  const a = r.account ? ` → ${r.account}${pin}` : '';
1110
- lines.push(` ${sp} ${gray(r.t)} ${sessionTag(r.sessionId)} ${r.method} ${r.path}${m}${a} ${dim(`(${el}s...)`)}`);
1153
+ lines.push(` ${sp} ${gray(r.t)} ${this._sessionTag(r.sessionId)} ${r.method} ${r.path}${m}${a} ${dim(`(${el}s...)`)}`);
1111
1154
  }
1112
1155
 
1113
1156
  // Completed log
@@ -1230,6 +1273,21 @@ export class TUI {
1230
1273
  if (q.unified7dSonnet != null && q.unified7dSonnet >= th) blocked.push('Sonnet');
1231
1274
  if (q.unified7dFable != null && q.unified7dFable >= th) blocked.push('Fable');
1232
1275
  if (blocked.length) line += ` ${red('⊘ ' + blocked.join(' '))}`;
1276
+
1277
+ // Burn-rate tags, most urgent first: TTL for a window that runs out before
1278
+ // it resets, an unspent share for one that expires with quota left. A
1279
+ // deficit will stop this account, so it is colored; a surplus is a note and
1280
+ // stays gray. Optional on the manager so a stand-in without projection
1281
+ // support still renders.
1282
+ const buckets = this.am.projectionsFor?.(idx) || {};
1283
+ const ranked = this.am.projection?.rank(Object.values(buckets)) || [];
1284
+ if (ranked.length) {
1285
+ const tags = ranked.map(p => {
1286
+ const text = formatProjection(p);
1287
+ return p.kind === 'deficit' ? yellow(text) : gray(text);
1288
+ });
1289
+ line += ` ${tags.join(gray(' · '))}`;
1290
+ }
1233
1291
  return line;
1234
1292
  }
1235
1293
 
@@ -1267,6 +1325,7 @@ export class TUI {
1267
1325
  // ── Activity log
1268
1326
  lines.push(bold(' Activity log') + dim(' — what to do with Claude Code\'s telemetry'));
1269
1327
  lines.push(row(byId('eventlog')));
1328
+ if (byId('sessionTitles')) lines.push(row(byId('sessionTitles')));
1270
1329
  lines.push('');
1271
1330
  // ── Routing
1272
1331
  lines.push(bold(' Routing') + dim(' — pin model families to specific accounts, or block them outright'));