@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.
@@ -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
+ }