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 +3 -1
- package/lib/asset-server.js +1 -1
- package/lib/codec.js +13 -4
- package/lib/http-handler.js +25 -10
- package/lib/server-startup2.js +1 -1
- package/lib/ws-handlers-util.js +103 -1
- package/package.json +1 -2
- package/scripts/smoke-ws-chat.mjs +62 -0
- package/server.js +5 -1
- package/site/app/js/app.js +110 -22
- package/site/app/js/backend.js +219 -51
- package/site/app/js/codec.js +8 -0
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/codec.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
+
}
|
package/lib/http-handler.js
CHANGED
|
@@ -23,18 +23,31 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
23
23
|
if (_pwd) {
|
|
24
24
|
const _auth = req.headers['authorization'] || '';
|
|
25
25
|
let _ok = false;
|
|
26
|
+
const _checkToken = (tok) => {
|
|
27
|
+
try { return tok.length === _pwd.length && crypto.timingSafeEqual(Buffer.from(tok), Buffer.from(_pwd)); }
|
|
28
|
+
catch { return false; }
|
|
29
|
+
};
|
|
26
30
|
if (_auth.startsWith('Basic ')) {
|
|
27
31
|
try {
|
|
28
32
|
const _decoded = Buffer.from(_auth.slice(6), 'base64').toString('utf8');
|
|
29
33
|
const _ci = _decoded.indexOf(':');
|
|
30
|
-
if (_ci !== -1)
|
|
34
|
+
if (_ci !== -1) _ok = _checkToken(_decoded.slice(_ci + 1));
|
|
35
|
+
} catch (_) {}
|
|
36
|
+
} else if (_auth.startsWith('Bearer ')) {
|
|
37
|
+
_ok = _checkToken(_auth.slice(7));
|
|
38
|
+
}
|
|
39
|
+
// EventSource and same-origin links can't set headers — accept ?token= as fallback.
|
|
40
|
+
if (!_ok) {
|
|
41
|
+
try {
|
|
42
|
+
const _qsTok = new URL(req.url, 'http://localhost').searchParams.get('token');
|
|
43
|
+
if (_qsTok) _ok = _checkToken(_qsTok);
|
|
31
44
|
} catch (_) {}
|
|
32
45
|
}
|
|
33
46
|
if (!_ok) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="agentgui"' }); res.end('Unauthorized'); return; }
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
const pathOnly = req.url.split('?')[0];
|
|
37
|
-
if (pathOnly.startsWith(BASE_URL + '/api/upload/') || pathOnly.startsWith(BASE_URL + '/files/') || pathOnly.startsWith('/v1/history')) return expressApp(req, res);
|
|
50
|
+
if (pathOnly.startsWith(BASE_URL + '/api/upload/') || pathOnly.startsWith(BASE_URL + '/files/') || pathOnly.startsWith('/v1/history') || (BASE_URL && pathOnly.startsWith(BASE_URL + '/v1/history'))) return expressApp(req, res);
|
|
38
51
|
|
|
39
52
|
if (req.url === '/favicon.ico' || req.url === BASE_URL + '/favicon.ico') {
|
|
40
53
|
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#3b82f6"/><text x="50" y="68" font-size="50" font-family="sans-serif" font-weight="bold" fill="white" text-anchor="middle">G</text></svg>';
|
|
@@ -45,13 +58,14 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
45
58
|
// serve index.html at root directly (no redirect)
|
|
46
59
|
|
|
47
60
|
let routePath = req.url;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
else if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
const _bareUrl = req.url.split('?')[0];
|
|
62
|
+
if (_bareUrl.startsWith(BASE_URL + '/')) { routePath = req.url.slice(BASE_URL.length); }
|
|
63
|
+
else if (_bareUrl === BASE_URL) { routePath = '/'; }
|
|
64
|
+
else if (_bareUrl.startsWith('/api/') || _bareUrl.startsWith('/js/') || _bareUrl.startsWith('/css/') ||
|
|
65
|
+
_bareUrl.startsWith('/vendor/') || _bareUrl.startsWith('/sync') || _bareUrl === '/' ||
|
|
66
|
+
_bareUrl === '/health' || _bareUrl.startsWith('/v1/') ||
|
|
67
|
+
_bareUrl.startsWith('/api/terminal/') ||
|
|
68
|
+
_bareUrl.startsWith('/conversations/')) { routePath = req.url; }
|
|
55
69
|
else { res.writeHead(404); res.end('Not found'); return; }
|
|
56
70
|
|
|
57
71
|
routePath = routePath || '/';
|
|
@@ -120,7 +134,8 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
120
134
|
|
|
121
135
|
if (pathOnly.match(/^\/conversations\/[^\/]+$/)) { serveFile(path.join(staticDir, 'index.html'), res, req); return; }
|
|
122
136
|
|
|
123
|
-
|
|
137
|
+
const routePathBare = routePath.split('?')[0];
|
|
138
|
+
let filePath = routePathBare === '/' ? '/index.html' : routePathBare;
|
|
124
139
|
filePath = path.join(staticDir, filePath);
|
|
125
140
|
const normalizedPath = path.normalize(filePath);
|
|
126
141
|
if (!normalizedPath.startsWith(staticDir)) { res.writeHead(403); res.end('Forbidden'); return; }
|
package/lib/server-startup2.js
CHANGED
|
@@ -28,7 +28,7 @@ export function createAutoImport({ queries, broadcastSync }) {
|
|
|
28
28
|
try {
|
|
29
29
|
if (process.env.AGENTGUI_SKIP_AUTO_IMPORT === '1') return;
|
|
30
30
|
if (!hasIndexFilesChanged()) return;
|
|
31
|
-
const imported = queries.importClaudeCodeConversations();
|
|
31
|
+
const imported = queries.importClaudeCodeConversations() || [];
|
|
32
32
|
if (imported.length > 0) {
|
|
33
33
|
const importedCount = imported.filter(i => i.status === 'imported').length;
|
|
34
34
|
if (importedCount > 0) {
|
package/lib/ws-handlers-util.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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, {
|
package/site/app/js/app.js
CHANGED
|
@@ -18,9 +18,28 @@ const state = {
|
|
|
18
18
|
events: [],
|
|
19
19
|
searchQ: '',
|
|
20
20
|
searchHits: null,
|
|
21
|
+
historyError: null,
|
|
22
|
+
showSubagents: false,
|
|
21
23
|
live: { es: null, connected: false, lastEventTs: 0, error: null, eventCount: 0 },
|
|
22
24
|
};
|
|
23
25
|
|
|
26
|
+
function readHash() {
|
|
27
|
+
const m = (location.hash || '').match(/sid=([^&]+)/);
|
|
28
|
+
return m ? decodeURIComponent(m[1]) : null;
|
|
29
|
+
}
|
|
30
|
+
function writeHash(sid) {
|
|
31
|
+
const h = sid ? '#sid=' + encodeURIComponent(sid) : '';
|
|
32
|
+
if (location.hash !== h) history.replaceState(null, '', location.pathname + location.search + h);
|
|
33
|
+
}
|
|
34
|
+
function fmtRelTime(ts) {
|
|
35
|
+
if (!ts) return '';
|
|
36
|
+
const s = Math.round((Date.now() - ts) / 1000);
|
|
37
|
+
if (s < 60) return s + 's ago';
|
|
38
|
+
if (s < 3600) return Math.round(s/60) + 'm ago';
|
|
39
|
+
if (s < 86400) return Math.round(s/3600) + 'h ago';
|
|
40
|
+
return Math.round(s/86400) + 'd ago';
|
|
41
|
+
}
|
|
42
|
+
|
|
24
43
|
let render;
|
|
25
44
|
let renderScheduled = false;
|
|
26
45
|
function scheduleRender() {
|
|
@@ -242,34 +261,72 @@ async function sendChat() {
|
|
|
242
261
|
|
|
243
262
|
// ── history ────────────────────────────────────────────────────────────────
|
|
244
263
|
function historyMain() {
|
|
264
|
+
if (!state.selectedSid) {
|
|
265
|
+
return [PageHeader({
|
|
266
|
+
title: '§ history',
|
|
267
|
+
lede: 'pick a session from the sidebar — events stream live from ccsniff /v1/history.',
|
|
268
|
+
})];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
|
|
272
|
+
const lede = sess
|
|
273
|
+
? (sess.project || sess.cwd || '?') + ' · ' + (sess.events || 0) + ' events · ' + (sess.userTurns || 0) + ' turns · ' + fmtRelTime(sess.last)
|
|
274
|
+
: state.selectedSid;
|
|
275
|
+
|
|
245
276
|
const head = PageHeader({
|
|
246
|
-
title: '§
|
|
247
|
-
lede
|
|
248
|
-
? 'session ' + state.selectedSid
|
|
249
|
-
: 'pick a session from the sidebar — events stream from ccsniff /v1/history.',
|
|
277
|
+
title: '§ ' + (sess?.title || state.selectedSid).slice(0, 80),
|
|
278
|
+
lede,
|
|
250
279
|
});
|
|
251
280
|
|
|
252
|
-
|
|
253
|
-
|
|
281
|
+
const actions = h('div', { key: 'acts', style: 'display:flex;gap:.5em;padding:0 0 .75em 0' },
|
|
282
|
+
Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: '▶ open in chat' }),
|
|
283
|
+
Btn({ key: 'copy', onClick: () => { try { navigator.clipboard.writeText(state.selectedSid); } catch {} }, children: '⎘ copy sid' }),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
if (state.events.length === 0) {
|
|
287
|
+
return [head, actions, Panel({ title: 'events', children: h('p', { class: 'lede' }, '◌ loading…') })];
|
|
288
|
+
}
|
|
254
289
|
|
|
255
290
|
return [
|
|
256
291
|
head,
|
|
292
|
+
actions,
|
|
257
293
|
Panel({
|
|
258
294
|
title: state.events.length + ' events',
|
|
259
295
|
children: EventList({
|
|
260
|
-
items: state.events.map((e, i) =>
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
296
|
+
items: state.events.slice(-300).map((e, i) => {
|
|
297
|
+
const role = e.role || '?';
|
|
298
|
+
const type = e.type || '?';
|
|
299
|
+
const tool = e.tool ? ' · ⌘ ' + e.tool : '';
|
|
300
|
+
const errMark = e.isError ? ' · ⚠' : '';
|
|
301
|
+
const text = (e.text || '').replace(/\s+/g, ' ').trim();
|
|
302
|
+
return {
|
|
303
|
+
key: 'ev' + (e.i ?? i),
|
|
304
|
+
code: String((e.i ?? i) + 1).padStart(4, '0'),
|
|
305
|
+
title: text.slice(0, 220) || '(' + type + ')',
|
|
306
|
+
sub: new Date(e.ts).toLocaleString() + ' · ' + role + ' · ' + type + tool + errMark,
|
|
307
|
+
};
|
|
308
|
+
}),
|
|
266
309
|
}),
|
|
267
310
|
}),
|
|
268
311
|
];
|
|
269
312
|
}
|
|
270
313
|
|
|
314
|
+
function resumeInChat(sess) {
|
|
315
|
+
state.tab = 'chat';
|
|
316
|
+
closeLiveStream();
|
|
317
|
+
state.chat.draft = '/resume ' + (sess?.sid || state.selectedSid);
|
|
318
|
+
render();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function visibleSessions() {
|
|
322
|
+
const arr = Array.isArray(state.sessions) ? state.sessions : [];
|
|
323
|
+
const filtered = state.showSubagents ? arr : arr.filter(s => !s.isSubagent);
|
|
324
|
+
return filtered.slice().sort((a, b) => (b.last || 0) - (a.last || 0));
|
|
325
|
+
}
|
|
326
|
+
|
|
271
327
|
function historySide() {
|
|
272
328
|
const searching = !!state.searchHits;
|
|
329
|
+
const sessionsView = visibleSessions();
|
|
273
330
|
const rows = searching
|
|
274
331
|
? state.searchHits.results.slice(0, 60).map((r, i) =>
|
|
275
332
|
Row({
|
|
@@ -281,17 +338,18 @@ function historySide() {
|
|
|
281
338
|
onClick: () => loadSession(r.sid),
|
|
282
339
|
})
|
|
283
340
|
)
|
|
284
|
-
:
|
|
341
|
+
: sessionsView.slice(0, 120).map((s, i) =>
|
|
285
342
|
Row({
|
|
286
|
-
key: 'sess' +
|
|
343
|
+
key: 'sess' + s.sid,
|
|
287
344
|
rank: String(i + 1).padStart(3, '0'),
|
|
288
|
-
title: s.title || s.project || s.sid,
|
|
289
|
-
sub: s.events + ' ev · ' + s.tools + ' tools' + (s.errors ? ' · ' + s.errors + ' err' : ''),
|
|
290
|
-
rail: s.errors ? 'flame' : 'green',
|
|
345
|
+
title: (s.isSubagent ? '↳ ' : '') + (s.title || s.project || s.sid),
|
|
346
|
+
sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools' + (s.errors ? ' · ' + s.errors + ' err' : ''),
|
|
347
|
+
rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
|
|
291
348
|
active: s.sid === state.selectedSid,
|
|
292
349
|
onClick: () => loadSession(s.sid),
|
|
293
350
|
})
|
|
294
351
|
);
|
|
352
|
+
const subagentCount = (Array.isArray(state.sessions) ? state.sessions : []).filter(s => s.isSubagent).length;
|
|
295
353
|
|
|
296
354
|
return [
|
|
297
355
|
Side({
|
|
@@ -307,7 +365,7 @@ function historySide() {
|
|
|
307
365
|
],
|
|
308
366
|
}),
|
|
309
367
|
Panel({
|
|
310
|
-
title: searching ? 'matches' : 'sessions',
|
|
368
|
+
title: searching ? 'matches' : ('sessions · ' + sessionsView.length + (subagentCount && !state.showSubagents ? ' (+'+subagentCount+' sub)' : '')),
|
|
311
369
|
children: [
|
|
312
370
|
SearchInput({
|
|
313
371
|
key: 'searchInput',
|
|
@@ -315,7 +373,14 @@ function historySide() {
|
|
|
315
373
|
value: state.searchQ,
|
|
316
374
|
onInput: (v) => { state.searchQ = v; runSearch(); },
|
|
317
375
|
}),
|
|
318
|
-
|
|
376
|
+
!searching && subagentCount
|
|
377
|
+
? h('label', { key: 'subtog', class: 'lede', style: 'display:flex;gap:.5em;align-items:center;padding:.25em 0' },
|
|
378
|
+
h('input', { type: 'checkbox', checked: state.showSubagents, onChange: (e) => { state.showSubagents = e.target.checked; render(); } }),
|
|
379
|
+
'show subagents (' + subagentCount + ')')
|
|
380
|
+
: null,
|
|
381
|
+
state.historyError
|
|
382
|
+
? h('p', { key: 'err', class: 'lede' }, '⚠ ' + state.historyError)
|
|
383
|
+
: (rows.length ? h('div', { key: 'rows' }, ...rows) : h('p', { key: 'empty', class: 'lede' }, 'no sessions yet')),
|
|
319
384
|
],
|
|
320
385
|
}),
|
|
321
386
|
];
|
|
@@ -363,7 +428,7 @@ function settingsMain() {
|
|
|
363
428
|
key: 'm' + i,
|
|
364
429
|
rank: String(i + 1).padStart(3, '0'),
|
|
365
430
|
title: m.id,
|
|
366
|
-
sub: m.
|
|
431
|
+
sub: m.name ? (m.name + ' · ' + (m.protocol || 'agent')) : (m.protocol || 'agent'),
|
|
367
432
|
rail: m.id === state.selectedModel ? 'green' : 'purple',
|
|
368
433
|
onClick: () => { state.selectedModel = m.id; render(); },
|
|
369
434
|
})
|
|
@@ -375,8 +440,15 @@ function settingsMain() {
|
|
|
375
440
|
|
|
376
441
|
// ── data ──────────────────────────────────────────────────────────────────
|
|
377
442
|
async function refreshHistory() {
|
|
378
|
-
try {
|
|
379
|
-
|
|
443
|
+
try {
|
|
444
|
+
state.sessions = await B.listSessions(state.backend);
|
|
445
|
+
state.historyError = null;
|
|
446
|
+
render();
|
|
447
|
+
} catch (e) {
|
|
448
|
+
state.historyError = e.message;
|
|
449
|
+
console.warn('history fetch failed:', e.message);
|
|
450
|
+
render();
|
|
451
|
+
}
|
|
380
452
|
}
|
|
381
453
|
|
|
382
454
|
async function runSearch() {
|
|
@@ -393,6 +465,7 @@ async function runSearch() {
|
|
|
393
465
|
async function loadSession(sid) {
|
|
394
466
|
state.selectedSid = sid;
|
|
395
467
|
state.events = [];
|
|
468
|
+
writeHash(sid);
|
|
396
469
|
render();
|
|
397
470
|
try { state.events = await B.getSessionEvents(state.backend, sid); render(); }
|
|
398
471
|
catch (e) {
|
|
@@ -414,6 +487,21 @@ async function init() {
|
|
|
414
487
|
if (!state.selectedModel && state.models[0]) state.selectedModel = state.models[0].id;
|
|
415
488
|
render();
|
|
416
489
|
} catch (e) { console.warn('models fetch failed:', e.message); }
|
|
490
|
+
|
|
491
|
+
const initialSid = readHash();
|
|
492
|
+
if (initialSid) {
|
|
493
|
+
navTo('history');
|
|
494
|
+
await refreshHistory();
|
|
495
|
+
await loadSession(initialSid);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
B.onWsStatus?.((s) => {
|
|
499
|
+
if (s === 'closed' || s === 'error') {
|
|
500
|
+
if (state.health.status === 'ok') { state.health = { ...state.health, ws: 'reconnecting' }; render(); }
|
|
501
|
+
} else if (s === 'open') {
|
|
502
|
+
if (state.health.ws) { delete state.health.ws; render(); }
|
|
503
|
+
}
|
|
504
|
+
});
|
|
417
505
|
}
|
|
418
506
|
|
|
419
507
|
render = mount(document.getElementById('app'), view);
|
package/site/app/js/backend.js
CHANGED
|
@@ -1,9 +1,32 @@
|
|
|
1
|
-
// agentgui backend client.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
41
|
+
const r = await authedFetch(base + '/health', { method: 'GET' });
|
|
21
42
|
if (!r.ok) return { ok: false, status: r.status };
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
52
|
+
const r = await authedFetch(base + '/v1/history/sessions');
|
|
38
53
|
if (!r.ok) throw new Error('sessions: ' + r.status);
|
|
39
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
+
}
|