cursorconnect 0.1.6 → 0.1.7

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.
Files changed (51) hide show
  1. package/bridge-runtime/.env.example +10 -2
  2. package/bridge-runtime/connector-version.json +1 -1
  3. package/bridge-runtime/dist/agent-completion-push.d.ts +42 -0
  4. package/bridge-runtime/dist/agent-completion-push.js +220 -0
  5. package/bridge-runtime/dist/agent-title-match.d.ts +8 -7
  6. package/bridge-runtime/dist/agent-title-match.js +11 -1
  7. package/bridge-runtime/dist/chat-display-store.d.ts +21 -9
  8. package/bridge-runtime/dist/chat-display-store.js +94 -23
  9. package/bridge-runtime/dist/chat-display.d.ts +2 -0
  10. package/bridge-runtime/dist/chat-display.js +197 -33
  11. package/bridge-runtime/dist/chat-history-mode.d.ts +5 -0
  12. package/bridge-runtime/dist/chat-history-mode.js +7 -0
  13. package/bridge-runtime/dist/command-executor.d.ts +2 -0
  14. package/bridge-runtime/dist/command-executor.js +44 -0
  15. package/bridge-runtime/dist/composer-title-index.d.ts +1 -0
  16. package/bridge-runtime/dist/composer-title-index.js +7 -7
  17. package/bridge-runtime/dist/debug-chats-page.d.ts +2 -0
  18. package/bridge-runtime/dist/debug-chats-page.js +491 -0
  19. package/bridge-runtime/dist/dom-transcript-store.d.ts +17 -0
  20. package/bridge-runtime/dist/dom-transcript-store.js +76 -0
  21. package/bridge-runtime/dist/extract-page.js +56 -85
  22. package/bridge-runtime/dist/history-limit.d.ts +2 -0
  23. package/bridge-runtime/dist/history-limit.js +2 -0
  24. package/bridge-runtime/dist/history-request.d.ts +8 -0
  25. package/bridge-runtime/dist/history-request.js +7 -0
  26. package/bridge-runtime/dist/index.js +1 -0
  27. package/bridge-runtime/dist/jsonl-index.d.ts +21 -3
  28. package/bridge-runtime/dist/jsonl-index.js +237 -73
  29. package/bridge-runtime/dist/jsonl-live-debug.d.ts +24 -0
  30. package/bridge-runtime/dist/jsonl-live-debug.js +175 -0
  31. package/bridge-runtime/dist/media-path.d.ts +2 -0
  32. package/bridge-runtime/dist/media-path.js +17 -0
  33. package/bridge-runtime/dist/message-filter.d.ts +2 -0
  34. package/bridge-runtime/dist/message-filter.js +21 -5
  35. package/bridge-runtime/dist/pairing-code.d.ts +2 -0
  36. package/bridge-runtime/dist/pairing-code.js +9 -2
  37. package/bridge-runtime/dist/relay-upstream.d.ts +2 -1
  38. package/bridge-runtime/dist/relay-upstream.js +4 -1
  39. package/bridge-runtime/dist/relay.d.ts +21 -0
  40. package/bridge-runtime/dist/relay.js +332 -28
  41. package/bridge-runtime/dist/types.d.ts +21 -0
  42. package/bridge-runtime/selectors.json +4 -5
  43. package/dist/index.js +79 -20
  44. package/dist/launch.js +23 -5
  45. package/dist/macos-autostart.js +87 -0
  46. package/dist/pairing-code.js +12 -3
  47. package/dist/print-pairing.js +2 -0
  48. package/dist/run-service.js +31 -0
  49. package/dist/startup-check.js +165 -0
  50. package/package.json +1 -1
  51. package/version-policy.json +1 -1
@@ -1,6 +1,10 @@
1
+ import { canonicalizeImagePaths } from './media-path.js';
1
2
  import { cleanUserText, isAssistantReflectionText, isPassiveStatusChatLine, parseUserImagePaths, stripJsonlRedactionArtifacts, } from './message-filter.js';
2
3
  function normalizeUserChatMessage(m) {
3
- const images = [...new Set([...(m.images ?? []), ...parseUserImagePaths(m.text ?? '')])];
4
+ const images = canonicalizeImagePaths([
5
+ ...(m.images ?? []),
6
+ ...parseUserImagePaths(m.text ?? ''),
7
+ ]);
4
8
  const text = cleanUserText(m.text ?? '');
5
9
  return {
6
10
  ...m,
@@ -18,7 +22,20 @@ export function compareUserText(text) {
18
22
  return cleanUserText(text).replace(/\s+/g, ' ').trim();
19
23
  }
20
24
  function userImagePaths(m) {
21
- return [...new Set([...(m.images ?? []), ...parseUserImagePaths(m.text ?? '')])];
25
+ return canonicalizeImagePaths([
26
+ ...(m.images ?? []),
27
+ ...parseUserImagePaths(m.text ?? ''),
28
+ ]);
29
+ }
30
+ function userImageSetsEquivalent(a, b) {
31
+ const pa = userImagePaths(a);
32
+ const pb = userImagePaths(b);
33
+ if (!pa.length || !pb.length)
34
+ return false;
35
+ if (pa.length !== pb.length)
36
+ return false;
37
+ const setB = new Set(pb);
38
+ return pa.every((p) => setB.has(p));
22
39
  }
23
40
  export function userTextsEquivalent(ta, tb) {
24
41
  const a = compareUserText(ta);
@@ -46,13 +63,10 @@ export function userMessagesEquivalent(a, b) {
46
63
  const tb = compareUserText(b.text ?? '');
47
64
  if (ta && tb && userTextsEquivalent(ta, tb))
48
65
  return true;
66
+ if (userImageSetsEquivalent(a, b))
67
+ return true;
49
68
  const pa = userImagePaths(a);
50
69
  const pb = userImagePaths(b);
51
- if (pa.length > 0 && pb.length > 0) {
52
- const setB = new Set(pb);
53
- if (pa.length === pb.length && pa.every((p) => setB.has(p)))
54
- return true;
55
- }
56
70
  if (ta && tb && pa.length > 0 && pb.length > 0) {
57
71
  const setB = new Set(pb);
58
72
  if (pa.every((p) => setB.has(p)))
@@ -82,7 +96,12 @@ function pickPreferredUserMessage(a, b, preferB) {
82
96
  const other = pick === na ? nb : na;
83
97
  if ((pick.text?.length ?? 0) < (other.text?.length ?? 0))
84
98
  pick = other;
85
- const images = [...new Set([...(pick.images ?? []), ...(other.images ?? [])])];
99
+ const images = canonicalizeImagePaths([
100
+ ...(pick.images ?? []),
101
+ ...(other.images ?? []),
102
+ ...parseUserImagePaths(pick.text ?? ''),
103
+ ...parseUserImagePaths(other.text ?? ''),
104
+ ]);
86
105
  const flatPick = Math.min(a.flatIndex ?? Number.MAX_SAFE_INTEGER, b.flatIndex ?? Number.MAX_SAFE_INTEGER);
87
106
  const id = !pick.id.startsWith('hist-')
88
107
  ? pick.id
@@ -96,6 +115,33 @@ function pickPreferredUserMessage(a, b, preferB) {
96
115
  flatIndex: Number.isFinite(flatPick) ? flatPick : pick.flatIndex ?? other.flatIndex,
97
116
  };
98
117
  }
118
+ /** DOM textContent vs JSONL markdown — same turn, different surface form. */
119
+ function normalizeAssistantCompareText(text) {
120
+ return text
121
+ .replace(/^#+\s*/gm, '')
122
+ .replace(/\|/g, ' ')
123
+ .replace(/[-]{3,}/g, ' ')
124
+ .replace(/\s+/g, ' ')
125
+ .trim()
126
+ .toLowerCase();
127
+ }
128
+ function assistantTextsEquivalent(a, b) {
129
+ if (a.role !== 'assistant' || b.role !== 'assistant')
130
+ return false;
131
+ const ta = normalizeAssistantCompareText(a.text ?? '');
132
+ const tb = normalizeAssistantCompareText(b.text ?? '');
133
+ if (!ta || !tb)
134
+ return false;
135
+ if (ta === tb)
136
+ return true;
137
+ const short = ta.length <= tb.length ? ta : tb;
138
+ const long = ta.length <= tb.length ? tb : ta;
139
+ if (short.length < 20)
140
+ return false;
141
+ if (!long.includes(short))
142
+ return false;
143
+ return short.length / long.length >= 0.35;
144
+ }
99
145
  function messageKey(m) {
100
146
  const n = normalizeMessage(m);
101
147
  if (n.role === 'user') {
@@ -104,17 +150,30 @@ function messageKey(m) {
104
150
  const html = n.html?.trim() ? '1' : '0';
105
151
  return `${n.role}:${n.text.trim().replace(/\s+/g, ' ')}:${(n.images ?? []).join('|')}:${html}`;
106
152
  }
107
- function messagesEquivalent(a, b) {
153
+ export function messagesEquivalent(a, b) {
108
154
  if (a.role === 'user' && b.role === 'user')
109
155
  return userMessagesEquivalent(a, b);
156
+ if (a.role === 'assistant' && b.role === 'assistant') {
157
+ return assistantTextsEquivalent(a, b) || messageKey(a) === messageKey(b);
158
+ }
110
159
  return messageKey(a) === messageKey(b);
111
160
  }
112
- function pickPreferredMessage(prev, next, preferNext) {
161
+ function mergeTimelineFields(prev, next, pick) {
162
+ const indices = [prev.flatIndex, next.flatIndex, pick.flatIndex].filter((n) => n != null && Number.isFinite(n));
163
+ const domSeqs = [prev.domSeq, next.domSeq, pick.domSeq].filter((n) => n != null && Number.isFinite(n));
164
+ return {
165
+ ...pick,
166
+ flatIndex: indices.length ? Math.min(...indices) : pick.flatIndex,
167
+ domSeq: domSeqs.length ? Math.min(...domSeqs) : pick.domSeq,
168
+ };
169
+ }
170
+ export function pickPreferredMessage(prev, next, preferNext) {
113
171
  if (prev.role === 'user' && next.role === 'user') {
114
172
  return pickPreferredUserMessage(prev, next, preferNext);
115
173
  }
116
174
  if (prev.role !== 'assistant' || next.role !== 'assistant') {
117
- return preferNext ? next : prev;
175
+ const pick = preferNext ? next : prev;
176
+ return mergeTimelineFields(prev, next, pick);
118
177
  }
119
178
  const score = (m) => {
120
179
  let s = 0;
@@ -127,15 +186,20 @@ function pickPreferredMessage(prev, next, preferNext) {
127
186
  };
128
187
  const sp = score(prev);
129
188
  const sn = score(next);
189
+ let pick;
130
190
  if (sn > sp)
131
- return next;
132
- if (sp > sn)
133
- return prev;
134
- return preferNext ? next : prev;
191
+ pick = next;
192
+ else if (sp > sn)
193
+ pick = prev;
194
+ else
195
+ pick = preferNext ? next : prev;
196
+ return mergeTimelineFields(prev, next, pick);
135
197
  }
136
198
  function messageOrderKey(m) {
137
199
  if (m.flatIndex != null && Number.isFinite(m.flatIndex))
138
200
  return m.flatIndex;
201
+ if (m.domSeq != null && Number.isFinite(m.domSeq))
202
+ return m.domSeq;
139
203
  const hist = /^hist-(\d+)/.exec(m.id);
140
204
  if (hist)
141
205
  return Number(hist[1]);
@@ -147,6 +211,10 @@ export function sortMessagesChronologically(messages) {
147
211
  const kb = messageOrderKey(b);
148
212
  if (ka !== kb)
149
213
  return ka - kb;
214
+ const da = a.domSeq ?? 0;
215
+ const db = b.domSeq ?? 0;
216
+ if (da !== db)
217
+ return da - db;
150
218
  return a.id.localeCompare(b.id);
151
219
  });
152
220
  }
@@ -168,6 +236,65 @@ function dedupeUserMessages(messages) {
168
236
  }
169
237
  return out;
170
238
  }
239
+ function assistantTailMatchesLongBody(tail, body) {
240
+ const a = tail.trim();
241
+ const b = body.trim();
242
+ if (!a || !b || b.length <= a.length)
243
+ return false;
244
+ if (b.includes(a))
245
+ return true;
246
+ const words = (s) => s.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
247
+ const wa = words(a);
248
+ const wb = words(b);
249
+ let shared = 0;
250
+ while (shared < wa.length && shared < wb.length && wa[shared] === wb[shared]) {
251
+ shared++;
252
+ }
253
+ return shared >= 3 && b.length > a.length * 1.15;
254
+ }
255
+ /** Cursor may render one assistant turn as A₁, user, A₂ (tail + body); restore A₂ before user. */
256
+ function fixAssistantFragmentSandwich(messages) {
257
+ const sorted = sortMessagesChronologically(normalizeAll(messages));
258
+ const out = [];
259
+ let i = 0;
260
+ while (i < sorted.length) {
261
+ if (i + 2 < sorted.length &&
262
+ sorted[i].role === 'assistant' &&
263
+ sorted[i + 1].role === 'user' &&
264
+ sorted[i + 2].role === 'assistant') {
265
+ const a1 = sorted[i];
266
+ const u = sorted[i + 1];
267
+ const a2 = sorted[i + 2];
268
+ const t1 = (a1.text ?? '').trim();
269
+ const t2 = (a2.text ?? '').trim();
270
+ if (assistantTailMatchesLongBody(t1, t2)) {
271
+ out.push(a2, u);
272
+ i += 3;
273
+ continue;
274
+ }
275
+ }
276
+ out.push(sorted[i]);
277
+ i++;
278
+ }
279
+ return out;
280
+ }
281
+ function assistantOneSupersedesOther(prev, next) {
282
+ if (assistantTextsEquivalent(prev, next))
283
+ return true;
284
+ const ta = (prev.text ?? '').trim();
285
+ const tb = (next.text ?? '').trim();
286
+ if (!ta || !tb)
287
+ return false;
288
+ if (assistantTailMatchesLongBody(ta, tb))
289
+ return true;
290
+ if (assistantTailMatchesLongBody(tb, ta))
291
+ return true;
292
+ const short = ta.length <= tb.length ? ta : tb;
293
+ const long = ta.length <= tb.length ? tb : ta;
294
+ if (short.length >= 40 && long.includes(short))
295
+ return true;
296
+ return false;
297
+ }
171
298
  function collapseAssistantBursts(messages) {
172
299
  const sorted = sortMessagesChronologically(normalizeAll(messages));
173
300
  const out = [];
@@ -177,12 +304,17 @@ function collapseAssistantBursts(messages) {
177
304
  return;
178
305
  const visible = run.filter((m) => !isAssistantReflectionText(m.text ?? ''));
179
306
  const pool = visible.length ? visible : run;
180
- if (pool.length === 1) {
181
- out.push(pool[0]);
182
- }
183
- else {
184
- out.push(pool.reduce((a, b) => ((a.flatIndex ?? 0) >= (b.flatIndex ?? 0) ? a : b)));
307
+ const merged = [];
308
+ for (const m of pool) {
309
+ const last = merged[merged.length - 1];
310
+ if (last && assistantOneSupersedesOther(last, m)) {
311
+ merged[merged.length - 1] = pickPreferredMessage(last, m, true);
312
+ }
313
+ else {
314
+ merged.push(m);
315
+ }
185
316
  }
317
+ out.push(...merged);
186
318
  run = [];
187
319
  };
188
320
  for (const m of sorted) {
@@ -197,7 +329,7 @@ function collapseAssistantBursts(messages) {
197
329
  return out;
198
330
  }
199
331
  export function prepareChatMessagesForDisplay(messages) {
200
- return collapseAssistantBursts(dedupeUserMessages(messages));
332
+ return collapseAssistantBursts(dedupeUserMessages(fixAssistantFragmentSandwich(messages)));
201
333
  }
202
334
  function tailOverlap(history, live) {
203
335
  const max = Math.min(history.length, live.length, 40);
@@ -210,6 +342,21 @@ function tailOverlap(history, live) {
210
342
  }
211
343
  return overlap;
212
344
  }
345
+ function upsertLiveMessage(merged, m, live, overlap, preferNext) {
346
+ const tailWindow = Math.max(live.length - overlap, 0) + 6;
347
+ const searchFrom = Math.max(0, merged.length - tailWindow);
348
+ let dupIdx = -1;
349
+ for (let j = merged.length - 1; j >= searchFrom; j--) {
350
+ if (messagesEquivalent(merged[j], m)) {
351
+ dupIdx = j;
352
+ break;
353
+ }
354
+ }
355
+ if (dupIdx >= 0)
356
+ merged[dupIdx] = pickPreferredMessage(merged[dupIdx], m, preferNext);
357
+ else
358
+ merged.push(m);
359
+ }
213
360
  export function mergeHistoryTail(prev, incoming) {
214
361
  if (!incoming.length)
215
362
  return prepareChatMessagesForDisplay(prev);
@@ -228,11 +375,7 @@ export function mergeHistoryTail(prev, incoming) {
228
375
  const prefix = prevN.slice(0, Math.max(0, prevN.length - overlap));
229
376
  const merged = [...prefix];
230
377
  for (const m of inc.slice(overlap)) {
231
- const dupIdx = merged.findIndex((x) => messagesEquivalent(x, m));
232
- if (dupIdx >= 0)
233
- merged[dupIdx] = pickPreferredMessage(merged[dupIdx], m, true);
234
- else
235
- merged.push(m);
378
+ upsertLiveMessage(merged, m, inc, overlap, true);
236
379
  }
237
380
  return prepareChatMessagesForDisplay(merged);
238
381
  }
@@ -246,12 +389,8 @@ export function mergeDomWithHistory(history, live) {
246
389
  const overlap = tailOverlap(hist, collapsedLive);
247
390
  const prefix = hist.slice(0, Math.max(0, hist.length - overlap));
248
391
  const merged = [...prefix];
249
- for (const m of collapsedLive) {
250
- const dupIdx = merged.findIndex((x) => messagesEquivalent(x, m));
251
- if (dupIdx >= 0)
252
- merged[dupIdx] = pickPreferredMessage(merged[dupIdx], m, true);
253
- else
254
- merged.push(m);
392
+ for (let i = overlap; i < collapsedLive.length; i++) {
393
+ upsertLiveMessage(merged, collapsedLive[i], collapsedLive, overlap, true);
255
394
  }
256
395
  return prepareChatMessagesForDisplay(merged);
257
396
  }
@@ -268,8 +407,33 @@ export function historyRowsToChat(rows) {
268
407
  return m.role === 'user' ? normalizeUserChatMessage(msg) : msg;
269
408
  });
270
409
  }
410
+ /** DOM sometimes attaches thumbnails to assistant rows; JSONL only has user images. */
411
+ function reassignAssistantImagesToUsers(messages) {
412
+ const orphan = [];
413
+ const stripped = messages.map((m) => {
414
+ if (m.role !== 'assistant' || !(m.images?.length ?? 0))
415
+ return m;
416
+ orphan.push(...m.images);
417
+ return { ...m, images: undefined };
418
+ });
419
+ if (!orphan.length)
420
+ return stripped;
421
+ const extra = canonicalizeImagePaths(orphan);
422
+ for (let i = stripped.length - 1; i >= 0; i--) {
423
+ const m = stripped[i];
424
+ if (m.role !== 'user')
425
+ continue;
426
+ const merged = canonicalizeImagePaths([...userImagePaths(m), ...extra]);
427
+ stripped[i] = normalizeUserChatMessage({
428
+ ...m,
429
+ images: merged.length ? merged : undefined,
430
+ });
431
+ break;
432
+ }
433
+ return stripped;
434
+ }
271
435
  export function filterClientDisplayList(messages) {
272
- return messages.filter((m) => {
436
+ return reassignAssistantImagesToUsers(messages).filter((m) => {
273
437
  const text = stripJsonlRedactionArtifacts(m.text ?? '');
274
438
  if (!text && !(m.images?.length ?? 0))
275
439
  return false;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Default chat lenta: JSONL via `agent:messages` (see `ChatDisplayStore`).
3
+ * `CHAT_HISTORY_JSONL=1` — additionally socket `agent:history` / app `agentHistory` (legacy).
4
+ */
5
+ export declare function isChatHistoryFromJsonl(): boolean;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Default chat lenta: JSONL via `agent:messages` (see `ChatDisplayStore`).
3
+ * `CHAT_HISTORY_JSONL=1` — additionally socket `agent:history` / app `agentHistory` (legacy).
4
+ */
5
+ export function isChatHistoryFromJsonl() {
6
+ return process.env.CHAT_HISTORY_JSONL === '1';
7
+ }
@@ -9,6 +9,8 @@ export declare class CommandExecutor {
9
9
  private parseImagePaths;
10
10
  private sendMessage;
11
11
  private focusAgent;
12
+ /** Прокрутка к низу ленты — там смонтированы последние user/assistant узлы. */
13
+ scrollChatToBottom(): Promise<void>;
12
14
  private switchTab;
13
15
  private clickSelector;
14
16
  private clickByLabel;
@@ -272,6 +272,50 @@ export class CommandExecutor {
272
272
  : 'Agent tab not open in Cursor — open it once or start a new chat',
273
273
  };
274
274
  }
275
+ /** Прокрутка к низу ленты — там смонтированы последние user/assistant узлы. */
276
+ async scrollChatToBottom() {
277
+ const client = this.client;
278
+ if (!client)
279
+ return;
280
+ const containerSelectors = this.selectors.chatContainer.strategies;
281
+ try {
282
+ await client.evaluate(`
283
+ (() => {
284
+ const sels = ${JSON.stringify(containerSelectors)};
285
+ function findFirst(sels) {
286
+ for (const sel of sels) {
287
+ try {
288
+ const el = document.querySelector(sel);
289
+ if (el) return el;
290
+ } catch { /* skip */ }
291
+ }
292
+ return null;
293
+ }
294
+ const anchor = findFirst(sels);
295
+ if (!anchor) return false;
296
+ let scrollEl = anchor;
297
+ for (let i = 0; i < 14 && scrollEl; i++) {
298
+ const st = getComputedStyle(scrollEl);
299
+ const oy = st.overflowY;
300
+ if (
301
+ (oy === 'auto' || oy === 'scroll' || oy === 'overlay') &&
302
+ scrollEl.scrollHeight > scrollEl.clientHeight + 8
303
+ ) {
304
+ break;
305
+ }
306
+ scrollEl = scrollEl.parentElement;
307
+ }
308
+ if (!scrollEl || scrollEl === document.body) return false;
309
+ scrollEl.scrollTop = scrollEl.scrollHeight;
310
+ scrollEl.dispatchEvent(new Event('scroll', { bubbles: true }));
311
+ return true;
312
+ })()
313
+ `);
314
+ }
315
+ catch {
316
+ /* non-fatal */
317
+ }
318
+ }
275
319
  async switchTab(cmd) {
276
320
  const client = this.client;
277
321
  const title = cmd.tabTitle?.trim();
@@ -1,6 +1,7 @@
1
1
  export declare function isComposerUuid(id: string | undefined): boolean;
2
2
  export declare function isSyntheticTabId(id: string | undefined): boolean;
3
3
  export declare function normalizeComposerTitle(title: string): string;
4
+ export declare function titlesAlign(a: string, b: string): boolean;
4
5
  /** Learns sidebar title → composer UUID while user switches chats in Cursor. */
5
6
  export declare class ComposerTitleIndex {
6
7
  private byTitle;
@@ -14,7 +14,7 @@ export function normalizeComposerTitle(title) {
14
14
  .trim()
15
15
  .toLowerCase();
16
16
  }
17
- function titlesAlign(a, b) {
17
+ export function titlesAlign(a, b) {
18
18
  const na = normalizeComposerTitle(a);
19
19
  const nb = normalizeComposerTitle(b);
20
20
  if (!na || !nb)
@@ -33,12 +33,12 @@ export class ComposerTitleIndex {
33
33
  return;
34
34
  const tabT = activeTabTitle?.trim();
35
35
  const headT = headerTitle?.trim();
36
- if (!tabT || !headT)
37
- return;
38
- if (!titlesAlign(tabT, headT))
39
- return;
40
- this.byTitle.set(normalizeComposerTitle(tabT), composerId);
41
- this.byTitle.set(normalizeComposerTitle(headT), composerId);
36
+ if (tabT) {
37
+ this.byTitle.set(normalizeComposerTitle(tabT), composerId);
38
+ }
39
+ if (tabT && headT && titlesAlign(tabT, headT)) {
40
+ this.byTitle.set(normalizeComposerTitle(headT), composerId);
41
+ }
42
42
  }
43
43
  lookup(title) {
44
44
  if (!title?.trim())
@@ -0,0 +1,2 @@
1
+ /** Read-only browser UI: shows what bridge already holds (no writes). */
2
+ export declare const DEBUG_CHATS_PAGE_HTML = "<!DOCTYPE html>\n<html lang=\"ru\">\n<head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>CursorConnect \u2014 bridge data</title>\n <style>\n :root { color-scheme: dark; font-family: system-ui, sans-serif; font-size: 14px; }\n * { box-sizing: border-box; }\n body { margin: 0; background: #111; color: #e8e8e8; }\n header { padding: 10px 14px; border-bottom: 1px solid #333; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }\n header h1 { font-size: 15px; margin: 0 12px 0 0; font-weight: 600; }\n label { font-size: 12px; color: #aaa; }\n input, select, button { background: #1c1c1c; border: 1px solid #444; color: #eee; border-radius: 6px; padding: 6px 8px; }\n button { cursor: pointer; }\n button:hover { border-color: #666; }\n .layout { display: grid; grid-template-columns: 260px 1fr; min-height: calc(100vh - 52px); }\n .agents { border-right: 1px solid #333; overflow: auto; max-height: calc(100vh - 52px); }\n .agents button { display: block; width: 100%; text-align: left; border: none; border-bottom: 1px solid #2a2a2a; border-radius: 0; padding: 10px 12px; background: transparent; }\n .agents button:hover { background: #1a1a1a; }\n .agents button.active { background: #243044; }\n .agents .meta { font-size: 11px; color: #888; margin-top: 2px; }\n main { display: flex; flex-direction: column; min-width: 0; }\n .tabs { display: flex; gap: 4px; padding: 8px 10px; border-bottom: 1px solid #333; flex-wrap: wrap; }\n .tabs button { font-size: 12px; }\n .tabs button.active { background: #2a3f5f; border-color: #5a7ab0; }\n .status { padding: 6px 12px; font-size: 12px; color: #9ab; border-bottom: 1px solid #2a2a2a; }\n .status.err { color: #f88; }\n .chat { flex: 1; overflow: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; }\n .bubble { max-width: 92%; padding: 8px 12px; border-radius: 10px; white-space: pre-wrap; word-break: break-word; }\n .bubble.user { align-self: flex-end; background: #2d4a3e; }\n .bubble.assistant { align-self: flex-start; background: #2a2a35; }\n .bubble.system { align-self: center; background: #333; font-size: 12px; color: #bbb; }\n .bubble .tag { font-size: 10px; color: #888; margin-bottom: 4px; font-family: ui-monospace, monospace; }\n .raw { flex: 1; overflow: auto; margin: 0; padding: 12px; font-size: 11px; line-height: 1.4; background: #0a0a0a; color: #bdbdbd; }\n .empty { color: #666; padding: 24px; text-align: center; }\n .note { font-size: 12px; color: #9ab; padding: 8px 12px; border-bottom: 1px solid #2a2a2a; line-height: 1.45; }\n table.cmp { width: 100%; border-collapse: collapse; font-size: 12px; }\n table.cmp th, table.cmp td { border: 1px solid #333; padding: 6px 8px; vertical-align: top; }\n table.cmp th { background: #1a1a1a; text-align: left; }\n table.cmp tr.match td { background: #152515; }\n table.cmp tr.miss td { background: #2a1818; }\n table.cmp tr.domonly td { background: #1a1a2a; }\n .jsonl-live-head { font-size: 12px; color: #9ab; margin-bottom: 10px; line-height: 1.45; }\n .jsonl-row { border-left: 3px solid #444; padding: 8px 10px; margin-bottom: 6px; background: #161616; border-radius: 4px; }\n .jsonl-row.new { animation: jsonl-flash 1.2s ease; border-left-color: #5a9fd4; }\n .jsonl-row.in-lenta { border-left-color: #3d7a52; }\n .jsonl-row.skip { border-left-color: #8a4040; }\n .jsonl-row .meta { font-size: 10px; color: #888; font-family: ui-monospace, monospace; margin-bottom: 4px; }\n .jsonl-row .body { white-space: pre-wrap; word-break: break-word; font-size: 13px; }\n @keyframes jsonl-flash { from { background: #243044; } to { background: #161616; } }\n @media (max-width: 720px) { .layout { grid-template-columns: 1fr; } .agents { max-height: 180px; } }\n </style>\n</head>\n<body>\n <header>\n <h1>Bridge data (read-only)</h1>\n <label>Token <input id=\"token\" type=\"password\" size=\"28\" placeholder=\"\u0435\u0441\u043B\u0438 WEBAPP_PASSWORD\" /></label>\n <label>Refresh <input id=\"interval\" type=\"number\" min=\"0\" max=\"60\" value=\"2\" style=\"width:48px\" /> s</label>\n <button type=\"button\" id=\"btnRefresh\">\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C</button>\n <span id=\"health\" style=\"font-size:12px;color:#8a8\"></span>\n </header>\n <div class=\"layout\">\n <nav class=\"agents\" id=\"agents\"><div class=\"empty\">\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u0441\u043F\u0438\u0441\u043A\u0430\u2026</div></nav>\n <main>\n <div class=\"tabs\" id=\"tabs\">\n <button type=\"button\" data-tab=\"history\" class=\"active\">JSONL \u00B7 /api/agents/history</button>\n <button type=\"button\" data-tab=\"jsonl-live\">JSONL live \u00B7 \u0444\u0430\u0439\u043B</button>\n <button type=\"button\" data-tab=\"cache\">JSONL cache</button>\n <button type=\"button\" data-tab=\"transcript\">DOM transcript</button>\n <button type=\"button\" data-tab=\"dom\">DOM \u00B7 state.messages</button>\n <button type=\"button\" data-tab=\"compare\">\u0421\u0440\u0430\u0432\u043D\u0435\u043D\u0438\u0435 JSONL \u2194 DOM</button>\n <button type=\"button\" data-tab=\"debug\">DOM extract debug</button>\n <button type=\"button\" data-tab=\"raw\">JSON snapshot</button>\n </div>\n <div class=\"status\" id=\"status\">\u2014</div>\n <div class=\"note\" id=\"note\" hidden></div>\n <div class=\"chat\" id=\"panel\"></div>\n <pre class=\"raw\" id=\"raw\" hidden></pre>\n </main>\n </div>\n <script src=\"https://cdn.socket.io/4.7.5/socket.io.min.js\"></script>\n <script>\n(function () {\n const params = new URLSearchParams(location.search);\n const roomId = params.get('roomId') || '';\n const tokenEl = document.getElementById('token');\n tokenEl.value = params.get('token') || localStorage.getItem('cc_debug_token') || '';\n tokenEl.addEventListener('change', () => localStorage.setItem('cc_debug_token', tokenEl.value.trim()));\n\n let tab = 'history';\n let selectedId = params.get('agentId') || '';\n let selectedTitle = '';\n let snapshot = null;\n let timer = null;\n let jsonlLiveTimer = null;\n let jsonlLiveLastLine = 0;\n let jsonlLiveRows = [];\n let jsonlLiveStickBottom = true;\n let jsonlLiveFile = '';\n let bridgeSocket = null;\n let bridgeSubscribedId = '';\n\n document.getElementById('tabs').addEventListener('click', (e) => {\n const b = e.target.closest('button[data-tab]');\n if (!b) return;\n tab = b.dataset.tab;\n document.querySelectorAll('#tabs button').forEach((x) => x.classList.toggle('active', x === b));\n scheduleJsonlLive();\n if (tab === 'jsonl-live') void resetJsonlLive();\n renderPanel();\n });\n document.getElementById('btnRefresh').addEventListener('click', () => void loadAll());\n document.getElementById('interval').addEventListener('change', schedule);\n\n function token() { return tokenEl.value.trim(); }\n function apiUrl(path) {\n const u = new URL(path, location.origin);\n if (roomId) u.searchParams.set('roomId', roomId);\n const t = token();\n if (t) u.searchParams.set('token', t);\n return u.toString();\n }\n async function apiFetch(path) {\n const headers = {};\n const t = token();\n if (t) headers.Authorization = 'Bearer ' + t;\n return fetch(apiUrl(path), { headers });\n }\n function setStatus(msg, err) {\n const el = document.getElementById('status');\n el.textContent = msg;\n el.className = 'status' + (err ? ' err' : '');\n }\n\n function roleLabel(role) {\n if (role === 'user') return '\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044C';\n if (role === 'assistant') return '\u0410\u0441\u0441\u0438\u0441\u0442\u0435\u043D\u0442';\n return role || 'system';\n }\n function normText(text, role) {\n let t = String(text || '').replace(/s+/g, ' ').trim().toLowerCase();\n if (role === 'assistant') {\n t = t.replace(/^#+\\s*/g, '').replace(/\\|/g, ' ').replace(/[-]{3,}/g, ' ');\n }\n return t.replace(/s+/g, ' ').trim();\n }\n function textsMatch(a, b, role) {\n const ta = normText(a, role);\n const tb = normText(b, role);\n if (!ta || !tb) return false;\n if (ta === tb) return true;\n const short = ta.length <= tb.length ? ta : tb;\n const long = ta.length <= tb.length ? tb : ta;\n return short.length >= 12 && long.includes(short) && short.length / long.length >= 0.35;\n }\n function renderBubbles(messages, source) {\n if (!messages || !messages.length) return '<div class=\"empty\">\u041D\u0435\u0442 \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0439</div>';\n return messages.map((m) => {\n const role = m.role || 'system';\n const tag = roleLabel(role) + ' \u00B7 ' + source + ' \u00B7 ' + (m.id || '?') + (m.flatIndex != null ? ' \u00B7 seq=' + m.flatIndex : '') + (m.html ? ' \u00B7 html' : '');\n const text = (m.text || '').trim() || (m.html ? '[html ' + m.html.length + ' chars \u2014 \u0432 Cursor \u0441 \u0440\u0430\u0437\u043C\u0435\u0442\u043A\u043E\u0439]' : '(\u043F\u0443\u0441\u0442\u043E)');\n return '<div class=\"bubble ' + role + '\"><div class=\"tag\">' + escapeHtml(tag) + '</div>' + escapeHtml(text) + '</div>';\n }).join('');\n }\n function renderCompare(hist, dom) {\n const rows = [];\n const usedDom = new Set();\n for (let i = 0; i < (hist || []).length; i++) {\n const h = hist[i];\n let domIdx = -1;\n for (let j = 0; j < (dom || []).length; j++) {\n if (usedDom.has(j)) continue;\n if (h.role === dom[j].role && textsMatch(h.text, dom[j].text, h.role)) {\n domIdx = j;\n usedDom.add(j);\n break;\n }\n }\n const cls = domIdx >= 0 ? 'match' : 'miss';\n rows.push({ cls, n: i + 1, h, d: domIdx >= 0 ? dom[domIdx] : null });\n }\n for (let j = 0; j < (dom || []).length; j++) {\n if (usedDom.has(j)) continue;\n rows.push({ cls: 'domonly', n: '\u2014', h: null, d: dom[j] });\n }\n if (!rows.length) return '<div class=\"empty\">\u041D\u0435\u0442 \u0434\u0430\u043D\u043D\u044B\u0445</div>';\n const tr = rows.map((r) => {\n const hp = r.h ? escapeHtml((r.h.text || '').slice(0, 120)) : '\u2014';\n const dp = r.d ? escapeHtml((r.d.text || '').slice(0, 120)) : '\u2014';\n return '<tr class=\"' + r.cls + '\"><td>' + r.n + '</td><td>' + (r.h ? r.h.role : '') + '</td><td>' + hp + '</td><td>' + (r.d ? r.d.role : '') + '</td><td>' + dp + '</td></tr>';\n }).join('');\n return '<table class=\"cmp\"><thead><tr><th>#</th><th>JSONL</th><th>\u0442\u0435\u043A\u0441\u0442 JSONL</th><th>DOM</th><th>\u0442\u0435\u043A\u0441\u0442 DOM</th></tr></thead><tbody>' + tr + '</tbody></table>';\n }\n function escapeHtml(s) {\n return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');\n }\n\n function renderJsonlLive() {\n if (!jsonlLiveRows.length) {\n return '<div class=\"empty\">\u041D\u0435\u0442 \u0441\u0442\u0440\u043E\u043A \u2014 \u0432\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0447\u0430\u0442 \u0438\u043B\u0438 \u0434\u043E\u0436\u0434\u0438\u0442\u0435\u0441\u044C \u0437\u0430\u043F\u0438\u0441\u0438 \u0432 JSONL</div>';\n }\n const head =\n '<div class=\"jsonl-live-head\">\u0424\u0430\u0439\u043B: <code>' +\n escapeHtml(jsonlLiveFile || '\u2014') +\n '</code> \u00B7 \u0441\u0442\u0440\u043E\u043A \u0432 \u0444\u0430\u0439\u043B\u0435: <b>' +\n jsonlLiveLastLine +\n '</b> \u00B7 \u043F\u043E\u043A\u0430\u0437\u0430\u043D\u043E: ' +\n jsonlLiveRows.length +\n ' \u00B7 \u043E\u043F\u0440\u043E\u0441 <b>300 ms</b> \u00B7 bridge subscribe: <b>' +\n escapeHtml(bridgeSubscribedId === selectedId ? 'on' : 'off') +\n '</b></div>';\n const body = jsonlLiveRows\n .map((r) => {\n const cls =\n 'jsonl-row' +\n (r._new ? ' new' : '') +\n (r.inLenta ? ' in-lenta' : ' skip');\n const tools = r.tools?.length ? ' \u00B7 tools: ' + r.tools.join(', ') : '';\n const skip = r.inLenta ? '\u2192 \u0432 \u043B\u0435\u043D\u0442\u0443' : '\u2192 skip: ' + (r.skipReason || '?');\n return (\n '<div class=\"' +\n cls +\n '\" data-line=\"' +\n r.lineNo +\n '\"><div class=\"meta\">#' +\n r.lineNo +\n ' \u00B7 ' +\n escapeHtml(r.role) +\n tools +\n ' \u00B7 ' +\n escapeHtml(skip) +\n (r._at ? ' \u00B7 ' + r._at : '') +\n '</div><div class=\"body\">' +\n escapeHtml(r.textPreview || '(\u043F\u0443\u0441\u0442\u043E)') +\n '</div></div>'\n );\n })\n .join('');\n return head + body;\n }\n\n async function pollJsonlLive() {\n if (!selectedId || tab !== 'jsonl-live') return;\n try {\n const q = new URLSearchParams();\n q.set('agentId', selectedId);\n if (selectedTitle) q.set('title', selectedTitle);\n if (jsonlLiveLastLine > 0) q.set('afterLine', String(jsonlLiveLastLine));\n else q.set('tail', '80');\n const res = await apiFetch('/debug/jsonl-live?' + q.toString());\n if (!res.ok) throw new Error('jsonl-live ' + res.status);\n const data = await res.json();\n jsonlLiveFile = data.filePath || '';\n const at = new Date().toLocaleTimeString();\n const incoming = (data.rows || []).map((r) => ({ ...r, _new: true, _at: at }));\n if (jsonlLiveLastLine === 0) {\n jsonlLiveRows = incoming;\n } else if (incoming.length) {\n const seen = new Set(jsonlLiveRows.map((x) => x.lineNo));\n for (const r of incoming) {\n if (!seen.has(r.lineNo)) jsonlLiveRows.push(r);\n }\n }\n if (data.totalLines) jsonlLiveLastLine = data.totalLines;\n else if (incoming.length) {\n jsonlLiveLastLine = Math.max(jsonlLiveLastLine, incoming[incoming.length - 1].lineNo);\n }\n if (jsonlLiveRows.length > 600) jsonlLiveRows = jsonlLiveRows.slice(-500);\n setTimeout(() => {\n for (const r of jsonlLiveRows) r._new = false;\n }, 1200);\n if (tab === 'jsonl-live') {\n renderPanel();\n if (jsonlLiveStickBottom) {\n const panel = document.getElementById('panel');\n panel.scrollTop = panel.scrollHeight;\n }\n setStatus(\n 'JSONL live: ' +\n jsonlLiveRows.length +\n ' shown \u00B7 file lines ' +\n (data.totalLines ?? '?') +\n ' \u00B7 ' +\n at\n );\n }\n } catch (e) {\n setStatus((e && e.message) || String(e), true);\n }\n }\n\n async function resetJsonlLive() {\n jsonlLiveLastLine = 0;\n jsonlLiveRows = [];\n jsonlLiveFile = '';\n await pollJsonlLive();\n }\n\n function ensureBridgeSubscribe() {\n if (typeof io === 'undefined') return;\n if (!bridgeSocket) {\n const headers = {};\n const t = token();\n if (t) headers.Authorization = 'Bearer ' + t;\n bridgeSocket = io(location.origin, { transports: ['websocket', 'polling'], auth: headers });\n bridgeSocket.on('agent:messages', (p) => {\n const n = p.historyMessages?.length ?? 0;\n const tail = (p.historyMessages || [])[(n || 1) - 1];\n const preview = (tail?.text || '').slice(0, 48);\n setStatus(\n 'app-pipe agent:messages append=' +\n !!p.append +\n ' n=' +\n n +\n ' \u00B7 ' +\n preview\n );\n });\n }\n if (!selectedId || (bridgeSubscribedId === selectedId && tab === 'jsonl-live')) return;\n bridgeSubscribedId = selectedId;\n bridgeSocket.emit('agents:subscribe', {\n agentId: selectedId,\n title: selectedTitle || undefined,\n focus: false,\n });\n setStatus('bridge subscribe ' + selectedId.slice(0, 8) + '\u2026 (\u043A\u0430\u043A \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u0435)');\n }\n\n function scheduleJsonlLive() {\n if (jsonlLiveTimer) clearInterval(jsonlLiveTimer);\n jsonlLiveTimer = null;\n if (tab === 'jsonl-live') {\n ensureBridgeSubscribe();\n jsonlLiveTimer = setInterval(() => void pollJsonlLive(), 300);\n }\n }\n\n function renderPanel() {\n const panel = document.getElementById('panel');\n const raw = document.getElementById('raw');\n panel.hidden = tab === 'raw';\n raw.hidden = tab !== 'raw';\n if (tab === 'raw') {\n raw.textContent = JSON.stringify({ snapshot, selectedId, tab }, null, 2);\n return;\n }\n const note = document.getElementById('note');\n note.hidden = tab !== 'dom' && tab !== 'compare' && tab !== 'jsonl-live';\n if (tab === 'jsonl-live') {\n note.textContent =\n '\u0424\u0430\u0439\u043B .jsonl \u043D\u0430 \u0434\u0438\u0441\u043A\u0435 (\u043E\u043F\u0440\u043E\u0441 300 ms). \u00AB\u2192 \u0432 \u043B\u0435\u043D\u0442\u0443\u00BB = \u043F\u043E\u0441\u043B\u0435 filter \u0432 bridge. \u0412 \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u0441\u0442\u0440\u043E\u043A\u0430 \u043F\u043E\u043F\u0430\u0434\u0451\u0442 \u0442\u043E\u043B\u044C\u043A\u043E \u0435\u0441\u043B\u0438 Connect \u043E\u0442\u043A\u0440\u044B\u043B \u044D\u0442\u043E\u0442 \u0447\u0430\u0442 (\u043D\u0438\u0436\u0435: bridge subscribe). Debug \u0441\u0430\u043C \u043F\u043E \u0441\u0435\u0431\u0435 app \u043D\u0435 \u043A\u043E\u0440\u043C\u0438\u0442.';\n } else if (tab === 'dom') {\n note.textContent = 'DOM = \u0441\u043C\u043E\u043D\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043D\u044B\u0435 [data-flat-index] \u0432 Cursor (\u0431\u0435\u0437 JSONL). \u041F\u0440\u0438 \u043E\u0442\u043A\u0440\u044B\u0442\u0438\u0438 \u0447\u0430\u0442\u0430 bridge \u043F\u0440\u043E\u043A\u0440\u0443\u0447\u0438\u0432\u0430\u0435\u0442 \u043B\u0435\u043D\u0442\u0443 \u0432\u043D\u0438\u0437 \u0438 poll. \u041F\u0440\u043E\u043F\u0443\u0441\u043A\u0438 = tool/thought \u0441\u0442\u0440\u043E\u043A\u0438.';\n } else if (tab === 'compare') {\n note.textContent =\n '\u0417\u0435\u043B\u0451\u043D\u0430\u044F = JSONL-\u043B\u0435\u043D\u0442\u0430 (API) \u0438 DOM \u0441\u043E\u0432\u043F\u0430\u043B\u0438. \u041A\u0440\u0430\u0441\u043D\u0430\u044F = \u0442\u043E\u043B\u044C\u043A\u043E \u0432 JSONL. \u0424\u0438\u043E\u043B\u0435\u0442\u043E\u0432\u0430\u044F (domonly) = \u0432\u0438\u0434\u043D\u043E \u0432 DOM Cursor, \u0441\u043B\u0435\u0432\u0430 \u00AB\u2014\u00BB (\u043D\u0435\u0442 \u0432 \u043B\u0435\u043D\u0442\u0435 API): \u0441\u0445\u043B\u043E\u043F\u043D\u0443\u043B\u0438 \u043D\u0435\u0441\u043A\u043E\u043B\u044C\u043A\u043E \u043E\u0442\u0432\u0435\u0442\u043E\u0432 \u0430\u0441\u0441\u0438\u0441\u0442\u0435\u043D\u0442\u0430 \u0432 \u043E\u0434\u0438\u043D, \u0438\u043B\u0438 DOM \u0435\u0449\u0451 \u0434\u0435\u0440\u0436\u0438\u0442 \u043A\u043E\u0440\u043E\u0442\u043A\u0438\u0439 \u0441\u0442\u0430\u0442\u0443\u0441 (\u00AB\u0418\u0449\u0443\u2026\u00BB, \u00AB\u0414\u043E\u0431\u0430\u0432\u043B\u044F\u044E\u2026\u00BB), \u0430 \u0432 API \u0443\u0436\u0435 \u0442\u043E\u043B\u044C\u043A\u043E \u0444\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439 \u00AB\u0421\u0434\u0435\u043B\u0430\u043D\u043E\u2026\u00BB. \u0412\u043A\u043B\u0430\u0434\u043A\u0430 \u00ABJSONL cache\u00BB = \u0442\u043E, \u0447\u0442\u043E \u0443\u0445\u043E\u0434\u0438\u0442 \u0432 app.';\n }\n if (!selectedId && tab !== 'dom' && tab !== 'debug') {\n panel.innerHTML = '<div class=\"empty\">\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0430\u0433\u0435\u043D\u0442\u0430 \u0441\u043B\u0435\u0432\u0430</div>';\n return;\n }\n if (tab === 'jsonl-live') {\n panel.innerHTML = renderJsonlLive();\n panel.onscroll = () => {\n const nearBottom = panel.scrollHeight - panel.scrollTop - panel.clientHeight < 80;\n jsonlLiveStickBottom = nearBottom;\n };\n return;\n }\n panel.onscroll = null;\n if (tab === 'history') {\n panel.innerHTML = window.__historyHtml || '<div class=\"empty\">\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 history\u2026</div>';\n } else if (tab === 'cache') {\n const rows = snapshot?.displayCache?.[selectedId];\n panel.innerHTML = renderBubbles(rows, 'jsonl-cache');\n } else if (tab === 'transcript') {\n const rows = snapshot?.domTranscript?.[selectedId];\n panel.innerHTML = renderBubbles(rows, 'dom-transcript');\n } else if (tab === 'dom') {\n const st = snapshot?.cursor;\n const active = st?.activeComposerId;\n const hint = active ? 'activeComposerId=' + active : '\u043D\u0435\u0442 activeComposerId';\n panel.innerHTML = '<div class=\"empty\" style=\"text-align:left;margin-bottom:8px\">' + escapeHtml(hint) + ' \u00B7 ' + (st?.domMessageCount ?? 0) + ' msgs</div>' + renderBubbles(st?.domMessages, 'dom');\n } else if (tab === 'compare') {\n const hist = window.__historyData || [];\n const dom =\n (selectedId && snapshot?.domTranscript?.[selectedId]?.length\n ? snapshot.domTranscript[selectedId]\n : null) ||\n snapshot?.cursor?.domMessages ||\n [];\n const active = snapshot?.cursor?.activeComposerId;\n const same = selectedId === active;\n const head = '<div class=\"empty\" style=\"text-align:left\">agent=' + escapeHtml(selectedId.slice(0, 8)) + '\u2026 active=' + escapeHtml((active || '\u2014').slice(0, 8)) + (same ? ' (\u0441\u043E\u0432\u043F\u0430\u0434\u0430\u0435\u0442)' : ' (\u0434\u0440\u0443\u0433\u043E\u0439 \u0447\u0430\u0442 \u0432 Cursor!)') + '</div>';\n panel.innerHTML = head + renderCompare(hist, dom);\n } else if (tab === 'debug') {\n const d = snapshot?.domExtractDebug?.latest;\n if (!d) { panel.innerHTML = '<div class=\"empty\">\u041D\u0435\u0442 extract debug</div>'; return; }\n panel.innerHTML = '<div class=\"bubble system\"><div class=\"tag\">extract debug</div>' + escapeHtml(JSON.stringify(d, null, 2)) + '</div>';\n }\n }\n\n function renderAgents(index) {\n const nav = document.getElementById('agents');\n const repos = index?.repos || [];\n const items = [];\n for (const repo of repos) {\n for (const a of repo.agents || []) {\n items.push({ id: a.id, title: a.title, repo: repo.name });\n }\n }\n if (!items.length) {\n nav.innerHTML = '<div class=\"empty\">agents:index \u043F\u0443\u0441\u0442</div>';\n return;\n }\n nav.innerHTML = items.map((a) => {\n const cls = a.id === selectedId ? ' active' : '';\n return '<button type=\"button\" class=\"' + cls.trim() + '\" data-id=\"' + escapeHtml(a.id) + '\" data-title=\"' + escapeHtml(a.title || '') + '\">' + escapeHtml(a.title || a.id) + '<div class=\"meta\">' + escapeHtml(a.repo) + ' \u00B7 ' + escapeHtml(a.id.slice(0, 8)) + '\u2026</div></button>';\n }).join('');\n nav.querySelectorAll('button[data-id]').forEach((btn) => {\n btn.addEventListener('click', () => {\n selectedId = btn.dataset.id;\n selectedTitle = btn.dataset.title || '';\n nav.querySelectorAll('button').forEach((x) => x.classList.toggle('active', x.dataset.id === selectedId));\n void loadHistory();\n if (tab === 'jsonl-live') void resetJsonlLive();\n renderPanel();\n });\n });\n if (!selectedId && items[0]) {\n selectedId = items[0].id;\n selectedTitle = items[0].title || '';\n nav.querySelector('button[data-id=\"' + selectedId + '\"]')?.classList.add('active');\n }\n }\n\n async function loadSnapshot() {\n const res = await apiFetch('/debug/chat-snapshot');\n if (!res.ok) throw new Error('chat-snapshot ' + res.status);\n snapshot = await res.json();\n const h = document.getElementById('health');\n h.textContent = 'CDP ' + (snapshot.health?.cdp ? 'on' : 'off') + ' \u00B7 updated ' + new Date(snapshot.at).toLocaleTimeString();\n }\n\n async function loadHistory() {\n if (!selectedId) return;\n const res = await apiFetch('/api/agents/history?agentId=' + encodeURIComponent(selectedId) + '&limit=120');\n if (!res.ok) throw new Error('history ' + res.status);\n const data = await res.json();\n window.__historyData = data.messages || [];\n window.__historyHtml = renderBubbles(window.__historyData, 'jsonl');\n if (tab === 'history' || tab === 'compare') renderPanel();\n setStatus('history: ' + (data.messages?.length ?? 0) + ' / total ' + (data.totalMessages ?? '?'));\n }\n\n async function loadIndex() {\n const res = await apiFetch('/api/agents/index');\n if (!res.ok) throw new Error('index ' + res.status);\n return res.json();\n }\n\n async function loadAll() {\n try {\n setStatus('\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430\u2026');\n await loadSnapshot();\n const index = await loadIndex();\n renderAgents(index);\n await loadHistory();\n renderPanel();\n setStatus('OK \u00B7 ' + new Date().toLocaleTimeString());\n } catch (e) {\n setStatus((e && e.message) || String(e), true);\n }\n }\n\n function schedule() {\n if (timer) clearInterval(timer);\n const sec = Number(document.getElementById('interval').value) || 0;\n if (sec > 0) timer = setInterval(() => void loadAll(), sec * 1000);\n }\n\n schedule();\n scheduleJsonlLive();\n void loadAll();\n})();\n </script>\n</body>\n</html>";