cc-viewer 1.6.288 → 1.6.289

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,530 @@
1
+ // Generic IM bridge orchestrator — platform-agnostic glue between one bound Claude Code PTY
2
+ // session and N IM platform adapters (DingTalk, Feishu, …).
3
+ //
4
+ // Inbound (IM → session): adapter normalizes its platform event → onInbound(normalized, ackCtx)
5
+ // → ack immediately → msgId dedup → access control → /stop interrupt OR inject the text as a
6
+ // prompt (bracketed paste, ⟦im:<id>⟧ origin marker).
7
+ // Outbound (session → IM): on the debounced turn_end, read the Claude session transcript JSONL
8
+ // (path forwarded from the Stop hook), assemble the last main-agent text turn, chunk it, and
9
+ // push via the owning adapter's sendOne.
10
+ //
11
+ // SINGLE SHARED PTY ⇒ SINGLE-FLIGHT INJECTION. Only one adapter may have an injected turn in
12
+ // flight at a time (`activeInjection`, a core-global). It replaces the per-adapter pendingReply:
13
+ // one source of truth. notifyTurnEnd routes the reply solely to the owner; the slot releases on
14
+ // every terminal path (turn-end reply / /stop / inject-failure / timeout) and the release kicks
15
+ // ALL adapters' drains so a second platform's queued prompt can proceed.
16
+ //
17
+ // Design notes:
18
+ // - This module NEVER imports pty-manager / server.js. All PTY access + the streaming-busy probe
19
+ // are injected per platform via `deps`, so unit tests mock them with zero node-pty / network.
20
+ // - Adapters get a `ctx` ({ fetch, store }) — `fetch` is the shared test-seam'd fetch; `store` is
21
+ // a per-instance scratch object (token cache, send client) cleared on reset.
22
+ import { existsSync, readFileSync, appendFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { LOG_DIR } from '../../findcc.js';
25
+ import { t } from '../i18n.js';
26
+
27
+ // ─── tunables (shared across platforms; per-platform rate caps come from adapter.rateLimit) ───
28
+ const SEEN_MAX = 500;
29
+ const RATE_WINDOW_MS = 60_000;
30
+ const MAX_CHUNKS_PER_TURN = 5;
31
+ const MAX_QUEUE = 50; // cap inbound backlog so an authorized sender can't grow it unbounded
32
+ const PENDING_TIMEOUT_MS = 10 * 60_000; // release a stuck injection so the queue can't wedge forever
33
+ const CONNECT_TIMEOUT_MS = 15_000; // bound adapter.connect() so a hung start can't block others
34
+ const STOP_WORDS = new Set(['/stop', 'stop', '停止', 'esc', '/esc']);
35
+
36
+ // ─── registry + core-global single-flight ───
37
+ const instances = new Map(); // platformId → instance
38
+ let activeInjection = null; // { platformId, since, target } — the one in-flight turn
39
+ let activeInjectionTimer = null; // self-heal timer if a turn_end never arrives
40
+ let fetchImpl = null; // shared test seam
41
+
42
+ // ─── test seams ───
43
+ export function __setFetchForTests(fn) { fetchImpl = fn; }
44
+ export function coreFetch(...args) { return (fetchImpl || globalThis.fetch)(...args); }
45
+
46
+ function newInstance(adapter) {
47
+ return {
48
+ adapter,
49
+ client: null,
50
+ running: false,
51
+ connected: false,
52
+ lastError: null,
53
+ bridgeDeps: null,
54
+ boundConversation: null,
55
+ lastRepliedTurnTs: null,
56
+ maxQueueOverride: null,
57
+ seenMsgIds: [],
58
+ queue: [],
59
+ sendTimes: [],
60
+ store: {}, // adapter scratch (token cache, send client)
61
+ };
62
+ }
63
+
64
+ /**
65
+ * The contract every platform adapter under server/lib/adapters/ must satisfy. The core owns all
66
+ * generic orchestration (dedup, access control, queue, single-flight inject, chunk, turn-end
67
+ * reply); an adapter only knows its platform's transport, payload shape, and send.
68
+ *
69
+ * @typedef {Object} ImAdapter
70
+ * @property {string} id Platform id, also the preferences.json key (e.g. 'dingtalk').
71
+ * @property {string} i18nNs i18n namespace for the core's user-facing replies (e.g. 'server.feishu').
72
+ * @property {string} allowListField Config key holding the sender allowlist (e.g. 'allowStaffIds').
73
+ * @property {{max:number,windowMs:number}} [rateLimit] Per-platform outbound cap (defaults 18/60s).
74
+ * @property {Object} [capabilities] Informational flags ({inboundAck, sdkManagesToken}); not read by the core.
75
+ * @property {(cfg:Object)=>boolean} hasCreds True when cfg carries enough creds to connect.
76
+ * @property {(cfg:Object)=>Object} [statusFields] Extra non-secret status fields for the admin API.
77
+ * @property {(cfg:Object, hooks:{onInbound:(normalized:Object, ackCtx:*)=>void}, ctx:{fetch,store})=>Promise<*>} connect
78
+ * Open the long connection; return the live client. Call hooks.onInbound(normalized, ackCtx)
79
+ * per inbound message, where normalized = {text, conversationId, senderId, msgId, target}.
80
+ * @property {(client:*, ctx:{fetch,store})=>Promise<void>} [disconnect] Tear down the client (best-effort).
81
+ * @property {(ackCtx:*, client:*)=>void} [ack] Ack an inbound msg (platforms that redeliver if not acked).
82
+ * @property {(cfg:Object, target:Object, content:string, ctx:{fetch,store})=>Promise<void>} sendOne Send one chunk.
83
+ * @property {(cfg:Object, ctx:{fetch,store})=>Promise<{ok:boolean, detail?:string}>} testConnection Validate creds, no socket.
84
+ */
85
+
86
+ /** Register a platform adapter (called at adapter-module import). Idempotent per id. */
87
+ export function registerAdapter(adapter) {
88
+ if (!instances.has(adapter.id)) instances.set(adapter.id, newInstance(adapter));
89
+ return instances.get(adapter.id);
90
+ }
91
+
92
+ function ctxFor(inst) {
93
+ return { fetch: coreFetch, store: inst.store };
94
+ }
95
+
96
+ function queueCap(inst) {
97
+ return inst.maxQueueOverride ?? MAX_QUEUE;
98
+ }
99
+
100
+ // ─── activeInjection lifecycle ───
101
+ function clearActiveInjection() {
102
+ activeInjection = null;
103
+ if (activeInjectionTimer) { clearTimeout(activeInjectionTimer); activeInjectionTimer = null; }
104
+ }
105
+ function armActiveInjection(inst, target, since) {
106
+ activeInjection = { platformId: inst.adapter.id, since, target };
107
+ if (activeInjectionTimer) clearTimeout(activeInjectionTimer);
108
+ activeInjectionTimer = setTimeout(() => {
109
+ // Only fire if THIS injection still owns the slot (symmetry with the inject-failure guard).
110
+ if (!activeInjection || activeInjection.since !== since) return;
111
+ audit(inst, 'reply-timeout', { conversationId: target?.conversationId });
112
+ clearActiveInjection(); // turn_end never came → release the slot globally…
113
+ drainAll(); // …and let any platform's queue proceed
114
+ }, PENDING_TIMEOUT_MS);
115
+ if (typeof activeInjectionTimer.unref === 'function') activeInjectionTimer.unref();
116
+ }
117
+
118
+ // ─── small helpers ───
119
+ function audit(inst, event, data) {
120
+ try {
121
+ appendFileSync(join(LOG_DIR, `${inst.adapter.id}-audit.log`),
122
+ JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + '\n');
123
+ } catch { /* best-effort */ }
124
+ }
125
+
126
+ /** Bracketed-paste + submit, matching the frontend's ptyChunkBuilder. Kept local so the core
127
+ * never imports pty-manager (which would pull node-pty into the unit test). */
128
+ function bracketPasteSubmit(text) {
129
+ return ['\x1b[200~' + text + '\x1b[201~', '\r'];
130
+ }
131
+
132
+ /** Prepend the IM-origin marker `⟦im:<id>⟧`, EXCEPT for slash commands (a marker prefix would
133
+ * stop the CLI from recognizing `/clear` etc.). trim() guards leading whitespace / full-width
134
+ * spaces. KEEP IN SYNC with IM_ORIGIN_RE in src/utils/imOrigin.js. */
135
+ function markOrigin(id, content) {
136
+ if (content.trim().startsWith('/')) return content;
137
+ return `⟦im:${id}⟧` + content;
138
+ }
139
+
140
+ /**
141
+ * Strip the bracketed-paste terminator/initiator and all C0 control bytes (except newline and
142
+ * tab) from untrusted inbound text. Without this, a crafted message containing `\x1b[201~` (or
143
+ * other ESC sequences) would break out of the paste frame and inject raw keystrokes into the
144
+ * Claude TUI. CR is removed too: it is the submit key, so leaving it in inbound text would be a
145
+ * submit byte smuggled into the paste frame.
146
+ */
147
+ function sanitizeInbound(text) {
148
+ return String(text)
149
+ .replace(/\x1b\[20[01]~/g, '')
150
+ // eslint-disable-next-line no-control-regex
151
+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
152
+ }
153
+
154
+ function remember(inst, msgId) {
155
+ if (!msgId) return false;
156
+ if (inst.seenMsgIds.includes(msgId)) return true;
157
+ inst.seenMsgIds.push(msgId);
158
+ if (inst.seenMsgIds.length > SEEN_MAX) inst.seenMsgIds.shift();
159
+ return false;
160
+ }
161
+
162
+ function isStopCommand(text) {
163
+ return STOP_WORDS.has(text.trim().toLowerCase());
164
+ }
165
+
166
+ function tr(inst, key) {
167
+ return t(`${inst.adapter.i18nNs}.${key}`);
168
+ }
169
+
170
+ // ─── transcript extraction (the safe outbound text source) ───
171
+ function parseLine(line) {
172
+ try { const o = JSON.parse(line); return o && typeof o === 'object' ? o : null; }
173
+ catch { return null; }
174
+ }
175
+
176
+ function isRealUserPrompt(obj) {
177
+ const c = obj.message?.content;
178
+ if (typeof c === 'string') return c.trim().length > 0;
179
+ if (Array.isArray(c)) return c.some(b => b && b.type !== 'tool_result'); // tool_result-only = continuation
180
+ return false;
181
+ }
182
+
183
+ /**
184
+ * Read the LAST main-agent text turn from a Claude Code transcript JSONL. Walks backward from
185
+ * EOF, collecting contiguous assistant `text` blocks, stopping at the previous real user prompt.
186
+ * Skips thinking/tool_use blocks, sidechain (subagent) entries, and non-message sidecar lines.
187
+ */
188
+ export function extractLastAssistantText(transcriptPath) {
189
+ if (!transcriptPath || !existsSync(transcriptPath)) return '';
190
+ let lines;
191
+ try { lines = readFileSync(transcriptPath, 'utf-8').split('\n'); }
192
+ catch { return ''; }
193
+ const parts = [];
194
+ for (let i = lines.length - 1; i >= 0; i--) {
195
+ const line = lines[i].trim();
196
+ if (!line) continue;
197
+ const obj = parseLine(line);
198
+ if (!obj || !obj.type) continue;
199
+ if (obj.type === 'assistant') {
200
+ if (obj.isSidechain) continue;
201
+ const content = obj.message?.content;
202
+ if (Array.isArray(content)) {
203
+ const txt = content.filter(b => b && b.type === 'text').map(b => b.text).join('\n').trim();
204
+ if (txt) parts.unshift(txt);
205
+ }
206
+ continue;
207
+ }
208
+ if (obj.type === 'user') {
209
+ if (obj.isSidechain) continue; // subagent prompt — not the main-agent turn boundary
210
+ if (isRealUserPrompt(obj)) break; // start of this turn — stop
211
+ continue; // tool_result continuation — keep scanning
212
+ }
213
+ // system / summary / file-history-snapshot / metadata sidecars → skip
214
+ }
215
+ return parts.join('\n\n').trim();
216
+ }
217
+
218
+ // ─── chunking + rate limiting ───
219
+ export function chunkText(text, max) {
220
+ if (!text) return [];
221
+ if (text.length <= max) return [text];
222
+ const chunks = [];
223
+ let buf = '';
224
+ for (const seg of text.split(/(\n\n)/)) {
225
+ if ((buf + seg).length <= max) { buf += seg; continue; }
226
+ if (buf) { chunks.push(buf); buf = ''; }
227
+ if (seg.length > max) {
228
+ let rest = seg;
229
+ while (rest.length > max) {
230
+ let cut = rest.lastIndexOf('\n', max);
231
+ if (cut <= 0) cut = rest.lastIndexOf(' ', max);
232
+ if (cut <= 0) cut = max;
233
+ chunks.push(rest.slice(0, cut));
234
+ rest = rest.slice(cut);
235
+ }
236
+ buf = rest;
237
+ } else {
238
+ buf = seg;
239
+ }
240
+ }
241
+ if (buf) chunks.push(buf);
242
+ return chunks.map(c => c.trim()).filter(Boolean);
243
+ }
244
+
245
+ async function rateLimitGate(inst) {
246
+ const max = inst.adapter.rateLimit?.max ?? 18;
247
+ const windowMs = inst.adapter.rateLimit?.windowMs ?? RATE_WINDOW_MS;
248
+ const now = Date.now();
249
+ while (inst.sendTimes.length && now - inst.sendTimes[0] > windowMs) inst.sendTimes.shift();
250
+ if (inst.sendTimes.length >= max) {
251
+ const wait = windowMs - (now - inst.sendTimes[0]) + 50;
252
+ await new Promise(r => setTimeout(r, wait));
253
+ return rateLimitGate(inst);
254
+ }
255
+ inst.sendTimes.push(Date.now());
256
+ }
257
+
258
+ async function sendReply(inst, target, text) {
259
+ const cfg = inst.bridgeDeps.getConfig();
260
+ let chunks = chunkText(text, cfg.maxChunkChars);
261
+ if (chunks.length > MAX_CHUNKS_PER_TURN) {
262
+ chunks = chunks.slice(0, MAX_CHUNKS_PER_TURN);
263
+ chunks[MAX_CHUNKS_PER_TURN - 1] += '\n\n' + tr(inst, 'truncated');
264
+ }
265
+ for (const c of chunks) {
266
+ try {
267
+ await rateLimitGate(inst);
268
+ await inst.adapter.sendOne(cfg, target, c, ctxFor(inst));
269
+ } catch (e) {
270
+ inst.lastError = String(e?.message || e);
271
+ audit(inst, 'send-error', { error: inst.lastError });
272
+ break;
273
+ }
274
+ }
275
+ audit(inst, 'out', { conversationId: target.conversationId, chunks: chunks.length });
276
+ }
277
+
278
+ // ─── inbound ───
279
+ function handleInbound(inst, normalized, ackCtx) {
280
+ // ACK first: some platforms (DingTalk) redeliver if not acked within ~5-15s.
281
+ try { inst.adapter.ack?.(ackCtx, inst.client); } catch { /* best-effort; dedup catches a redelivery */ }
282
+ try { handleInboundInner(inst, normalized); }
283
+ catch (e) { audit(inst, 'inbound-error', { error: String(e?.message || e) }); }
284
+ }
285
+
286
+ function handleInboundInner(inst, normalized) {
287
+ if (!normalized) return;
288
+ const msgId = normalized.msgId;
289
+ if (remember(inst, msgId)) return; // redelivery
290
+
291
+ const text = sanitizeInbound(normalized.text ?? '').trim();
292
+ const conversationId = normalized.conversationId;
293
+ const senderId = normalized.senderId;
294
+ const target = normalized.target;
295
+ const cfg = inst.bridgeDeps.getConfig();
296
+ const allowList = cfg[inst.adapter.allowListField] || [];
297
+
298
+ // access control: allowlist (if any) else bind-first-conversation
299
+ if (allowList.length > 0) {
300
+ if (!allowList.includes(senderId)) {
301
+ audit(inst, 'reject-sender', { senderId, conversationId });
302
+ void sendReply(inst, target, tr(inst, 'notAuthorized'));
303
+ return;
304
+ }
305
+ } else if (!inst.boundConversation) {
306
+ inst.boundConversation = { conversationId };
307
+ audit(inst, 'bind', { conversationId });
308
+ } else if (conversationId !== inst.boundConversation.conversationId) {
309
+ audit(inst, 'reject-conversation', { conversationId });
310
+ void sendReply(inst, target, tr(inst, 'notBound'));
311
+ return;
312
+ }
313
+
314
+ audit(inst, 'in', { msgId, senderId, conversationId, len: text.length });
315
+ if (!text) return; // non-text messages (image/voice/file) are ignored in v1
316
+
317
+ if (isStopCommand(text)) {
318
+ inst.bridgeDeps.writeToPty('\x1b'); // ESC interrupts the current turn (NOT killPty)
319
+ audit(inst, 'stop', { conversationId });
320
+ // ESC interrupts whatever turn is live on the shared PTY (possibly another platform's), which
321
+ // may mean its turn_end never fires. Release the global slot and resume all queues so /stop
322
+ // can never wedge the bridge.
323
+ clearActiveInjection();
324
+ void sendReply(inst, target, tr(inst, 'interrupted'));
325
+ drainAll();
326
+ return;
327
+ }
328
+
329
+ if (inst.queue.length >= queueCap(inst)) {
330
+ audit(inst, 'queue-full', { conversationId, queued: inst.queue.length });
331
+ void sendReply(inst, target, tr(inst, 'queueFull'));
332
+ return;
333
+ }
334
+ inst.queue.push({ ...target, content: text });
335
+ if (activeInjection || inst.bridgeDeps.isStreaming()) {
336
+ void sendReply(inst, target, tr(inst, 'busyQueued'));
337
+ }
338
+ drainQueue(inst);
339
+ }
340
+
341
+ function drainQueue(inst) {
342
+ const d = inst.bridgeDeps;
343
+ if (!d) return;
344
+ while (inst.queue.length) {
345
+ if (activeInjection || d.isStreaming()) return; // a turn is in flight (any platform)
346
+ const item = inst.queue[0];
347
+ const st = d.getPtyState();
348
+ if (!st.running || d.getPtyKind() !== 'claude') {
349
+ inst.queue.shift();
350
+ void sendReply(inst, item, tr(inst, 'noSession'));
351
+ continue;
352
+ }
353
+ inst.queue.shift();
354
+ const cfg = d.getConfig();
355
+ const skipPerm = d.getPtySkipPermissions();
356
+ // Optional hard block — when the session runs skip-permissions AND the admin opted in, refuse
357
+ // to inject (remote input would execute with no approval) and tell the sender.
358
+ if (skipPerm && cfg.blockOnSkipPermissions) {
359
+ audit(inst, 'skip-perm-blocked', { conversationId: item.conversationId });
360
+ void sendReply(inst, item, tr(inst, 'skipPermBlocked'));
361
+ continue; // not armed, not injected — move to the next queued prompt
362
+ }
363
+ const since = Date.now();
364
+ armActiveInjection(inst, item, since);
365
+ if (skipPerm) {
366
+ audit(inst, 'skip-perm-warning', { conversationId: item.conversationId });
367
+ void sendReply(inst, item, tr(inst, 'skipPermWarning'));
368
+ }
369
+ // React to a failed injection (PTY gone/died mid-write → onComplete(false)). Without this the
370
+ // prompt never submits, no turn_end ever comes, and the slot wedges until the timeout. Only
371
+ // act if THIS injection still owns the slot (a /stop or timeout may have released it).
372
+ d.writeToPtySequential(bracketPasteSubmit(markOrigin(inst.adapter.id, item.content)), (ok) => {
373
+ if (ok) return;
374
+ if (!activeInjection || activeInjection.platformId !== inst.adapter.id || activeInjection.since !== since) return;
375
+ audit(inst, 'inject-failed', { conversationId: item.conversationId });
376
+ clearActiveInjection();
377
+ void sendReply(inst, item, tr(inst, 'injectFailed'));
378
+ drainAll();
379
+ }, { settleMs: 250 });
380
+ return; // one at a time; resume on the next turn_end
381
+ }
382
+ }
383
+
384
+ /** Resume every platform's queue (used after the in-flight slot is released). */
385
+ function drainAll() {
386
+ for (const inst of instances.values()) {
387
+ if (inst.running) drainQueue(inst);
388
+ }
389
+ }
390
+
391
+ // ─── outbound trigger (called from server.js _emitTurnEnd) ───
392
+ export async function notifyTurnEnd(sessionId, ts, transcriptPath) {
393
+ if (!activeInjection) { drainAll(); return; } // only reply to turns a bridge initiated
394
+ const inst = instances.get(activeInjection.platformId);
395
+ if (!inst) { clearActiveInjection(); drainAll(); return; }
396
+ // A turn_end whose turn ended before we injected belongs to an earlier (e.g. local) turn, not
397
+ // ours — don't consume our slot or leak that turn's text. (Narrow window; full per-turn
398
+ // correlation is a v2 item.)
399
+ if (ts && activeInjection.since && ts < activeInjection.since) { drainAll(); return; }
400
+ const target = activeInjection.target;
401
+ clearActiveInjection();
402
+ // Idempotency for a doubled turn_end of the SAME turn (a re-broadcast carries the same ts).
403
+ // Keyed on ts, NOT reply text.
404
+ if (ts && ts === inst.lastRepliedTurnTs) { drainAll(); return; }
405
+ inst.lastRepliedTurnTs = ts || null;
406
+ // Resume every platform's queue NOW (the slot is already released). The reply send below is
407
+ // independent of the single-flight slot, so a slow/rate-limited sendOne must not starve the
408
+ // other platform's queued prompt behind this await.
409
+ drainAll();
410
+ let text = extractLastAssistantText(transcriptPath);
411
+ if (!text) text = tr(inst, 'noTextReply');
412
+ try { await sendReply(inst, target, text); }
413
+ catch (e) { inst.lastError = String(e?.message || e); audit(inst, 'send-error', { error: inst.lastError }); }
414
+ }
415
+
416
+ // ─── per-platform lifecycle ───
417
+ export async function startBridge(id, deps) {
418
+ const inst = instances.get(id);
419
+ if (!inst) return;
420
+ if (deps) inst.bridgeDeps = deps;
421
+ if (inst.running) return;
422
+ // Guard: reloadBridge (from the config route) calls startBridge with no deps. If the instance
423
+ // was never primed with deps (non-CLI mode, no singleton PTY), refuse to start — otherwise the
424
+ // inbound handler would dereference a null bridgeDeps.
425
+ if (!inst.bridgeDeps || typeof inst.bridgeDeps.getConfig !== 'function') { audit(inst, 'start-skipped', { reason: 'no-deps' }); return; }
426
+ const cfg = inst.bridgeDeps.getConfig();
427
+ if (!cfg || !cfg.enabled || !inst.adapter.hasCreds(cfg)) return; // off / incomplete → no-op
428
+ try {
429
+ const hooks = { onInbound: (normalized, ackCtx) => handleInbound(inst, normalized, ackCtx) };
430
+ // Bound the connect so a hung adapter (e.g. Feishu WSClient.start() on a misconfigured app)
431
+ // becomes a lastError instead of blocking the whole startup chain.
432
+ inst.client = await Promise.race([
433
+ inst.adapter.connect(cfg, hooks, ctxFor(inst)),
434
+ new Promise((_, reject) => { const tm = setTimeout(() => reject(new Error('connect timeout')), CONNECT_TIMEOUT_MS); if (typeof tm.unref === 'function') tm.unref(); }),
435
+ ]);
436
+ inst.running = true;
437
+ inst.connected = true;
438
+ inst.lastError = null;
439
+ audit(inst, 'start', inst.adapter.statusFields ? inst.adapter.statusFields(cfg) : {});
440
+ } catch (e) {
441
+ inst.lastError = String(e?.message || e);
442
+ inst.connected = false;
443
+ audit(inst, 'start-error', { error: inst.lastError });
444
+ }
445
+ }
446
+
447
+ export async function stopBridge(id) {
448
+ const inst = instances.get(id);
449
+ if (!inst) return;
450
+ try { await inst.adapter.disconnect?.(inst.client, ctxFor(inst)); } catch { /* best-effort */ }
451
+ inst.client = null;
452
+ inst.running = false;
453
+ inst.connected = false;
454
+ inst.boundConversation = null;
455
+ if (activeInjection && activeInjection.platformId === id) clearActiveInjection();
456
+ inst.queue.length = 0;
457
+ }
458
+
459
+ export async function reloadBridge(id, deps) {
460
+ await stopBridge(id);
461
+ await startBridge(id, deps);
462
+ }
463
+
464
+ export function isBridgeRunning(id) {
465
+ const inst = instances.get(id);
466
+ return !!(inst && inst.running);
467
+ }
468
+
469
+ export function getBridgeStatus(id) {
470
+ const inst = instances.get(id);
471
+ if (!inst) return { running: false, connected: false, lastError: null, boundConversationId: null };
472
+ const base = {
473
+ running: inst.running,
474
+ connected: inst.connected,
475
+ lastError: inst.lastError,
476
+ boundConversationId: inst.boundConversation?.conversationId || null,
477
+ };
478
+ const cfg = inst.bridgeDeps?.getConfig?.();
479
+ return inst.adapter.statusFields ? { ...base, ...inst.adapter.statusFields(cfg) } : base;
480
+ }
481
+
482
+ /** Validate credentials without opening a Stream connection (the Test button). */
483
+ export async function testConnection(id, cfg) {
484
+ const inst = instances.get(id);
485
+ if (!inst) return { ok: false, detail: 'unknown platform' };
486
+ try {
487
+ return await inst.adapter.testConnection(cfg, ctxFor(inst));
488
+ } catch (e) {
489
+ return { ok: false, detail: String(e?.message || e) };
490
+ }
491
+ }
492
+
493
+ // ─── multi-platform fan-out (server.js startup/shutdown) ───
494
+ export async function startAll(makeDeps) {
495
+ // Start adapters independently so one platform's slow/failed connect can't block the others
496
+ // (and, via server.js, the whole server bring-up). startBridge self-catches; the connect
497
+ // timeout bounds a hang.
498
+ await Promise.allSettled([...instances.keys()].map((id) => startBridge(id, makeDeps(id))));
499
+ }
500
+
501
+ export async function stopAll() {
502
+ for (const id of instances.keys()) {
503
+ await stopBridge(id);
504
+ }
505
+ }
506
+
507
+ // ─── test seams ───
508
+ export function __setMaxQueueForTests(id, n) {
509
+ const inst = instances.get(id);
510
+ if (inst) inst.maxQueueOverride = n;
511
+ }
512
+
513
+ /** Reset one platform's singleton state (and release the global slot if it owns it). */
514
+ export function __resetForTests(id) {
515
+ const inst = instances.get(id);
516
+ if (!inst) return;
517
+ inst.client = null; inst.running = false; inst.connected = false; inst.lastError = null;
518
+ inst.bridgeDeps = null; inst.boundConversation = null; inst.lastRepliedTurnTs = null;
519
+ inst.maxQueueOverride = null;
520
+ inst.seenMsgIds.length = 0; inst.queue.length = 0; inst.sendTimes.length = 0;
521
+ inst.store = {};
522
+ if (activeInjection && activeInjection.platformId === id) clearActiveInjection();
523
+ }
524
+
525
+ /** Whole-core reset across all platforms — for cross-adapter tests. */
526
+ export function __resetAllForTests() {
527
+ for (const id of instances.keys()) __resetForTests(id);
528
+ clearActiveInjection();
529
+ fetchImpl = null;
530
+ }