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 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 (acptoapi history integration)
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
- ## ACP-to-API History Integration
77
+ ## History Integration via ccsniff (2026-05-21 reverted from acptoapi)
78
78
 
79
- **acptoapi (c:\dev\acptoapi) merged Claude Code history routes as of 2026-05-02; ccsniff package is no longer needed.**
79
+ **agentgui mounts `ccsniff`'s history router in-process no external proxy.**
80
80
 
81
- History functionality (`GET /v1/history/*` endpoints) is now built into acptoapi. Routes: `snapshot` (event/session/project/tool/error counts + byte/date range), `sessions` (list with title/project/cwd/counts), `sessions/:sid/events` (flattened events), `search` (BM25 with snippets), `reindex` (rebuild index), `stream` (SSE). Implementation: `lib/history/` (bm25.js for tokenize/buildIndex/search/snippet, watcher.js for JsonlWatcher + JsonlReplayer, index.js for HistoryStore singleton + flattenEvent). Reads `~/.claude/projects` by default; override with `CLAUDE_PROJECTS_DIR` env var. The ccsniff package itself is no longer needed acptoapi covers the functionality entirely.
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 acptoapi's /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 }`.
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 from acptoapi.
135
+ Throttle renders via `requestAnimationFrame` to avoid event storm during burst loads.
136
136
 
137
- **First request to acptoapi /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).
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
- acptoapi binary: `c:/dev/acptoapi/bin/agentapi.js`, default port 4800, no args; CORS `Access-Control-Allow-Origin: *` is enabled.
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
- ## Live client (new)
13
+ ## How it works
14
14
 
15
- AgentGUI now ships a static GH Pages client at **`/app/`** that talks to any [`acptoapi`](https://github.com/AnEntrypoint/acptoapi) backend over plain HTTP no install, no bundler, no DB. Open `https://anentrypoint.github.io/agentgui/app/?backend=http://your-acptoapi-host:4800` and chat with any provider acptoapi proxies (Claude / Gemini / OpenAI-compat brands / kilo / opencode), plus browse local Claude Code JSONL history right in the page.
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
- - Backend: [acptoapi](https://www.npmjs.com/package/acptoapi)`npx acptoapi` on the host with Claude Code / API keys; exposes `/v1/chat/completions`, `/v1/messages`, `/v1/history/*`
19
- - Source: `site/app/` (this repo)
18
+ - Server: `server.js`ACP daemon manager + WebSocket chat + ccsniff history mount
19
+ - Source: `site/app/` (browser) + `lib/` (server)
20
20
 
21
- History endpoints in `acptoapi` (formerly `ccsniff`'s job):
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
- The legacy Node server in this repo (`server.js`, `lib/`, `static/`) still ships in the npm `agentgui` package and is the install-friendly path. It is being phased out as the static client + acptoapi pair reach feature parity.
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
- 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
+ }
@@ -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 {
@@ -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
+ }
@@ -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.933",
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, {
@@ -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 — live client for any acptoapi backend.">
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" } }
@@ -1,4 +1,11 @@
1
- // acptoapi backend client. Resolves base URL from ?backend= or localStorage or default.
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
- const j = await r.json();
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
- export async function listModels(base) {
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
- // Streaming chat completions using OpenAI-style SSE.
65
- export async function* streamChat(base, { model, messages, signal }) {
66
- const r = await fetch(base + '/v1/chat/completions', {
67
- method: 'POST',
68
- headers: { 'Content-Type': 'application/json' },
69
- body: JSON.stringify({ model, messages, stream: true }),
70
- signal,
71
- });
72
- if (!r.ok) {
73
- const t = await r.text();
74
- throw new Error('chat: ' + r.status + ' ' + t.slice(0, 300));
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
- const reader = r.body.getReader();
77
- const dec = new TextDecoder();
78
- let buf = '';
79
- while (true) {
80
- const { done, value } = await reader.read();
81
- if (done) break;
82
- buf += dec.decode(value, { stream: true });
83
- let idx;
84
- while ((idx = buf.indexOf('\n\n')) !== -1) {
85
- const block = buf.slice(0, idx);
86
- buf = buf.slice(idx + 2);
87
- const line = block.split('\n').find(l => l.startsWith('data:'));
88
- if (!line) continue;
89
- const payload = line.slice(5).trim();
90
- if (payload === '[DONE]') return;
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
- const j = JSON.parse(payload);
93
- const delta = j.choices?.[0]?.delta?.content;
94
- if (delta) yield { type: 'text', text: delta };
95
- if (j.error) yield { type: 'error', error: j.error };
96
- } catch (_) {}
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
+ }