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 +3 -1
- package/lib/asset-server.js +1 -1
- package/lib/http-handler.js +25 -10
- package/lib/server-startup2.js +1 -1
- package/package.json +1 -1
- package/server.js +2 -1
- package/site/app/js/app.js +110 -22
- package/site/app/js/backend.js +41 -15
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/`
|
|
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
|
package/lib/asset-server.js
CHANGED
|
@@ -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')) {
|
package/lib/http-handler.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
else if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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; }
|
package/lib/server-startup2.js
CHANGED
|
@@ -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
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
|
-
|
|
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 = [];
|
package/site/app/js/app.js
CHANGED
|
@@ -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: '§
|
|
247
|
-
lede
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
:
|
|
341
|
+
: sessionsView.slice(0, 120).map((s, i) =>
|
|
285
342
|
Row({
|
|
286
|
-
key: 'sess' +
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
379
|
-
|
|
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);
|
package/site/app/js/backend.js
CHANGED
|
@@ -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
|
|
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
|
|
52
|
+
const r = await authedFetch(base + '/v1/history/sessions');
|
|
35
53
|
if (!r.ok) throw new Error('sessions: ' + r.status);
|
|
36
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
78
|
-
|
|
100
|
+
proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
101
|
+
host = u.host;
|
|
79
102
|
} catch {}
|
|
80
103
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
120
|
+
emitStatus('closed');
|
|
95
121
|
for (const [, p] of _pending) p.reject(new Error('ws closed'));
|
|
96
122
|
_pending.clear();
|
|
97
123
|
_ws = null;
|