@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 CHANGED
@@ -1,8 +1,12 @@
1
1
  # TeamClaude
2
2
 
3
- > **Fork notice (rikbrown).** This fork adds three features 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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rikcodes/teamclaude",
3
- "version": "1.1.13-rik.2",
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",
@@ -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
- 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/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
- if (await isProxyUp(port)) {
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
+ }