agentgui 1.0.934 → 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')) {
package/lib/codec.js CHANGED
@@ -1,4 +1,13 @@
1
- import { pack, unpack } from 'msgpackr';
2
-
3
- export function encode(obj) { return pack(obj); }
4
- export function decode(buf) { return unpack(buf instanceof Uint8Array ? buf : new Uint8Array(buf)); }
1
+ // agentgui WS wire codec plain JSON (UTF-8 text frames).
2
+ // Browser-compatible (no msgpackr). encode() returns a string the ws library
3
+ // sends as a text frame; decode() handles both Buffer/Uint8Array (Node) and
4
+ // string (browser) inputs.
5
+
6
+ export function encode(obj) { return JSON.stringify(obj); }
7
+
8
+ export function decode(buf) {
9
+ if (typeof buf === 'string') return JSON.parse(buf);
10
+ if (buf instanceof Uint8Array) return JSON.parse(new TextDecoder().decode(buf));
11
+ if (buf && typeof buf.toString === 'function') return JSON.parse(buf.toString('utf8'));
12
+ return JSON.parse(String(buf));
13
+ }
@@ -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) {
@@ -1,7 +1,10 @@
1
1
  import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
+ import crypto from 'crypto';
4
5
  import { execSync, spawnSync } from 'child_process';
6
+ import { runClaudeWithStreaming } from './claude-runner-run.js';
7
+ import { registry } from './claude-runner-agents.js';
5
8
 
6
9
  function err(code, message) { const e = new Error(message); e.code = code; throw e; }
7
10
 
@@ -13,7 +16,106 @@ const SUB_AGENT_MAP = {
13
16
  };
14
17
 
15
18
  export function register(router, deps) {
16
- const { queries, wsOptimizer, broadcastSync, getProviderConfigs, saveProviderConfig, STARTUP_CWD, discoveredAgents } = deps;
19
+ const { queries, wsOptimizer, broadcastSync, getProviderConfigs, saveProviderConfig, STARTUP_CWD, discoveredAgents, subscriptionIndex, activeChats } = deps;
20
+
21
+ // --- agents.list: enumerate registered ACP agents + claude-code ---
22
+ router.handle('agents.list', () => {
23
+ const agents = registry.list().map(a => ({
24
+ id: a.id,
25
+ name: a.name,
26
+ protocol: a.protocol,
27
+ supportsStdin: !!a.supportsStdin,
28
+ features: a.supportedFeatures || [],
29
+ }));
30
+ return { agents };
31
+ });
32
+
33
+ // --- conversation.subscribe: register this ws for sessionId broadcasts ---
34
+ router.handle('conversation.subscribe', (p, ws) => {
35
+ const sid = p?.sessionId;
36
+ if (!sid || typeof sid !== 'string') err(400, 'sessionId required');
37
+ if (!subscriptionIndex.has(sid)) subscriptionIndex.set(sid, new Set());
38
+ subscriptionIndex.get(sid).add(ws);
39
+ ws.subscriptions = ws.subscriptions || new Set();
40
+ ws.subscriptions.add(sid);
41
+ return { subscribed: true, sessionId: sid };
42
+ });
43
+
44
+ // --- chat.sendMessage: start a one-shot streaming chat with an agent.
45
+ // Bypasses the gutted db-queries layer entirely; calls runClaudeWithStreaming
46
+ // directly and broadcasts streaming_* events scoped to an ephemeral sessionId.
47
+ router.handle('chat.sendMessage', async (p, ws) => {
48
+ const content = (p?.content || '').toString();
49
+ if (!content) err(400, 'content required');
50
+ const agentId = p?.agentId || 'claude-code';
51
+ const model = p?.model || undefined;
52
+ const subAgent = p?.subAgent || undefined;
53
+ const cwd = p?.cwd || STARTUP_CWD;
54
+ if (!registry.has(agentId)) err(404, `Unknown agentId: ${agentId}`);
55
+
56
+ const sessionId = 'chat-' + crypto.randomBytes(8).toString('hex');
57
+ // Auto-subscribe the originating ws so it receives its own broadcasts.
58
+ if (!subscriptionIndex.has(sessionId)) subscriptionIndex.set(sessionId, new Set());
59
+ subscriptionIndex.get(sessionId).add(ws);
60
+ ws.subscriptions = ws.subscriptions || new Set();
61
+ ws.subscriptions.add(sessionId);
62
+
63
+ const ctrl = { aborted: false, proc: null };
64
+ activeChats.set(sessionId, ctrl);
65
+
66
+ // Fire-and-forget. Errors broadcast as streaming_error.
67
+ (async () => {
68
+ let eventCount = 0;
69
+ broadcastSync({ type: 'streaming_start', sessionId, agentId, timestamp: Date.now() });
70
+ const onEvent = (parsed) => {
71
+ eventCount++;
72
+ if (parsed?.type === 'assistant' && parsed.message?.content) {
73
+ for (const block of parsed.message.content) {
74
+ broadcastSync({ type: 'streaming_progress', sessionId, block, blockRole: 'assistant', seq: eventCount, timestamp: Date.now() });
75
+ }
76
+ } else if (parsed?.type === 'user' && parsed.message?.content) {
77
+ const blocks = Array.isArray(parsed.message.content) ? parsed.message.content : [];
78
+ for (const block of blocks) {
79
+ if (block?.type === 'tool_result') {
80
+ broadcastSync({ type: 'streaming_progress', sessionId, block, blockRole: 'tool_result', seq: eventCount, timestamp: Date.now() });
81
+ }
82
+ }
83
+ } else if (parsed?.type === 'result') {
84
+ const block = { type: 'result', result: parsed.result, subtype: parsed.subtype, duration_ms: parsed.duration_ms, total_cost_usd: parsed.total_cost_usd, is_error: !!parsed.is_error };
85
+ broadcastSync({ type: 'streaming_progress', sessionId, block, blockRole: 'result', seq: eventCount, isResult: true, timestamp: Date.now() });
86
+ }
87
+ };
88
+ try {
89
+ const config = {
90
+ verbose: true, outputFormat: 'stream-json', timeout: 1800000, print: true,
91
+ model, subAgent, onEvent,
92
+ onPid: () => {}, onProcess: (proc) => { ctrl.proc = proc; },
93
+ };
94
+ await runClaudeWithStreaming(content, cwd, agentId, config);
95
+ broadcastSync({ type: 'streaming_complete', sessionId, agentId, eventCount, timestamp: Date.now() });
96
+ } catch (e) {
97
+ broadcastSync({ type: 'streaming_error', sessionId, agentId, error: e.message || String(e), recoverable: false, timestamp: Date.now() });
98
+ } finally {
99
+ activeChats.delete(sessionId);
100
+ }
101
+ })();
102
+
103
+ return { sessionId, started: true };
104
+ });
105
+
106
+ // --- chat.cancel: abort an in-flight chat ---
107
+ router.handle('chat.cancel', (p) => {
108
+ const sid = p?.sessionId;
109
+ if (!sid) err(400, 'sessionId required');
110
+ const ctrl = activeChats.get(sid);
111
+ if (!ctrl) return { cancelled: false, reason: 'not-found' };
112
+ ctrl.aborted = true;
113
+ try { ctrl.proc?.kill?.(); } catch {}
114
+ activeChats.delete(sid);
115
+ return { cancelled: true };
116
+ });
117
+
118
+
17
119
 
18
120
  router.handle('home', () => ({ home: os.homedir(), cwd: STARTUP_CWD }));
19
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.934",
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",
@@ -37,7 +37,6 @@
37
37
  "form-data": "^4.0.5",
38
38
  "fsbrowse": "latest",
39
39
  "lru-cache": "^11.2.7",
40
- "msgpackr": "^1.11.8",
41
40
  "opencode-ai": "^1.2.15",
42
41
  "p-retry": "^7.1.1",
43
42
  "puppeteer-core": "^24.37.5",
@@ -0,0 +1,62 @@
1
+ // Smoke-test the WS chat protocol added in this session.
2
+ // Boots no server — assumes one is already running at ws://localhost:$PORT/sync.
3
+ //
4
+ // Usage: PORT=3990 node scripts/smoke-ws-chat.mjs
5
+ //
6
+ // Checks:
7
+ // 1. WS connects, receives sync_connected.
8
+ // 2. agents.list returns a non-empty array.
9
+ // 3. (optional) conversation.subscribe returns subscribed:true.
10
+
11
+ import WebSocket from 'ws';
12
+
13
+ const PORT = process.env.PORT || 3000;
14
+ const URL = `ws://localhost:${PORT}/sync`;
15
+
16
+ const ws = new WebSocket(URL);
17
+ let reqId = 0;
18
+ const pending = new Map();
19
+
20
+ const call = (method, params) => new Promise((resolve, reject) => {
21
+ const r = ++reqId;
22
+ pending.set(r, { resolve, reject });
23
+ ws.send(JSON.stringify({ m: method, r, p: params || {} }));
24
+ setTimeout(() => { if (pending.has(r)) { pending.delete(r); reject(new Error('timeout: ' + method)); } }, 5000);
25
+ });
26
+
27
+ ws.on('open', async () => {
28
+ console.log('WS open:', URL);
29
+ });
30
+
31
+ ws.on('message', async (data) => {
32
+ let msg;
33
+ try { msg = JSON.parse(data.toString('utf8')); } catch { console.log('decode fail:', data); return; }
34
+ const items = Array.isArray(msg) ? msg : [msg];
35
+ for (const m of items) {
36
+ if (m && m.r !== undefined && (m.d !== undefined || m.e !== undefined)) {
37
+ const p = pending.get(m.r);
38
+ if (!p) continue;
39
+ pending.delete(m.r);
40
+ if (m.e) p.reject(new Error(m.e.m));
41
+ else p.resolve(m.d);
42
+ } else if (m?.type === 'sync_connected') {
43
+ console.log('sync_connected, clientId =', m.clientId);
44
+ try {
45
+ const r1 = await call('agents.list');
46
+ console.log('agents.list OK, count =', r1.agents.length, '— first =', r1.agents[0]?.id);
47
+ const r2 = await call('conversation.subscribe', { sessionId: 'smoke-test-sid' });
48
+ console.log('conversation.subscribe OK =', r2);
49
+ console.log('PASS');
50
+ process.exit(0);
51
+ } catch (e) {
52
+ console.error('FAIL:', e.message);
53
+ process.exit(1);
54
+ }
55
+ }
56
+ }
57
+ });
58
+
59
+ ws.on('error', (e) => { console.error('ws error:', e.message); process.exit(1); });
60
+ ws.on('close', (code, reason) => { console.log('ws close:', code, reason?.toString()); });
61
+
62
+ setTimeout(() => { console.error('overall timeout'); process.exit(1); }, 15000);
package/server.js CHANGED
@@ -11,6 +11,7 @@ import { runClaudeWithStreaming } from './lib/claude-runner-run.js';
11
11
  import { initializeDescriptors, getAgentDescriptor } from './lib/agent-descriptors.js';
12
12
  import { discoverExternalACPServers, initializeAgentDiscovery } from './lib/agent-discovery.js';
13
13
  import { createRegistry } from './lib/routes-registry.js';
14
+ import { register as registerWsHandlers } from './lib/ws-handlers-util.js';
14
15
  import { BROADCAST_TYPES } from './lib/broadcast.js';
15
16
  import { WSOptimizer } from './lib/ws-optimizer.js';
16
17
  import { WsRouter } from './lib/ws-protocol.js';
@@ -72,7 +73,8 @@ const expressApp = createExpressApp({ queries, BASE_URL });
72
73
  try {
73
74
  const historyRouter = await createHistoryRouter({ projectsDir: process.env.CLAUDE_PROJECTS_DIR });
74
75
  expressApp.use('/', historyRouter);
75
- 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 || '/'));
76
78
  } catch (e) { console.error('[ccsniff] mount failed:', e.message); }
77
79
 
78
80
  let discoveredAgents = [];
@@ -123,8 +125,10 @@ const { processMessageWithStreaming } = createProcessMessage({
123
125
  scheduleRetry, drainMessageQueue, createEventHandler
124
126
  });
125
127
 
128
+ const activeChats = new Map();
126
129
  const wsRouter = new WsRouter();
127
130
  createRegistry(wsRouter, { queries, sendJSON, parseBody, broadcastSync, debugLog, PORT, BASE_URL, rootDir, STARTUP_CWD, PKG_VERSION, processMessageWithStreaming, activeExecutions, activeProcessesByRunId, activeScripts, messageQueues, rateLimitState, cleanupExecution, discoveredAgents, getACPStatus, modelCache, getModelsForAgent, logError, syncClients, wsOptimizer, errLogPath, getJsonlWatcher: () => getJsonlWatcher(), routes: _routes });
131
+ registerWsHandlers(wsRouter, { queries, wsOptimizer, broadcastSync, getProviderConfigs, saveProviderConfig, STARTUP_CWD, discoveredAgents, subscriptionIndex, activeChats });
128
132
 
129
133
 
130
134
  const { wss, hotReloadClients } = createWsSetup(server, {
@@ -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);
@@ -1,9 +1,32 @@
1
- // agentgui backend client. Resolves base URL from ?backend= or localStorage or default
2
- // (same-origin). Talks to the local agentgui server: ccsniff history routes, /health,
3
- // and the /sync WebSocket for chat streaming.
1
+ // agentgui backend client. Same-origin by default. Talks to:
2
+ // - HTTP: /health, /v1/history/* (served by ccsniff)
3
+ // - WS : /sync (JSON envelope: requests {m, r, p}; replies {r, d|e};
4
+ // broadcasts {type, sessionId, ...})
5
+ // No external acptoapi dependency. Chat + agent listing flow over the WS.
6
+
7
+ import { encode, decode } from './codec.js';
8
+
4
9
  const KEY = 'agentgui.backend';
5
10
  const DEFAULT_BACKEND = '';
6
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
+
7
30
  export function getBackend() {
8
31
  const u = new URL(location.href);
9
32
  const fromQs = u.searchParams.get('backend');
@@ -11,49 +34,42 @@ export function getBackend() {
11
34
  return localStorage.getItem(KEY) || DEFAULT_BACKEND;
12
35
  }
13
36
 
14
- export function setBackend(url) {
15
- localStorage.setItem(KEY, url);
16
- }
37
+ export function setBackend(url) { localStorage.setItem(KEY, url); }
17
38
 
18
39
  export async function probeBackend(base) {
19
40
  try {
20
- const r = await fetch(base + '/health', { method: 'GET' });
41
+ const r = await authedFetch(base + '/health', { method: 'GET' });
21
42
  if (!r.ok) return { ok: false, status: r.status };
22
- const j = await r.json();
23
- return { ok: true, info: j };
43
+ return { ok: true, info: await r.json() };
24
44
  } catch (e) {
25
45
  return { ok: false, error: e.message };
26
46
  }
27
47
  }
28
48
 
29
- export async function listModels(base) {
30
- const r = await fetch(base + '/v1/models');
31
- if (!r.ok) throw new Error('models: ' + r.status);
32
- const j = await r.json();
33
- return j.data || [];
34
- }
49
+ // ---------- History (HTTP, served by ccsniff) ----------
35
50
 
36
51
  export async function listSessions(base) {
37
- const r = await fetch(base + '/v1/history/sessions');
52
+ const r = await authedFetch(base + '/v1/history/sessions');
38
53
  if (!r.ok) throw new Error('sessions: ' + r.status);
39
- return r.json();
54
+ const j = await r.json();
55
+ return Array.isArray(j) ? j : (j.sessions || []);
40
56
  }
41
57
 
42
58
  export async function getSessionEvents(base, sid) {
43
- const r = await fetch(base + '/v1/history/sessions/' + encodeURIComponent(sid) + '/events');
59
+ const r = await authedFetch(base + '/v1/history/sessions/' + encodeURIComponent(sid) + '/events');
44
60
  if (!r.ok) throw new Error('events: ' + r.status);
45
61
  const j = await r.json();
46
62
  return j.events || [];
47
63
  }
48
64
 
49
65
  export async function searchHistory(base, q, limit = 50) {
50
- 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);
51
67
  if (!r.ok) throw new Error('search: ' + r.status);
52
68
  return r.json();
53
69
  }
54
70
 
55
71
  export function streamHistory(base, onEvent) {
56
- const es = new EventSource(base + '/v1/history/stream');
72
+ const es = new EventSource(withToken(base + '/v1/history/stream'));
57
73
  for (const k of ['hello', 'event', 'error', 'start', 'complete', 'conversation']) {
58
74
  es.addEventListener(k, ev => {
59
75
  let data; try { data = JSON.parse(ev.data); } catch { data = null; }
@@ -63,39 +79,191 @@ export function streamHistory(base, onEvent) {
63
79
  return es;
64
80
  }
65
81
 
66
- // Streaming chat completions using OpenAI-style SSE.
67
- export async function* streamChat(base, { model, messages, signal }) {
68
- const r = await fetch(base + '/v1/chat/completions', {
69
- method: 'POST',
70
- headers: { 'Content-Type': 'application/json' },
71
- body: JSON.stringify({ model, messages, stream: true }),
72
- signal,
73
- });
74
- if (!r.ok) {
75
- const t = await r.text();
76
- throw new Error('chat: ' + r.status + ' ' + t.slice(0, 300));
82
+ // ---------- WebSocket client (/sync) ----------
83
+
84
+ const SYNC_PATH = '/sync';
85
+ let _ws = null;
86
+ let _wsReady = null; // Promise that resolves when ws is OPEN
87
+ let _nextReqId = 1;
88
+ const _pending = new Map(); // requestId → { resolve, reject }
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 {} } }
94
+
95
+ function wsUrl(base) {
96
+ let proto, host;
97
+ if (base) {
98
+ try {
99
+ const u = new URL(base);
100
+ proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
101
+ host = u.host;
102
+ } catch {}
103
+ }
104
+ if (!host) {
105
+ proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
106
+ host = location.host;
77
107
  }
78
- const reader = r.body.getReader();
79
- const dec = new TextDecoder();
80
- let buf = '';
81
- while (true) {
82
- const { done, value } = await reader.read();
83
- if (done) break;
84
- buf += dec.decode(value, { stream: true });
85
- let idx;
86
- while ((idx = buf.indexOf('\n\n')) !== -1) {
87
- const block = buf.slice(0, idx);
88
- buf = buf.slice(idx + 2);
89
- const line = block.split('\n').find(l => l.startsWith('data:'));
90
- if (!line) continue;
91
- const payload = line.slice(5).trim();
92
- if (payload === '[DONE]') return;
108
+ const tok = authToken();
109
+ return proto + '//' + host + SYNC_PATH + (tok ? '?token=' + encodeURIComponent(tok) : '');
110
+ }
111
+
112
+ function ensureWs(base) {
113
+ if (_ws && _ws.readyState === 1) return _wsReady;
114
+ if (_ws && _ws.readyState === 0) return _wsReady;
115
+ _ws = new WebSocket(wsUrl(base));
116
+ _wsReady = new Promise((resolve, reject) => {
117
+ _ws.addEventListener('open', () => { emitStatus('open'); resolve(_ws); });
118
+ _ws.addEventListener('error', (e) => { emitStatus('error'); reject(e); });
119
+ _ws.addEventListener('close', () => {
120
+ emitStatus('closed');
121
+ for (const [, p] of _pending) p.reject(new Error('ws closed'));
122
+ _pending.clear();
123
+ _ws = null;
124
+ _wsReady = null;
125
+ });
126
+ _ws.addEventListener('message', (ev) => {
127
+ let msg;
93
128
  try {
94
- const j = JSON.parse(payload);
95
- const delta = j.choices?.[0]?.delta?.content;
96
- if (delta) yield { type: 'text', text: delta };
97
- if (j.error) yield { type: 'error', error: j.error };
98
- } catch (_) {}
129
+ // Server sends text frames (JSON via codec). ev.data is string.
130
+ msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : decode(ev.data);
131
+ } catch { return; }
132
+ // Reply to a prior request?
133
+ if (msg && msg.r !== undefined && (msg.d !== undefined || msg.e !== undefined)) {
134
+ const p = _pending.get(msg.r);
135
+ if (!p) return;
136
+ _pending.delete(msg.r);
137
+ if (msg.e) p.reject(new Error(msg.e.m || ('ws error ' + msg.e.c)));
138
+ else p.resolve(msg.d);
139
+ return;
140
+ }
141
+ // Unsolicited broadcast — route by sessionId to subscribers.
142
+ // Server may send a single event or a batch (array) per ws-optimizer.
143
+ const items = Array.isArray(msg) ? msg : [msg];
144
+ for (const item of items) {
145
+ const sid = item?.sessionId;
146
+ if (!sid) continue;
147
+ const subs = _sessionListeners.get(sid);
148
+ if (!subs) continue;
149
+ for (const fn of subs) { try { fn(item); } catch {} }
150
+ }
151
+ });
152
+ });
153
+ return _wsReady;
154
+ }
155
+
156
+ function wsCall(base, method, params) {
157
+ return ensureWs(base).then(() => new Promise((resolve, reject) => {
158
+ const r = _nextReqId++;
159
+ _pending.set(r, { resolve, reject });
160
+ _ws.send(encode({ m: method, r, p: params || {} }));
161
+ }));
162
+ }
163
+
164
+ function addSessionListener(sessionId, fn) {
165
+ if (!_sessionListeners.has(sessionId)) _sessionListeners.set(sessionId, new Set());
166
+ _sessionListeners.get(sessionId).add(fn);
167
+ return () => {
168
+ const s = _sessionListeners.get(sessionId);
169
+ if (s) { s.delete(fn); if (s.size === 0) _sessionListeners.delete(sessionId); }
170
+ };
171
+ }
172
+
173
+ // ---------- Agents / models (WS) ----------
174
+
175
+ export async function listModels(base) {
176
+ const { agents } = await wsCall(base, 'agents.list', {});
177
+ // Compatibility shape: app.js expects an array of {id, name?, ...}
178
+ return agents || [];
179
+ }
180
+
181
+ // ---------- Streaming chat (WS) ----------
182
+ //
183
+ // Yields events of shape:
184
+ // { type: 'text', text: '...' } — assistant text deltas
185
+ // { type: 'tool', block: {...} } — tool_use blocks
186
+ // { type: 'result', block: {...} } — terminal result block
187
+ // { type: 'error', error: '...' }
188
+ //
189
+ // Caller signature kept compatible with the previous HTTP/SSE impl.
190
+ export async function* streamChat(base, { model, messages, signal, agentId }) {
191
+ // The last user message is the prompt; agentgui's claude-runner doesn't
192
+ // accept a full message list — it spawns the agent for a single prompt.
193
+ // For multi-turn, the agent's own session/resume handles continuity.
194
+ const last = messages[messages.length - 1];
195
+ const content = last?.content || '';
196
+ if (!content) return;
197
+
198
+ // app.js treats the "model" picker as the agent picker (selects from
199
+ // agents.list ids). If no explicit agentId is given, model IS the agent.
200
+ // If `model` looks like a real model id (has a slash), keep it as model
201
+ // and default agent to claude-code.
202
+ let resolvedAgentId = agentId;
203
+ let resolvedModel = model;
204
+ if (!resolvedAgentId) {
205
+ if (!model || /^[a-z][a-z0-9-]*$/.test(model)) {
206
+ // Bare slug — treat as agentId.
207
+ resolvedAgentId = model || 'claude-code';
208
+ resolvedModel = undefined;
209
+ } else {
210
+ resolvedAgentId = 'claude-code';
211
+ }
212
+ }
213
+
214
+ // Queue events here; the async iterator pulls from it.
215
+ const queue = [];
216
+ let resolveWait = null;
217
+ let done = false;
218
+ let errored = null;
219
+ const push = (ev) => { queue.push(ev); if (resolveWait) { resolveWait(); resolveWait = null; } };
220
+
221
+ // Kick off the chat on the server.
222
+ let started;
223
+ try {
224
+ started = await wsCall(base, 'chat.sendMessage', { content, agentId: resolvedAgentId, model: resolvedModel });
225
+ } catch (e) {
226
+ yield { type: 'error', error: e.message };
227
+ return;
228
+ }
229
+ const sessionId = started?.sessionId;
230
+ if (!sessionId) { yield { type: 'error', error: 'no sessionId from server' }; return; }
231
+
232
+ const unsub = addSessionListener(sessionId, (ev) => {
233
+ if (ev.type === 'streaming_progress') {
234
+ const block = ev.block;
235
+ if (block?.type === 'text' && block.text) push({ type: 'text', text: block.text });
236
+ else if (block?.type === 'tool_use') push({ type: 'tool', block });
237
+ else if (block?.type === 'tool_result') push({ type: 'tool', block });
238
+ else if (block?.type === 'result') push({ type: 'result', block });
239
+ } else if (ev.type === 'streaming_complete') {
240
+ done = true;
241
+ if (resolveWait) { resolveWait(); resolveWait = null; }
242
+ } else if (ev.type === 'streaming_error') {
243
+ errored = ev.error || 'streaming error';
244
+ done = true;
245
+ if (resolveWait) { resolveWait(); resolveWait = null; }
246
+ }
247
+ });
248
+
249
+ // Wire AbortSignal to chat.cancel.
250
+ const onAbort = () => { wsCall(base, 'chat.cancel', { sessionId }).catch(() => {}); };
251
+ if (signal) {
252
+ if (signal.aborted) onAbort();
253
+ else signal.addEventListener('abort', onAbort, { once: true });
254
+ }
255
+
256
+ try {
257
+ while (!done || queue.length > 0) {
258
+ if (queue.length === 0) {
259
+ await new Promise(r => { resolveWait = r; });
260
+ continue;
261
+ }
262
+ yield queue.shift();
99
263
  }
264
+ if (errored) yield { type: 'error', error: errored };
265
+ } finally {
266
+ unsub();
267
+ if (signal) signal.removeEventListener?.('abort', onAbort);
100
268
  }
101
269
  }
@@ -0,0 +1,8 @@
1
+ // Browser mirror of lib/codec.js. JSON over WS text frames.
2
+ export function encode(obj) { return JSON.stringify(obj); }
3
+ export function decode(buf) {
4
+ if (typeof buf === 'string') return JSON.parse(buf);
5
+ if (buf instanceof ArrayBuffer) return JSON.parse(new TextDecoder().decode(new Uint8Array(buf)));
6
+ if (buf instanceof Uint8Array) return JSON.parse(new TextDecoder().decode(buf));
7
+ return JSON.parse(String(buf));
8
+ }