agentgui 1.0.935 → 1.0.936

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/AGENTS.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  ## Architecture (2026-05-19 pivot — single surface)
4
4
 
5
- One surface. `server.js` serves `site/app/` at `/` and mounts `ccsniff`'s `/v1/history/*` Express router in-process. The legacy `static/` tree and the legacy `lib/routes-*`/`lib/db-queries-*`/`lib/jsonl-watcher.js` modules are gone. `acptoapi` is no longer used by this project.
5
+ One surface. `server.js` serves `site/app/` under `BASE_URL` (default `/gm`) and mounts `ccsniff`'s `/v1/history/*` Express router in-process at both `/` and `BASE_URL`. The legacy `static/` tree and the legacy `lib/routes-*`/`lib/db-queries-*`/`lib/jsonl-watcher.js` modules are gone. `acptoapi` is no longer used by this project.
6
+
7
+ When `PASSWORD` env var is set, every HTTP route is gated by `lib/http-handler.js` accepting **Basic auth**, **`Authorization: Bearer <pwd>`**, OR **`?token=<pwd>`** query param (added 2026-05-26 so `EventSource` and direct deep-links work — neither can set headers). WS `/sync` requires `?token=` only. The HTML head script injects `window.__BASE_URL`, `window.__SERVER_VERSION`, and `window.__WS_TOKEN`; `site/app/js/backend.js` reads `__WS_TOKEN` and threads it onto every fetch (Bearer header) / EventSource (qs) / WebSocket (qs).
6
8
 
7
9
  - `site/app/index.html` — shell + CSS, imports `anentrypoint-design` from unpkg
8
10
  - `site/app/js/backend.js` — same-origin client (`DEFAULT_BACKEND = ''`); `?backend=` query override for cross-origin debugging
@@ -96,7 +96,7 @@ export function serveFile(filePath, res, req, { compressAndSend, acceptsEncoding
96
96
  content = content.replace(/(href|src)="vendor\//g, `$1="${BASE_URL}/vendor/`);
97
97
  content = content.replace(/(src)="\/gm\/js\//g, `$1="${BASE_URL}/js/`);
98
98
  if (watch) {
99
- content += `\n<script>(function(){const ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'${BASE_URL}/hot-reload');ws.onmessage=e=>{if(JSON.parse(e.data).type==='reload')location.reload()};})();</script>`;
99
+ content += `\n<script>(function(){const tok=window.__WS_TOKEN?'?token='+encodeURIComponent(window.__WS_TOKEN):'';const ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'${BASE_URL}/hot-reload'+tok);ws.onmessage=e=>{if(JSON.parse(e.data).type==='reload')location.reload()};})();</script>`;
100
100
  }
101
101
  compressAndSend(req, res, 200, contentType, content);
102
102
  if (!watch && acceptsEncoding(req, 'gzip')) {
@@ -23,18 +23,31 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
23
23
  if (_pwd) {
24
24
  const _auth = req.headers['authorization'] || '';
25
25
  let _ok = false;
26
+ const _checkToken = (tok) => {
27
+ try { return tok.length === _pwd.length && crypto.timingSafeEqual(Buffer.from(tok), Buffer.from(_pwd)); }
28
+ catch { return false; }
29
+ };
26
30
  if (_auth.startsWith('Basic ')) {
27
31
  try {
28
32
  const _decoded = Buffer.from(_auth.slice(6), 'base64').toString('utf8');
29
33
  const _ci = _decoded.indexOf(':');
30
- if (_ci !== -1) { const _p = _decoded.slice(_ci + 1); try { _ok = _p.length === _pwd.length && crypto.timingSafeEqual(Buffer.from(_p), Buffer.from(_pwd)); } catch { _ok = false; } }
34
+ if (_ci !== -1) _ok = _checkToken(_decoded.slice(_ci + 1));
35
+ } catch (_) {}
36
+ } else if (_auth.startsWith('Bearer ')) {
37
+ _ok = _checkToken(_auth.slice(7));
38
+ }
39
+ // EventSource and same-origin links can't set headers — accept ?token= as fallback.
40
+ if (!_ok) {
41
+ try {
42
+ const _qsTok = new URL(req.url, 'http://localhost').searchParams.get('token');
43
+ if (_qsTok) _ok = _checkToken(_qsTok);
31
44
  } catch (_) {}
32
45
  }
33
46
  if (!_ok) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="agentgui"' }); res.end('Unauthorized'); return; }
34
47
  }
35
48
 
36
49
  const pathOnly = req.url.split('?')[0];
37
- if (pathOnly.startsWith(BASE_URL + '/api/upload/') || pathOnly.startsWith(BASE_URL + '/files/') || pathOnly.startsWith('/v1/history')) return expressApp(req, res);
50
+ if (pathOnly.startsWith(BASE_URL + '/api/upload/') || pathOnly.startsWith(BASE_URL + '/files/') || pathOnly.startsWith('/v1/history') || (BASE_URL && pathOnly.startsWith(BASE_URL + '/v1/history'))) return expressApp(req, res);
38
51
 
39
52
  if (req.url === '/favicon.ico' || req.url === BASE_URL + '/favicon.ico') {
40
53
  const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#3b82f6"/><text x="50" y="68" font-size="50" font-family="sans-serif" font-weight="bold" fill="white" text-anchor="middle">G</text></svg>';
@@ -45,13 +58,14 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
45
58
  // serve index.html at root directly (no redirect)
46
59
 
47
60
  let routePath = req.url;
48
- if (req.url.startsWith(BASE_URL + '/')) { routePath = req.url.slice(BASE_URL.length); }
49
- else if (req.url === BASE_URL) { routePath = '/'; }
50
- else if (req.url.startsWith('/api/') || req.url.startsWith('/js/') || req.url.startsWith('/css/') ||
51
- req.url.startsWith('/vendor/') || req.url.startsWith('/sync') || req.url === '/' ||
52
- req.url === '/health' || req.url.startsWith('/v1/') ||
53
- req.url.startsWith('/api/terminal/') ||
54
- req.url.startsWith('/conversations/')) { routePath = req.url; }
61
+ const _bareUrl = req.url.split('?')[0];
62
+ if (_bareUrl.startsWith(BASE_URL + '/')) { routePath = req.url.slice(BASE_URL.length); }
63
+ else if (_bareUrl === BASE_URL) { routePath = '/'; }
64
+ else if (_bareUrl.startsWith('/api/') || _bareUrl.startsWith('/js/') || _bareUrl.startsWith('/css/') ||
65
+ _bareUrl.startsWith('/vendor/') || _bareUrl.startsWith('/sync') || _bareUrl === '/' ||
66
+ _bareUrl === '/health' || _bareUrl.startsWith('/v1/') ||
67
+ _bareUrl.startsWith('/api/terminal/') ||
68
+ _bareUrl.startsWith('/conversations/')) { routePath = req.url; }
55
69
  else { res.writeHead(404); res.end('Not found'); return; }
56
70
 
57
71
  routePath = routePath || '/';
@@ -120,7 +134,8 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
120
134
 
121
135
  if (pathOnly.match(/^\/conversations\/[^\/]+$/)) { serveFile(path.join(staticDir, 'index.html'), res, req); return; }
122
136
 
123
- let filePath = routePath === '/' ? '/index.html' : routePath;
137
+ const routePathBare = routePath.split('?')[0];
138
+ let filePath = routePathBare === '/' ? '/index.html' : routePathBare;
124
139
  filePath = path.join(staticDir, filePath);
125
140
  const normalizedPath = path.normalize(filePath);
126
141
  if (!normalizedPath.startsWith(staticDir)) { res.writeHead(403); res.end('Forbidden'); return; }
@@ -28,7 +28,7 @@ export function createAutoImport({ queries, broadcastSync }) {
28
28
  try {
29
29
  if (process.env.AGENTGUI_SKIP_AUTO_IMPORT === '1') return;
30
30
  if (!hasIndexFilesChanged()) return;
31
- const imported = queries.importClaudeCodeConversations();
31
+ const imported = queries.importClaudeCodeConversations() || [];
32
32
  if (imported.length > 0) {
33
33
  const importedCount = imported.filter(i => i.status === 'imported').length;
34
34
  if (importedCount > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.935",
3
+ "version": "1.0.936",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
package/server.js CHANGED
@@ -73,7 +73,8 @@ const expressApp = createExpressApp({ queries, BASE_URL });
73
73
  try {
74
74
  const historyRouter = await createHistoryRouter({ projectsDir: process.env.CLAUDE_PROJECTS_DIR });
75
75
  expressApp.use('/', historyRouter);
76
- console.log('[ccsniff] /v1/history/* mounted');
76
+ if (BASE_URL && BASE_URL !== '/') expressApp.use(BASE_URL, historyRouter);
77
+ console.log('[ccsniff] /v1/history/* mounted at / and ' + (BASE_URL || '/'));
77
78
  } catch (e) { console.error('[ccsniff] mount failed:', e.message); }
78
79
 
79
80
  let discoveredAgents = [];
@@ -18,9 +18,28 @@ const state = {
18
18
  events: [],
19
19
  searchQ: '',
20
20
  searchHits: null,
21
+ historyError: null,
22
+ showSubagents: false,
21
23
  live: { es: null, connected: false, lastEventTs: 0, error: null, eventCount: 0 },
22
24
  };
23
25
 
26
+ function readHash() {
27
+ const m = (location.hash || '').match(/sid=([^&]+)/);
28
+ return m ? decodeURIComponent(m[1]) : null;
29
+ }
30
+ function writeHash(sid) {
31
+ const h = sid ? '#sid=' + encodeURIComponent(sid) : '';
32
+ if (location.hash !== h) history.replaceState(null, '', location.pathname + location.search + h);
33
+ }
34
+ function fmtRelTime(ts) {
35
+ if (!ts) return '';
36
+ const s = Math.round((Date.now() - ts) / 1000);
37
+ if (s < 60) return s + 's ago';
38
+ if (s < 3600) return Math.round(s/60) + 'm ago';
39
+ if (s < 86400) return Math.round(s/3600) + 'h ago';
40
+ return Math.round(s/86400) + 'd ago';
41
+ }
42
+
24
43
  let render;
25
44
  let renderScheduled = false;
26
45
  function scheduleRender() {
@@ -242,34 +261,72 @@ async function sendChat() {
242
261
 
243
262
  // ── history ────────────────────────────────────────────────────────────────
244
263
  function historyMain() {
264
+ if (!state.selectedSid) {
265
+ return [PageHeader({
266
+ title: '§ history',
267
+ lede: 'pick a session from the sidebar — events stream live from ccsniff /v1/history.',
268
+ })];
269
+ }
270
+
271
+ const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
272
+ const lede = sess
273
+ ? (sess.project || sess.cwd || '?') + ' · ' + (sess.events || 0) + ' events · ' + (sess.userTurns || 0) + ' turns · ' + fmtRelTime(sess.last)
274
+ : state.selectedSid;
275
+
245
276
  const head = PageHeader({
246
- title: '§ history',
247
- lede: state.selectedSid
248
- ? 'session ' + state.selectedSid
249
- : 'pick a session from the sidebar — events stream from ccsniff /v1/history.',
277
+ title: '§ ' + (sess?.title || state.selectedSid).slice(0, 80),
278
+ lede,
250
279
  });
251
280
 
252
- if (!state.selectedSid) return [head];
253
- if (state.events.length === 0) return [head, Panel({ title: 'events', children: h('p', { class: 'lede' }, ' loading…') })];
281
+ const actions = h('div', { key: 'acts', style: 'display:flex;gap:.5em;padding:0 0 .75em 0' },
282
+ Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: ' open in chat' }),
283
+ Btn({ key: 'copy', onClick: () => { try { navigator.clipboard.writeText(state.selectedSid); } catch {} }, children: '⎘ copy sid' }),
284
+ );
285
+
286
+ if (state.events.length === 0) {
287
+ return [head, actions, Panel({ title: 'events', children: h('p', { class: 'lede' }, '◌ loading…') })];
288
+ }
254
289
 
255
290
  return [
256
291
  head,
292
+ actions,
257
293
  Panel({
258
294
  title: state.events.length + ' events',
259
295
  children: EventList({
260
- items: state.events.map((e, i) => ({
261
- key: 'ev' + i,
262
- code: String(i + 1).padStart(3, '0'),
263
- title: (e.text || '').slice(0, 200) || '(empty)',
264
- sub: new Date(e.ts).toLocaleString() + ' · ' + (e.role || '?') + ' · ' + (e.type || '?') + (e.tool ? ' · ⌘ ' + e.tool : ''),
265
- })),
296
+ items: state.events.slice(-300).map((e, i) => {
297
+ const role = e.role || '?';
298
+ const type = e.type || '?';
299
+ const tool = e.tool ? ' · ⌘ ' + e.tool : '';
300
+ const errMark = e.isError ? ' · ' : '';
301
+ const text = (e.text || '').replace(/\s+/g, ' ').trim();
302
+ return {
303
+ key: 'ev' + (e.i ?? i),
304
+ code: String((e.i ?? i) + 1).padStart(4, '0'),
305
+ title: text.slice(0, 220) || '(' + type + ')',
306
+ sub: new Date(e.ts).toLocaleString() + ' · ' + role + ' · ' + type + tool + errMark,
307
+ };
308
+ }),
266
309
  }),
267
310
  }),
268
311
  ];
269
312
  }
270
313
 
314
+ function resumeInChat(sess) {
315
+ state.tab = 'chat';
316
+ closeLiveStream();
317
+ state.chat.draft = '/resume ' + (sess?.sid || state.selectedSid);
318
+ render();
319
+ }
320
+
321
+ function visibleSessions() {
322
+ const arr = Array.isArray(state.sessions) ? state.sessions : [];
323
+ const filtered = state.showSubagents ? arr : arr.filter(s => !s.isSubagent);
324
+ return filtered.slice().sort((a, b) => (b.last || 0) - (a.last || 0));
325
+ }
326
+
271
327
  function historySide() {
272
328
  const searching = !!state.searchHits;
329
+ const sessionsView = visibleSessions();
273
330
  const rows = searching
274
331
  ? state.searchHits.results.slice(0, 60).map((r, i) =>
275
332
  Row({
@@ -281,17 +338,18 @@ function historySide() {
281
338
  onClick: () => loadSession(r.sid),
282
339
  })
283
340
  )
284
- : state.sessions.slice(0, 120).map((s, i) =>
341
+ : sessionsView.slice(0, 120).map((s, i) =>
285
342
  Row({
286
- key: 'sess' + i,
343
+ key: 'sess' + s.sid,
287
344
  rank: String(i + 1).padStart(3, '0'),
288
- title: s.title || s.project || s.sid,
289
- sub: s.events + ' ev · ' + s.tools + ' tools' + (s.errors ? ' · ' + s.errors + ' err' : ''),
290
- rail: s.errors ? 'flame' : 'green',
345
+ title: (s.isSubagent ? '↳ ' : '') + (s.title || s.project || s.sid),
346
+ sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools' + (s.errors ? ' · ' + s.errors + ' err' : ''),
347
+ rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
291
348
  active: s.sid === state.selectedSid,
292
349
  onClick: () => loadSession(s.sid),
293
350
  })
294
351
  );
352
+ const subagentCount = (Array.isArray(state.sessions) ? state.sessions : []).filter(s => s.isSubagent).length;
295
353
 
296
354
  return [
297
355
  Side({
@@ -307,7 +365,7 @@ function historySide() {
307
365
  ],
308
366
  }),
309
367
  Panel({
310
- title: searching ? 'matches' : 'sessions',
368
+ title: searching ? 'matches' : ('sessions · ' + sessionsView.length + (subagentCount && !state.showSubagents ? ' (+'+subagentCount+' sub)' : '')),
311
369
  children: [
312
370
  SearchInput({
313
371
  key: 'searchInput',
@@ -315,7 +373,14 @@ function historySide() {
315
373
  value: state.searchQ,
316
374
  onInput: (v) => { state.searchQ = v; runSearch(); },
317
375
  }),
318
- rows.length ? h('div', { key: 'rows' }, ...rows) : h('p', { key: 'empty', class: 'lede' }, 'no sessions yet'),
376
+ !searching && subagentCount
377
+ ? h('label', { key: 'subtog', class: 'lede', style: 'display:flex;gap:.5em;align-items:center;padding:.25em 0' },
378
+ h('input', { type: 'checkbox', checked: state.showSubagents, onChange: (e) => { state.showSubagents = e.target.checked; render(); } }),
379
+ 'show subagents (' + subagentCount + ')')
380
+ : null,
381
+ state.historyError
382
+ ? h('p', { key: 'err', class: 'lede' }, '⚠ ' + state.historyError)
383
+ : (rows.length ? h('div', { key: 'rows' }, ...rows) : h('p', { key: 'empty', class: 'lede' }, 'no sessions yet')),
319
384
  ],
320
385
  }),
321
386
  ];
@@ -363,7 +428,7 @@ function settingsMain() {
363
428
  key: 'm' + i,
364
429
  rank: String(i + 1).padStart(3, '0'),
365
430
  title: m.id,
366
- sub: m.owned_by || m.object || 'model',
431
+ sub: m.name ? (m.name + ' · ' + (m.protocol || 'agent')) : (m.protocol || 'agent'),
367
432
  rail: m.id === state.selectedModel ? 'green' : 'purple',
368
433
  onClick: () => { state.selectedModel = m.id; render(); },
369
434
  })
@@ -375,8 +440,15 @@ function settingsMain() {
375
440
 
376
441
  // ── data ──────────────────────────────────────────────────────────────────
377
442
  async function refreshHistory() {
378
- try { state.sessions = await B.listSessions(state.backend); render(); }
379
- catch (e) { console.warn('history fetch failed:', e.message); }
443
+ try {
444
+ state.sessions = await B.listSessions(state.backend);
445
+ state.historyError = null;
446
+ render();
447
+ } catch (e) {
448
+ state.historyError = e.message;
449
+ console.warn('history fetch failed:', e.message);
450
+ render();
451
+ }
380
452
  }
381
453
 
382
454
  async function runSearch() {
@@ -393,6 +465,7 @@ async function runSearch() {
393
465
  async function loadSession(sid) {
394
466
  state.selectedSid = sid;
395
467
  state.events = [];
468
+ writeHash(sid);
396
469
  render();
397
470
  try { state.events = await B.getSessionEvents(state.backend, sid); render(); }
398
471
  catch (e) {
@@ -414,6 +487,21 @@ async function init() {
414
487
  if (!state.selectedModel && state.models[0]) state.selectedModel = state.models[0].id;
415
488
  render();
416
489
  } catch (e) { console.warn('models fetch failed:', e.message); }
490
+
491
+ const initialSid = readHash();
492
+ if (initialSid) {
493
+ navTo('history');
494
+ await refreshHistory();
495
+ await loadSession(initialSid);
496
+ }
497
+
498
+ B.onWsStatus?.((s) => {
499
+ if (s === 'closed' || s === 'error') {
500
+ if (state.health.status === 'ok') { state.health = { ...state.health, ws: 'reconnecting' }; render(); }
501
+ } else if (s === 'open') {
502
+ if (state.health.ws) { delete state.health.ws; render(); }
503
+ }
504
+ });
417
505
  }
418
506
 
419
507
  render = mount(document.getElementById('app'), view);
@@ -9,6 +9,24 @@ import { encode, decode } from './codec.js';
9
9
  const KEY = 'agentgui.backend';
10
10
  const DEFAULT_BACKEND = '';
11
11
 
12
+ function authToken() {
13
+ try { return (typeof window !== 'undefined' && window.__WS_TOKEN) || ''; } catch { return ''; }
14
+ }
15
+
16
+ function authedFetch(url, opts = {}) {
17
+ const tok = authToken();
18
+ if (!tok) return fetch(url, opts);
19
+ const h = new Headers(opts.headers || {});
20
+ h.set('Authorization', 'Bearer ' + tok);
21
+ return fetch(url, { ...opts, headers: h });
22
+ }
23
+
24
+ function withToken(url) {
25
+ const tok = authToken();
26
+ if (!tok) return url;
27
+ return url + (url.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(tok);
28
+ }
29
+
12
30
  export function getBackend() {
13
31
  const u = new URL(location.href);
14
32
  const fromQs = u.searchParams.get('backend');
@@ -20,7 +38,7 @@ export function setBackend(url) { localStorage.setItem(KEY, url); }
20
38
 
21
39
  export async function probeBackend(base) {
22
40
  try {
23
- const r = await fetch(base + '/health', { method: 'GET' });
41
+ const r = await authedFetch(base + '/health', { method: 'GET' });
24
42
  if (!r.ok) return { ok: false, status: r.status };
25
43
  return { ok: true, info: await r.json() };
26
44
  } catch (e) {
@@ -31,26 +49,27 @@ export async function probeBackend(base) {
31
49
  // ---------- History (HTTP, served by ccsniff) ----------
32
50
 
33
51
  export async function listSessions(base) {
34
- const r = await fetch(base + '/v1/history/sessions');
52
+ const r = await authedFetch(base + '/v1/history/sessions');
35
53
  if (!r.ok) throw new Error('sessions: ' + r.status);
36
- return r.json();
54
+ const j = await r.json();
55
+ return Array.isArray(j) ? j : (j.sessions || []);
37
56
  }
38
57
 
39
58
  export async function getSessionEvents(base, sid) {
40
- const r = await fetch(base + '/v1/history/sessions/' + encodeURIComponent(sid) + '/events');
59
+ const r = await authedFetch(base + '/v1/history/sessions/' + encodeURIComponent(sid) + '/events');
41
60
  if (!r.ok) throw new Error('events: ' + r.status);
42
61
  const j = await r.json();
43
62
  return j.events || [];
44
63
  }
45
64
 
46
65
  export async function searchHistory(base, q, limit = 50) {
47
- const r = await fetch(base + '/v1/history/search?q=' + encodeURIComponent(q) + '&limit=' + limit);
66
+ const r = await authedFetch(base + '/v1/history/search?q=' + encodeURIComponent(q) + '&limit=' + limit);
48
67
  if (!r.ok) throw new Error('search: ' + r.status);
49
68
  return r.json();
50
69
  }
51
70
 
52
71
  export function streamHistory(base, onEvent) {
53
- const es = new EventSource(base + '/v1/history/stream');
72
+ const es = new EventSource(withToken(base + '/v1/history/stream'));
54
73
  for (const k of ['hello', 'event', 'error', 'start', 'complete', 'conversation']) {
55
74
  es.addEventListener(k, ev => {
56
75
  let data; try { data = JSON.parse(ev.data); } catch { data = null; }
@@ -68,19 +87,26 @@ let _wsReady = null; // Promise that resolves when ws is OPEN
68
87
  let _nextReqId = 1;
69
88
  const _pending = new Map(); // requestId → { resolve, reject }
70
89
  const _sessionListeners = new Map(); // sessionId → Set<(event)=>void>
90
+ const _statusListeners = new Set(); // fn(state) where state in 'open'|'closed'|'error'
91
+
92
+ export function onWsStatus(fn) { _statusListeners.add(fn); return () => _statusListeners.delete(fn); }
93
+ function emitStatus(s) { for (const fn of _statusListeners) { try { fn(s); } catch {} } }
71
94
 
72
95
  function wsUrl(base) {
96
+ let proto, host;
73
97
  if (base) {
74
- // Absolute base like http://host:port → ws(s)://host:port/sync
75
98
  try {
76
99
  const u = new URL(base);
77
- const proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
78
- return proto + '//' + u.host + SYNC_PATH;
100
+ proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
101
+ host = u.host;
79
102
  } catch {}
80
103
  }
81
- // Same-origin
82
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
83
- return proto + '//' + location.host + SYNC_PATH;
104
+ if (!host) {
105
+ proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
106
+ host = location.host;
107
+ }
108
+ const tok = authToken();
109
+ return proto + '//' + host + SYNC_PATH + (tok ? '?token=' + encodeURIComponent(tok) : '');
84
110
  }
85
111
 
86
112
  function ensureWs(base) {
@@ -88,10 +114,10 @@ function ensureWs(base) {
88
114
  if (_ws && _ws.readyState === 0) return _wsReady;
89
115
  _ws = new WebSocket(wsUrl(base));
90
116
  _wsReady = new Promise((resolve, reject) => {
91
- _ws.addEventListener('open', () => resolve(_ws));
92
- _ws.addEventListener('error', (e) => reject(e));
117
+ _ws.addEventListener('open', () => { emitStatus('open'); resolve(_ws); });
118
+ _ws.addEventListener('error', (e) => { emitStatus('error'); reject(e); });
93
119
  _ws.addEventListener('close', () => {
94
- // Reject all pending requests on close so callers can recover.
120
+ emitStatus('closed');
95
121
  for (const [, p] of _pending) p.reject(new Error('ws closed'));
96
122
  _pending.clear();
97
123
  _ws = null;