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.
- package/dist/assets/App-5qf4V-IT.css +1 -0
- package/dist/assets/App-zw1VXo0m.js +1 -0
- package/dist/assets/{MdxEditorPanel-B7qeMjuJ.js → MdxEditorPanel-D-yZyfRx.js} +1 -1
- package/dist/assets/{Mobile-fEx_3vW1.js → Mobile-CH0mudvq.js} +1 -1
- package/dist/assets/index-CwWxkILC.js +2 -0
- package/dist/assets/seqResourceLoaders-CoDjpIz4.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +9 -2
- package/server/i18n.js +36 -0
- package/server/lib/adapters/dingtalk-adapter.js +129 -0
- package/server/lib/adapters/discord-adapter.js +140 -0
- package/server/lib/adapters/feishu-adapter.js +133 -0
- package/server/lib/adapters/wecom-adapter.js +146 -0
- package/server/lib/dingtalk-bridge.js +28 -474
- package/server/lib/dingtalk-config.js +11 -153
- package/server/lib/im-bridge-core.js +530 -0
- package/server/lib/im-config.js +246 -0
- package/server/routes/im.js +133 -0
- package/server/routes/preferences.js +13 -6
- package/server/server.js +31 -18
- package/dist/assets/App-BRgb-Ukj.css +0 -1
- package/dist/assets/App-D73sTzGX.js +0 -1
- package/dist/assets/index-C_SecKTB.js +0 -2
- package/dist/assets/seqResourceLoaders-wAP4dArl.js +0 -2
|
@@ -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
|
+
}
|