@rikcodes/teamclaude 1.1.13-rik.1
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/LICENSE +21 -0
- package/README.md +122 -0
- package/package.json +43 -0
- package/src/account-manager.js +1459 -0
- package/src/account-uuid-rewrite.js +115 -0
- package/src/alias.js +125 -0
- package/src/claude-env.js +65 -0
- package/src/config.js +146 -0
- package/src/crash-log.js +27 -0
- package/src/egress-guard.js +132 -0
- package/src/identity.js +96 -0
- package/src/index.js +1873 -0
- package/src/json-format-stream.js +63 -0
- package/src/mitm.js +336 -0
- package/src/model.js +276 -0
- package/src/oauth.js +459 -0
- package/src/prober.js +158 -0
- package/src/request-log.js +32 -0
- package/src/resolve-accounts.js +43 -0
- package/src/server.js +1319 -0
- package/src/service.js +241 -0
- package/src/session-tracker.js +133 -0
- package/src/status-renderer.js +316 -0
- package/src/sx.js +218 -0
- package/src/terminal-title.js +31 -0
- package/src/tool-pair-sanitize.js +193 -0
- package/src/tui-remote.js +274 -0
- package/src/tui.js +1634 -0
- package/src/updater.js +177 -0
- package/src/upstream-fetch.js +267 -0
- package/src/upstream-proxy.js +214 -0
- package/src/warmer.js +237 -0
- package/src/x509.js +166 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { findFamilyBlock, modelGlobOverlaps } from './model.js';
|
|
2
|
+
|
|
3
|
+
const ESC = '\x1b[';
|
|
4
|
+
const RESET = `${ESC}0m`;
|
|
5
|
+
|
|
6
|
+
export function renderStatus(status, { color = process.stdout.isTTY, now = Date.now() } = {}) {
|
|
7
|
+
const paint = colors(color);
|
|
8
|
+
const lines = [];
|
|
9
|
+
const probe = status.probe || { enabled: false, intervalSeconds: 0, accounts: [] };
|
|
10
|
+
const warm = status.warm || { enabled: false, intervalSeconds: 0, accounts: [] };
|
|
11
|
+
const accounts = status.accounts || [];
|
|
12
|
+
const blocked = (status.blockedModels || []).filter(p => typeof p === 'string' && p.length);
|
|
13
|
+
|
|
14
|
+
lines.push(paint.bold('TeamClaude status'));
|
|
15
|
+
lines.push(`${paint.dim('Active'.padEnd(12))} ${paint.cyan(status.currentAccount || 'none')}`);
|
|
16
|
+
lines.push(`${paint.dim('Switch at'.padEnd(12))} ${formatPercent(status.switchThreshold)}`);
|
|
17
|
+
// Only when something is blocked: a always-visible "Blocked" row would be
|
|
18
|
+
// noise for the common case, but its ABSENCE is what made a blocked model
|
|
19
|
+
// read as available — the per-account Models row reports quota headroom and
|
|
20
|
+
// knows nothing about the blocklist.
|
|
21
|
+
if (blocked.length) {
|
|
22
|
+
lines.push(`${paint.dim('Blocked'.padEnd(12))} ${paint.red(blocked.join(', '))}`);
|
|
23
|
+
}
|
|
24
|
+
if (status.sessions) {
|
|
25
|
+
lines.push(`${paint.dim('Sessions'.padEnd(12))} ${formatSessions(status.sessions, paint)}`);
|
|
26
|
+
}
|
|
27
|
+
lines.push(`${paint.dim('Probe'.padEnd(12))} ${formatProbeSummary(probe, now, paint)}`);
|
|
28
|
+
if (warm.enabled) {
|
|
29
|
+
lines.push(`${paint.dim('Keep-warm'.padEnd(12))} ${formatProbeSummary(warm, now, paint)}`);
|
|
30
|
+
}
|
|
31
|
+
if (status.server?.startedAt || status.server?.uptimeSeconds != null) {
|
|
32
|
+
lines.push(`${paint.dim('Server'.padEnd(12))} ${formatServerSummary(status.server, now)}`);
|
|
33
|
+
}
|
|
34
|
+
lines.push('');
|
|
35
|
+
|
|
36
|
+
for (const line of routingLines(status.routes, blocked, paint)) lines.push(line);
|
|
37
|
+
|
|
38
|
+
for (const account of accounts) {
|
|
39
|
+
lines.push(renderAccountHeader(account, status.currentAccount, paint, now));
|
|
40
|
+
for (const quotaLine of quotaLines(account, now, paint)) {
|
|
41
|
+
lines.push(` ${quotaLine}`);
|
|
42
|
+
}
|
|
43
|
+
const routing = modelRoutingLine(account, status.switchThreshold, blocked, now, paint);
|
|
44
|
+
if (routing) lines.push(` ${routing}`);
|
|
45
|
+
lines.push(` ${paint.dim('Usage'.padEnd(8))} ${formatUsage(account.usage, now)}`);
|
|
46
|
+
lines.push(` ${paint.dim('Probe'.padEnd(8))} ${formatAccountProbe(account.name, probe, now, paint)}`);
|
|
47
|
+
lines.push('');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return lines.join('\n').trimEnd();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function colors(enabled) {
|
|
54
|
+
const wrap = code => value => enabled ? `${ESC}${code}m${value}${RESET}` : String(value);
|
|
55
|
+
return {
|
|
56
|
+
rgb: (r, g, b, value) => enabled ? `${ESC}38;2;${r};${g};${b}m${value}${RESET}` : String(value),
|
|
57
|
+
bold: wrap(1),
|
|
58
|
+
dim: wrap(2),
|
|
59
|
+
gray: wrap(90),
|
|
60
|
+
green: wrap(32),
|
|
61
|
+
yellow: wrap(33),
|
|
62
|
+
red: wrap(31),
|
|
63
|
+
blue: wrap(34),
|
|
64
|
+
magenta: wrap(35),
|
|
65
|
+
cyan: wrap(36),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Paint a route's name/globs in its configured color, defaulting to cyan.
|
|
70
|
+
const ROUTE_COLORS = ['red', 'green', 'yellow', 'blue', 'magenta', 'cyan'];
|
|
71
|
+
function paintRoute(paint, color, value) {
|
|
72
|
+
const fn = ROUTE_COLORS.includes(String(color || '').toLowerCase()) ? paint[color.toLowerCase()] : paint.cyan;
|
|
73
|
+
return fn(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The routing table: one line per route (configured first, then auto-detected),
|
|
77
|
+
// listing the model globs it matches and the accounts it can use, each colored
|
|
78
|
+
// by live eligibility. Auto-created routes (a family metered separately with no
|
|
79
|
+
// configured route) are tagged (auto); a bucket override shows in [brackets].
|
|
80
|
+
function routingLines(routes, blocked, paint) {
|
|
81
|
+
if (!Array.isArray(routes) || routes.length === 0) return [];
|
|
82
|
+
const lines = [paint.bold('Routing')];
|
|
83
|
+
for (const route of routes) {
|
|
84
|
+
const globs = route.match || [];
|
|
85
|
+
const match = globs.join(', ');
|
|
86
|
+
// A route every one of whose globs is blocked can carry no traffic at all —
|
|
87
|
+
// say so, rather than listing eligible accounts it will never reach.
|
|
88
|
+
const routeBlocked = globs.length > 0
|
|
89
|
+
&& globs.every(g => blocked.some(p => modelGlobOverlaps(p, g)));
|
|
90
|
+
const accounts = routeBlocked
|
|
91
|
+
? paint.red('blocked')
|
|
92
|
+
: (route.accounts || [])
|
|
93
|
+
.map(a => (a.eligible ? paint.green(a.name) : paint.red(a.name))).join(' ') || paint.gray('(none)');
|
|
94
|
+
const tag = route.autocreated ? paint.dim(' (auto)') : route.bucket ? paint.dim(` [${route.bucket}]`) : '';
|
|
95
|
+
const pin = route.pinned ? paint.dim(` [pinned: ${route.pinned}]`) : '';
|
|
96
|
+
// padEnd on the raw text, color after, so ANSI codes don't throw off alignment.
|
|
97
|
+
const label = paintRoute(paint, route.color, match.padEnd(16));
|
|
98
|
+
lines.push(` ${label} ${paint.dim('→')} ${accounts}${tag}${pin}`);
|
|
99
|
+
}
|
|
100
|
+
lines.push('');
|
|
101
|
+
return lines;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderAccountHeader(account, currentAccount, paint, now) {
|
|
105
|
+
const current = account.name === currentAccount;
|
|
106
|
+
const marker = current ? paint.cyan('>') : ' ';
|
|
107
|
+
const name = current ? paint.bold(account.name) : account.name;
|
|
108
|
+
const status = formatAccountStatus(account, now, paint);
|
|
109
|
+
const org = account.orgName ? ` ${paint.dim(account.orgName)}` : '';
|
|
110
|
+
const sess = account.sessions ? ` ${paint.dim(`${account.sessions} sess`)}` : '';
|
|
111
|
+
return `${marker} ${name} ${paint.dim(`(${account.type}, prio ${account.priority || 0})`)} ${status}${org}${sess}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// "2 active / 3 known · distributing" — the running-sessions readout.
|
|
115
|
+
function formatSessions(sessions, paint) {
|
|
116
|
+
const active = sessions.active || 0;
|
|
117
|
+
const known = sessions.known || 0;
|
|
118
|
+
const mode = sessions.distribute ? paint.green('distributing') : paint.dim('single-account');
|
|
119
|
+
return `${active} active / ${known} known ${paint.dim('·')} ${mode}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function formatAccountStatus(account, now, paint) {
|
|
123
|
+
const parts = [];
|
|
124
|
+
if (account.disabled) parts.push(paint.gray('disabled'));
|
|
125
|
+
|
|
126
|
+
const status = account.status || 'unknown';
|
|
127
|
+
const colored = status === 'active'
|
|
128
|
+
? paint.green(status)
|
|
129
|
+
: status === 'throttled'
|
|
130
|
+
? paint.yellow(status)
|
|
131
|
+
: status === 'error' || status === 'exhausted'
|
|
132
|
+
? paint.red(status)
|
|
133
|
+
: status;
|
|
134
|
+
parts.push(colored);
|
|
135
|
+
|
|
136
|
+
const throttleAt = parseTs(account.rateLimitedUntil);
|
|
137
|
+
if (throttleAt && throttleAt > now) {
|
|
138
|
+
parts.push(`throttle ${formatDuration(throttleAt - now)}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return parts.join(' / ');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Per-account, per-family eligibility — the "some accounts are disabled for
|
|
145
|
+
// specific models" view. Only rendered for accounts that meter a family
|
|
146
|
+
// separately (a Sonnet or Fable weekly bucket), since that is the only case
|
|
147
|
+
// where a request's model changes where it can route. A family reads ✗ when the
|
|
148
|
+
// shared 5h bucket is spent (blocks everything) or when its own weekly bucket is
|
|
149
|
+
// over the switch threshold; the reset is shown when the family bucket is the
|
|
150
|
+
// blocker so it's clear when that model becomes available on this account again.
|
|
151
|
+
function modelRoutingLine(account, threshold, blocked, now, paint) {
|
|
152
|
+
const q = account.quota || {};
|
|
153
|
+
if (q.unified7dSonnet == null && q.unified7dFable == null) return null;
|
|
154
|
+
const t = Number(threshold);
|
|
155
|
+
const fiveOver = q.unified5h != null && !Number.isNaN(t) && q.unified5h >= t;
|
|
156
|
+
|
|
157
|
+
const cell = (label, weekly, reset) => {
|
|
158
|
+
// The blocklist outranks quota: a blocked family cannot be served however
|
|
159
|
+
// much headroom the account has, so it must not read ✓. Reporting quota
|
|
160
|
+
// alone is what made a fully-blocked model look available.
|
|
161
|
+
if (findFamilyBlock(blocked, label)) {
|
|
162
|
+
return `${label} ${paint.red('⊘')}${paint.dim(' blocked')}`;
|
|
163
|
+
}
|
|
164
|
+
const weeklyOver = weekly != null && !Number.isNaN(t) && weekly >= t;
|
|
165
|
+
const mark = fiveOver || weeklyOver ? paint.red('✗') : paint.green('✓');
|
|
166
|
+
const resetTs = parseTs(reset);
|
|
167
|
+
const when = weeklyOver && resetTs && resetTs > now ? paint.dim(` ${formatDuration(resetTs - now)}`) : '';
|
|
168
|
+
return `${label} ${mark}${when}`;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const cells = [cell('Opus', q.unified7d, q.unified7dReset)];
|
|
172
|
+
if (q.unified7dSonnet != null) cells.push(cell('Sonnet', q.unified7dSonnet, q.unified7dSonnetReset));
|
|
173
|
+
if (q.unified7dFable != null) cells.push(cell('Fable', q.unified7dFable, q.unified7dFableReset));
|
|
174
|
+
return `${paint.dim('Models'.padEnd(8))} ${cells.join(' ')}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function quotaLines(account, now, paint) {
|
|
178
|
+
const quota = account.quota || {};
|
|
179
|
+
const lines = [];
|
|
180
|
+
|
|
181
|
+
if (quota.unified5h != null || quota.unified7d != null || quota.unified7dSonnet != null || quota.unified7dFable != null) {
|
|
182
|
+
lines.push(formatQuotaLine('Session', quota.unified5h, quota.unified5hReset, now, paint));
|
|
183
|
+
lines.push(formatQuotaLine('Weekly', quota.unified7d, quota.unified7dReset, now, paint));
|
|
184
|
+
if (quota.unified7dSonnet != null) {
|
|
185
|
+
lines.push(formatQuotaLine('Sonnet', quota.unified7dSonnet, quota.unified7dSonnetReset, now, paint));
|
|
186
|
+
}
|
|
187
|
+
if (quota.unified7dFable != null) {
|
|
188
|
+
lines.push(formatQuotaLine('Fable', quota.unified7dFable, quota.unified7dFableReset, now, paint));
|
|
189
|
+
}
|
|
190
|
+
return lines;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (quota.tokensLimit != null && quota.tokensRemaining != null) {
|
|
194
|
+
const ratio = 1 - quota.tokensRemaining / quota.tokensLimit;
|
|
195
|
+
lines.push(formatQuotaLine('Tokens', ratio, quota.resetsAt, now, paint));
|
|
196
|
+
}
|
|
197
|
+
if (quota.requestsLimit != null && quota.requestsRemaining != null) {
|
|
198
|
+
const ratio = 1 - quota.requestsRemaining / quota.requestsLimit;
|
|
199
|
+
lines.push(formatQuotaLine('Requests', ratio, quota.resetsAt, now, paint));
|
|
200
|
+
}
|
|
201
|
+
if (lines.length === 0) lines.push(`${paint.dim('Quota'.padEnd(8))} ${paint.gray('unknown')}`);
|
|
202
|
+
return lines;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function formatQuotaLine(label, ratio, resetAt, now, paint) {
|
|
206
|
+
const resetTs = parseTs(resetAt);
|
|
207
|
+
const reset = resetTs && resetTs > now ? ` reset ${formatDuration(resetTs - now)}` : '';
|
|
208
|
+
return `${paint.dim(label.padEnd(8))} ${usageBar(ratio, paint)} ${formatPercent(ratio)}${reset}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function usageBar(ratio, paint) {
|
|
212
|
+
if (ratio == null || Number.isNaN(Number(ratio))) return `[${paint.gray('??????????????????')}]`;
|
|
213
|
+
const width = 18;
|
|
214
|
+
const safeRatio = Math.max(0, Math.min(1, Number(ratio)));
|
|
215
|
+
const full = Math.round(safeRatio * width);
|
|
216
|
+
const fill = Array.from({ length: full }, (_, i) => {
|
|
217
|
+
const [r, g, b] = gradientColor(i, width);
|
|
218
|
+
return paint.rgb(r, g, b, '█');
|
|
219
|
+
}).join('');
|
|
220
|
+
return `[${fill}${paint.gray('░'.repeat(width - full))}]`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function gradientColor(index, width) {
|
|
224
|
+
const t = width <= 1 ? 1 : index / (width - 1);
|
|
225
|
+
const from = t < 0.5 ? [35, 209, 96] : [245, 185, 40];
|
|
226
|
+
const to = t < 0.5 ? [245, 185, 40] : [239, 68, 68];
|
|
227
|
+
const p = t < 0.5 ? t * 2 : (t - 0.5) * 2;
|
|
228
|
+
return from.map((value, i) => Math.round(value + (to[i] - value) * p));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function formatProbeSummary(probe, now, paint) {
|
|
232
|
+
if (!probe.enabled) return paint.gray('off (passive only)');
|
|
233
|
+
const bits = [`on every ${formatDuration((probe.intervalSeconds || 0) * 1000)}`];
|
|
234
|
+
if (probe.running) bits.push(paint.yellow('running'));
|
|
235
|
+
const last = parseTs(probe.lastRunFinishedAt);
|
|
236
|
+
if (last) bits.push(`last ${formatAgo(last, now)}`);
|
|
237
|
+
const next = parseTs(probe.nextRunAt);
|
|
238
|
+
if (next && next > now) bits.push(`next ${formatDuration(next - now)}`);
|
|
239
|
+
return bits.join(', ');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function formatAccountProbe(accountName, probe, now, paint) {
|
|
243
|
+
const row = (probe.accounts || []).find(account => account.name === accountName);
|
|
244
|
+
if (!probe.enabled) return paint.gray('off');
|
|
245
|
+
if (!row) return paint.gray('never');
|
|
246
|
+
if (row.status === 'not-applicable') return paint.gray('not applicable');
|
|
247
|
+
const status = row.status === 'ok'
|
|
248
|
+
? paint.green('ok')
|
|
249
|
+
: row.status === 'running'
|
|
250
|
+
? paint.yellow('running')
|
|
251
|
+
: row.status === 'never'
|
|
252
|
+
? paint.gray('never')
|
|
253
|
+
: paint.red(row.status || 'error');
|
|
254
|
+
const last = parseTs(row.lastProbedAt || row.startedAt);
|
|
255
|
+
const when = last ? ` ${formatAgo(last, now)}` : '';
|
|
256
|
+
const duration = typeof row.durationMs === 'number' ? `, ${Math.round(row.durationMs)}ms` : '';
|
|
257
|
+
const error = row.error ? `, ${safeLine(row.error)}` : '';
|
|
258
|
+
return `${status}${when}${duration}${error}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function safeLine(value) {
|
|
262
|
+
return String(value).replace(/\x1b\[[0-?]*[ -/]*[@-~]|\p{C}/gu, ' ').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function formatUsage(usage = {}, now) {
|
|
266
|
+
const requests = usage.totalRequests || 0;
|
|
267
|
+
const tokens = (usage.totalInputTokens || 0) + (usage.totalOutputTokens || 0);
|
|
268
|
+
const last = parseTs(usage.lastUsed);
|
|
269
|
+
const lastText = last ? `, last ${formatAgo(last, now)}` : '';
|
|
270
|
+
return `${requests} req, ${formatNumber(tokens)} tok${lastText}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function formatServerSummary(server, now) {
|
|
274
|
+
if (server.uptimeSeconds != null) return `up ${formatDuration(server.uptimeSeconds * 1000)}`;
|
|
275
|
+
const started = parseTs(server.startedAt);
|
|
276
|
+
return started ? `up ${formatDuration(now - started)}` : 'unknown';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function formatPercent(value) {
|
|
280
|
+
if (value == null || Number.isNaN(Number(value))) return '-';
|
|
281
|
+
return `${Math.round(Number(value) * 100)}%`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function formatNumber(value) {
|
|
285
|
+
const num = Number(value) || 0;
|
|
286
|
+
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}m`;
|
|
287
|
+
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}k`;
|
|
288
|
+
return String(num);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function formatAgo(timestamp, now) {
|
|
292
|
+
const delta = now - timestamp;
|
|
293
|
+
if (delta < 0) return `in ${formatDuration(-delta)}`;
|
|
294
|
+
return `${formatDuration(delta)} ago`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function formatDuration(ms) {
|
|
298
|
+
if (!Number.isFinite(ms) || ms < 0) return '-';
|
|
299
|
+
const totalSeconds = Math.max(1, Math.round(ms / 1000));
|
|
300
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
301
|
+
const totalMinutes = Math.ceil(totalSeconds / 60);
|
|
302
|
+
if (totalMinutes < 60) return `${totalMinutes}m`;
|
|
303
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
304
|
+
const minutes = totalMinutes % 60;
|
|
305
|
+
if (hours < 24) return minutes ? `${hours}h${minutes}m` : `${hours}h`;
|
|
306
|
+
const days = Math.floor(hours / 24);
|
|
307
|
+
const remHours = hours % 24;
|
|
308
|
+
return remHours ? `${days}d${remHours}h` : `${days}d`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function parseTs(value) {
|
|
312
|
+
if (value == null) return null;
|
|
313
|
+
if (typeof value === 'number') return value;
|
|
314
|
+
const parsed = Date.parse(value);
|
|
315
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
316
|
+
}
|
package/src/sx.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// sx.org proxy integration — an IP-based-429 workaround.
|
|
2
|
+
//
|
|
3
|
+
// teamclaude's transient 429s key on the proxy's OUTBOUND IP, not the account,
|
|
4
|
+
// so account failover doesn't help. sx.org is a residential proxy-port provider:
|
|
5
|
+
// with an API key we provision a port and tunnel upstream Anthropic traffic
|
|
6
|
+
// through it, giving a different egress IP. Crucially, TLS terminates END-TO-END
|
|
7
|
+
// at the upstream (we `tls.connect` over the tunnel with the upstream's
|
|
8
|
+
// servername and the default secure cert check) — the sx.org proxy only ever
|
|
9
|
+
// relays ciphertext and cannot see request content.
|
|
10
|
+
//
|
|
11
|
+
// When no API key is configured (or mode is 'off') this module is dormant and the
|
|
12
|
+
// dial paths behave exactly as before — routing is decided per-attempt by
|
|
13
|
+
// useByDefault() / useOn429() / useForConnect(), all false until provisioned.
|
|
14
|
+
|
|
15
|
+
import net from 'node:net';
|
|
16
|
+
import tls from 'node:tls';
|
|
17
|
+
|
|
18
|
+
const CONNECT_TIMEOUT_MS = 30000; // residential exits can be slow to establish
|
|
19
|
+
|
|
20
|
+
// Resolved per call (not at import) so tests can point it at a local mock.
|
|
21
|
+
const sxBase = () => process.env.SX_API_BASE || 'https://api.sx.org';
|
|
22
|
+
|
|
23
|
+
// ── sx.org REST (apiKey is a query param; these hit api.sx.org directly, never
|
|
24
|
+
// the proxy, and are unrelated to Anthropic traffic) ──
|
|
25
|
+
async function sxGet(path, apiKey, params = {}) {
|
|
26
|
+
const url = new URL(sxBase() + path);
|
|
27
|
+
url.searchParams.set('apiKey', apiKey);
|
|
28
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
|
|
29
|
+
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
|
30
|
+
return res.json();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function sxPost(path, apiKey, body) {
|
|
34
|
+
const url = new URL(sxBase() + path);
|
|
35
|
+
url.searchParams.set('apiKey', apiKey);
|
|
36
|
+
const res = await fetch(url, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
39
|
+
body: JSON.stringify(body),
|
|
40
|
+
});
|
|
41
|
+
return res.json();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const SX_MODES = ['off', '429', 'always'];
|
|
45
|
+
const normalizeMode = (m) => (SX_MODES.includes(m) ? m : 'always');
|
|
46
|
+
|
|
47
|
+
// Normalize either API shape into { host, port, username, password, portId }.
|
|
48
|
+
// ports-list: { proxy: "host:port", login, password, id }
|
|
49
|
+
// create-port: { server, port, login, password, id }
|
|
50
|
+
function parsePort(p) {
|
|
51
|
+
let host, port;
|
|
52
|
+
if (typeof p.proxy === 'string' && p.proxy.includes(':')) {
|
|
53
|
+
const i = p.proxy.lastIndexOf(':');
|
|
54
|
+
host = p.proxy.slice(0, i); port = p.proxy.slice(i + 1);
|
|
55
|
+
} else {
|
|
56
|
+
host = p.server; port = p.port;
|
|
57
|
+
}
|
|
58
|
+
return { host, port: parseInt(port, 10), username: p.login, password: p.password, portId: p.id };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Open a CONNECT tunnel through an HTTP proxy to targetHost:targetPort and
|
|
63
|
+
* resolve with the raw (still-plaintext) socket once the proxy answers 200.
|
|
64
|
+
*/
|
|
65
|
+
export function connectThroughProxy({ proxyHost, proxyPort, auth, targetHost, targetPort, timeout = CONNECT_TIMEOUT_MS, label = 'sx.org proxy' }) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
// autoSelectFamily (happy-eyeballs) — default on Node 20+ but not 18; set it
|
|
68
|
+
// so a dual-stack proxy host whose IPv6 path is unreachable falls back to IPv4
|
|
69
|
+
// instead of hanging the connect (sx.org returns an IP, but be robust).
|
|
70
|
+
const sock = net.connect({ port: proxyPort, host: proxyHost, autoSelectFamily: true });
|
|
71
|
+
let buf = '';
|
|
72
|
+
const timer = setTimeout(() => fail(new Error(`${label} CONNECT timed out after ${timeout}ms`)), timeout);
|
|
73
|
+
const cleanup = () => {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
sock.removeListener('data', onData);
|
|
76
|
+
sock.removeListener('error', fail);
|
|
77
|
+
};
|
|
78
|
+
const fail = (err) => { cleanup(); sock.destroy(); reject(err); };
|
|
79
|
+
const onData = (chunk) => {
|
|
80
|
+
buf += chunk.toString('latin1');
|
|
81
|
+
const idx = buf.indexOf('\r\n\r\n');
|
|
82
|
+
if (idx < 0) { if (buf.length > 65536) fail(new Error(`${label} CONNECT response too large`)); return; }
|
|
83
|
+
const statusLine = buf.slice(0, buf.indexOf('\r\n'));
|
|
84
|
+
const m = statusLine.match(/^HTTP\/\d\.\d\s+(\d{3})/);
|
|
85
|
+
if (!m || m[1] !== '200') { fail(new Error(`${label} refused CONNECT: ${statusLine}`)); return; }
|
|
86
|
+
cleanup();
|
|
87
|
+
sock.pause(); // stop flowing so the TLS layer we hand it to sees every byte
|
|
88
|
+
const rest = Buffer.from(buf.slice(idx + 4), 'latin1'); // bytes already past the header
|
|
89
|
+
if (rest.length) sock.unshift(rest);
|
|
90
|
+
resolve(sock);
|
|
91
|
+
};
|
|
92
|
+
sock.once('connect', () => {
|
|
93
|
+
const lines = [`CONNECT ${targetHost}:${targetPort} HTTP/1.1`, `Host: ${targetHost}:${targetPort}`];
|
|
94
|
+
if (auth) lines.push(`Proxy-Authorization: Basic ${Buffer.from(auth).toString('base64')}`);
|
|
95
|
+
lines.push('Proxy-Connection: keep-alive', '', '');
|
|
96
|
+
sock.write(lines.join('\r\n'));
|
|
97
|
+
});
|
|
98
|
+
sock.on('data', onData);
|
|
99
|
+
sock.once('error', fail);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* CONNECT through `proxy`, then complete a TLS handshake to targetHost so TLS is
|
|
105
|
+
* end-to-end (the proxy sees ciphertext only). Resolves with the TLSSocket after
|
|
106
|
+
* secureConnect. Cert verification stays at its secure default; tests inject a CA
|
|
107
|
+
* via tlsOptions.ca.
|
|
108
|
+
*/
|
|
109
|
+
export async function tunnelTls({ proxy, targetHost, targetPort = 443, tlsOptions = {} }) {
|
|
110
|
+
const sock = await connectThroughProxy({
|
|
111
|
+
proxyHost: proxy.host,
|
|
112
|
+
proxyPort: proxy.port,
|
|
113
|
+
auth: proxy.username ? `${proxy.username}:${proxy.password}` : null,
|
|
114
|
+
targetHost,
|
|
115
|
+
targetPort,
|
|
116
|
+
});
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const tlsSock = tls.connect({ socket: sock, servername: targetHost, ...tlsOptions });
|
|
119
|
+
const onErr = (err) => { tlsSock.removeListener('secureConnect', onOk); sock.destroy(); reject(err); };
|
|
120
|
+
const onOk = () => { tlsSock.removeListener('error', onErr); resolve(tlsSock); };
|
|
121
|
+
tlsSock.once('secureConnect', onOk);
|
|
122
|
+
tlsSock.once('error', onErr);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Holds the sx.org credential + the provisioned proxy. Shared in-process by the
|
|
128
|
+
* reverse proxy, the MITM handler, and the TUI so a key change applies live.
|
|
129
|
+
*/
|
|
130
|
+
export class SxManager {
|
|
131
|
+
constructor({ log = () => {} } = {}) {
|
|
132
|
+
this.log = log;
|
|
133
|
+
this.apiKey = null;
|
|
134
|
+
this.proxy = null; // { host, port, username, password, portId }
|
|
135
|
+
this.mode = 'always'; // off | 429 | always — how routing decisions are made
|
|
136
|
+
this._rlUntil = 0; // sticky-routing window end (ms) for '429' mode
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
isProvisioned() { return !!(this.apiKey && this.proxy); }
|
|
140
|
+
getProxy() { return this.proxy; }
|
|
141
|
+
getMode() { return this.mode; }
|
|
142
|
+
|
|
143
|
+
// ── routing decisions ──
|
|
144
|
+
// Reverse-proxy first attempt: only 'always' routes pre-emptively.
|
|
145
|
+
useByDefault() { return this.isProvisioned() && this.mode === 'always'; }
|
|
146
|
+
// Reverse-proxy retry after a 429: 'always' and '429' both route (the 429 is
|
|
147
|
+
// IP-based, so a fresh egress IP can clear it).
|
|
148
|
+
useOn429() { return this.isProvisioned() && this.mode !== 'off'; }
|
|
149
|
+
// MITM connect-time (one tunnel carries many requests, so no per-request
|
|
150
|
+
// failover): 'always' routes; '429' routes only inside the sticky window set
|
|
151
|
+
// when a 429 was recently observed.
|
|
152
|
+
useForConnect() {
|
|
153
|
+
if (!this.isProvisioned() || this.mode === 'off') return false;
|
|
154
|
+
return this.mode === 'always' || this.isRecentlyRateLimited();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
noteRateLimited(seconds = 60) { this._rlUntil = Date.now() + Math.min(Math.max(seconds, 1), 300) * 1000; }
|
|
158
|
+
isRecentlyRateLimited() { return Date.now() < this._rlUntil; }
|
|
159
|
+
|
|
160
|
+
/** Set the API key (+ optional mode) and provision unless mode is 'off'. */
|
|
161
|
+
async configure(apiKey, mode = this.mode) {
|
|
162
|
+
this.mode = normalizeMode(mode);
|
|
163
|
+
if (!apiKey) { this.disable(); return { ok: false, error: 'no API key' }; }
|
|
164
|
+
this.apiKey = apiKey;
|
|
165
|
+
if (this.mode === 'off') { this.proxy = null; return { ok: true, mode: this.mode, proxy: null }; }
|
|
166
|
+
return this._ensureProxy();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Switch mode WITHOUT clearing the key; provision lazily when turning on. */
|
|
170
|
+
async setMode(mode) {
|
|
171
|
+
this.mode = normalizeMode(mode);
|
|
172
|
+
if (this.mode === 'off') { this.proxy = null; return { ok: true, mode: this.mode }; }
|
|
173
|
+
if (this.apiKey && !this.proxy) return this._ensureProxy();
|
|
174
|
+
return { ok: true, mode: this.mode, proxy: this.proxy };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Full deconfigure — forget the key entirely. */
|
|
178
|
+
disable() { this.apiKey = null; this.proxy = null; }
|
|
179
|
+
|
|
180
|
+
async _ensureProxy() {
|
|
181
|
+
try {
|
|
182
|
+
this.proxy = await this.provision();
|
|
183
|
+
this.log(`[TeamClaude] sx.org proxy ready: ${this.proxy.host}:${this.proxy.port}`);
|
|
184
|
+
return { ok: true, mode: this.mode, proxy: this.proxy };
|
|
185
|
+
} catch (err) {
|
|
186
|
+
this.proxy = null;
|
|
187
|
+
this.log(`[TeamClaude] sx.org provisioning failed: ${err.message}`);
|
|
188
|
+
return { ok: false, error: err.message };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Account balance/traffic, or null on error. */
|
|
193
|
+
async getBalance() {
|
|
194
|
+
if (!this.apiKey) return null;
|
|
195
|
+
try {
|
|
196
|
+
const r = await sxGet('/v2/user/balance', this.apiKey);
|
|
197
|
+
return r?.success ? r : null;
|
|
198
|
+
} catch { return null; }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Reuse an active port if one exists, else create a residential US one. */
|
|
202
|
+
async provision() {
|
|
203
|
+
if (!this.apiKey) throw new Error('sx.org API key not set');
|
|
204
|
+
const list = await sxGet('/v2/proxy/ports', this.apiKey, { per_page: 50 });
|
|
205
|
+
const proxies = list?.message?.proxies || [];
|
|
206
|
+
const active = proxies.find((p) => p.status === 1 && p.login && p.password && p.proxy);
|
|
207
|
+
if (active) return parsePort(active);
|
|
208
|
+
|
|
209
|
+
const created = await sxPost('/v2/proxy/create-port', this.apiKey, {
|
|
210
|
+
country_code: 'US', proxy_type_id: 1, type_id: 1, // type_id 1 = residential
|
|
211
|
+
});
|
|
212
|
+
if (!created?.success || !created.data) {
|
|
213
|
+
const detail = created?.errors ? JSON.stringify(created.errors) : (created?.message || JSON.stringify(created));
|
|
214
|
+
throw new Error(`sx.org create-port failed: ${detail}`);
|
|
215
|
+
}
|
|
216
|
+
return parsePort(created.data);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Reflect the active account in the terminal title (e.g. "teamclaude 2/4 work"),
|
|
2
|
+
// so a backgrounded or tabbed `teamclaude server` is glanceable without
|
|
3
|
+
// switching to it. Pure/side-effect-free here so it can be unit-tested; the
|
|
4
|
+
// caller owns the TTY gate and the interval.
|
|
5
|
+
|
|
6
|
+
const OSC_TITLE = '\x1b]0;'; // OSC 0 — set icon name + window title
|
|
7
|
+
const BEL = '\x07';
|
|
8
|
+
|
|
9
|
+
// xterm title stack: save the shell's current title on start and restore it on
|
|
10
|
+
// exit. No-op on terminals that don't implement it.
|
|
11
|
+
export const TITLE_STACK_PUSH = '\x1b[22;2t';
|
|
12
|
+
export const TITLE_STACK_POP = '\x1b[23;2t';
|
|
13
|
+
|
|
14
|
+
function truncate(s, max) {
|
|
15
|
+
s = String(s);
|
|
16
|
+
return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Short, glanceable title: "teamclaude <pos>/<total> <name>". `index` is 0-based.
|
|
20
|
+
export function formatTerminalTitle({ index = 0, total = 0, name = null } = {}) {
|
|
21
|
+
const pos = total > 0 ? `${index + 1}/${total}` : '0/0';
|
|
22
|
+
const who = name ? ` ${truncate(name, 24)}` : '';
|
|
23
|
+
return `teamclaude ${pos}${who}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Wrap a title string in the OSC set-title sequence, stripping control chars so a
|
|
27
|
+
// crafted account name can't break out of the escape or move the cursor.
|
|
28
|
+
export function titleSequence(title) {
|
|
29
|
+
const safe = String(title).replace(/[\x00-\x1f\x7f]/g, ' ').trimEnd();
|
|
30
|
+
return `${OSC_TITLE}${safe}${BEL}`;
|
|
31
|
+
}
|