agentgui 1.0.933 → 1.0.935
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 +8 -8
- package/README.md +6 -6
- package/lib/codec.js +13 -4
- package/lib/http-handler.js +26 -0
- package/lib/terminal.js +112 -0
- package/lib/ws-handlers-util.js +103 -1
- package/lib/ws-setup.js +7 -0
- package/package.json +1 -2
- package/scripts/smoke-ws-chat.mjs +62 -0
- package/server.js +3 -0
- package/site/app/index.html +1 -1
- package/site/app/js/backend.js +187 -43
- package/site/app/js/codec.js +8 -0
package/AGENTS.md
CHANGED
|
@@ -25,7 +25,7 @@ Local server on PORT=3056 (default), `bun server.js`:
|
|
|
25
25
|
|
|
26
26
|
## Learning audit
|
|
27
27
|
|
|
28
|
-
- 2026-05-02 session: 5 items audited (CI bun, stream imports, windows fallback, GM blocker, ACP history), 0 removed (rs-learn retrieval not yet confirmed; safety default kept all), 1 new fact ingested (
|
|
28
|
+
- 2026-05-02 session: 5 items audited (CI bun, stream imports, windows fallback, GM blocker, ACP history), 0 removed (rs-learn retrieval not yet confirmed; safety default kept all), 1 new fact ingested (in-process ccsniff history integration)
|
|
29
29
|
|
|
30
30
|
## CI / GitHub Actions
|
|
31
31
|
|
|
@@ -74,11 +74,11 @@ The actual hook content is generated/templated from c:/dev/gm, likely from `lib/
|
|
|
74
74
|
|
|
75
75
|
Only after the real generator is patched will agentgui sessions run autonomously without per-tool ceremony.
|
|
76
76
|
|
|
77
|
-
##
|
|
77
|
+
## History Integration via ccsniff (2026-05-21 — reverted from acptoapi)
|
|
78
78
|
|
|
79
|
-
**
|
|
79
|
+
**agentgui mounts `ccsniff`'s history router in-process — no external proxy.**
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
`server.js` imports `createHistoryRouter` from the `ccsniff` package and mounts it on the internal Express app at `/`, exposing `GET /v1/history/{snapshot,sessions,sessions/:sid/events,search,reindex,stream}`. Reads `~/.claude/projects` by default; override with `CLAUDE_PROJECTS_DIR` env var. acptoapi's previously-bundled history routes were removed in acptoapi 1.0.103; ccsniff is now the canonical source. Browser client (`site/app/js/backend.js`) calls these same-origin via the agentgui server.
|
|
82
82
|
|
|
83
83
|
## buildSystemPrompt System Prompt for claude-code
|
|
84
84
|
|
|
@@ -124,7 +124,7 @@ Surfaced 2026-05-04 while validating the chat surface in `site/app/`. Fix applie
|
|
|
124
124
|
|
|
125
125
|
**`site/app/js/app.js` opens SSE EventSource on navTo('history'); first /v1/history/* request triggers 30-90s loadOnce() synchronous walk.**
|
|
126
126
|
|
|
127
|
-
The live history feature wires
|
|
127
|
+
The live history feature wires the in-process ccsniff `/v1/history/stream` SSE endpoint via `B.streamHistory(base, onEvent)` on tab entry and closes the EventSource on tab exit. State shape: `state.live = { es, connected, lastEventTs, error, eventCount }`.
|
|
128
128
|
|
|
129
129
|
Event dispatch loop:
|
|
130
130
|
- `hello` event: set `connected=true`
|
|
@@ -132,8 +132,8 @@ Event dispatch loop:
|
|
|
132
132
|
- `conversation` event: call `refreshHistory()` to reload session list
|
|
133
133
|
- `error` event: set `live.error`
|
|
134
134
|
|
|
135
|
-
Throttle renders via `requestAnimationFrame` to avoid event storm during burst loads
|
|
135
|
+
Throttle renders via `requestAnimationFrame` to avoid event storm during burst loads.
|
|
136
136
|
|
|
137
|
-
**First request to
|
|
137
|
+
**First request to `/v1/history/*` triggers loadOnce() that walks all JSONL files under ~/.claude/projects** (env default; override with `CLAUDE_PROJECTS_DIR`). In our test env: 299 files, 80MB, 69k events → 30-90s startup latency. Health check timeouts during this window are normal and expected. Subsequent requests are fast (cached index).
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
The endpoints are served by ccsniff's Express router mounted in-process from `server.js`. No external proxy.
|
package/README.md
CHANGED
|
@@ -10,22 +10,22 @@
|
|
|
10
10
|
|
|
11
11
|
Multi-agent GUI client for AI coding agents with real-time streaming, WebSocket sync, and SQLite persistence.
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## How it works
|
|
14
14
|
|
|
15
|
-
AgentGUI
|
|
15
|
+
AgentGUI is a single Node server (`server.js`) plus a same-origin browser client (`site/app/`). The server speaks ACP directly to local agent daemons (Claude Code, OpenCode, Kilo, Gemini CLI, etc.) via `@agentclientprotocol/sdk` and hosts `ccsniff`'s `/v1/history/*` Express router in-process for Claude Code JSONL session browsing — no external proxy required.
|
|
16
16
|
|
|
17
17
|
- UI: [anentrypoint-design](https://www.npmjs.com/package/anentrypoint-design) (CDN, single-file ESM)
|
|
18
|
-
-
|
|
19
|
-
- Source: `site/app/` (
|
|
18
|
+
- Server: `server.js` — ACP daemon manager + WebSocket chat + ccsniff history mount
|
|
19
|
+
- Source: `site/app/` (browser) + `lib/` (server)
|
|
20
20
|
|
|
21
|
-
History endpoints
|
|
21
|
+
History endpoints served by [`ccsniff`](https://github.com/AnEntrypoint/ccsniff) directly:
|
|
22
22
|
|
|
23
23
|
- `GET /v1/history/sessions` — list Claude Code sessions on the host
|
|
24
24
|
- `GET /v1/history/sessions/:sid/events` — flattened events for one session
|
|
25
25
|
- `GET /v1/history/search?q=…` — BM25-ranked search across all events
|
|
26
26
|
- `GET /v1/history/stream` — Server-Sent Events for live tailing
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Chat streams over the `/sync` WebSocket using the `chat.sendMessage` method — see "WebSocket Chat Protocol" below.
|
|
29
29
|
|
|
30
30
|
### Supported Agents
|
|
31
31
|
|
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
|
@@ -2,6 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import crypto from 'crypto';
|
|
5
|
+
import * as term from './terminal.js';
|
|
5
6
|
|
|
6
7
|
export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
|
|
7
8
|
return async function httpHandler(req, res) {
|
|
@@ -49,6 +50,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
49
50
|
else if (req.url.startsWith('/api/') || req.url.startsWith('/js/') || req.url.startsWith('/css/') ||
|
|
50
51
|
req.url.startsWith('/vendor/') || req.url.startsWith('/sync') || req.url === '/' ||
|
|
51
52
|
req.url === '/health' || req.url.startsWith('/v1/') ||
|
|
53
|
+
req.url.startsWith('/api/terminal/') ||
|
|
52
54
|
req.url.startsWith('/conversations/')) { routePath = req.url; }
|
|
53
55
|
else { res.writeHead(404); res.end('Not found'); return; }
|
|
54
56
|
|
|
@@ -66,6 +68,30 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
66
68
|
return;
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
// Terminal sessions — gated by the Basic-auth check at the top of this handler.
|
|
72
|
+
// Never expose these routes without PASSWORD set.
|
|
73
|
+
if (pathOnly === '/api/terminal/sessions' && req.method === 'GET') {
|
|
74
|
+
sendJSON(req, res, 200, term.listSessions()); return;
|
|
75
|
+
}
|
|
76
|
+
if (pathOnly === '/api/terminal/sessions' && req.method === 'POST') {
|
|
77
|
+
let body = ''; for await (const c of req) body += c;
|
|
78
|
+
let p = {}; try { p = body ? JSON.parse(body) : {}; } catch {}
|
|
79
|
+
const s = term.createSession({ shell: p.shell, cwd: p.cwd, cols: p.cols, rows: p.rows, env: p.env });
|
|
80
|
+
sendJSON(req, res, 200, { sid: s.sid, kind: s.kind, shell: s.shell, cwd: s.cwd, cols: s.cols, rows: s.rows, pid: s.proc.pid });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const termMatch = pathOnly.match(/^\/api\/terminal\/sessions\/([0-9a-f]+)$/);
|
|
84
|
+
if (termMatch && req.method === 'GET') {
|
|
85
|
+
const s = term.getSession(termMatch[1]);
|
|
86
|
+
if (!s) { sendJSON(req, res, 404, { error: 'session not found' }); return; }
|
|
87
|
+
sendJSON(req, res, 200, { sid: s.sid, kind: s.kind, shell: s.shell, cwd: s.cwd, cols: s.cols, rows: s.rows, pid: s.proc.pid, clients: s.clients.size });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (termMatch && req.method === 'DELETE') {
|
|
91
|
+
const ok = term.closeSession(termMatch[1]);
|
|
92
|
+
sendJSON(req, res, ok ? 200 : 404, { ok }); return;
|
|
93
|
+
}
|
|
94
|
+
|
|
69
95
|
// Legacy REST handlers removed. History served by ccsniff at /v1/history/*.
|
|
70
96
|
for (const key of Object.keys(routes)) {
|
|
71
97
|
try {
|
package/lib/terminal.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// WebSocket terminal sessions. Ported from acptoapi 2026-05-21.
|
|
2
|
+
// Auth: callers MUST gate /api/terminal/* + the WS upgrade behind agentgui's
|
|
3
|
+
// PASSWORD Basic-auth check. This module does no auth on its own — never
|
|
4
|
+
// expose without the gate.
|
|
5
|
+
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
import { createRequire } from 'module';
|
|
10
|
+
const require_ = createRequire(import.meta.url);
|
|
11
|
+
|
|
12
|
+
let _pty = null;
|
|
13
|
+
function getPty() {
|
|
14
|
+
if (_pty !== null) return _pty;
|
|
15
|
+
try { _pty = require_('node-pty'); } catch { _pty = false; }
|
|
16
|
+
return _pty;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sessions = new Map();
|
|
20
|
+
|
|
21
|
+
function newSid() { return crypto.randomBytes(8).toString('hex'); }
|
|
22
|
+
|
|
23
|
+
function defaultShell() {
|
|
24
|
+
if (os.platform() === 'win32') return process.env.COMSPEC || 'cmd.exe';
|
|
25
|
+
return process.env.SHELL || '/bin/bash';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createSession({ shell, cwd, cols = 80, rows = 24, env } = {}) {
|
|
29
|
+
const sid = newSid();
|
|
30
|
+
const pty = getPty();
|
|
31
|
+
const _shell = shell || defaultShell();
|
|
32
|
+
const _cwd = cwd || process.env.HOME || os.homedir();
|
|
33
|
+
const _env = { ...process.env, ...(env || {}), TERM: 'xterm-256color', COLORTERM: 'truecolor' };
|
|
34
|
+
let proc, kind;
|
|
35
|
+
if (pty) {
|
|
36
|
+
proc = pty.spawn(_shell, [], { name: 'xterm-256color', cols, rows, cwd: _cwd, env: _env });
|
|
37
|
+
kind = 'pty';
|
|
38
|
+
} else {
|
|
39
|
+
proc = spawn(_shell, os.platform() === 'win32' ? [] : ['-i'], { cwd: _cwd, env: _env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
40
|
+
kind = 'pipe';
|
|
41
|
+
}
|
|
42
|
+
const session = { sid, kind, proc, shell: _shell, cwd: _cwd, cols, rows, createdAt: Date.now(), clients: new Set(), exitCode: null };
|
|
43
|
+
sessions.set(sid, session);
|
|
44
|
+
const onData = (chunk) => {
|
|
45
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8');
|
|
46
|
+
for (const ws of session.clients) { if (ws.readyState === 1) try { ws.send(buf); } catch {} }
|
|
47
|
+
};
|
|
48
|
+
if (kind === 'pty') {
|
|
49
|
+
proc.on('data', onData);
|
|
50
|
+
proc.on('exit', (code) => { session.exitCode = code; for (const ws of session.clients) { try { ws.close(1000, 'exit:' + code); } catch {} } sessions.delete(sid); });
|
|
51
|
+
} else {
|
|
52
|
+
proc.stdout.on('data', onData);
|
|
53
|
+
proc.stderr.on('data', onData);
|
|
54
|
+
proc.stdout.on('error', () => {});
|
|
55
|
+
proc.stderr.on('error', () => {});
|
|
56
|
+
proc.stdin.on('error', () => {});
|
|
57
|
+
proc.on('exit', (code) => { session.exitCode = code; for (const ws of session.clients) { try { ws.close(1000, 'exit:' + code); } catch {} } sessions.delete(sid); });
|
|
58
|
+
proc.on('error', (err) => { session.exitCode = -1; for (const ws of session.clients) { try { ws.close(1011, 'error:' + err.message); } catch {} } sessions.delete(sid); });
|
|
59
|
+
}
|
|
60
|
+
return session;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getSession(sid) { return sessions.get(sid); }
|
|
64
|
+
|
|
65
|
+
export function listSessions() {
|
|
66
|
+
return [...sessions.values()].map(s => ({ sid: s.sid, kind: s.kind, shell: s.shell, cwd: s.cwd, cols: s.cols, rows: s.rows, createdAt: s.createdAt, clients: s.clients.size }));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function closeSession(sid) {
|
|
70
|
+
const s = sessions.get(sid);
|
|
71
|
+
if (!s) return false;
|
|
72
|
+
try { s.proc.kill(); } catch {}
|
|
73
|
+
sessions.delete(sid);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function writeToSession(sid, data) {
|
|
78
|
+
const s = sessions.get(sid);
|
|
79
|
+
if (!s) return false;
|
|
80
|
+
const buf = typeof data === 'string' ? data : Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
81
|
+
if (s.kind === 'pty') {
|
|
82
|
+
try { s.proc.write(buf); } catch { return false; }
|
|
83
|
+
} else {
|
|
84
|
+
if (!s.proc.stdin || !s.proc.stdin.writable) return false;
|
|
85
|
+
try { s.proc.stdin.write(buf); } catch { return false; }
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function resizeSession(sid, cols, rows) {
|
|
91
|
+
const s = sessions.get(sid);
|
|
92
|
+
if (!s || s.kind !== 'pty' || typeof s.proc.resize !== 'function') return false;
|
|
93
|
+
s.cols = cols; s.rows = rows;
|
|
94
|
+
try { s.proc.resize(cols, rows); return true; } catch { return false; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function attachWs(sid, ws) {
|
|
98
|
+
const s = sessions.get(sid);
|
|
99
|
+
if (!s) { try { ws.close(4404, 'session-not-found'); } catch {} return false; }
|
|
100
|
+
s.clients.add(ws);
|
|
101
|
+
ws.on('close', () => { s.clients.delete(ws); });
|
|
102
|
+
ws.on('message', (msg, isBinary) => {
|
|
103
|
+
if (isBinary || Buffer.isBuffer(msg)) { writeToSession(sid, msg); return; }
|
|
104
|
+
const text = msg.toString();
|
|
105
|
+
if (text && text.length > 0 && text[0] === '{') {
|
|
106
|
+
try { const j = JSON.parse(text); if (j.type === 'resize' && j.cols && j.rows) { resizeSession(sid, j.cols, j.rows); return; } } catch {}
|
|
107
|
+
}
|
|
108
|
+
writeToSession(sid, text);
|
|
109
|
+
});
|
|
110
|
+
ws.on('error', () => { s.clients.delete(ws); });
|
|
111
|
+
return true;
|
|
112
|
+
}
|
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/lib/ws-setup.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { WebSocketServer } from 'ws';
|
|
4
|
+
import * as term from './terminal.js';
|
|
4
5
|
// Legacy WS handlers removed; no-op shim kept for callsite compatibility.
|
|
5
6
|
const registerLegacyHandler = () => {};
|
|
6
7
|
|
|
@@ -22,6 +23,12 @@ export function createWsSetup(server, { BASE_URL, watch, staticDir, _assetCache,
|
|
|
22
23
|
if (wsRoute === '/hot-reload') {
|
|
23
24
|
hotReloadClients.push(ws);
|
|
24
25
|
ws.on('close', () => { const i = hotReloadClients.indexOf(ws); if (i > -1) hotReloadClients.splice(i, 1); });
|
|
26
|
+
} else if (wsRoute.startsWith('/api/terminal/sessions/')) {
|
|
27
|
+
// Terminal session WS — auth was already enforced by wss-level PASSWORD
|
|
28
|
+
// check at the top of this connection callback. attach to existing session.
|
|
29
|
+
const m = wsRoute.match(/^\/api\/terminal\/sessions\/([0-9a-f]+)$/);
|
|
30
|
+
if (!m) { ws.close(4400, 'bad-terminal-path'); return; }
|
|
31
|
+
term.attachWs(m[1], ws);
|
|
25
32
|
} else if (wsRoute === '/sync') {
|
|
26
33
|
syncClients.add(ws);
|
|
27
34
|
ws.isAlive = true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentgui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.935",
|
|
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';
|
|
@@ -123,8 +124,10 @@ const { processMessageWithStreaming } = createProcessMessage({
|
|
|
123
124
|
scheduleRetry, drainMessageQueue, createEventHandler
|
|
124
125
|
});
|
|
125
126
|
|
|
127
|
+
const activeChats = new Map();
|
|
126
128
|
const wsRouter = new WsRouter();
|
|
127
129
|
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 });
|
|
130
|
+
registerWsHandlers(wsRouter, { queries, wsOptimizer, broadcastSync, getProviderConfigs, saveProviderConfig, STARTUP_CWD, discoveredAgents, subscriptionIndex, activeChats });
|
|
128
131
|
|
|
129
132
|
|
|
130
133
|
const { wss, hotReloadClients } = createWsSetup(server, {
|
package/site/app/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
6
|
<title>agentgui</title>
|
|
7
|
-
<meta name="description" content="agentgui —
|
|
7
|
+
<meta name="description" content="agentgui — multi-agent client with same-origin server, in-process ccsniff history, and ACP chat.">
|
|
8
8
|
<link rel="stylesheet" href="https://unpkg.com/anentrypoint-design@0.0.127/dist/247420.css">
|
|
9
9
|
<script type="importmap">
|
|
10
10
|
{ "imports": { "anentrypoint-design": "https://unpkg.com/anentrypoint-design@0.0.127/dist/247420.js" } }
|
package/site/app/js/backend.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
//
|
|
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
|
+
|
|
2
9
|
const KEY = 'agentgui.backend';
|
|
3
10
|
const DEFAULT_BACKEND = '';
|
|
4
11
|
|
|
@@ -9,27 +16,19 @@ export function getBackend() {
|
|
|
9
16
|
return localStorage.getItem(KEY) || DEFAULT_BACKEND;
|
|
10
17
|
}
|
|
11
18
|
|
|
12
|
-
export function setBackend(url) {
|
|
13
|
-
localStorage.setItem(KEY, url);
|
|
14
|
-
}
|
|
19
|
+
export function setBackend(url) { localStorage.setItem(KEY, url); }
|
|
15
20
|
|
|
16
21
|
export async function probeBackend(base) {
|
|
17
22
|
try {
|
|
18
23
|
const r = await fetch(base + '/health', { method: 'GET' });
|
|
19
24
|
if (!r.ok) return { ok: false, status: r.status };
|
|
20
|
-
|
|
21
|
-
return { ok: true, info: j };
|
|
25
|
+
return { ok: true, info: await r.json() };
|
|
22
26
|
} catch (e) {
|
|
23
27
|
return { ok: false, error: e.message };
|
|
24
28
|
}
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
const r = await fetch(base + '/v1/models');
|
|
29
|
-
if (!r.ok) throw new Error('models: ' + r.status);
|
|
30
|
-
const j = await r.json();
|
|
31
|
-
return j.data || [];
|
|
32
|
-
}
|
|
31
|
+
// ---------- History (HTTP, served by ccsniff) ----------
|
|
33
32
|
|
|
34
33
|
export async function listSessions(base) {
|
|
35
34
|
const r = await fetch(base + '/v1/history/sessions');
|
|
@@ -61,39 +60,184 @@ export function streamHistory(base, onEvent) {
|
|
|
61
60
|
return es;
|
|
62
61
|
}
|
|
63
62
|
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
63
|
+
// ---------- WebSocket client (/sync) ----------
|
|
64
|
+
|
|
65
|
+
const SYNC_PATH = '/sync';
|
|
66
|
+
let _ws = null;
|
|
67
|
+
let _wsReady = null; // Promise that resolves when ws is OPEN
|
|
68
|
+
let _nextReqId = 1;
|
|
69
|
+
const _pending = new Map(); // requestId → { resolve, reject }
|
|
70
|
+
const _sessionListeners = new Map(); // sessionId → Set<(event)=>void>
|
|
71
|
+
|
|
72
|
+
function wsUrl(base) {
|
|
73
|
+
if (base) {
|
|
74
|
+
// Absolute base like http://host:port → ws(s)://host:port/sync
|
|
75
|
+
try {
|
|
76
|
+
const u = new URL(base);
|
|
77
|
+
const proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
78
|
+
return proto + '//' + u.host + SYNC_PATH;
|
|
79
|
+
} catch {}
|
|
75
80
|
}
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
81
|
+
// Same-origin
|
|
82
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
83
|
+
return proto + '//' + location.host + SYNC_PATH;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ensureWs(base) {
|
|
87
|
+
if (_ws && _ws.readyState === 1) return _wsReady;
|
|
88
|
+
if (_ws && _ws.readyState === 0) return _wsReady;
|
|
89
|
+
_ws = new WebSocket(wsUrl(base));
|
|
90
|
+
_wsReady = new Promise((resolve, reject) => {
|
|
91
|
+
_ws.addEventListener('open', () => resolve(_ws));
|
|
92
|
+
_ws.addEventListener('error', (e) => reject(e));
|
|
93
|
+
_ws.addEventListener('close', () => {
|
|
94
|
+
// Reject all pending requests on close so callers can recover.
|
|
95
|
+
for (const [, p] of _pending) p.reject(new Error('ws closed'));
|
|
96
|
+
_pending.clear();
|
|
97
|
+
_ws = null;
|
|
98
|
+
_wsReady = null;
|
|
99
|
+
});
|
|
100
|
+
_ws.addEventListener('message', (ev) => {
|
|
101
|
+
let msg;
|
|
91
102
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
103
|
+
// Server sends text frames (JSON via codec). ev.data is string.
|
|
104
|
+
msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : decode(ev.data);
|
|
105
|
+
} catch { return; }
|
|
106
|
+
// Reply to a prior request?
|
|
107
|
+
if (msg && msg.r !== undefined && (msg.d !== undefined || msg.e !== undefined)) {
|
|
108
|
+
const p = _pending.get(msg.r);
|
|
109
|
+
if (!p) return;
|
|
110
|
+
_pending.delete(msg.r);
|
|
111
|
+
if (msg.e) p.reject(new Error(msg.e.m || ('ws error ' + msg.e.c)));
|
|
112
|
+
else p.resolve(msg.d);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Unsolicited broadcast — route by sessionId to subscribers.
|
|
116
|
+
// Server may send a single event or a batch (array) per ws-optimizer.
|
|
117
|
+
const items = Array.isArray(msg) ? msg : [msg];
|
|
118
|
+
for (const item of items) {
|
|
119
|
+
const sid = item?.sessionId;
|
|
120
|
+
if (!sid) continue;
|
|
121
|
+
const subs = _sessionListeners.get(sid);
|
|
122
|
+
if (!subs) continue;
|
|
123
|
+
for (const fn of subs) { try { fn(item); } catch {} }
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
return _wsReady;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function wsCall(base, method, params) {
|
|
131
|
+
return ensureWs(base).then(() => new Promise((resolve, reject) => {
|
|
132
|
+
const r = _nextReqId++;
|
|
133
|
+
_pending.set(r, { resolve, reject });
|
|
134
|
+
_ws.send(encode({ m: method, r, p: params || {} }));
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function addSessionListener(sessionId, fn) {
|
|
139
|
+
if (!_sessionListeners.has(sessionId)) _sessionListeners.set(sessionId, new Set());
|
|
140
|
+
_sessionListeners.get(sessionId).add(fn);
|
|
141
|
+
return () => {
|
|
142
|
+
const s = _sessionListeners.get(sessionId);
|
|
143
|
+
if (s) { s.delete(fn); if (s.size === 0) _sessionListeners.delete(sessionId); }
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------- Agents / models (WS) ----------
|
|
148
|
+
|
|
149
|
+
export async function listModels(base) {
|
|
150
|
+
const { agents } = await wsCall(base, 'agents.list', {});
|
|
151
|
+
// Compatibility shape: app.js expects an array of {id, name?, ...}
|
|
152
|
+
return agents || [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------- Streaming chat (WS) ----------
|
|
156
|
+
//
|
|
157
|
+
// Yields events of shape:
|
|
158
|
+
// { type: 'text', text: '...' } — assistant text deltas
|
|
159
|
+
// { type: 'tool', block: {...} } — tool_use blocks
|
|
160
|
+
// { type: 'result', block: {...} } — terminal result block
|
|
161
|
+
// { type: 'error', error: '...' }
|
|
162
|
+
//
|
|
163
|
+
// Caller signature kept compatible with the previous HTTP/SSE impl.
|
|
164
|
+
export async function* streamChat(base, { model, messages, signal, agentId }) {
|
|
165
|
+
// The last user message is the prompt; agentgui's claude-runner doesn't
|
|
166
|
+
// accept a full message list — it spawns the agent for a single prompt.
|
|
167
|
+
// For multi-turn, the agent's own session/resume handles continuity.
|
|
168
|
+
const last = messages[messages.length - 1];
|
|
169
|
+
const content = last?.content || '';
|
|
170
|
+
if (!content) return;
|
|
171
|
+
|
|
172
|
+
// app.js treats the "model" picker as the agent picker (selects from
|
|
173
|
+
// agents.list ids). If no explicit agentId is given, model IS the agent.
|
|
174
|
+
// If `model` looks like a real model id (has a slash), keep it as model
|
|
175
|
+
// and default agent to claude-code.
|
|
176
|
+
let resolvedAgentId = agentId;
|
|
177
|
+
let resolvedModel = model;
|
|
178
|
+
if (!resolvedAgentId) {
|
|
179
|
+
if (!model || /^[a-z][a-z0-9-]*$/.test(model)) {
|
|
180
|
+
// Bare slug — treat as agentId.
|
|
181
|
+
resolvedAgentId = model || 'claude-code';
|
|
182
|
+
resolvedModel = undefined;
|
|
183
|
+
} else {
|
|
184
|
+
resolvedAgentId = 'claude-code';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Queue events here; the async iterator pulls from it.
|
|
189
|
+
const queue = [];
|
|
190
|
+
let resolveWait = null;
|
|
191
|
+
let done = false;
|
|
192
|
+
let errored = null;
|
|
193
|
+
const push = (ev) => { queue.push(ev); if (resolveWait) { resolveWait(); resolveWait = null; } };
|
|
194
|
+
|
|
195
|
+
// Kick off the chat on the server.
|
|
196
|
+
let started;
|
|
197
|
+
try {
|
|
198
|
+
started = await wsCall(base, 'chat.sendMessage', { content, agentId: resolvedAgentId, model: resolvedModel });
|
|
199
|
+
} catch (e) {
|
|
200
|
+
yield { type: 'error', error: e.message };
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const sessionId = started?.sessionId;
|
|
204
|
+
if (!sessionId) { yield { type: 'error', error: 'no sessionId from server' }; return; }
|
|
205
|
+
|
|
206
|
+
const unsub = addSessionListener(sessionId, (ev) => {
|
|
207
|
+
if (ev.type === 'streaming_progress') {
|
|
208
|
+
const block = ev.block;
|
|
209
|
+
if (block?.type === 'text' && block.text) push({ type: 'text', text: block.text });
|
|
210
|
+
else if (block?.type === 'tool_use') push({ type: 'tool', block });
|
|
211
|
+
else if (block?.type === 'tool_result') push({ type: 'tool', block });
|
|
212
|
+
else if (block?.type === 'result') push({ type: 'result', block });
|
|
213
|
+
} else if (ev.type === 'streaming_complete') {
|
|
214
|
+
done = true;
|
|
215
|
+
if (resolveWait) { resolveWait(); resolveWait = null; }
|
|
216
|
+
} else if (ev.type === 'streaming_error') {
|
|
217
|
+
errored = ev.error || 'streaming error';
|
|
218
|
+
done = true;
|
|
219
|
+
if (resolveWait) { resolveWait(); resolveWait = null; }
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Wire AbortSignal to chat.cancel.
|
|
224
|
+
const onAbort = () => { wsCall(base, 'chat.cancel', { sessionId }).catch(() => {}); };
|
|
225
|
+
if (signal) {
|
|
226
|
+
if (signal.aborted) onAbort();
|
|
227
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
while (!done || queue.length > 0) {
|
|
232
|
+
if (queue.length === 0) {
|
|
233
|
+
await new Promise(r => { resolveWait = r; });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
yield queue.shift();
|
|
97
237
|
}
|
|
238
|
+
if (errored) yield { type: 'error', error: errored };
|
|
239
|
+
} finally {
|
|
240
|
+
unsub();
|
|
241
|
+
if (signal) signal.removeEventListener?.('abort', onAbort);
|
|
98
242
|
}
|
|
99
243
|
}
|
|
@@ -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
|
+
}
|