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 +3 -1
- package/lib/asset-server.js +1 -1
- package/lib/http-handler.js +30 -11
- package/lib/server-startup2.js +1 -1
- package/lib/ws-handlers-util.js +2 -1
- package/package.json +1 -1
- package/server.js +2 -1
- package/site/app/js/app.js +173 -35
- package/site/app/js/backend.js +75 -18
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
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
else if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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; }
|
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/lib/ws-handlers-util.js
CHANGED
|
@@ -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
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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
83
|
-
state.live.error
|
|
84
|
-
|
|
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
|
|
104
|
-
|
|
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: '§
|
|
247
|
-
lede
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
?
|
|
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
|
-
:
|
|
378
|
+
: visible.map((s, i) =>
|
|
285
379
|
Row({
|
|
286
|
-
key: 'sess' +
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
379
|
-
|
|
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);
|
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,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
|
-
|
|
78
|
-
|
|
120
|
+
proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
121
|
+
host = u.host;
|
|
79
122
|
} catch {}
|
|
80
123
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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(
|
|
136
|
+
_ws = new WebSocket(wsUrl(_wsBaseHint));
|
|
90
137
|
_wsReady = new Promise((resolve, reject) => {
|
|
91
|
-
_ws.addEventListener('open', () =>
|
|
92
|
-
|
|
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
|
-
|
|
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;
|