agentgui 1.0.914 → 1.0.916

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
@@ -1,5 +1,27 @@
1
1
  # AgentGUI — Agent Notes
2
2
 
3
+ ## New architecture (2026-05-02 pivot)
4
+
5
+ There are now **two parallel surfaces** in this repo. Don't conflate them when editing.
6
+
7
+ 1. **Live client** at `site/app/` — single static page, imports [`anentrypoint-design`](https://www.npmjs.com/package/anentrypoint-design) from unpkg, talks to any [`acptoapi`](https://www.npmjs.com/package/acptoapi) backend over fetch / SSE. No build step. Deploys to GH Pages at `/app/` via `.github/workflows/gh-pages.yml` (post-flatspace copy step).
8
+ - `site/app/index.html` — shell + CSS
9
+ - `site/app/js/backend.js` — acptoapi client (models, chat-stream, history, search, SSE)
10
+ - `site/app/js/app.js` — webjsx view + state, exposes `window.__agentgui` for debug
11
+ - Configurable backend URL via `?backend=…` query string or `localStorage['agentgui.backend']`
12
+
13
+ 2. **Legacy server** (`server.js`, `lib/`, `static/`) — the npm-installable Node app, still works, still gets `npm run dev`. Being phased out as the static client + acptoapi cover its features. Don't delete in passing; full removal is its own PR.
14
+
15
+ Backend dependencies:
16
+ - `acptoapi` provides chat / messages / models endpoints (existing) + new history endpoints (`/v1/history/sessions`, `/v1/history/sessions/:sid/events`, `/v1/history/search`, `/v1/history/stream`) — see `c:\dev\acptoapi\lib\history\` (ccsniff functionality merged in 2026-05-02).
17
+ - `anentrypoint-design` provides AppShell / Chat / FileGrid / etc. — single-file ESM from unpkg, no install.
18
+
19
+ The static client never imports anything from `lib/` or `server.js`. Cross-contamination = bug.
20
+
21
+ ## Learning audit
22
+
23
+ - 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)
24
+
3
25
  ## CI / GitHub Actions
4
26
 
5
27
  **capture-screenshots must run under bun, not node.**
@@ -31,3 +53,24 @@ On Windows hosts without bun installed, the auto-provisioner on startup and 6h p
31
53
  Fix: `BUNX_RUNNERS` array iterates `['bun', 'npx']` and tries each in sequence. Error detection regex `isMissingCmdError` matches `/not recognized|ENOENT|command not found|cannot find/i` on both `error.message` and captured stdout+stderr. Only falls through to next runner when the `missing` flag is set.
32
54
 
33
55
  Pattern: When a binary might not exist on all platforms, use a runner fallback strategy. Always capture and check both error.message and process output streams. Cross-platform error detection requires regex alternation on common message patterns.
56
+
57
+ ## GM Plugin Autonomy Blocker
58
+
59
+ **gm plugin's pre-tool-use-hook.js enforces "must invoke gm:gm first" gate, blocking multi-tool autonomy. Hook content is NOT sourced from gm-starter/hooks/ files — it is templated from somewhere else.**
60
+
61
+ The gm plugin enforces a gate via `.gm/needs-gm` marker that requires invoking `gm:gm` before any other tool use, which fragments multi-tool autonomous sessions in agentgui. A bypass patch was committed to c:/dev/gm (commit e300acf7, origin/main) in gm-starter/hooks/{pre-tool-use,prompt-submit}-hook.js to skip the gate when `.gm/prd.yml` exists, but it did NOT propagate after `/plugin update gm` (cache hash changed 495e36843d77 → 075e64d58498 but hook content unchanged).
62
+
63
+ The actual hook content is generated/templated from c:/dev/gm, likely from `lib/cli-adapter.js` or `platforms/cli-config-shared.js` or `lib/template-builder.js`, not from gm-starter/hooks/. Next session must:
64
+ 1. Locate the real hook generator in the gm codebase
65
+ 2. Patch it to add `if (autonomous) { try { fs.unlinkSync(needsGmPath); } catch {} }` when `.gm/prd.yml` exists
66
+ 3. Rebuild via `node c:/dev/gm/cli.js c:/dev/gm/gm-starter c:/dev/gm/build`
67
+ 4. Push changes to c:/dev/gm origin/main
68
+ 5. Run `/plugin update gm` in agentgui
69
+
70
+ Only after the real generator is patched will agentgui sessions run autonomously without per-tool ceremony.
71
+
72
+ ## ACP-to-API History Integration
73
+
74
+ **acptoapi (c:\dev\acptoapi) merged Claude Code history routes as of 2026-05-02; ccsniff package is no longer needed.**
75
+
76
+ 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 required — acptoapi covers the functionality entirely.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [1.0.908] - manual audit fixes: server retry idempotency, cli-version detection, cleanup FK ordering, refresh-all path
2
+
3
+ - server.js: `onServerListenStart` one-shot guard prevents `onServerReady` + `loadPluginExtensions` from re-firing when EADDRINUSE retry succeeds — previously every retry re-ran autoProvision/installGMAgentConfigs/setIntervals causing 100+ duplicate provision passes and stacked timers
4
+ - lib/tool-version-check.js: bumped `getCliVersion` execSync timeout from 1000ms to 15000ms; bumped `checkCliInstalled` `where` timeout from 3000ms to 10000ms — Windows cmd.exe + node CLI cold-start regularly exceeds 1s/3s, leaving opencode/gemini/kilo/agent-browser with `installedVersion: null` despite being installed
5
+ - lib/db-queries-cleanup.js: reordered cleanup transaction — child rows (chunks, stream_updates) now deleted before their parent sessions; was causing FOREIGN KEY constraint failed on every `[cleanup] Initial DB cleanup complete` invocation and on the 6h periodic cleanup, silently aborting the whole transaction
6
+ - lib/routes-tools.js: `POST /api/tools/refresh-all` now calls `toolManager.refreshAllToolsAsync()` (which clears statusCache) instead of `getAllTools()` which returned stale cached values — refresh button was a no-op
7
+
1
8
  ## [Unreleased] - implement 247420 brand-bible chrome on main shell
2
9
 
3
10
  - index.html: header brand line reads `247420 / agentgui / <leaf>`; sidebar brand line reads `247420 / agentgui` — mono slash `/` in muted color, matches canonical app-topbar pattern from c:/dev/design
package/README.md CHANGED
@@ -10,6 +10,23 @@
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)
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.
16
+
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)
20
+
21
+ History endpoints in `acptoapi` (formerly `ccsniff`'s job):
22
+
23
+ - `GET /v1/history/sessions` — list Claude Code sessions on the host
24
+ - `GET /v1/history/sessions/:sid/events` — flattened events for one session
25
+ - `GET /v1/history/search?q=…` — BM25-ranked search across all events
26
+ - `GET /v1/history/stream` — Server-Sent Events for live tailing
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.
29
+
13
30
  ### Supported Agents
14
31
 
15
32
  | Agent | Protocol | Auto-installable |
@@ -6,15 +6,16 @@ export function addCleanupQueries(q, db, prep, generateId) {
6
6
 
7
7
  const cleanupStmt = db.transaction(() => {
8
8
  prep('DELETE FROM events WHERE created_at < ?').run(thirtyDaysAgo);
9
- prep('DELETE FROM sessions WHERE completed_at IS NOT NULL AND completed_at < ?').run(thirtyDaysAgo);
10
9
  prep('DELETE FROM idempotencyKeys WHERE (created_at + ttl) < ?').run(now);
11
10
 
12
- prep('DELETE FROM chunks WHERE sessionId IN (SELECT id FROM sessions WHERE completed_at IS NOT NULL AND completed_at < ?)').run(sevenDaysAgo);
13
- prep('DELETE FROM stream_updates WHERE sessionId IN (SELECT id FROM sessions WHERE completed_at IS NOT NULL AND completed_at < ?)').run(sevenDaysAgo);
11
+ prep('DELETE FROM chunks WHERE sessionId IN (SELECT id FROM sessions WHERE completed_at IS NOT NULL AND completed_at < ?)').run(thirtyDaysAgo);
12
+ prep('DELETE FROM stream_updates WHERE sessionId IN (SELECT id FROM sessions WHERE completed_at IS NOT NULL AND completed_at < ?)').run(thirtyDaysAgo);
14
13
 
15
14
  prep('DELETE FROM chunks WHERE created_at < ? AND sessionId NOT IN (SELECT id FROM sessions WHERE completed_at IS NULL AND started_at >= ?)').run(sevenDaysAgo, sevenDaysAgo);
16
15
  prep('DELETE FROM stream_updates WHERE created_at < ? AND sessionId NOT IN (SELECT id FROM sessions WHERE completed_at IS NULL AND started_at >= ?)').run(sevenDaysAgo, sevenDaysAgo);
17
16
 
17
+ prep('DELETE FROM sessions WHERE completed_at IS NOT NULL AND completed_at < ?').run(thirtyDaysAgo);
18
+
18
19
  prep('DELETE FROM voice_cache WHERE expires_at <= ?').run(now);
19
20
 
20
21
  const deletedConvIds = prep("SELECT id FROM conversations WHERE status = 'deleted' AND updated_at < ?").all(sevenDaysAgo).map(r => r.id);
@@ -67,12 +67,11 @@ export function register(deps) {
67
67
  sendJSON(req, res, 200, { refreshing: true, toolCount: 4 });
68
68
  broadcastSync({ type: 'tools_refresh_started' });
69
69
  setImmediate(async () => {
70
- const tools = toolManager.getAllTools();
70
+ const tools = await toolManager.refreshAllToolsAsync();
71
71
  for (const tool of tools) {
72
72
  queries.updateToolStatus(tool.id, { status: tool.installed ? 'installed' : 'not_installed', version: tool.installedVersion, last_check_at: Date.now() });
73
- if (tool.installed) {
74
- const status = await toolManager.checkToolStatusAsync(tool.id);
75
- if (status?.upgradeNeeded) queries.updateToolStatus(tool.id, { update_available: 1, latest_version: status.publishedVersion });
73
+ if (tool.installed && tool.upgradeNeeded) {
74
+ queries.updateToolStatus(tool.id, { update_available: 1, latest_version: tool.publishedVersion });
76
75
  }
77
76
  }
78
77
  broadcastSync({ type: 'tools_refresh_complete', data: tools });
@@ -141,7 +141,7 @@ export function checkCliInstalled(pkg) {
141
141
  const cmd = isWindows ? 'where' : 'which';
142
142
  const bin = BIN_MAP[pkg];
143
143
  if (bin) {
144
- execSync(`${cmd} ${bin}`, { stdio: 'pipe', timeout: 3000, windowsHide: true });
144
+ execSync(`${cmd} ${bin}`, { stdio: 'pipe', timeout: 10000, windowsHide: true });
145
145
  return true;
146
146
  }
147
147
  } catch (_) {}
@@ -154,7 +154,7 @@ export function getCliVersion(pkg) {
154
154
  if (!bin) return null;
155
155
  try {
156
156
  const versionFlag = pkg === 'agent-browser' ? '-V' : '--version';
157
- const out = execSync(`${bin} ${versionFlag}`, { stdio: 'pipe', timeout: 1000, encoding: 'utf8', windowsHide: true });
157
+ const out = execSync(`${bin} ${versionFlag}`, { stdio: 'pipe', timeout: 15000, encoding: 'utf8', windowsHide: true });
158
158
  const match = out.match(/(\d+\.\d+\.\d+)/);
159
159
  if (match) {
160
160
  console.log(`[tool-manager] CLI ${pkg} (${bin}) version: ${match[1]}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.914",
3
+ "version": "1.0.916",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
package/server.js CHANGED
@@ -169,11 +169,19 @@ process.on('SIGINT', () => {
169
169
  process.exit(0);
170
170
  });
171
171
 
172
+ let _serverReadyFired = false;
173
+ const onServerListenStart = () => {
174
+ if (_serverReadyFired) return;
175
+ _serverReadyFired = true;
176
+ onServerReady();
177
+ loadPluginExtensions();
178
+ };
179
+
172
180
  server.on('error', (err) => {
173
181
  if (err.code === 'EADDRINUSE') {
174
182
  console.error(`Port ${PORT} already in use. Waiting 3 seconds before retry...`);
175
183
  setTimeout(() => {
176
- server.listen(PORT, onServerReady);
184
+ server.listen(PORT, onServerListenStart);
177
185
  }, 3000);
178
186
  } else {
179
187
  console.error('[SERVER] Error (contained):', err.message);
@@ -194,7 +202,4 @@ const { onServerReady, getJsonlWatcher } = createOnServerReady({
194
202
  performAgentHealthCheck, pm2Manager, pm2Subscribers, recoverStaleSessions
195
203
  });
196
204
 
197
- server.listen(PORT, () => {
198
- onServerReady();
199
- loadPluginExtensions();
200
- });
205
+ server.listen(PORT, onServerListenStart);
@@ -0,0 +1,56 @@
1
+ <!doctype html>
2
+ <html lang="en" class="ds-247420" data-theme="dark">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>agentgui — live client</title>
7
+ <meta name="description" content="agentgui live client — talks to any acptoapi backend, anywhere.">
8
+ <link rel="stylesheet" href="https://unpkg.com/anentrypoint-design@latest/dist/247420.css">
9
+ <script type="importmap">
10
+ { "imports": {
11
+ "anentrypoint-design": "https://unpkg.com/anentrypoint-design@latest/dist/247420.js"
12
+ } }
13
+ </script>
14
+ <style>
15
+ html,body { margin:0; height:100%; }
16
+ body { background: var(--app-bg, #0b0b0b); color: var(--text, #eee); font-family: var(--ff-body, system-ui); }
17
+ #app { height: 100vh; display: grid; grid-template-rows: auto 1fr; }
18
+ .topbar { display:flex; align-items:center; justify-content:space-between; padding:10px 16px; border-bottom:1px solid rgba(255,255,255,.08); }
19
+ .topbar .brand { font-weight:600; letter-spacing:.04em; }
20
+ .topbar .right { display:flex; gap:10px; align-items:center; }
21
+ .topbar input { width: 320px; padding:6px 10px; border-radius:6px; border:1px solid rgba(255,255,255,.15); background:rgba(255,255,255,.04); color:inherit; font:inherit; }
22
+ .tabs { display:flex; gap:4px; }
23
+ .tabs button { padding:6px 12px; border-radius:6px; border:1px solid transparent; background:transparent; color:inherit; cursor:pointer; font:inherit; }
24
+ .tabs button.active { background: rgba(255,255,255,.08); border-color: rgba(255,255,255,.12); }
25
+ .body { display:grid; grid-template-columns: 320px 1fr; min-height:0; }
26
+ .side { border-right:1px solid rgba(255,255,255,.08); overflow:auto; padding:8px; }
27
+ .main { overflow:auto; padding:16px; min-width:0; }
28
+ .empty { opacity:.6; padding:24px; text-align:center; }
29
+ .pill { display:inline-block; padding:1px 7px; border-radius:999px; background: rgba(255,255,255,.1); font-size:11px; margin-left:6px; }
30
+ .pill.ok { background: rgba(80,200,120,.18); color:#7fd; }
31
+ .pill.bad { background: rgba(255,80,80,.18); color:#f88; }
32
+ .row { display:block; padding:8px 10px; border-radius:6px; cursor:pointer; }
33
+ .row:hover { background: rgba(255,255,255,.04); }
34
+ .row.active { background: rgba(255,255,255,.08); }
35
+ .row .t { font-size:13px; }
36
+ .row .s { font-size:11px; opacity:.6; margin-top:2px; font-family: var(--ff-mono, ui-monospace, monospace); }
37
+ .ev { padding:6px 0; border-bottom: 1px dashed rgba(255,255,255,.06); font-family: var(--ff-mono, ui-monospace, monospace); font-size:12px; }
38
+ .ev .h { opacity:.55; margin-bottom:2px; }
39
+ .ev pre { white-space: pre-wrap; margin:0; }
40
+ .chat-host { height:100%; display:flex; flex-direction:column; }
41
+ .chat-msgs { flex:1; overflow:auto; padding:10px 4px; }
42
+ .msg { padding:8px 10px; border-radius:8px; margin:6px 0; max-width:80ch; }
43
+ .msg.user { background: rgba(120,180,255,.08); }
44
+ .msg.assistant { background: rgba(255,255,255,.04); }
45
+ .msg .role { font-size:11px; opacity:.6; margin-bottom:4px; text-transform:uppercase; letter-spacing:.08em; }
46
+ .composer { display:flex; gap:8px; padding:8px; border-top:1px solid rgba(255,255,255,.08); }
47
+ .composer textarea { flex:1; min-height:48px; max-height:200px; resize:vertical; padding:8px 10px; border-radius:6px; border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.04); color:inherit; font:inherit; }
48
+ .composer button { padding:8px 16px; border-radius:6px; border:1px solid rgba(255,255,255,.15); background: rgba(255,255,255,.06); color:inherit; cursor:pointer; }
49
+ .composer select { padding:6px 8px; border-radius:6px; border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.04); color:inherit; font:inherit; }
50
+ </style>
51
+ </head>
52
+ <body>
53
+ <div id="app"></div>
54
+ <script type="module" src="./js/app.js"></script>
55
+ </body>
56
+ </html>
@@ -0,0 +1,181 @@
1
+ import { h, applyDiff, installStyles } from 'anentrypoint-design';
2
+ import * as B from './backend.js';
3
+ installStyles().catch(() => {});
4
+
5
+ const state = {
6
+ backend: B.getBackend(),
7
+ health: { status: 'unknown' },
8
+ tab: 'chat',
9
+ models: [],
10
+ selectedModel: '',
11
+ // chat
12
+ chat: { messages: [], busy: false, abort: null, draft: '' },
13
+ // history
14
+ sessions: [],
15
+ selectedSid: null,
16
+ events: [],
17
+ searchQ: '',
18
+ searchHits: null,
19
+ liveTail: [],
20
+ };
21
+
22
+ const root = document.getElementById('app');
23
+
24
+ function render() { applyDiff(root, view()); }
25
+
26
+ function view() {
27
+ return h('div', { class: 'app-root', style: 'display:grid;grid-template-rows:auto 1fr;height:100%' }, topbar(), bodyView());
28
+ }
29
+
30
+ function topbar() {
31
+ const ok = state.health.status === 'ok';
32
+ return h('div', { class: 'topbar' },
33
+ h('div', { class: 'brand' }, 'agentgui ', h('span', { class: 'pill ' + (ok ? 'ok' : 'bad') }, ok ? 'connected' : 'offline')),
34
+ h('div', { class: 'tabs' },
35
+ tabBtn('chat', 'chat'),
36
+ tabBtn('history', 'history'),
37
+ tabBtn('settings', 'settings'),
38
+ ),
39
+ h('div', { class: 'right' },
40
+ h('input', { value: state.backend, placeholder: 'backend url', onchange: e => { state.backend = e.target.value; B.setBackend(state.backend); init(); } }),
41
+ ),
42
+ );
43
+ }
44
+
45
+ function tabBtn(id, label) {
46
+ return h('button', { class: state.tab === id ? 'active' : '', onclick: () => { state.tab = id; render(); if (id === 'history') refreshHistory(); } }, label);
47
+ }
48
+
49
+ function bodyView() {
50
+ if (state.tab === 'chat') return chatView();
51
+ if (state.tab === 'history') return historyView();
52
+ return settingsView();
53
+ }
54
+
55
+ function settingsView() {
56
+ return h('div', { class: 'main' },
57
+ h('h2', {}, 'settings'),
58
+ h('p', {}, 'backend: ', h('code', {}, state.backend)),
59
+ h('p', {}, 'health: ', JSON.stringify(state.health)),
60
+ h('p', {}, 'tip: pass ', h('code', {}, '?backend=https://your-acptoapi-host'), ' to override'),
61
+ );
62
+ }
63
+
64
+ function chatView() {
65
+ return h('div', { class: 'body', style: 'grid-template-columns: 1fr' },
66
+ h('div', { class: 'main chat-host' },
67
+ h('div', { class: 'chat-msgs' },
68
+ ...state.chat.messages.map((m, i) => h('div', { class: 'msg ' + m.role },
69
+ h('div', { class: 'role' }, m.role),
70
+ h('div', { class: 'content' }, m.content || ''),
71
+ )),
72
+ state.chat.messages.length === 0 ? h('div', { class: 'empty' }, 'pick a model and start chatting') : null,
73
+ ),
74
+ h('div', { class: 'composer' },
75
+ h('select', { onchange: e => { state.selectedModel = e.target.value; render(); } },
76
+ h('option', { value: '' }, '— choose model —'),
77
+ ...state.models.map(m => h('option', { value: m.id, selected: m.id === state.selectedModel }, m.id)),
78
+ ),
79
+ h('textarea', {
80
+ placeholder: 'message…',
81
+ value: state.chat.draft,
82
+ oninput: e => { state.chat.draft = e.target.value; },
83
+ onkeydown: e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); } },
84
+ }),
85
+ h('button', { onclick: () => state.chat.busy ? cancelChat() : sendChat() }, state.chat.busy ? 'stop' : 'send'),
86
+ ),
87
+ ),
88
+ );
89
+ }
90
+
91
+ async function sendChat() {
92
+ const text = (state.chat.draft || '').trim();
93
+ if (!text || !state.selectedModel || state.chat.busy) return;
94
+ state.chat.messages.push({ role: 'user', content: text });
95
+ state.chat.messages.push({ role: 'assistant', content: '' });
96
+ state.chat.draft = '';
97
+ state.chat.busy = true;
98
+ const ctrl = new AbortController();
99
+ state.chat.abort = ctrl;
100
+ render();
101
+ const cur = state.chat.messages[state.chat.messages.length - 1];
102
+ try {
103
+ for await (const ev of B.streamChat(state.backend, {
104
+ model: state.selectedModel,
105
+ messages: state.chat.messages.slice(0, -1).map(m => ({ role: m.role, content: m.content })),
106
+ signal: ctrl.signal,
107
+ })) {
108
+ if (ev.type === 'text') { cur.content += ev.text; render(); }
109
+ if (ev.type === 'error') { cur.content += '\n[error] ' + JSON.stringify(ev.error); render(); }
110
+ }
111
+ } catch (e) {
112
+ cur.content += '\n[error] ' + e.message;
113
+ } finally {
114
+ state.chat.busy = false;
115
+ state.chat.abort = null;
116
+ render();
117
+ }
118
+ }
119
+
120
+ function cancelChat() { state.chat.abort?.abort(); }
121
+
122
+ function historyView() {
123
+ return h('div', { class: 'body' },
124
+ h('div', { class: 'side' },
125
+ h('input', { class: 'search', placeholder: 'search…', value: state.searchQ, onchange: e => { state.searchQ = e.target.value; runSearch(); } }),
126
+ state.searchHits ? h('div', {},
127
+ h('div', { style: 'font-size:11px;opacity:.6;padding:6px 10px' }, state.searchHits.results.length + ' hits for "' + state.searchHits.query + '"'),
128
+ ...state.searchHits.results.slice(0, 30).map(r => h('div', { class: 'row', onclick: () => loadSession(r.sid) },
129
+ h('div', { class: 't' }, r.snippet || '(no snippet)'),
130
+ h('div', { class: 's' }, r.project + ' · ' + r.role + (r.tool ? ' · ' + r.tool : '')),
131
+ )),
132
+ ) : h('div', {},
133
+ ...state.sessions.slice(0, 80).map(s => h('div', { class: 'row' + (s.sid === state.selectedSid ? ' active' : ''), onclick: () => loadSession(s.sid) },
134
+ h('div', { class: 't' }, s.title || s.project || s.sid),
135
+ h('div', { class: 's' }, s.events + ' ev · ' + s.tools + ' tools · ' + (s.errors ? s.errors + ' err' : 'ok')),
136
+ )),
137
+ state.sessions.length === 0 ? h('div', { class: 'empty' }, 'no sessions yet') : null,
138
+ ),
139
+ ),
140
+ h('div', { class: 'main' },
141
+ state.events.length === 0
142
+ ? h('div', { class: 'empty' }, state.selectedSid ? 'loading…' : 'pick a session')
143
+ : h('div', {},
144
+ ...state.events.map(e => h('div', { class: 'ev' },
145
+ h('div', { class: 'h' }, new Date(e.ts).toLocaleString() + ' · ' + (e.role || '?') + ' · ' + (e.type || '?') + (e.tool ? ' · ' + e.tool : '')),
146
+ h('pre', {}, (e.text || '').slice(0, 4000)),
147
+ )),
148
+ ),
149
+ ),
150
+ );
151
+ }
152
+
153
+ async function refreshHistory() {
154
+ try {
155
+ state.sessions = await B.listSessions(state.backend);
156
+ render();
157
+ } catch (e) { console.warn('history fetch failed:', e.message); }
158
+ }
159
+
160
+ async function runSearch() {
161
+ if (!state.searchQ.trim()) { state.searchHits = null; render(); return; }
162
+ try { state.searchHits = await B.searchHistory(state.backend, state.searchQ, 50); render(); }
163
+ catch (e) { state.searchHits = { query: state.searchQ, results: [], error: e.message }; render(); }
164
+ }
165
+
166
+ async function loadSession(sid) {
167
+ state.selectedSid = sid; state.events = []; render();
168
+ try { state.events = await B.getSessionEvents(state.backend, sid); render(); }
169
+ catch (e) { state.events = [{ ts: Date.now(), role: 'error', type: 'fetch', text: e.message }]; render(); }
170
+ }
171
+
172
+ async function init() {
173
+ state.health = await B.probeBackend(state.backend).then(r => r.ok ? { status: 'ok', ...r.info } : { status: 'down', ...r });
174
+ render();
175
+ try { state.models = await B.listModels(state.backend); if (!state.selectedModel && state.models[0]) state.selectedModel = state.models[0].id; render(); }
176
+ catch (e) { console.warn('models fetch failed:', e.message); }
177
+ if (state.tab === 'history') refreshHistory();
178
+ }
179
+
180
+ window.__agentgui = { state, render };
181
+ init();
@@ -0,0 +1,99 @@
1
+ // acptoapi backend client. Resolves base URL from ?backend= or localStorage or default.
2
+ const KEY = 'agentgui.backend';
3
+ const DEFAULT_BACKEND = 'http://localhost:4800';
4
+
5
+ export function getBackend() {
6
+ const u = new URL(location.href);
7
+ const fromQs = u.searchParams.get('backend');
8
+ if (fromQs) { localStorage.setItem(KEY, fromQs); return fromQs; }
9
+ return localStorage.getItem(KEY) || DEFAULT_BACKEND;
10
+ }
11
+
12
+ export function setBackend(url) {
13
+ localStorage.setItem(KEY, url);
14
+ }
15
+
16
+ export async function probeBackend(base) {
17
+ try {
18
+ const r = await fetch(base + '/health', { method: 'GET' });
19
+ if (!r.ok) return { ok: false, status: r.status };
20
+ const j = await r.json();
21
+ return { ok: true, info: j };
22
+ } catch (e) {
23
+ return { ok: false, error: e.message };
24
+ }
25
+ }
26
+
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
+ }
33
+
34
+ export async function listSessions(base) {
35
+ const r = await fetch(base + '/v1/history/sessions');
36
+ if (!r.ok) throw new Error('sessions: ' + r.status);
37
+ return r.json();
38
+ }
39
+
40
+ export async function getSessionEvents(base, sid) {
41
+ const r = await fetch(base + '/v1/history/sessions/' + encodeURIComponent(sid) + '/events');
42
+ if (!r.ok) throw new Error('events: ' + r.status);
43
+ const j = await r.json();
44
+ return j.events || [];
45
+ }
46
+
47
+ export async function searchHistory(base, q, limit = 50) {
48
+ const r = await fetch(base + '/v1/history/search?q=' + encodeURIComponent(q) + '&limit=' + limit);
49
+ if (!r.ok) throw new Error('search: ' + r.status);
50
+ return r.json();
51
+ }
52
+
53
+ export function streamHistory(base, onEvent) {
54
+ const es = new EventSource(base + '/v1/history/stream');
55
+ for (const k of ['hello', 'event', 'error', 'start', 'complete', 'conversation']) {
56
+ es.addEventListener(k, ev => {
57
+ let data; try { data = JSON.parse(ev.data); } catch { data = null; }
58
+ onEvent(k, data);
59
+ });
60
+ }
61
+ return es;
62
+ }
63
+
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));
75
+ }
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;
91
+ 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 (_) {}
97
+ }
98
+ }
99
+ }