agentgui 1.0.935 → 1.0.937

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')) {
@@ -20,21 +20,38 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
20
20
  if (hits > RATE_LIMIT_MAX) { res.writeHead(429, { 'Retry-After': '60' }); res.end('Too Many Requests'); return; }
21
21
 
22
22
  const _pwd = process.env.PASSWORD;
23
- if (_pwd) {
23
+ // Optional: exempt /health from auth so container/k8s probes work
24
+ // without distributing the password to monitoring infra.
25
+ const _bareEarly = req.url.split('?')[0];
26
+ const _healthExempt = process.env.HEALTH_NO_AUTH === '1' && (_bareEarly === '/health' || _bareEarly === '/api/health' || _bareEarly === (BASE_URL + '/health') || _bareEarly === (BASE_URL + '/api/health'));
27
+ if (_pwd && !_healthExempt) {
24
28
  const _auth = req.headers['authorization'] || '';
25
29
  let _ok = false;
30
+ const _checkToken = (tok) => {
31
+ try { return tok.length === _pwd.length && crypto.timingSafeEqual(Buffer.from(tok), Buffer.from(_pwd)); }
32
+ catch { return false; }
33
+ };
26
34
  if (_auth.startsWith('Basic ')) {
27
35
  try {
28
36
  const _decoded = Buffer.from(_auth.slice(6), 'base64').toString('utf8');
29
37
  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; } }
38
+ if (_ci !== -1) _ok = _checkToken(_decoded.slice(_ci + 1));
39
+ } catch (_) {}
40
+ } else if (_auth.startsWith('Bearer ')) {
41
+ _ok = _checkToken(_auth.slice(7));
42
+ }
43
+ // EventSource and same-origin links can't set headers — accept ?token= as fallback.
44
+ if (!_ok) {
45
+ try {
46
+ const _qsTok = new URL(req.url, 'http://localhost').searchParams.get('token');
47
+ if (_qsTok) _ok = _checkToken(_qsTok);
31
48
  } catch (_) {}
32
49
  }
33
50
  if (!_ok) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="agentgui"' }); res.end('Unauthorized'); return; }
34
51
  }
35
52
 
36
53
  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);
54
+ 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
55
 
39
56
  if (req.url === '/favicon.ico' || req.url === BASE_URL + '/favicon.ico') {
40
57
  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 +62,14 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
45
62
  // serve index.html at root directly (no redirect)
46
63
 
47
64
  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; }
65
+ const _bareUrl = req.url.split('?')[0];
66
+ if (_bareUrl.startsWith(BASE_URL + '/')) { routePath = req.url.slice(BASE_URL.length); }
67
+ else if (_bareUrl === BASE_URL) { routePath = '/'; }
68
+ else if (_bareUrl.startsWith('/api/') || _bareUrl.startsWith('/js/') || _bareUrl.startsWith('/css/') ||
69
+ _bareUrl.startsWith('/vendor/') || _bareUrl.startsWith('/sync') || _bareUrl === '/' ||
70
+ _bareUrl === '/health' || _bareUrl.startsWith('/v1/') ||
71
+ _bareUrl.startsWith('/api/terminal/') ||
72
+ _bareUrl.startsWith('/conversations/')) { routePath = req.url; }
55
73
  else { res.writeHead(404); res.end('Not found'); return; }
56
74
 
57
75
  routePath = routePath || '/';
@@ -120,7 +138,8 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
120
138
 
121
139
  if (pathOnly.match(/^\/conversations\/[^\/]+$/)) { serveFile(path.join(staticDir, 'index.html'), res, req); return; }
122
140
 
123
- let filePath = routePath === '/' ? '/index.html' : routePath;
141
+ const routePathBare = routePath.split('?')[0];
142
+ let filePath = routePathBare === '/' ? '/index.html' : routePathBare;
124
143
  filePath = path.join(staticDir, filePath);
125
144
  const normalizedPath = path.normalize(filePath);
126
145
  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) {
@@ -51,6 +51,7 @@ export function register(router, deps) {
51
51
  const model = p?.model || undefined;
52
52
  const subAgent = p?.subAgent || undefined;
53
53
  const cwd = p?.cwd || STARTUP_CWD;
54
+ const resumeSessionId = p?.resumeSid || p?.resumeSessionId || undefined;
54
55
  if (!registry.has(agentId)) err(404, `Unknown agentId: ${agentId}`);
55
56
 
56
57
  const sessionId = 'chat-' + crypto.randomBytes(8).toString('hex');
@@ -88,7 +89,7 @@ export function register(router, deps) {
88
89
  try {
89
90
  const config = {
90
91
  verbose: true, outputFormat: 'stream-json', timeout: 1800000, print: true,
91
- model, subAgent, onEvent,
92
+ model, subAgent, onEvent, resumeSessionId,
92
93
  onPid: () => {}, onProcess: (proc) => { ctrl.proc = proc; },
93
94
  };
94
95
  await runClaudeWithStreaming(content, cwd, agentId, config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.935",
3
+ "version": "1.0.937",
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 = [];
@@ -12,15 +12,36 @@ const state = {
12
12
  tab: 'chat',
13
13
  models: [],
14
14
  selectedModel: '',
15
- chat: { messages: [], busy: false, abort: null, draft: '' },
15
+ chat: { messages: [], busy: false, abort: null, draft: '', resumeSid: null },
16
16
  sessions: [],
17
17
  selectedSid: null,
18
18
  events: [],
19
19
  searchQ: '',
20
20
  searchHits: null,
21
- live: { es: null, connected: false, lastEventTs: 0, error: null, eventCount: 0 },
21
+ historyError: null,
22
+ showSubagents: false,
23
+ sessionsLimit: 60,
24
+ projectFilter: '',
25
+ live: { es: null, connected: false, lastEventTs: 0, error: null, eventCount: 0, reconnects: 0 },
22
26
  };
23
27
 
28
+ function readHash() {
29
+ const m = (location.hash || '').match(/sid=([^&]+)/);
30
+ return m ? decodeURIComponent(m[1]) : null;
31
+ }
32
+ function writeHash(sid) {
33
+ const h = sid ? '#sid=' + encodeURIComponent(sid) : '';
34
+ if (location.hash !== h) history.replaceState(null, '', location.pathname + location.search + h);
35
+ }
36
+ function fmtRelTime(ts) {
37
+ if (!ts) return '';
38
+ const s = Math.round((Date.now() - ts) / 1000);
39
+ if (s < 60) return s + 's ago';
40
+ if (s < 3600) return Math.round(s/60) + 'm ago';
41
+ if (s < 86400) return Math.round(s/3600) + 'h ago';
42
+ return Math.round(s/86400) + 'd ago';
43
+ }
44
+
24
45
  let render;
25
46
  let renderScheduled = false;
26
47
  function scheduleRender() {
@@ -55,12 +76,14 @@ function openLiveStream() {
55
76
  state.live.lastEventTs = Date.now();
56
77
  state.live.eventCount++;
57
78
  if (kind === 'hello') {
58
- state.live.connected = true;
79
+ if (!state.live.connected) state.live.connected = true;
80
+ if (state.live.error) { state.live.error = null; state.live.reconnects++; }
59
81
  } else if (kind === 'event' && data) {
60
82
  if (state.selectedSid && data.sid === state.selectedSid) {
61
83
  state.events.push(data);
62
84
  }
63
- const sess = state.sessions.find(s => s.sid === data.sid);
85
+ const arr = Array.isArray(state.sessions) ? state.sessions : [];
86
+ const sess = arr.find(s => s.sid === data.sid);
64
87
  if (sess) {
65
88
  sess.events = (sess.events || 0) + 1;
66
89
  sess.last = data.ts || Date.now();
@@ -79,9 +102,12 @@ function openLiveStream() {
79
102
  scheduleRender();
80
103
  });
81
104
  state.live.es.addEventListener('error', () => {
82
- state.live.connected = false;
83
- state.live.error = 'connection lost';
84
- scheduleRender();
105
+ // EventSource auto-reconnects; only flap state once per disconnect.
106
+ if (!state.live.error) {
107
+ state.live.connected = false;
108
+ state.live.error = 'connection lost (auto-retry)';
109
+ scheduleRender();
110
+ }
85
111
  });
86
112
  } catch (e) {
87
113
  state.live.error = e.message;
@@ -100,8 +126,10 @@ function view() {
100
126
  const ok = state.health.status === 'ok';
101
127
  const liveActive = state.tab === 'history' && state.live.connected && (Date.now() - state.live.lastEventTs < 30000);
102
128
  const dotText = state.tab === 'history'
103
- ? (state.live.error ? '○ stream offline' : (liveActive ? '● live · ' + state.live.eventCount : (state.live.connected ? '● live' : '◌ connecting…')))
104
- : (ok ? ' connected' : ' offline');
129
+ ? (state.live.error
130
+ ? '◌ ' + state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
131
+ : (liveActive ? '● live · ' + state.live.eventCount : (state.live.connected ? '● live' : '◌ connecting…')))
132
+ : (ok ? (state.health.ws === 'reconnecting' ? '◌ ws reconnecting' : '● connected') : '○ offline');
105
133
  const dot = h('span', { key: 'dot' }, dotText);
106
134
 
107
135
  const topbar = Topbar({
@@ -192,19 +220,25 @@ function chatMain() {
192
220
  onSend: (v) => { state.chat.draft = v; sendChat(); },
193
221
  });
194
222
 
223
+ const resumeBanner = state.chat.resumeSid
224
+ ? h('div', { key: 'rb', style: 'padding:.5em .75em;background:rgba(80,200,120,.1);border-radius:4px;display:flex;justify-content:space-between;align-items:center;margin-bottom:.5em' },
225
+ h('span', { class: 'lede' }, '▶ resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via claude --resume'),
226
+ Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; render(); }, children: '× clear' }))
227
+ : null;
195
228
  return [
229
+ resumeBanner,
196
230
  Chat({
197
- title: state.selectedModel || 'agent',
231
+ title: (state.selectedModel || 'agent') + (state.chat.resumeSid ? ' · resume' : ''),
198
232
  sub: state.chat.busy ? 'streaming…' : (state.chat.messages.length + ' messages'),
199
233
  messages: msgs,
200
234
  composer,
201
235
  }),
202
- ];
236
+ ].filter(Boolean);
203
237
  }
204
238
 
205
239
  function newChat() {
206
240
  state.chat.abort?.abort();
207
- state.chat = { messages: [], busy: false, abort: null, draft: '' };
241
+ state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
208
242
  render();
209
243
  }
210
244
 
@@ -227,6 +261,7 @@ async function sendChat() {
227
261
  model: state.selectedModel,
228
262
  messages: state.chat.messages.slice(0, -1).map(m => ({ role: m.role, content: m.content })),
229
263
  signal: ctrl.signal,
264
+ resumeSid: state.chat.resumeSid || undefined,
230
265
  })) {
231
266
  if (ev.type === 'text') { cur.content += ev.text; render(); }
232
267
  if (ev.type === 'error') { cur.content += '\n[error] ' + JSON.stringify(ev.error); render(); }
@@ -242,36 +277,95 @@ async function sendChat() {
242
277
 
243
278
  // ── history ────────────────────────────────────────────────────────────────
244
279
  function historyMain() {
280
+ if (!state.selectedSid) {
281
+ return [PageHeader({
282
+ title: '§ history',
283
+ lede: 'pick a session from the sidebar — events stream live from ccsniff /v1/history.',
284
+ })];
285
+ }
286
+
287
+ const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
288
+ const lede = sess
289
+ ? (sess.project || sess.cwd || '?') + ' · ' + (sess.events || 0) + ' events · ' + (sess.userTurns || 0) + ' turns · ' + fmtRelTime(sess.last)
290
+ : state.selectedSid;
291
+
245
292
  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.',
293
+ title: '§ ' + (sess?.title || state.selectedSid).slice(0, 80),
294
+ lede,
250
295
  });
251
296
 
252
- if (!state.selectedSid) return [head];
253
- if (state.events.length === 0) return [head, Panel({ title: 'events', children: h('p', { class: 'lede' }, ' loading…') })];
297
+ const actions = h('div', { key: 'acts', style: 'display:flex;gap:.5em;padding:0 0 .75em 0' },
298
+ Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: ' open in chat' }),
299
+ Btn({ key: 'copy', onClick: () => { try { navigator.clipboard.writeText(state.selectedSid); } catch {} }, children: '⎘ copy sid' }),
300
+ );
301
+
302
+ if (state.events.length === 0) {
303
+ return [head, actions, Panel({ title: 'events', children: h('p', { class: 'lede' }, '◌ loading…') })];
304
+ }
254
305
 
255
306
  return [
256
307
  head,
308
+ actions,
257
309
  Panel({
258
310
  title: state.events.length + ' events',
259
311
  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
- })),
312
+ items: state.events.slice(-300).map((e, i) => {
313
+ const role = e.role || '?';
314
+ const type = e.type || '?';
315
+ const tool = e.tool ? ' · ⌘ ' + e.tool : '';
316
+ const errMark = e.isError ? ' · ' : '';
317
+ const text = (e.text || '').replace(/\s+/g, ' ').trim();
318
+ return {
319
+ key: 'ev' + (e.i ?? i),
320
+ code: String((e.i ?? i) + 1).padStart(4, '0'),
321
+ title: text.slice(0, 220) || '(' + type + ')',
322
+ sub: new Date(e.ts).toLocaleString() + ' · ' + role + ' · ' + type + tool + errMark,
323
+ };
324
+ }),
266
325
  }),
267
326
  }),
268
327
  ];
269
328
  }
270
329
 
330
+ function resumeInChat(sess) {
331
+ state.tab = 'chat';
332
+ closeLiveStream();
333
+ state.chat.resumeSid = sess?.sid || state.selectedSid;
334
+ state.chat.messages = [];
335
+ state.chat.draft = '';
336
+ // Default to claude-code if no model yet (only claude supports --resume by sid here).
337
+ if (!state.selectedModel || state.selectedModel !== 'claude-code') state.selectedModel = 'claude-code';
338
+ render();
339
+ }
340
+
341
+ function visibleSessions() {
342
+ const arr = Array.isArray(state.sessions) ? state.sessions : [];
343
+ let filtered = state.showSubagents ? arr : arr.filter(s => !s.isSubagent);
344
+ if (state.projectFilter) {
345
+ const pf = state.projectFilter.toLowerCase();
346
+ filtered = filtered.filter(s => (s.project || '').toLowerCase().includes(pf));
347
+ }
348
+ return filtered.slice().sort((a, b) => (b.last || 0) - (a.last || 0));
349
+ }
350
+
351
+ function uniqueProjects() {
352
+ const arr = Array.isArray(state.sessions) ? state.sessions : [];
353
+ const seen = new Map();
354
+ for (const s of arr) {
355
+ if (!s.project) continue;
356
+ seen.set(s.project, (seen.get(s.project) || 0) + 1);
357
+ }
358
+ return Array.from(seen.entries()).sort((a, b) => b[1] - a[1]);
359
+ }
360
+
271
361
  function historySide() {
272
362
  const searching = !!state.searchHits;
363
+ const sessionsView = visibleSessions();
364
+ const limit = state.sessionsLimit;
365
+ const visible = searching ? state.searchHits.results.slice(0, 60) : sessionsView.slice(0, limit);
366
+ const truncatedBy = searching ? Math.max(0, state.searchHits.results.length - 60) : Math.max(0, sessionsView.length - limit);
273
367
  const rows = searching
274
- ? state.searchHits.results.slice(0, 60).map((r, i) =>
368
+ ? visible.map((r, i) =>
275
369
  Row({
276
370
  key: 'sr' + i,
277
371
  rank: String(i + 1).padStart(3, '0'),
@@ -281,17 +375,19 @@ function historySide() {
281
375
  onClick: () => loadSession(r.sid),
282
376
  })
283
377
  )
284
- : state.sessions.slice(0, 120).map((s, i) =>
378
+ : visible.map((s, i) =>
285
379
  Row({
286
- key: 'sess' + i,
380
+ key: 'sess' + s.sid,
287
381
  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',
382
+ title: (s.isSubagent ? '↳ ' : '') + (s.title || s.project || s.sid),
383
+ sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools' + (s.errors ? ' · ' + s.errors + ' err' : ''),
384
+ rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
291
385
  active: s.sid === state.selectedSid,
292
386
  onClick: () => loadSession(s.sid),
293
387
  })
294
388
  );
389
+ const subagentCount = (Array.isArray(state.sessions) ? state.sessions : []).filter(s => s.isSubagent).length;
390
+ const projects = uniqueProjects();
295
391
 
296
392
  return [
297
393
  Side({
@@ -307,7 +403,7 @@ function historySide() {
307
403
  ],
308
404
  }),
309
405
  Panel({
310
- title: searching ? 'matches' : 'sessions',
406
+ title: searching ? 'matches' : ('sessions · ' + sessionsView.length + (subagentCount && !state.showSubagents ? ' (+'+subagentCount+' sub)' : '')),
311
407
  children: [
312
408
  SearchInput({
313
409
  key: 'searchInput',
@@ -315,7 +411,26 @@ function historySide() {
315
411
  value: state.searchQ,
316
412
  onInput: (v) => { state.searchQ = v; runSearch(); },
317
413
  }),
318
- rows.length ? h('div', { key: 'rows' }, ...rows) : h('p', { key: 'empty', class: 'lede' }, 'no sessions yet'),
414
+ state.searchQ && searching
415
+ ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; render(); }, children: '× clear search' })
416
+ : null,
417
+ !searching && projects.length > 1
418
+ ? h('div', { key: 'projfilter', style: 'display:flex;flex-wrap:wrap;gap:.25em;padding:.25em 0' },
419
+ h('span', { key: 'allp', class: 'lede', style: 'cursor:pointer;padding:.15em .5em;border-radius:3px;' + (!state.projectFilter ? 'background:rgba(80,200,120,.15)' : ''), onClick: () => { state.projectFilter = ''; render(); } }, 'all'),
420
+ ...projects.slice(0, 8).map(([name, count]) =>
421
+ h('span', { key: 'p'+name, class: 'lede', style: 'cursor:pointer;padding:.15em .5em;border-radius:3px;' + (state.projectFilter === name ? 'background:rgba(80,200,120,.15)' : ''), title: name, onClick: () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); } }, (name.length > 20 ? name.slice(0, 20) + '…' : name) + ' (' + count + ')')))
422
+ : null,
423
+ !searching && subagentCount
424
+ ? h('label', { key: 'subtog', class: 'lede', style: 'display:flex;gap:.5em;align-items:center;padding:.25em 0' },
425
+ h('input', { type: 'checkbox', checked: state.showSubagents, onChange: (e) => { state.showSubagents = e.target.checked; render(); } }),
426
+ 'show subagents (' + subagentCount + ')')
427
+ : null,
428
+ state.historyError
429
+ ? h('p', { key: 'err', class: 'lede' }, '⚠ ' + state.historyError)
430
+ : (rows.length ? h('div', { key: 'rows' }, ...rows) : h('p', { key: 'empty', class: 'lede' }, 'no sessions yet')),
431
+ !searching && truncatedBy > 0
432
+ ? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: '↓ show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
433
+ : null,
319
434
  ],
320
435
  }),
321
436
  ];
@@ -363,7 +478,7 @@ function settingsMain() {
363
478
  key: 'm' + i,
364
479
  rank: String(i + 1).padStart(3, '0'),
365
480
  title: m.id,
366
- sub: m.owned_by || m.object || 'model',
481
+ sub: m.name ? (m.name + ' · ' + (m.protocol || 'agent')) : (m.protocol || 'agent'),
367
482
  rail: m.id === state.selectedModel ? 'green' : 'purple',
368
483
  onClick: () => { state.selectedModel = m.id; render(); },
369
484
  })
@@ -375,8 +490,15 @@ function settingsMain() {
375
490
 
376
491
  // ── data ──────────────────────────────────────────────────────────────────
377
492
  async function refreshHistory() {
378
- try { state.sessions = await B.listSessions(state.backend); render(); }
379
- catch (e) { console.warn('history fetch failed:', e.message); }
493
+ try {
494
+ state.sessions = await B.listSessions(state.backend);
495
+ state.historyError = null;
496
+ render();
497
+ } catch (e) {
498
+ state.historyError = e.message;
499
+ console.warn('history fetch failed:', e.message);
500
+ render();
501
+ }
380
502
  }
381
503
 
382
504
  async function runSearch() {
@@ -393,6 +515,7 @@ async function runSearch() {
393
515
  async function loadSession(sid) {
394
516
  state.selectedSid = sid;
395
517
  state.events = [];
518
+ writeHash(sid);
396
519
  render();
397
520
  try { state.events = await B.getSessionEvents(state.backend, sid); render(); }
398
521
  catch (e) {
@@ -414,6 +537,21 @@ async function init() {
414
537
  if (!state.selectedModel && state.models[0]) state.selectedModel = state.models[0].id;
415
538
  render();
416
539
  } catch (e) { console.warn('models fetch failed:', e.message); }
540
+
541
+ const initialSid = readHash();
542
+ if (initialSid) {
543
+ navTo('history');
544
+ await refreshHistory();
545
+ await loadSession(initialSid);
546
+ }
547
+
548
+ B.onWsStatus?.((s) => {
549
+ if (s === 'closed' || s === 'error') {
550
+ if (state.health.status === 'ok') { state.health = { ...state.health, ws: 'reconnecting' }; render(); }
551
+ } else if (s === 'open') {
552
+ if (state.health.ws) { delete state.health.ws; render(); }
553
+ }
554
+ });
417
555
  }
418
556
 
419
557
  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,34 +87,72 @@ 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'|'reconnecting'
91
+ let _reconnectAttempts = 0;
92
+ let _reconnectTimer = null;
93
+ let _wsBaseHint = ''; // base remembered for reconnect
94
+
95
+ export function onWsStatus(fn) { _statusListeners.add(fn); return () => _statusListeners.delete(fn); }
96
+ function emitStatus(s) { for (const fn of _statusListeners) { try { fn(s); } catch {} } }
97
+
98
+ function scheduleReconnect() {
99
+ if (_reconnectTimer) return;
100
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) {
101
+ // Wait for online before retrying.
102
+ const onOnline = () => { window.removeEventListener('online', onOnline); _reconnectAttempts = 0; ensureWs(_wsBaseHint).catch(() => {}); };
103
+ window.addEventListener('online', onOnline);
104
+ return;
105
+ }
106
+ const delay = Math.min(30000, 500 * Math.pow(2, _reconnectAttempts));
107
+ _reconnectAttempts++;
108
+ emitStatus('reconnecting');
109
+ _reconnectTimer = setTimeout(() => {
110
+ _reconnectTimer = null;
111
+ ensureWs(_wsBaseHint).catch(() => {});
112
+ }, delay);
113
+ }
71
114
 
72
115
  function wsUrl(base) {
116
+ let proto, host;
73
117
  if (base) {
74
- // Absolute base like http://host:port → ws(s)://host:port/sync
75
118
  try {
76
119
  const u = new URL(base);
77
- const proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
78
- return proto + '//' + u.host + SYNC_PATH;
120
+ proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
121
+ host = u.host;
79
122
  } catch {}
80
123
  }
81
- // Same-origin
82
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
83
- return proto + '//' + location.host + SYNC_PATH;
124
+ if (!host) {
125
+ proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
126
+ host = location.host;
127
+ }
128
+ const tok = authToken();
129
+ return proto + '//' + host + SYNC_PATH + (tok ? '?token=' + encodeURIComponent(tok) : '');
84
130
  }
85
131
 
86
132
  function ensureWs(base) {
133
+ _wsBaseHint = base || _wsBaseHint;
87
134
  if (_ws && _ws.readyState === 1) return _wsReady;
88
135
  if (_ws && _ws.readyState === 0) return _wsReady;
89
- _ws = new WebSocket(wsUrl(base));
136
+ _ws = new WebSocket(wsUrl(_wsBaseHint));
90
137
  _wsReady = new Promise((resolve, reject) => {
91
- _ws.addEventListener('open', () => resolve(_ws));
92
- _ws.addEventListener('error', (e) => reject(e));
138
+ _ws.addEventListener('open', () => {
139
+ _reconnectAttempts = 0;
140
+ emitStatus('open');
141
+ // Re-subscribe any session listeners that survived the disconnect.
142
+ for (const sid of _sessionListeners.keys()) {
143
+ try { _ws.send(encode({ m: 'conversation.subscribe', r: _nextReqId++, p: { sessionId: sid } })); } catch {}
144
+ }
145
+ resolve(_ws);
146
+ });
147
+ _ws.addEventListener('error', (e) => { emitStatus('error'); reject(e); });
93
148
  _ws.addEventListener('close', () => {
94
- // Reject all pending requests on close so callers can recover.
149
+ emitStatus('closed');
95
150
  for (const [, p] of _pending) p.reject(new Error('ws closed'));
96
151
  _pending.clear();
97
152
  _ws = null;
98
153
  _wsReady = null;
154
+ // Auto-reconnect if there are listeners or callers will retry.
155
+ if (_sessionListeners.size > 0 || _statusListeners.size > 0) scheduleReconnect();
99
156
  });
100
157
  _ws.addEventListener('message', (ev) => {
101
158
  let msg;
@@ -161,7 +218,7 @@ export async function listModels(base) {
161
218
  // { type: 'error', error: '...' }
162
219
  //
163
220
  // Caller signature kept compatible with the previous HTTP/SSE impl.
164
- export async function* streamChat(base, { model, messages, signal, agentId }) {
221
+ export async function* streamChat(base, { model, messages, signal, agentId, resumeSid }) {
165
222
  // The last user message is the prompt; agentgui's claude-runner doesn't
166
223
  // accept a full message list — it spawns the agent for a single prompt.
167
224
  // For multi-turn, the agent's own session/resume handles continuity.
@@ -195,7 +252,7 @@ export async function* streamChat(base, { model, messages, signal, agentId }) {
195
252
  // Kick off the chat on the server.
196
253
  let started;
197
254
  try {
198
- started = await wsCall(base, 'chat.sendMessage', { content, agentId: resolvedAgentId, model: resolvedModel });
255
+ started = await wsCall(base, 'chat.sendMessage', { content, agentId: resolvedAgentId, model: resolvedModel, resumeSid });
199
256
  } catch (e) {
200
257
  yield { type: 'error', error: e.message };
201
258
  return;