@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,193 @@
|
|
|
1
|
+
// Drop orphaned tool_use / tool_result blocks from an Anthropic /v1/messages
|
|
2
|
+
// request body so a client that compacted or interrupted a turn can't wedge the
|
|
3
|
+
// session with Anthropic's non-retryable 400:
|
|
4
|
+
//
|
|
5
|
+
// messages.N: `tool_use` ids were found without `tool_result` blocks
|
|
6
|
+
// immediately after: toolu_XXXX. Each `tool_use` block must have a
|
|
7
|
+
// corresponding `tool_result` block in the next message.
|
|
8
|
+
//
|
|
9
|
+
// Anthropic enforces this POSITIONALLY: every tool_use block in an assistant
|
|
10
|
+
// message must be answered by a matching tool_result in the IMMEDIATELY FOLLOWING
|
|
11
|
+
// message, and every tool_result must be grounded by a tool_use in the message
|
|
12
|
+
// right before it. A client that summarizes ("compacts") a long conversation or
|
|
13
|
+
// gets an in-flight tool call interrupted can break that in two ways:
|
|
14
|
+
// 1. the counterpart is dropped entirely (a tool_use with no result), or
|
|
15
|
+
// 2. the pair is SEPARATED — both blocks survive but other messages slip
|
|
16
|
+
// between them, so the result is no longer "immediately after".
|
|
17
|
+
// Whole-body pairing (does the id exist somewhere?) misses case 2 — that is the
|
|
18
|
+
// bug that let the 400 through. This checks the true neighbor instead.
|
|
19
|
+
//
|
|
20
|
+
// The proxy already buffers and rewrites the body (account_uuid, model map), so
|
|
21
|
+
// it is the natural single place to normalize this for every client that routes
|
|
22
|
+
// through it. This pass only ever REMOVES provably-unpaired blocks; it never
|
|
23
|
+
// fabricates a tool_result the model would reason over. A well-formed body is
|
|
24
|
+
// returned as the SAME Buffer instance (identity preserved), so the forwarder's
|
|
25
|
+
// `sendBody !== body` check keeps it a no-op with zero cost on the hot path.
|
|
26
|
+
|
|
27
|
+
const MESSAGES_PATH = '/v1/messages';
|
|
28
|
+
|
|
29
|
+
// A tool_use / tool_result block type can only appear in the body as one of
|
|
30
|
+
// these exact JSON substrings. If NEITHER is present there is nothing this pass
|
|
31
|
+
// could ever prune, so we can skip the (potentially multi-hundred-KB) JSON.parse
|
|
32
|
+
// and tree walk entirely — a cheap Buffer scan on the hot path instead. A false
|
|
33
|
+
// positive (the literal text appearing inside some string content) only costs an
|
|
34
|
+
// unnecessary parse that still returns the same Buffer, so it stays correct.
|
|
35
|
+
const TOOL_USE_MARKER = Buffer.from('"tool_use"');
|
|
36
|
+
const TOOL_RESULT_MARKER = Buffer.from('"tool_result"');
|
|
37
|
+
|
|
38
|
+
// Is this a JSON /v1/messages (or /v1/messages/count_tokens) request we can
|
|
39
|
+
// reason about? Everything else (token refreshes, GETs, non-JSON) is left alone.
|
|
40
|
+
function isMessagesRequest(url, contentType) {
|
|
41
|
+
if (typeof url !== 'string' || !url.includes(MESSAGES_PATH)) return false;
|
|
42
|
+
if (contentType && !/json/i.test(contentType)) return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Ids of the tool_use blocks in a message (empty for a non-array / absent message).
|
|
47
|
+
function toolUseIds(msg) {
|
|
48
|
+
const ids = new Set();
|
|
49
|
+
if (msg && Array.isArray(msg.content)) {
|
|
50
|
+
for (const b of msg.content) {
|
|
51
|
+
if (b && typeof b === 'object' && b.type === 'tool_use' && typeof b.id === 'string') ids.add(b.id);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return ids;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// tool_use_ids referenced by the tool_result blocks in a message.
|
|
58
|
+
function toolResultIds(msg) {
|
|
59
|
+
const ids = new Set();
|
|
60
|
+
if (msg && Array.isArray(msg.content)) {
|
|
61
|
+
for (const b of msg.content) {
|
|
62
|
+
if (b && typeof b === 'object' && b.type === 'tool_result' && typeof b.tool_use_id === 'string') ids.add(b.tool_use_id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return ids;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Normalize a message's `content` to a block array so same-role messages can be
|
|
69
|
+
// merged losslessly. Anthropic accepts a single-text-block array as equivalent
|
|
70
|
+
// to a plain string, so this never changes meaning. Returns null for shapes we
|
|
71
|
+
// don't recognize (caller then declines to merge rather than risk corruption).
|
|
72
|
+
function toBlocks(content) {
|
|
73
|
+
if (Array.isArray(content)) return content;
|
|
74
|
+
if (typeof content === 'string') return [{ type: 'text', text: content }];
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// When pruning empties whole messages, two same-role messages can end up adjacent
|
|
79
|
+
// (a user turn that held only an orphaned tool_result is removed, leaving the
|
|
80
|
+
// assistant turns on either side touching). Anthropic requires roles to alternate,
|
|
81
|
+
// so coalesce same-role neighbors by concatenating their content.
|
|
82
|
+
function coalesceSameRole(messages) {
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const msg of messages) {
|
|
85
|
+
const prev = out[out.length - 1];
|
|
86
|
+
if (prev && msg && prev.role && prev.role === msg.role) {
|
|
87
|
+
const a = toBlocks(prev.content);
|
|
88
|
+
const b = toBlocks(msg.content);
|
|
89
|
+
if (a && b) {
|
|
90
|
+
out[out.length - 1] = { ...prev, content: [...a, ...b] };
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
out.push(msg);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// One pruning pass over the array. Strips positionally-unpaired blocks, drops any
|
|
100
|
+
// message it empties, and (only when it dropped something) coalesces same-role
|
|
101
|
+
// neighbors so roles still alternate. Returns the possibly-new array plus whether
|
|
102
|
+
// it changed anything. Mutates the `content` arrays of the (already-cloned) input.
|
|
103
|
+
function pruneOnce(messages) {
|
|
104
|
+
let changed = false;
|
|
105
|
+
|
|
106
|
+
for (let i = 0; i < messages.length; i++) {
|
|
107
|
+
const msg = messages[i];
|
|
108
|
+
if (!msg || !Array.isArray(msg.content)) continue;
|
|
109
|
+
const answeredByNext = toolResultIds(messages[i + 1]); // results that answer THIS msg's tool_use
|
|
110
|
+
const groundedByPrev = toolUseIds(messages[i - 1]); // tool_use that grounds THIS msg's tool_result
|
|
111
|
+
const kept = [];
|
|
112
|
+
for (const b of msg.content) {
|
|
113
|
+
if (b && typeof b === 'object') {
|
|
114
|
+
// A tool_use whose result is not in the immediately following message.
|
|
115
|
+
if (b.type === 'tool_use' && typeof b.id === 'string' && !answeredByNext.has(b.id)) {
|
|
116
|
+
changed = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// A tool_result whose tool_use is not in the immediately preceding message.
|
|
120
|
+
if (b.type === 'tool_result' && typeof b.tool_use_id === 'string' && !groundedByPrev.has(b.tool_use_id)) {
|
|
121
|
+
changed = true;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
kept.push(b);
|
|
126
|
+
}
|
|
127
|
+
if (kept.length !== msg.content.length) msg.content = kept;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let droppedAny = false;
|
|
131
|
+
const kept = [];
|
|
132
|
+
for (const msg of messages) {
|
|
133
|
+
if (msg && Array.isArray(msg.content) && msg.content.length === 0) {
|
|
134
|
+
changed = true;
|
|
135
|
+
droppedAny = true;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
kept.push(msg);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const result = droppedAny ? coalesceSameRole(kept) : kept;
|
|
142
|
+
if (result.length !== kept.length) changed = true;
|
|
143
|
+
return { messages: result, changed };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Repeat pruning to a fixed point: dropping a message shifts adjacency, which can
|
|
147
|
+
// expose a new positional orphan (the cascade), so one pass is not enough. Each
|
|
148
|
+
// pass only removes, so this terminates. Returns the new array, or null if the
|
|
149
|
+
// body was already valid (so the caller can forward the original bytes untouched).
|
|
150
|
+
function pruneOrphans(messages) {
|
|
151
|
+
let current = messages;
|
|
152
|
+
let everChanged = false;
|
|
153
|
+
for (let guard = 0; guard < 1000; guard++) {
|
|
154
|
+
const { messages: next, changed } = pruneOnce(current);
|
|
155
|
+
current = next;
|
|
156
|
+
if (!changed) break;
|
|
157
|
+
everChanged = true;
|
|
158
|
+
}
|
|
159
|
+
return everChanged ? current : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Strip orphaned tool_use / tool_result blocks from a buffered /v1/messages body.
|
|
164
|
+
*
|
|
165
|
+
* @param {Buffer} body fully-buffered request body
|
|
166
|
+
* @param {string} url req.url (only /v1/messages bodies are inspected)
|
|
167
|
+
* @param {string} [contentType] the request's content-type header
|
|
168
|
+
* @returns {Buffer} the original buffer when nothing was unpaired (or on any
|
|
169
|
+
* parse / shape surprise), else a re-serialized buffer with orphans removed.
|
|
170
|
+
*/
|
|
171
|
+
export function sanitizeToolPairs(body, url, contentType) {
|
|
172
|
+
if (!Buffer.isBuffer(body) || body.length === 0) return body;
|
|
173
|
+
if (!isMessagesRequest(url, contentType)) return body;
|
|
174
|
+
// Fast path: no tool block markers at all → nothing to prune, skip the parse.
|
|
175
|
+
if (!body.includes(TOOL_USE_MARKER) && !body.includes(TOOL_RESULT_MARKER)) return body;
|
|
176
|
+
|
|
177
|
+
let payload;
|
|
178
|
+
try {
|
|
179
|
+
payload = JSON.parse(body.toString('utf8'));
|
|
180
|
+
} catch {
|
|
181
|
+
return body; // not JSON we can reason about — never break it
|
|
182
|
+
}
|
|
183
|
+
if (!payload || !Array.isArray(payload.messages)) return body;
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const pruned = pruneOrphans(payload.messages);
|
|
187
|
+
if (!pruned) return body;
|
|
188
|
+
payload.messages = pruned;
|
|
189
|
+
return Buffer.from(JSON.stringify(payload), 'utf8');
|
|
190
|
+
} catch {
|
|
191
|
+
return body; // any surprise → forward the original untouched
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { TUI } from './tui.js';
|
|
2
|
+
import { modelGlobMatches } from './model.js';
|
|
3
|
+
|
|
4
|
+
// Attach mode — the dashboard against a server running somewhere else (a
|
|
5
|
+
// background service, another terminal). The renderer is the same one the
|
|
6
|
+
// in-process TUI uses; only its data source changes, from a live AccountManager
|
|
7
|
+
// to a status snapshot polled over the localhost control plane.
|
|
8
|
+
|
|
9
|
+
const DEFAULT_POLL_MS = 1000;
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
11
|
+
|
|
12
|
+
// Addresses that reach this machine. A server bound to one of these exempts
|
|
13
|
+
// loopback clients from the proxy-key gate, which changes what a 401 can mean.
|
|
14
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
15
|
+
|
|
16
|
+
/** Client for the server's control endpoints. */
|
|
17
|
+
export class RemoteControl {
|
|
18
|
+
constructor({ port, apiKey = null, host = '127.0.0.1', fetchImpl = fetch, timeoutMs = null }) {
|
|
19
|
+
this.port = port;
|
|
20
|
+
this.apiKey = apiKey;
|
|
21
|
+
this.host = host;
|
|
22
|
+
// null = unset, so a caller that knows its own cadence (the attach poller)
|
|
23
|
+
// can derive one; DEFAULT_TIMEOUT_MS covers the one-shot callers.
|
|
24
|
+
this.timeoutMs = timeoutMs;
|
|
25
|
+
this._fetch = fetchImpl;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The current status payload (the same one `teamclaude status` renders). */
|
|
29
|
+
async status() {
|
|
30
|
+
const payload = await this._call('GET', '/teamclaude/status');
|
|
31
|
+
// A status reply always carries an accounts array, even when it is empty.
|
|
32
|
+
// Anything else answered on this port is not this control plane, and calling
|
|
33
|
+
// that "connected with no accounts" would diagnose the wrong problem.
|
|
34
|
+
if (!Array.isArray(payload?.accounts)) {
|
|
35
|
+
throw new Error('unexpected reply — this is not a teamclaude status endpoint');
|
|
36
|
+
}
|
|
37
|
+
return payload;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Re-read config and refresh credentials on the running server. */
|
|
41
|
+
reload() {
|
|
42
|
+
return this._action('POST', '/teamclaude/reload');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Make the running server prefer `name`.
|
|
47
|
+
*
|
|
48
|
+
* The endpoint answers 404 for an account it cannot resolve, and a server
|
|
49
|
+
* predating the endpoint has no handler for the path at all — two different
|
|
50
|
+
* failures behind one status code. The control endpoints always answer with
|
|
51
|
+
* `ok: false` plus a reason, so a 404 without that came from somewhere else
|
|
52
|
+
* and means the feature is missing. Either way it is reported, never swallowed:
|
|
53
|
+
* the dashboard must not show a switch that did not happen.
|
|
54
|
+
*/
|
|
55
|
+
async switchAccount(name) {
|
|
56
|
+
try {
|
|
57
|
+
return await this._action('POST', '/teamclaude/switch', { account: name });
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (!err.answered && (err.status === 404 || err.status === 501)) {
|
|
60
|
+
throw new Error('this server does not support switching accounts');
|
|
61
|
+
}
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A call that changes something on the server.
|
|
68
|
+
*
|
|
69
|
+
* The control plane confirms an applied action with `ok: true`. A 200 carrying
|
|
70
|
+
* anything else did not perform it — the configured port may well be answering
|
|
71
|
+
* from some other service, which will happily 200 an unknown POST — and
|
|
72
|
+
* reporting success from a bare status code would invent a switch that never
|
|
73
|
+
* happened.
|
|
74
|
+
*/
|
|
75
|
+
async _action(method, path, body) {
|
|
76
|
+
const payload = await this._call(method, path, body);
|
|
77
|
+
if (payload?.ok !== true) throw new Error('unexpected reply — this is not a teamclaude control endpoint');
|
|
78
|
+
return payload;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async _call(method, path, body) {
|
|
82
|
+
const deadline = this.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
83
|
+
const headers = {};
|
|
84
|
+
if (this.apiKey) headers['x-api-key'] = this.apiKey;
|
|
85
|
+
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
86
|
+
|
|
87
|
+
let res;
|
|
88
|
+
try {
|
|
89
|
+
res = await this._fetch(`http://${this.host}:${this.port}${path}`, {
|
|
90
|
+
method, headers,
|
|
91
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
92
|
+
// A socket that is open but silent — the server stopped, the laptop
|
|
93
|
+
// suspended mid-request — would otherwise hold this call for minutes
|
|
94
|
+
// while the dashboard showed a live marker over a frozen snapshot.
|
|
95
|
+
signal: AbortSignal.timeout(deadline),
|
|
96
|
+
});
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (err?.name === 'TimeoutError' || err?.name === 'AbortError') {
|
|
99
|
+
throw new Error(`no reply within ${deadline}ms`);
|
|
100
|
+
}
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
const text = await res.text();
|
|
104
|
+
let payload = null;
|
|
105
|
+
try { payload = text ? JSON.parse(text) : null; } catch { /* not JSON — the status carries the meaning */ }
|
|
106
|
+
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
// `ok: false` + a string reason is this control plane's own error shape;
|
|
109
|
+
// anything else reached a handler that is not ours (an old server forwards
|
|
110
|
+
// unknown paths upstream, and Anthropic's error body looks nothing like it).
|
|
111
|
+
const answered = payload?.ok === false && typeof payload.error === 'string';
|
|
112
|
+
// 401/403 needs a different fix from an unreachable server — but which fix
|
|
113
|
+
// depends on where we are pointed. A teamclaude server exempts loopback
|
|
114
|
+
// clients from the key gate, so a 401 from there cannot be about the key
|
|
115
|
+
// and blaming it would send the operator to edit a config that is fine.
|
|
116
|
+
const auth = !answered && (res.status === 401 || res.status === 403);
|
|
117
|
+
const err = new Error(auth
|
|
118
|
+
? (LOOPBACK_HOSTS.has(this.host)
|
|
119
|
+
? `something other than teamclaude is answering on port ${this.port} (HTTP ${res.status})`
|
|
120
|
+
: `the server rejected the proxy API key (HTTP ${res.status})`)
|
|
121
|
+
: answered ? payload.error : `HTTP ${res.status}`);
|
|
122
|
+
err.status = res.status;
|
|
123
|
+
err.answered = answered;
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
// A 200 body can still report failure (the reload endpoint does this).
|
|
127
|
+
if (payload && payload.ok === false) throw new Error(payload.error || 'request rejected');
|
|
128
|
+
return payload;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The read surface the dashboard renders from, filled from a status payload.
|
|
134
|
+
*
|
|
135
|
+
* Deliberately not an AccountManager: attach mode has no rotation state of its
|
|
136
|
+
* own, and anything the payload does not carry stays absent rather than being
|
|
137
|
+
* guessed at.
|
|
138
|
+
*/
|
|
139
|
+
export class RemoteAccountManager {
|
|
140
|
+
constructor() {
|
|
141
|
+
this.accounts = [];
|
|
142
|
+
this.currentIndex = -1;
|
|
143
|
+
this.switchThreshold = 0.98;
|
|
144
|
+
this.distributeSessions = false;
|
|
145
|
+
this.routes = [];
|
|
146
|
+
this.sessions = { active: 0, known: 0, perAccount: {} };
|
|
147
|
+
this.connected = false; // false ⇒ the view is a stale snapshot
|
|
148
|
+
this.lastError = null;
|
|
149
|
+
this.status = null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
applyStatus(status) {
|
|
153
|
+
const accounts = Array.isArray(status?.accounts) ? status.accounts : [];
|
|
154
|
+
// The payload crosses a process boundary, and the renderer calls string
|
|
155
|
+
// methods on name/type unguarded: a malformed reply (wrong port, older or
|
|
156
|
+
// newer server) should read as unknown, not take the dashboard down.
|
|
157
|
+
this.accounts = accounts.map((a, index) => ({
|
|
158
|
+
...a,
|
|
159
|
+
index,
|
|
160
|
+
name: a.name || '(unnamed)',
|
|
161
|
+
type: a.type || '?',
|
|
162
|
+
quota: { ...(a.quota || {}) },
|
|
163
|
+
}));
|
|
164
|
+
// -1 when the payload names an account that is no longer listed: nothing is
|
|
165
|
+
// marked current, which is the truth, rather than defaulting to the first row.
|
|
166
|
+
this.currentIndex = this.accounts.findIndex(a => a.name === status?.currentAccount);
|
|
167
|
+
if (status?.switchThreshold != null) this.switchThreshold = status.switchThreshold;
|
|
168
|
+
|
|
169
|
+
const sessions = status?.sessions || {};
|
|
170
|
+
this.sessions = {
|
|
171
|
+
active: sessions.active || 0,
|
|
172
|
+
known: sessions.known || 0,
|
|
173
|
+
perAccount: sessions.perAccount || {},
|
|
174
|
+
};
|
|
175
|
+
this.distributeSessions = !!sessions.distribute;
|
|
176
|
+
// Same rule as the accounts above, and for the same reason: the renderer
|
|
177
|
+
// walks route.accounts and route.match directly, so a route the payload
|
|
178
|
+
// leaves half-specified would take the whole dashboard down mid-frame.
|
|
179
|
+
this.routes = (Array.isArray(status?.routes) ? status.routes : []).map(r => ({
|
|
180
|
+
...r,
|
|
181
|
+
match: Array.isArray(r?.match) ? r.match : [],
|
|
182
|
+
accounts: Array.isArray(r?.accounts) ? r.accounts : [],
|
|
183
|
+
}));
|
|
184
|
+
this.status = status;
|
|
185
|
+
this.connected = true;
|
|
186
|
+
this.lastError = null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
markDisconnected(err) {
|
|
190
|
+
this.connected = false;
|
|
191
|
+
this.lastError = err?.message || String(err);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
sessionStats() {
|
|
195
|
+
return { ...this.sessions };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
getRoutes() {
|
|
199
|
+
return this.routes;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The account index a request for `model` would land on, from the route
|
|
203
|
+
* target the server published, or null when no route matches or none can
|
|
204
|
+
* serve it. */
|
|
205
|
+
previewRouteIndex(model) {
|
|
206
|
+
const route = this.routes.find(r => (r.match || []).some(g => modelGlobMatches(g, model)));
|
|
207
|
+
if (!route?.target) return null;
|
|
208
|
+
const idx = this.accounts.findIndex(a => a.name === route.target);
|
|
209
|
+
return idx >= 0 ? idx : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Quota windows expire on the server, which re-reports them; nothing to do. */
|
|
213
|
+
refreshExpiredQuotas() {}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Wire a dashboard to a remote server: polling, control actions and quit.
|
|
218
|
+
* Returns the pieces so a caller (or a test) can drive the poll itself.
|
|
219
|
+
*/
|
|
220
|
+
export function createAttachSession({ control, config, onQuit, pollMs = DEFAULT_POLL_MS }) {
|
|
221
|
+
const am = new RemoteAccountManager();
|
|
222
|
+
let timer = null;
|
|
223
|
+
let polling = false;
|
|
224
|
+
// A poll still outstanding after a few intervals is not going to arrive, and
|
|
225
|
+
// holding it hides an outage behind the last good frame. An explicitly
|
|
226
|
+
// configured deadline wins.
|
|
227
|
+
control.timeoutMs ??= Math.max(2000, pollMs * 3);
|
|
228
|
+
|
|
229
|
+
const stop = () => {
|
|
230
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const tui = new TUI({
|
|
234
|
+
accountManager: am,
|
|
235
|
+
config,
|
|
236
|
+
remote: true,
|
|
237
|
+
// Every screen that writes config is unreachable in attach mode; if one ever
|
|
238
|
+
// becomes reachable, this fails loudly instead of silently dropping a save.
|
|
239
|
+
saveConfig: async () => { throw new Error('attach mode cannot write config'); },
|
|
240
|
+
syncAccounts: async () => (await control.reload())?.added || 0,
|
|
241
|
+
applySwitch: name => control.switchAccount(name),
|
|
242
|
+
onQuit: () => { stop(); onQuit?.(); },
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const poll = async () => {
|
|
246
|
+
// A server that accepts the connection and then never answers would other-
|
|
247
|
+
// wise collect one pending request per tick, for as long as it stays wedged.
|
|
248
|
+
if (polling) return;
|
|
249
|
+
polling = true;
|
|
250
|
+
try {
|
|
251
|
+
const status = await control.status();
|
|
252
|
+
const recovered = !am.connected && am.lastError != null;
|
|
253
|
+
am.applyStatus(status);
|
|
254
|
+
if (recovered) tui._addLog('Reconnected to the server');
|
|
255
|
+
} catch (err) {
|
|
256
|
+
// One line per outage, not one per second.
|
|
257
|
+
if (am.connected || am.lastError == null) {
|
|
258
|
+
tui._addLog(`Lost contact with the server: ${err.message}`);
|
|
259
|
+
}
|
|
260
|
+
am.markDisconnected(err);
|
|
261
|
+
} finally {
|
|
262
|
+
polling = false;
|
|
263
|
+
}
|
|
264
|
+
tui.render();
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const start = () => {
|
|
268
|
+
tui.start();
|
|
269
|
+
poll();
|
|
270
|
+
timer = setInterval(poll, pollMs);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
return { tui, am, poll, start, stop };
|
|
274
|
+
}
|