@karpeleslab/teamclaude 1.0.7 → 1.0.9
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 +85 -4
- package/package.json +9 -1
- package/src/account-manager.js +182 -27
- package/src/account-uuid-rewrite.js +115 -0
- package/src/h2/frames.js +83 -0
- package/src/h2/hpack.js +314 -0
- package/src/h2/relay.js +417 -0
- package/src/index.js +177 -22
- package/src/json-format-stream.js +65 -0
- package/src/mitm.js +387 -0
- package/src/oauth.js +76 -1
- package/src/prober.js +82 -0
- package/src/request-log.js +194 -0
- package/src/server.js +148 -90
- package/src/sx.js +218 -0
- package/src/tui.js +275 -7
- package/src/upstream-fetch.js +85 -0
- package/src/x509.js +166 -0
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 }) {
|
|
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(`sx.org proxy 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('sx.org proxy 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(`sx.org proxy 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
|
+
}
|
package/src/tui.js
CHANGED
|
@@ -8,6 +8,7 @@ const ESC = '\x1b[';
|
|
|
8
8
|
const RESET = `${ESC}0m`;
|
|
9
9
|
const BOLD = `${ESC}1m`;
|
|
10
10
|
const DIM = `${ESC}2m`;
|
|
11
|
+
const REV = `${ESC}7m`; // reverse video — used for the BIOS-style settings cursor
|
|
11
12
|
|
|
12
13
|
const bold = s => `${BOLD}${s}${RESET}`;
|
|
13
14
|
const dim = s => `${DIM}${s}${RESET}`;
|
|
@@ -115,21 +116,25 @@ function timestamp() {
|
|
|
115
116
|
// ── TUI class ────────────────────────────────────────────────
|
|
116
117
|
|
|
117
118
|
export class TUI {
|
|
118
|
-
constructor({ accountManager, config, saveConfig, syncAccounts, onQuit }) {
|
|
119
|
+
constructor({ accountManager, config, saveConfig, syncAccounts, onQuit, sx = null }) {
|
|
119
120
|
this.am = accountManager;
|
|
120
121
|
this.config = config;
|
|
121
122
|
this.saveConfig = saveConfig;
|
|
122
123
|
this.syncAccounts = syncAccounts;
|
|
123
124
|
this.onQuit = onQuit;
|
|
125
|
+
this.sx = sx; // sx.org proxy manager (may be null)
|
|
126
|
+
this.sxBalance = null; // last fetched sx.org balance, for the settings screen
|
|
124
127
|
|
|
125
128
|
this.log = []; // completed activity entries
|
|
126
129
|
this.active = new Map(); // in-flight requests
|
|
127
|
-
this.mode = 'normal'; // normal | select | add | input
|
|
130
|
+
this.mode = 'normal'; // normal | select | add | input | settings
|
|
128
131
|
this.selAction = null; // switch | remove
|
|
129
132
|
this.selIdx = 0;
|
|
133
|
+
this.setIdx = 0; // cursor row on the settings screen (BIOS-style nav)
|
|
130
134
|
this.inputPrompt = '';
|
|
131
135
|
this.inputBuf = '';
|
|
132
136
|
this.inputCb = null;
|
|
137
|
+
this.inputReturn = 'normal'; // mode to fall back to when an input is cancelled
|
|
133
138
|
this.frame = 0;
|
|
134
139
|
this.running = false;
|
|
135
140
|
this.timer = null;
|
|
@@ -206,6 +211,8 @@ export class TUI {
|
|
|
206
211
|
_onData(d) {
|
|
207
212
|
if (d === '\x1b[A') return this._key('up');
|
|
208
213
|
if (d === '\x1b[B') return this._key('down');
|
|
214
|
+
if (d === '\x1b[C') return this._key('right');
|
|
215
|
+
if (d === '\x1b[D') return this._key('left');
|
|
209
216
|
if (d === '\x1b') return this._key('esc');
|
|
210
217
|
if (d === '\r' || d === '\n') return this._key('enter');
|
|
211
218
|
if (d === '\x03') return this._key('ctrl-c');
|
|
@@ -221,6 +228,7 @@ export class TUI {
|
|
|
221
228
|
case 'select': this._keySelect(k); break;
|
|
222
229
|
case 'add': this._keyAdd(k); break;
|
|
223
230
|
case 'input': this._keyInput(k); break;
|
|
231
|
+
case 'settings': this._keySettings(k); break;
|
|
224
232
|
}
|
|
225
233
|
this.render();
|
|
226
234
|
}
|
|
@@ -238,6 +246,148 @@ export class TUI {
|
|
|
238
246
|
}
|
|
239
247
|
else if (k === 'a') { this.mode = 'add'; }
|
|
240
248
|
else if (k === 'R') { this._doSync(); }
|
|
249
|
+
else if (k === 'g' && this.sx) { this.mode = 'settings'; this.setIdx = 0; this._loadSxBalance(); }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Navigable rows on the settings screen, top to bottom. Both the renderer and
|
|
253
|
+
// the key handler build this list so the cursor and the display stay in sync.
|
|
254
|
+
// Rows are conditional (sx.org rows only when that build feature is present),
|
|
255
|
+
// so always index through the returned array — never hard-code positions.
|
|
256
|
+
_settingsFields() {
|
|
257
|
+
const fields = [];
|
|
258
|
+
|
|
259
|
+
fields.push({
|
|
260
|
+
id: 'threshold',
|
|
261
|
+
label: 'Switch threshold',
|
|
262
|
+
hint: '←→ ±1%',
|
|
263
|
+
value: () => {
|
|
264
|
+
const thr = this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98;
|
|
265
|
+
return green(`${Math.round(thr * 100)}%`);
|
|
266
|
+
},
|
|
267
|
+
left: () => this._nudgeThreshold(-1),
|
|
268
|
+
right: () => this._nudgeThreshold(+1),
|
|
269
|
+
enter: () => this._promptInput('Switch threshold % (1-100)', v => this._doSetThreshold(v.trim())),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
fields.push({
|
|
273
|
+
id: 'probe',
|
|
274
|
+
label: 'Quota probe',
|
|
275
|
+
hint: '←→ ±30s',
|
|
276
|
+
value: () => {
|
|
277
|
+
const probe = this.config.quotaProbeSeconds || 0;
|
|
278
|
+
return probe > 0 ? green(`${probe}s`) : gray('off (passive)');
|
|
279
|
+
},
|
|
280
|
+
left: () => this._nudgeProbe(-30),
|
|
281
|
+
right: () => this._nudgeProbe(+30),
|
|
282
|
+
enter: () => this._promptInput('Quota probe seconds (0=off, min 30)', v => this._doSetProbe(v.trim())),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
if (this.sx) {
|
|
286
|
+
fields.push({
|
|
287
|
+
id: 'sxmode',
|
|
288
|
+
label: 'sx.org mode',
|
|
289
|
+
hint: '←→ cycle',
|
|
290
|
+
value: () => {
|
|
291
|
+
const mode = this.sx.getMode();
|
|
292
|
+
return mode === 'always' ? green('always')
|
|
293
|
+
: mode === '429' ? cyan('on 429 only')
|
|
294
|
+
: gray('off');
|
|
295
|
+
},
|
|
296
|
+
left: () => this._cycleSxMode(-1),
|
|
297
|
+
right: () => this._cycleSxMode(+1),
|
|
298
|
+
enter: () => this._cycleSxMode(+1),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
fields.push({
|
|
302
|
+
id: 'sxkey',
|
|
303
|
+
label: 'sx.org API key',
|
|
304
|
+
hint: 'Enter to set',
|
|
305
|
+
value: () => {
|
|
306
|
+
const key = this.config.sx?.apiKey;
|
|
307
|
+
return key ? key.slice(0, 4) + '…' + key.slice(-4) : dim('(not set)');
|
|
308
|
+
},
|
|
309
|
+
enter: () => this._promptInput('sx.org API key', v => this._doSetSxKey(v.trim())),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
if (this.config.sx?.apiKey) {
|
|
313
|
+
fields.push({
|
|
314
|
+
id: 'sxclear',
|
|
315
|
+
label: 'Clear sx.org key',
|
|
316
|
+
hint: 'Enter to clear',
|
|
317
|
+
value: () => dim('—'),
|
|
318
|
+
enter: () => this._doClearSxKey(),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return fields;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
_keySettings(k) {
|
|
327
|
+
const fields = this._settingsFields();
|
|
328
|
+
const n = fields.length;
|
|
329
|
+
if (n > 0 && this.setIdx >= n) this.setIdx = n - 1;
|
|
330
|
+
const f = fields[this.setIdx];
|
|
331
|
+
|
|
332
|
+
if (k === 'up' || k === 'k') this.setIdx = (this.setIdx - 1 + n) % n;
|
|
333
|
+
else if (k === 'down' || k === 'j') this.setIdx = (this.setIdx + 1) % n;
|
|
334
|
+
else if (k === 'left') f?.left?.();
|
|
335
|
+
else if (k === 'right') f?.right?.();
|
|
336
|
+
else if (k === 'enter') f?.enter?.();
|
|
337
|
+
else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Open the text-input prompt and return to the settings screen afterward.
|
|
341
|
+
_promptInput(prompt, cb) {
|
|
342
|
+
this.mode = 'input';
|
|
343
|
+
this.inputReturn = 'settings';
|
|
344
|
+
this.inputPrompt = prompt;
|
|
345
|
+
this.inputBuf = '';
|
|
346
|
+
this.inputCb = v => { if (v) cb(v); };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
_nudgeThreshold(deltaPct) {
|
|
350
|
+
const cur = Math.round((this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98) * 100);
|
|
351
|
+
const next = Math.max(1, Math.min(100, cur + deltaPct));
|
|
352
|
+
if (next !== cur) this._doSetThreshold(String(next));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
_nudgeProbe(deltaSec) {
|
|
356
|
+
const cur = this.config.quotaProbeSeconds || 0;
|
|
357
|
+
const next = Math.max(0, cur + deltaSec);
|
|
358
|
+
if (next !== cur) this._doSetProbe(String(next));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async _doSetThreshold(input) {
|
|
362
|
+
const pct = Number(input);
|
|
363
|
+
if (!Number.isFinite(pct) || pct < 1 || pct > 100) {
|
|
364
|
+
this._addLog('Invalid threshold — enter 1–100'); this.mode = 'settings'; if (this.running) this.render(); return;
|
|
365
|
+
}
|
|
366
|
+
const v = Math.round(pct) / 100;
|
|
367
|
+
this.config.switchThreshold = v;
|
|
368
|
+
this.am.switchThreshold = v; // apply to the running rotation immediately
|
|
369
|
+
try { await this.saveConfig(this.config); }
|
|
370
|
+
catch (e) { this._addLog(`Failed to save: ${e.message}`); }
|
|
371
|
+
this._addLog(`Switch threshold set to ${Math.round(v * 100)}%`);
|
|
372
|
+
this.mode = 'settings';
|
|
373
|
+
if (this.running) this.render();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async _doSetProbe(input) {
|
|
377
|
+
let secs = parseInt(input, 10);
|
|
378
|
+
if (Number.isNaN(secs) || secs < 0) {
|
|
379
|
+
this._addLog('Invalid interval — enter 0 (off) or seconds'); this.mode = 'settings'; if (this.running) this.render(); return;
|
|
380
|
+
}
|
|
381
|
+
if (secs > 0 && secs < 30) secs = 30; // match the CLI minimum (don't hammer the usage endpoint)
|
|
382
|
+
this.config.quotaProbeSeconds = secs;
|
|
383
|
+
try { await this.saveConfig(this.config); }
|
|
384
|
+
catch (e) { this._addLog(`Failed to save: ${e.message}`); }
|
|
385
|
+
// syncAccounts re-reads disk config and reschedules the running prober live.
|
|
386
|
+
try { await this.syncAccounts(); }
|
|
387
|
+
catch (e) { this._addLog(`Reload failed: ${e.message}`); }
|
|
388
|
+
this._addLog(secs > 0 ? `Quota probe every ${secs}s` : 'Quota probe disabled');
|
|
389
|
+
this.mode = 'settings';
|
|
390
|
+
if (this.running) this.render();
|
|
241
391
|
}
|
|
242
392
|
|
|
243
393
|
_keySelect(k) {
|
|
@@ -262,6 +412,7 @@ export class TUI {
|
|
|
262
412
|
if (k === 'i') { this._doImport(); this.mode = 'normal'; }
|
|
263
413
|
else if (k === 'k') {
|
|
264
414
|
this.mode = 'input';
|
|
415
|
+
this.inputReturn = 'normal';
|
|
265
416
|
this.inputPrompt = 'API key';
|
|
266
417
|
this.inputBuf = '';
|
|
267
418
|
this.inputCb = v => { if (v) this._doAddKey(v); };
|
|
@@ -273,10 +424,10 @@ export class TUI {
|
|
|
273
424
|
if (k === 'enter') {
|
|
274
425
|
const cb = this.inputCb;
|
|
275
426
|
const v = this.inputBuf;
|
|
276
|
-
this.mode =
|
|
427
|
+
this.mode = this.inputReturn; this.inputCb = null; this.inputBuf = '';
|
|
277
428
|
cb?.(v);
|
|
278
429
|
}
|
|
279
|
-
else if (k === 'esc') { this.mode =
|
|
430
|
+
else if (k === 'esc') { this.mode = this.inputReturn; this.inputCb = null; this.inputBuf = ''; }
|
|
280
431
|
else if (k === 'bs') { this.inputBuf = this.inputBuf.slice(0, -1); }
|
|
281
432
|
else if (k.length === 1) { this.inputBuf += k; }
|
|
282
433
|
}
|
|
@@ -296,6 +447,57 @@ export class TUI {
|
|
|
296
447
|
}
|
|
297
448
|
}
|
|
298
449
|
|
|
450
|
+
// ── sx.org settings ────────────────────────────────
|
|
451
|
+
|
|
452
|
+
_loadSxBalance() {
|
|
453
|
+
this.sxBalance = null;
|
|
454
|
+
if (!this.sx?.apiKey) return;
|
|
455
|
+
this.sx.getBalance()
|
|
456
|
+
.then(b => { this.sxBalance = b; if (this.running) this.render(); })
|
|
457
|
+
.catch(() => {});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
_sxModeLabel(m) { return m === 'always' ? 'always' : m === '429' ? 'on 429 only' : 'off'; }
|
|
461
|
+
|
|
462
|
+
async _doSetSxKey(key) {
|
|
463
|
+
const mode = this.config.sx?.mode || 'always';
|
|
464
|
+
this.config.sx = { apiKey: key, mode };
|
|
465
|
+
try { await this.saveConfig(this.config); }
|
|
466
|
+
catch (e) { this._addLog(`Failed to save sx.org key: ${e.message}`); }
|
|
467
|
+
this._addLog('sx.org: configuring...');
|
|
468
|
+
const r = await this.sx.configure(key, mode);
|
|
469
|
+
if (r.ok && r.proxy) this._addLog(`sx.org key saved — proxy ${r.proxy.host}:${r.proxy.port} (mode: ${this._sxModeLabel(mode)})`);
|
|
470
|
+
else if (r.ok) this._addLog(`sx.org key saved (mode: ${this._sxModeLabel(mode)})`);
|
|
471
|
+
else this._addLog(`sx.org error: ${r.error}`);
|
|
472
|
+
this._loadSxBalance();
|
|
473
|
+
this.mode = 'settings';
|
|
474
|
+
if (this.running) this.render();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Cycle off → on-429 → always (dir +1) or the reverse (dir -1). Keeps the API
|
|
478
|
+
// key, so the user can disable sx.org without deconfiguring it.
|
|
479
|
+
async _cycleSxMode(dir = 1) {
|
|
480
|
+
const order = ['off', '429', 'always'];
|
|
481
|
+
const next = order[(order.indexOf(this.sx.getMode()) + dir + order.length) % order.length];
|
|
482
|
+
this.config.sx = { ...(this.config.sx || {}), mode: next };
|
|
483
|
+
try { await this.saveConfig(this.config); }
|
|
484
|
+
catch (e) { this._addLog(`Failed to save: ${e.message}`); }
|
|
485
|
+
const r = await this.sx.setMode(next);
|
|
486
|
+
this._addLog(`sx.org mode: ${this._sxModeLabel(next)}${r.ok ? '' : ` — ${r.error}`}`);
|
|
487
|
+
if (next !== 'off') this._loadSxBalance();
|
|
488
|
+
if (this.running) this.render();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async _doClearSxKey() {
|
|
492
|
+
this.config.sx = null;
|
|
493
|
+
try { await this.saveConfig(this.config); }
|
|
494
|
+
catch (e) { this._addLog(`Failed to save: ${e.message}`); }
|
|
495
|
+
this.sx.disable();
|
|
496
|
+
this.sxBalance = null;
|
|
497
|
+
this._addLog('sx.org key cleared');
|
|
498
|
+
if (this.running) this.render();
|
|
499
|
+
}
|
|
500
|
+
|
|
299
501
|
async _doImport() {
|
|
300
502
|
try {
|
|
301
503
|
this._addLog('Importing credentials...');
|
|
@@ -439,6 +641,10 @@ export class TUI {
|
|
|
439
641
|
lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
|
|
440
642
|
lines.push(' ' + dim('─'.repeat(W - 2)));
|
|
441
643
|
|
|
644
|
+
const footerH = 2;
|
|
645
|
+
if (this.mode === 'settings') {
|
|
646
|
+
this._renderSettings(lines);
|
|
647
|
+
} else {
|
|
442
648
|
// ── Accounts
|
|
443
649
|
if (this.am.accounts.length === 0) {
|
|
444
650
|
lines.push('');
|
|
@@ -472,11 +678,11 @@ export class TUI {
|
|
|
472
678
|
}
|
|
473
679
|
|
|
474
680
|
// Completed log
|
|
475
|
-
const footerH = 2;
|
|
476
681
|
const space = Math.max(0, H - lines.length - footerH);
|
|
477
682
|
for (let i = 0; i < space && i < this.log.length; i++) {
|
|
478
683
|
lines.push(` ${gray(this.log[i].t)} ${this.log[i].msg}`);
|
|
479
684
|
}
|
|
685
|
+
} // end non-settings body
|
|
480
686
|
|
|
481
687
|
// Pad to fill
|
|
482
688
|
while (lines.length < H - footerH) lines.push('');
|
|
@@ -529,7 +735,7 @@ export class TUI {
|
|
|
529
735
|
const q = a.quota;
|
|
530
736
|
let r1 = null, r2 = null, l1 = 'Ses', l2 = 'Wk ', t1 = null, t2 = null;
|
|
531
737
|
|
|
532
|
-
if (q.unified5h != null || q.unified7d != null) {
|
|
738
|
+
if (q.unified5h != null || q.unified7d != null || q.unified7dSonnet != null) {
|
|
533
739
|
r1 = q.unified5h;
|
|
534
740
|
r2 = q.unified7d;
|
|
535
741
|
t1 = q.unified5hReset;
|
|
@@ -548,14 +754,76 @@ export class TUI {
|
|
|
548
754
|
let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw, t1)}`;
|
|
549
755
|
if (showBoth) {
|
|
550
756
|
line += ` ${l2} ${bar(r2, bw, t2)}`;
|
|
757
|
+
// Sonnet weekly bar — only shown when the usage probe has populated it.
|
|
758
|
+
if (q.unified7dSonnet != null) {
|
|
759
|
+
line += ` S7 ${bar(q.unified7dSonnet, bw, q.unified7dSonnetReset)}`;
|
|
760
|
+
}
|
|
551
761
|
}
|
|
552
762
|
return line;
|
|
553
763
|
}
|
|
554
764
|
|
|
765
|
+
_renderSettings(lines) {
|
|
766
|
+
const fields = this._settingsFields();
|
|
767
|
+
if (this.setIdx >= fields.length) this.setIdx = Math.max(0, fields.length - 1);
|
|
768
|
+
const selId = fields[this.setIdx]?.id;
|
|
769
|
+
const byId = id => fields.find(f => f.id === id);
|
|
770
|
+
|
|
771
|
+
// Render a navigable setting row with a BIOS-style highlight bar on the
|
|
772
|
+
// cursor row. Read-only info rows pass field=null and never highlight.
|
|
773
|
+
const row = field => {
|
|
774
|
+
const selected = field && field.id === selId;
|
|
775
|
+
const label = (field ? field.label : '').padEnd(16);
|
|
776
|
+
const value = field ? field.value() : '';
|
|
777
|
+
if (selected) {
|
|
778
|
+
const hint = field.hint ? ` ${dim(field.hint)}` : '';
|
|
779
|
+
const inner = rpad(` ${label} ${strip(value)} `, 34);
|
|
780
|
+
return ` ${cyan('▸')}${REV}${inner}${RESET}${hint}`;
|
|
781
|
+
}
|
|
782
|
+
return ` ${dim(label)} ${value}`;
|
|
783
|
+
};
|
|
784
|
+
// A plain read-only info line (not selectable), aligned with the rows above.
|
|
785
|
+
const info = (label, value) => ` ${dim(label.padEnd(16))} ${value}`;
|
|
786
|
+
|
|
787
|
+
lines.push('');
|
|
788
|
+
// ── Rotation
|
|
789
|
+
lines.push(bold(' Rotation') + dim(' — switch accounts when quota crosses the threshold'));
|
|
790
|
+
lines.push(row(byId('threshold')));
|
|
791
|
+
lines.push('');
|
|
792
|
+
// ── Quota probe
|
|
793
|
+
lines.push(bold(' Quota probe') + dim(' — refresh idle accounts from the usage endpoint'));
|
|
794
|
+
lines.push(row(byId('probe')));
|
|
795
|
+
lines.push('');
|
|
796
|
+
// ── sx.org
|
|
797
|
+
lines.push(bold(' sx.org proxy') + dim(' — route upstream via a residential IP (429 workaround)'));
|
|
798
|
+
lines.push('');
|
|
799
|
+
if (!this.sx) { lines.push(yellow(' Unavailable in this build.')); return; }
|
|
800
|
+
const key = this.config.sx?.apiKey;
|
|
801
|
+
const mode = this.sx.getMode();
|
|
802
|
+
const p = this.sx.getProxy?.();
|
|
803
|
+
const proxyStr = mode === 'off' ? gray('—')
|
|
804
|
+
: this.sx.isProvisioned() ? green(`${p.host}:${p.port}`)
|
|
805
|
+
: key ? yellow('not provisioned')
|
|
806
|
+
: gray('no key');
|
|
807
|
+
const b = this.sxBalance;
|
|
808
|
+
lines.push(row(byId('sxmode')));
|
|
809
|
+
lines.push(row(byId('sxkey')));
|
|
810
|
+
lines.push(info('Proxy', proxyStr));
|
|
811
|
+
lines.push(info('Balance', b ? green('$' + Number(b.balance).toFixed(4)) : dim('…')));
|
|
812
|
+
if (byId('sxclear')) lines.push(row(byId('sxclear')));
|
|
813
|
+
lines.push('');
|
|
814
|
+
lines.push(dim(' always tunnel ALL upstream traffic through sx.org'));
|
|
815
|
+
lines.push(dim(' on 429 only retry through sx.org after a 429 (fresh IP)'));
|
|
816
|
+
lines.push(dim(' off never use sx.org (API key is kept)'));
|
|
817
|
+
lines.push('');
|
|
818
|
+
lines.push(dim(' TLS stays end-to-end; residential traffic is metered by sx.org.'));
|
|
819
|
+
}
|
|
820
|
+
|
|
555
821
|
_renderFooter() {
|
|
556
822
|
switch (this.mode) {
|
|
557
823
|
case 'normal':
|
|
558
|
-
return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('d')}isable ${bold('R')}eload ${bold('q')}uit`;
|
|
824
|
+
return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('d')}isable ${bold('R')}eload ${bold('g')} settings ${bold('q')}uit`;
|
|
825
|
+
case 'settings':
|
|
826
|
+
return ` ${dim('↑↓')} navigate ${dim('←→')} change ${bold('Enter')} edit ${bold('Esc')} back`;
|
|
559
827
|
case 'select': {
|
|
560
828
|
const act = this.selAction === 'switch' ? 'switch'
|
|
561
829
|
: this.selAction === 'toggle' ? 'enable/disable'
|