@rikcodes/teamclaude 1.1.13-rik.2 → 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 +7 -1
- package/package.json +1 -1
- package/src/account-manager.js +35 -0
- package/src/claude-env.js +65 -1
- package/src/index.js +29 -2
- package/src/server.js +24 -7
- package/src/sidecar.js +133 -0
package/README.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# TeamClaude
|
|
2
2
|
|
|
3
|
-
> **Fork notice (rikbrown).** This fork adds
|
|
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
|
|
@@ -68,6 +72,7 @@ Already logged into Claude Code? `teamclaude import` takes its credentials inste
|
|
|
68
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).
|
|
69
73
|
- Refreshes OAuth tokens before they expire and writes them back to config. Client refreshes pass through untouched.
|
|
70
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).
|
|
71
76
|
- No dependencies. Node built-ins only.
|
|
72
77
|
|
|
73
78
|
## Everyday commands
|
|
@@ -107,6 +112,7 @@ Step-by-step lifecycle: [docs/routing.md](docs/routing.md#request-lifecycle).
|
|
|
107
112
|
| [Usage](docs/usage.md) | Server and TUI, running Claude Code, shell alias, command reference, logging |
|
|
108
113
|
| [Routing](docs/routing.md) | Rotation, the two kinds of 429, storm control, model routes, session spreading, pinning, prompt cache |
|
|
109
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 |
|
|
110
116
|
| [Configuration](docs/configuration.md) | Config format, every field, environment variables, network tuning |
|
|
111
117
|
| [Proxy modes](docs/proxy-modes.md) | MITM forward proxy, sx.org residential egress |
|
|
112
118
|
| [Compliance](docs/compliance.md) | Terms of service notes |
|
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -22,6 +22,22 @@ const PERSISTED_QUOTA_FIELDS = [
|
|
|
22
22
|
'tokensLimit', 'tokensRemaining', 'requestsLimit', 'requestsRemaining', 'resetsAt',
|
|
23
23
|
];
|
|
24
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
|
+
|
|
25
41
|
function emptyQuota() {
|
|
26
42
|
return {
|
|
27
43
|
// Standard API rate limits (API key accounts)
|
|
@@ -1204,6 +1220,25 @@ export class AccountManager {
|
|
|
1204
1220
|
const uStatus = headers['anthropic-ratelimit-unified-status'];
|
|
1205
1221
|
if (uStatus) account.quota.unifiedStatus = uStatus;
|
|
1206
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
|
+
|
|
1207
1242
|
// Standard rate limits (API key accounts)
|
|
1208
1243
|
const tokensLimit = parseInt(headers['anthropic-ratelimit-tokens-limit'], 10);
|
|
1209
1244
|
const tokensRemaining = parseInt(headers['anthropic-ratelimit-tokens-remaining'], 10);
|
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
|
-
|
|
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/index.js
CHANGED
|
@@ -15,13 +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';
|
|
19
20
|
import { SessionTitles } from './session-titles.js';
|
|
20
21
|
import { RemoteControl, createAttachSession } from './tui-remote.js';
|
|
21
22
|
import { SxManager } from './sx.js';
|
|
22
23
|
import { autoUpdate, checkForUpdate, currentVersion, runUpdate, installKind, PKG_NAME } from './updater.js';
|
|
23
24
|
import { renderStatus } from './status-renderer.js';
|
|
24
|
-
import { buildClaudeEnvLines, encodePinComponent } from './claude-env.js';
|
|
25
|
+
import { buildClaudeEnvLines, buildCustomModelAgents, buildCustomModelSettings, buildCustomModelVars, encodePinComponent } from './claude-env.js';
|
|
25
26
|
import { serviceKind, installService, uninstallService, serviceStatus, renderService, logPath } from './service.js';
|
|
26
27
|
import { formatTerminalTitle, titleSequence, TITLE_STACK_PUSH, TITLE_STACK_POP } from './terminal-title.js';
|
|
27
28
|
import { getUpstreamProxy, describeProxy } from './upstream-proxy.js';
|
|
@@ -248,6 +249,9 @@ async function serverCommand() {
|
|
|
248
249
|
let prober = null;
|
|
249
250
|
// Opt-in keep-warm scheduler (config.warmupSeconds, default 0 = off).
|
|
250
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;
|
|
251
255
|
const serverStartedAt = Date.now();
|
|
252
256
|
|
|
253
257
|
// sx.org proxy (IP-based-429 workaround). Dormant unless an API key is set in
|
|
@@ -428,6 +432,7 @@ async function serverCommand() {
|
|
|
428
432
|
error: null,
|
|
429
433
|
})),
|
|
430
434
|
},
|
|
435
|
+
sidecars: sidecar?.getStatus() || [],
|
|
431
436
|
});
|
|
432
437
|
|
|
433
438
|
const server = createProxyServer(accountManager, config, hooks, sx);
|
|
@@ -497,6 +502,10 @@ async function serverCommand() {
|
|
|
497
502
|
});
|
|
498
503
|
warmer.start();
|
|
499
504
|
|
|
505
|
+
// Launch supervised sidecars (no-op when config.sidecars is empty).
|
|
506
|
+
sidecar = new Sidecar(config.sidecars);
|
|
507
|
+
sidecar.start();
|
|
508
|
+
|
|
500
509
|
// Background self-update for a backgrounded (headless) server. Skipped under
|
|
501
510
|
// the TUI, where npm's install output would corrupt the display — interactive
|
|
502
511
|
// users update via `teamclaude run` (post-session) or `teamclaude update`.
|
|
@@ -517,6 +526,7 @@ async function serverCommand() {
|
|
|
517
526
|
if (!tui) console.log('\n[TeamClaude] Shutting down...');
|
|
518
527
|
prober?.stop();
|
|
519
528
|
warmer?.stop();
|
|
529
|
+
sidecar?.stop();
|
|
520
530
|
if (quotaSaveInterval) clearInterval(quotaSaveInterval);
|
|
521
531
|
await persistQuotaState();
|
|
522
532
|
// Don't linger waiting on keep-alive / streaming connections: actively
|
|
@@ -679,6 +689,7 @@ async function envCommand() {
|
|
|
679
689
|
const lines = buildClaudeEnvLines({
|
|
680
690
|
port, useMitm, caPath, holdSeconds: config.holdSeconds,
|
|
681
691
|
account, proxyApiKey: config.proxy?.apiKey || '',
|
|
692
|
+
customModels: config.customModels,
|
|
682
693
|
});
|
|
683
694
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
684
695
|
|
|
@@ -736,7 +747,8 @@ async function runCommand() {
|
|
|
736
747
|
// also pins (shipped in 1.1.10). TC_ACCT is the supported way now — it works in
|
|
737
748
|
// MITM mode too, and keeps the pin out of the API path.
|
|
738
749
|
const pinnedBase = isLocalAccountPin(process.env.ANTHROPIC_BASE_URL, port);
|
|
739
|
-
|
|
750
|
+
const proxyUp = await isProxyUp(port);
|
|
751
|
+
if (proxyUp) {
|
|
740
752
|
if (useMitm) {
|
|
741
753
|
// Route ALL of claude's traffic through us as an HTTPS forward proxy, so
|
|
742
754
|
// even hardcoded api.anthropic.com endpoints (e.g. the design MCP) get the
|
|
@@ -784,6 +796,21 @@ async function runCommand() {
|
|
|
784
796
|
process.exit(1);
|
|
785
797
|
}
|
|
786
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
|
+
|
|
787
814
|
// If holdSeconds is set, ensure API_TIMEOUT_MS on the Claude Code side is
|
|
788
815
|
// large enough for the hold to complete. Add 60s padding (one extra poll
|
|
789
816
|
// cycle) so the client doesn't time out while we're still waiting.
|
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) {
|
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
|
+
}
|