agentgui 1.0.942 → 1.0.943

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
@@ -16,13 +16,23 @@ Dependencies:
16
16
  - `ccsniff` (>=1.1.0) — exports `createHistoryRouter({projectsDir})` mountable on Express; serves `/v1/history/{sessions,sessions/:sid/events,search,snapshot,reindex,stream}`. Reads `~/.claude/projects` (override via `CLAUDE_PROJECTS_DIR`).
17
17
  - `anentrypoint-design` (>=0.0.119) — kit library, single-file ESM from unpkg
18
18
 
19
- ## Browser Witness
19
+ ## Orchestration agents
20
20
 
21
- `bun server.js`. Default `PORT=3000` (server.js); the SPA is served under `BASE_URL` (default `/gm/`), so the live app is **http://localhost:3000/gm/** — `/health` and `/` answer at root, the app is under `/gm/`. First request to `/gm/` or `/v1/history/*` triggers a 30-90s ccsniff JSONL walk (curl with a short timeout returns 000 during warmup). AppShell renders nav=[chat,history,settings], SSE `hello`, 0 console errors, backend resolves to `''` (same origin).
21
+ The four flagship agents the GUI drives are **Claude Code, OpenCode, Kilo, and Antigravity (`agy`)**; the agent picker (`site/app/js/app.js`, `PRIMARY_AGENTS`) sorts these first, then other-available, then npx-installable, then not-installed.
22
+
23
+ Two runner protocols exist. **Direct** (`lib/claude-runner-direct.js`): claude-code and agy — spawn the CLI per turn, parse stdout. **ACP** (`lib/acp-sdk-manager.js`): opencode/kilo/codex — an on-demand long-lived server on ports 18100/18101/18102, health-checked via `/provider`. The on-demand start + restart-backoff is correct even when an ACP agent lacks provider auth (it reports running-not-healthy rather than crashing).
24
+
25
+ `agy` (Antigravity) is a Gemini-backed Go CLI. Its invocation is `agy --print "<prompt>" --dangerously-skip-permissions [--continue]` — `--print` is a **value flag** (the prompt is its argument; a positional prompt exits 2). It emits **plain text** (not stream-json) and prints no session id, so its `parseOutput` wraps each line into an assistant-text event and resume is `--continue`-only. A live model response needs an authenticated Antigravity session; without it `agy` returns empty (the direct runner resolves gracefully, no hang). Detail in rs-learn (recall "agentgui agy Antigravity agent").
26
+
27
+ **The direct runner spawns with `shell:false` against a resolved binary path — never `shell:true`.** `shell:true` on Windows concatenates argv without escaping, so a chat prompt containing `&`/`|`/`>`/backticks executes as shell commands (arbitrary command execution). `lib/claude-runner.js` `resolveBinaryPath` resolves the command to an absolute `.exe`; `getSpawnOptions` only defaults `shell:true` when a caller passes no explicit `shell`. Detail in rs-learn (recall "agentgui SECURITY direct runner shell:false").
22
28
 
23
- ## Learning audit
29
+ **Agent availability comes from `registry.isAvailable(id)`** (`lib/ws-handlers-util.js`, `agents.list`), which runs `where`/`which`. A binary installed outside the system PATH reads as "(not installed)"; the fix is an `npxPackage` on the registry entry so it falls back to bun/npx presence. Detail in rs-learn (recall "agentgui agent availability where npxPackage").
24
30
 
25
- - 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)
31
+ **Output and source carry no decorative glyphs.** The GUI uses ASCII words and CSS-drawn discs (`.status-dot-disc`) for status, never `●/◌/○/⌘/§/▶` etc. Convert any decorative glyph to its ASCII equivalent on sight.
32
+
33
+ ## Browser Witness
34
+
35
+ `bun server.js`. Default `PORT=3000` (server.js); the SPA is served under `BASE_URL` (default `/gm/`), so the live app is **http://localhost:3000/gm/** — `/health` and `/` answer at root, the app is under `/gm/`. First request to `/gm/` or `/v1/history/*` triggers a 30-90s ccsniff JSONL walk (curl with a short timeout returns 000 during warmup). AppShell renders nav=[chat,history,settings], SSE `hello`, 0 console errors, backend resolves to `''` (same origin).
26
36
 
27
37
  ## CI / GitHub Actions
28
38
 
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [Unreleased] - Antigravity (agy) orchestration + glyph-to-ASCII sweep + spawn-injection fix
2
+
3
+ - lib/agent-discovery.js + lib/claude-runner-agents.js + lib/agent-descriptors.js: added `agy` (Antigravity) as a direct-protocol CLI agent — `agy --print "<prompt>" --dangerously-skip-permissions [--continue]`, plain-text parseOutput, no model picker. Completes the four flagship orchestration targets (Claude Code, OpenCode, Kilo, Antigravity). Why: the GUI could not drive Antigravity at all.
4
+ - lib/claude-runner.js + lib/claude-runner-direct.js: SECURITY — the direct runner now spawns a resolved binary with `shell:false`. Previously `shell:true` on Windows concatenated argv unescaped, so a prompt containing `&`/`|`/`>`/backticks executed as shell commands (verified injection). Why: arbitrary command execution from chat input.
5
+ - lib/claude-runner-agents.js: claude-code registry entry gained `npxPackage` so it reports available via npx fallback when `where claude` misses a non-PATH install. Why: the flagship agent showed "(not installed)" despite being present.
6
+ - site/app/js/app.js + site/app/index.html: removed every decorative glyph (status dots, cmd/section/box/arrow/check/cross marks) in favour of ASCII words and a CSS-drawn status disc; agent picker now sorts the four flagship targets first, then available, then npx-installable, then not-installed. Why: machine-shaped glyph tells and a flat 17-item list obscured the orchestration targets.
7
+
1
8
  ## [Unreleased] - site/app live client migrated to 247420 SDK components
2
9
 
3
10
  - site/app/index.html: removed 35 lines of bespoke CSS overriding design tokens; now uses SDK panel/font tokens with only structural overrides (chat flex layout, history pane grid)
@@ -2,6 +2,7 @@ const agentDescriptorCache = new Map();
2
2
 
3
3
  const AGENT_DESCRIPTIONS = {
4
4
  'claude-code': 'Claude Code is an AI coding agent that can read, write, and execute code with streaming output support. It provides comprehensive code editing, file management, and terminal execution capabilities.',
5
+ 'agy': 'Antigravity (agy) is a Gemini-backed coding agent CLI. It runs prompts non-interactively with plain-text streaming output, workspace directory scoping, and conversation resume.',
5
6
  'gemini': 'Gemini CLI is Google AI coding agent with streaming support, code execution, and file management capabilities.',
6
7
  'opencode': 'OpenCode is a multi-provider AI coding agent with streaming support and comprehensive code manipulation capabilities.',
7
8
  };
@@ -6,6 +6,7 @@ import { execSync } from 'child_process';
6
6
 
7
7
  const BINARIES = [
8
8
  { cmd: 'claude', id: 'claude-code', name: 'Claude Code', icon: 'C', protocol: 'cli' },
9
+ { cmd: 'agy', id: 'agy', name: 'Antigravity', icon: 'Y', protocol: 'cli' },
9
10
  { cmd: 'opencode', id: 'opencode', name: 'OpenCode', icon: 'O', protocol: 'acp', npxPackage: 'opencode-ai' },
10
11
  { cmd: 'gemini', id: 'gemini', name: 'Gemini CLI', icon: 'G', protocol: 'acp', npxPackage: '@google/gemini-cli' },
11
12
  { cmd: 'kilo', id: 'kilo', name: 'Kilo Code', icon: 'K', protocol: 'acp', npxPackage: '@kilocode/cli' },
@@ -97,6 +97,10 @@ function parseClaudeOutput(line) {
97
97
  registry.register({
98
98
  id: 'claude-code', name: 'Claude Code', command: 'claude', protocol: 'direct', supportsStdin: false, closeStdin: true,
99
99
  useJsonRpcStdin: false, supportedFeatures: ['streaming', 'resume', 'system-prompt', 'permissions-skip', 'steering'],
100
+ // claude has an npx fallback (matches agent-discovery), so when `where claude`
101
+ // misses a non-PATH install the agent still reports as runnable rather than
102
+ // "(not installed)".
103
+ npxPackage: '@anthropic-ai/claude-code',
100
104
  spawnEnv: { MAX_THINKING_TOKENS: '0', AGENTGUI_SUBPROCESS: '1' },
101
105
  buildArgs(prompt, config) {
102
106
  const { verbose = true, outputFormat = 'stream-json', print = true, resumeSessionId = null, systemPrompt = null, model = null } = config;
@@ -114,6 +118,29 @@ registry.register({
114
118
  parseOutput: parseClaudeOutput
115
119
  });
116
120
 
121
+ // Antigravity (`agy`): a direct-protocol CLI like claude-code, but emits PLAIN
122
+ // TEXT (no stream-json, no model flag, no printed session_id). Each stdout line
123
+ // is wrapped into the assistant-text event the non-JSONL stream handler expects.
124
+ // Resume uses --continue (most-recent conversation) since agy prints no session id.
125
+ registry.register({
126
+ id: 'agy', name: 'Antigravity', command: 'agy', protocol: 'direct',
127
+ supportsStdin: false, closeStdin: true,
128
+ supportedFeatures: ['streaming', 'resume'],
129
+ spawnEnv: { AGENTGUI_SUBPROCESS: '1' },
130
+ buildArgs(prompt, config) {
131
+ const { resumeSessionId = null } = config;
132
+ // agy's --print is a VALUE flag (the prompt is its argument), not a boolean
133
+ // followed by a positional. A positional prompt exits with code 2.
134
+ const flags = ['--print', typeof prompt === 'string' ? prompt : String(prompt), '--dangerously-skip-permissions'];
135
+ if (resumeSessionId) flags.push('--continue');
136
+ return flags;
137
+ },
138
+ parseOutput(line) {
139
+ const text = line.replace(/\r$/, '');
140
+ if (!text.trim()) return null;
141
+ return { type: 'assistant', message: { content: [{ type: 'text', text }] } };
142
+ },
143
+ });
117
144
  registry.register({ id: 'opencode', name: 'OpenCode', command: 'opencode', protocol: 'acp', supportsStdin: false, npxPackage: 'opencode-ai', supportedFeatures: ['streaming', 'resume', 'acp-protocol'], buildArgs: () => ['acp'], protocolHandler: acpProtocolHandler });
118
145
  registry.register({ id: 'gemini', name: 'Gemini CLI', command: 'gemini', protocol: 'acp', supportsStdin: false, npxPackage: '@google/gemini-cli', supportedFeatures: ['streaming', 'resume', 'acp-protocol'], buildArgs(prompt, config) { const args = ['--experimental-acp', '--yolo']; if (config?.model) args.push('--model', config.model); return args; }, protocolHandler: acpProtocolHandler });
119
146
  registry.register({ id: 'goose', name: 'Goose', command: 'goose', protocol: 'acp', supportsStdin: false, supportedFeatures: ['streaming', 'resume', 'acp-protocol'], buildArgs: () => ['acp'], protocolHandler: acpProtocolHandler });
@@ -1,14 +1,20 @@
1
1
  import { spawn } from 'child_process';
2
- import { AgentRunner, getSpawnOptions } from './claude-runner.js';
2
+ import { AgentRunner, getSpawnOptions, resolveBinaryPath } from './claude-runner.js';
3
3
 
4
4
  AgentRunner.prototype.runDirect = function(prompt, cwd, config = {}) {
5
5
  return new Promise((resolve, reject) => {
6
6
  const { timeout = 300000, onEvent = null, onError = null, onRateLimit = null } = config;
7
7
  const args = this.buildArgs(prompt, config);
8
- const spawnOpts = getSpawnOptions(cwd);
8
+ // The direct runner passes the untrusted user prompt as an argv element.
9
+ // Spawn with shell:false against a resolved binary so the prompt cannot be
10
+ // interpreted by a shell (shell:true would let "& cmd" inject). If the
11
+ // binary resolves to a .cmd/.bat shim, resolveBinaryPath returns the name
12
+ // unchanged and we fall back to the shell (npx-style fallback launchers).
13
+ const binary = resolveBinaryPath(this.command);
14
+ const spawnOpts = getSpawnOptions(cwd, { shell: false });
9
15
  if (Object.keys(this.spawnEnv).length > 0) spawnOpts.env = { ...spawnOpts.env, ...this.spawnEnv };
10
16
  if (this.closeStdin) spawnOpts.stdio = ['ignore', 'pipe', 'pipe'];
11
- const proc = spawn(this.command, args, spawnOpts);
17
+ const proc = spawn(binary, args, spawnOpts);
12
18
  console.log(`[${this.id}] Spawned PID ${proc.pid} closeStdin=${this.closeStdin}`);
13
19
 
14
20
  if (config.onPid) { try { config.onPid(proc.pid); } catch (e) { console.error(`[${this.id}] onPid callback failed:`, e.message); } }
@@ -4,12 +4,33 @@ const isWindows = process.platform === 'win32';
4
4
 
5
5
  export function getSpawnOptions(cwd, additionalOptions = {}) {
6
6
  const options = { cwd, ...additionalOptions };
7
- if (isWindows) options.shell = true;
7
+ // shell:true on Windows concatenates argv WITHOUT escaping, so a user prompt
8
+ // containing &, |, >, or backticks executes as a shell command (verified
9
+ // injection). Callers that pass untrusted args (the direct runner) must set
10
+ // shell:false and spawn a resolved binary. Default keeps the legacy shell
11
+ // behaviour only when a caller has not already chosen.
12
+ if (isWindows && options.shell === undefined) options.shell = true;
8
13
  if (!options.env) options.env = { ...process.env };
9
14
  delete options.env.CLAUDECODE;
10
15
  return options;
11
16
  }
12
17
 
18
+ // Resolve a bare command name to an absolute executable path so it can be
19
+ // spawned with shell:false (no argv concatenation, no injection surface).
20
+ // Returns the original command if resolution fails (Node will still PATH-resolve).
21
+ export function resolveBinaryPath(command) {
22
+ try {
23
+ const whichCmd = isWindows ? 'where' : 'which';
24
+ const r = spawnSync(whichCmd, [command], { encoding: 'utf-8', timeout: 3000 });
25
+ if (r.status === 0) {
26
+ const first = (r.stdout || '').trim().split(/\r?\n/)[0].trim();
27
+ // A .cmd/.bat shim cannot be exec'd without a shell; leave it to the caller.
28
+ if (first && !/\.(cmd|bat)$/i.test(first)) return first;
29
+ }
30
+ } catch (_) {}
31
+ return command;
32
+ }
33
+
13
34
  export function resolveCommand(command, npxPackage) {
14
35
  const whichCmd = isWindows ? 'where' : 'which';
15
36
  const check = spawnSync(whichCmd, [command], { encoding: 'utf-8', timeout: 3000 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.942",
3
+ "version": "1.0.943",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -63,21 +63,28 @@
63
63
  }
64
64
  .skip-link:focus { left: 8px; top: 8px; outline: 2px solid #fff; outline-offset: 2px; }
65
65
 
66
- /* status connection dot */
67
- .status-dot { display: inline-flex; align-items: center; gap: .35em; white-space: nowrap; }
68
- .status-dot-live::before {
69
- content: ''; width: 8px; height: 8px; border-radius: 50%;
70
- background: var(--accent, var(--agentgui-accent));
71
- box-shadow: 0 0 0 0 rgba(80, 200, 120, .5);
66
+ /* status connection dot — a CSS-drawn disc (real UI affordance, not a text
67
+ glyph). The .status-dot-disc child is always present; the parent's state
68
+ class colours it, and only is-live pulses. */
69
+ .status-dot { display: inline-flex; align-items: center; gap: .4em; white-space: nowrap; }
70
+ .status-dot-disc {
71
+ flex: none; width: 8px; height: 8px; border-radius: 50%;
72
+ background: var(--muted, #8a8f98);
73
+ }
74
+ .status-dot.is-live .status-dot-disc,
75
+ .status-dot-live .status-dot-disc {
76
+ background: var(--accent, var(--agentgui-accent, #50c878));
72
77
  animation: agentgui-pulse 2s infinite;
73
78
  }
79
+ .status-dot.is-connecting .status-dot-disc { background: #d9a441; }
80
+ .status-dot.is-offline .status-dot-disc { background: #c0504d; animation: none; }
74
81
  @keyframes agentgui-pulse {
75
82
  0% { box-shadow: 0 0 0 0 rgba(80, 200, 120, .5); }
76
83
  70% { box-shadow: 0 0 0 6px rgba(80, 200, 120, 0); }
77
84
  100% { box-shadow: 0 0 0 0 rgba(80, 200, 120, 0); }
78
85
  }
79
86
  @media (prefers-reduced-motion: reduce) {
80
- .status-dot-live::before { animation: none; }
87
+ .status-dot-disc { animation: none !important; }
81
88
  }
82
89
 
83
90
  /* resume banner */
@@ -263,21 +263,20 @@ function closeLiveStream() {
263
263
  function view() {
264
264
  const ok = state.health.status === 'ok';
265
265
  const liveActive = state.tab === 'history' && state.live.connected && (Date.now() - state.live.lastEventTs < 30000);
266
- const dotText = state.tab === 'history'
266
+ const dotLabel = state.tab === 'history'
267
267
  ? (state.live.error
268
- ? '◌ ' + state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
269
- : (liveActive ? 'live · ' + state.live.eventCount : (state.live.connected ? 'live' : 'connecting…')))
270
- : (ok ? (state.health.ws === 'reconnecting' ? 'ws reconnecting' : 'connected') : 'offline');
268
+ ? state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
269
+ : (liveActive ? 'live · ' + state.live.eventCount : (state.live.connected ? 'live' : 'connecting…')))
270
+ : (ok ? (state.health.ws === 'reconnecting' ? 'ws reconnecting' : 'connected') : 'offline');
271
271
  const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
272
- // Split the leading status glyph (● ○) from the words: glyph is decorative
273
- // (aria-hidden), only the text is announced, so AT reads "live" not "black circle live".
274
- const glyphMatch = dotText.match(/^([●◌○])\s*(.*)$/);
275
- const dotGlyph = glyphMatch ? glyphMatch[1] : '';
276
- const dotLabel = glyphMatch ? glyphMatch[2] : dotText;
277
- // When live, the CSS .status-dot-live::before draws the (pulsing) dot, so the
278
- // literal glyph would render a second dot only emit the glyph when NOT live.
279
- const dot = h('span', { key: 'dot', class: 'status-dot' + (dotLive ? ' status-dot-live' : ''), role: 'status', 'aria-live': 'polite' },
280
- (!dotLive && dotGlyph) ? h('span', { key: 'dg', 'aria-hidden': 'true' }, dotGlyph + ' ') : null,
272
+ // The status dot is drawn entirely by CSS (.status-dot::before) a small
273
+ // colored disc, real product design, not a text glyph. State drives its colour
274
+ // via the modifier class; the label carries only words so AT reads "live", and
275
+ // there are no literal status-glyph characters in the DOM.
276
+ const dotState = state.live.error || (!dotLive) ? 'is-offline'
277
+ : (dotLive && state.tab !== 'history' && state.health.ws === 'reconnecting' ? 'is-connecting' : 'is-live');
278
+ const dot = h('span', { key: 'dot', class: 'status-dot ' + dotState + (dotLive ? ' status-dot-live' : ''), role: 'status', 'aria-live': 'polite' },
279
+ h('span', { key: 'dd', class: 'status-dot-disc', 'aria-hidden': 'true' }),
281
280
  h('span', { key: 'dl' }, dotLabel));
282
281
 
283
282
  const topbar = Topbar({
@@ -288,11 +287,27 @@ function view() {
288
287
  onNav: (label) => navTo(label),
289
288
  });
290
289
 
291
- const agentOptions = state.agents.map(a => ({
292
- value: a.id,
293
- label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
294
- disabled: a.available === false && !a.npxInstallable,
295
- }));
290
+ // The four flagship orchestration targets surface first, then the rest of the
291
+ // available agents, then unavailable ones — so the agents the GUI exists to
292
+ // drive are reachable without scanning a flat 17-item list. Ordering only;
293
+ // the DS Select renders a plain option list.
294
+ const PRIMARY_AGENTS = ['claude-code', 'opencode', 'kilo', 'agy'];
295
+ const agentRank = (a) => {
296
+ const primary = PRIMARY_AGENTS.indexOf(a.id);
297
+ const avail = a.available !== false;
298
+ if (primary !== -1 && avail) return primary; // 0..3 flagship + available
299
+ if (avail) return 10; // other available
300
+ if (a.npxInstallable) return 20; // installable via npx
301
+ return 30; // not installed
302
+ };
303
+ const agentOptions = state.agents
304
+ .map(a => ({ a, rank: agentRank(a) }))
305
+ .sort((x, y) => x.rank - y.rank || x.a.name.localeCompare(y.a.name))
306
+ .map(({ a }) => ({
307
+ value: a.id,
308
+ label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
309
+ disabled: a.available === false && !a.npxInstallable,
310
+ }));
296
311
  const showModelPicker = state.tab === 'chat' && state.agentModels.length > 0;
297
312
 
298
313
  const crumbRight = state.tab === 'chat'
@@ -316,7 +331,7 @@ function view() {
316
331
  })
317
332
  : null,
318
333
  state.chat.busy
319
- ? Btn({ key: 'stop', onClick: cancelChat, children: 'stop', title: 'Stop streaming' })
334
+ ? Btn({ key: 'stop', onClick: cancelChat, children: 'stop', title: 'Stop streaming' })
320
335
  : Btn({ key: 'new', onClick: newChat, children: '+ new', title: 'Start new chat (clears history)' }),
321
336
  dot,
322
337
  )]
@@ -335,10 +350,10 @@ function view() {
335
350
  const side = state.tab === 'history' ? historySide() : null;
336
351
 
337
352
  const agentLabel = state.selectedAgent
338
- ? ' ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
339
- : 'no agent';
353
+ ? 'agent: ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
354
+ : 'no agent';
340
355
  const status = Status({
341
- left: [state.backend || 'same-origin', ok ? 'live' : 'offline'],
356
+ left: [state.backend || 'same-origin', ok ? 'live' : 'offline'],
342
357
  right: [agentLabel],
343
358
  });
344
359
 
@@ -374,14 +389,14 @@ function toolSummary(block) {
374
389
  arg = inp.command || inp.file_path || inp.path || inp.pattern || inp.query || inp.url || '';
375
390
  if (!arg) { try { arg = JSON.stringify(inp).slice(0, 120); } catch {} }
376
391
  }
377
- return ' ' + name + (arg ? ' · ' + String(arg).slice(0, 120) : '');
392
+ return 'tool: ' + name + (arg ? ' · ' + String(arg).slice(0, 120) : '');
378
393
  }
379
394
 
380
395
  function toolResultSummary(block) {
381
396
  const c = block?.content ?? block?.output ?? block;
382
397
  let s = typeof c === 'string' ? c : (() => { try { return JSON.stringify(c); } catch { return String(c); } })();
383
398
  s = s.replace(/\s+/g, ' ').trim();
384
- return (block?.is_error ? ' ' : '') + s.slice(0, 160);
399
+ return (block?.is_error ? '[error] ' : '') + s.slice(0, 160);
385
400
  }
386
401
 
387
402
  function errText(e) {
@@ -426,8 +441,8 @@ function chatMain() {
426
441
  const banners = [];
427
442
  if (state.chat.resumeSid) {
428
443
  banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
429
- h('span', { key: 'rbtxt', class: 'lede' }, 'resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'),
430
- Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: '× clear' })));
444
+ h('span', { key: 'rbtxt', class: 'lede' }, 'resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'),
445
+ Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
431
446
  if (state.chat.resumeNote) {
432
447
  banners.push(Alert({ key: 'rnote', kind: 'info', title: 'Agent switched', children: state.chat.resumeNote }));
433
448
  }
@@ -488,9 +503,9 @@ function cwdBanner() {
488
503
  }
489
504
  return h('div', { key: 'cwdb', class: 'cwd-bar', role: 'group', 'aria-label': 'Working directory' },
490
505
  h('span', { key: 'cwdtxt', class: 'lede cwd-bar-text', title: state.chatCwd || 'server default working directory' },
491
- state.chatCwd ? ' ' + truncate(state.chatCwd, 28, 60) : 'cwd: server default'),
506
+ state.chatCwd ? 'cwd: ' + truncate(state.chatCwd, 28, 60) : 'cwd: server default'),
492
507
  h('button', { key: 'cwdset', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); } }, state.chatCwd ? 'change' : 'set'),
493
- state.chatCwd ? h('button', { key: 'cwdclr', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); } }, '× default') : null);
508
+ state.chatCwd ? h('button', { key: 'cwdclr', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); } }, 'use default') : null);
494
509
  }
495
510
 
496
511
  function newChat() {
@@ -553,7 +568,7 @@ async function sendChat() {
553
568
  })) {
554
569
  if (ev.type === 'text') { cur.content += ev.text; render(); scrollChatToBottom(); }
555
570
  else if (ev.type === 'tool') { cur.parts.push(toolSummary(ev.block)); render(); scrollChatToBottom(); }
556
- else if (ev.type === 'tool_result') { cur.parts.push(' ' + toolResultSummary(ev.block)); render(); scrollChatToBottom(); }
571
+ else if (ev.type === 'tool_result') { cur.parts.push('-> ' + toolResultSummary(ev.block)); render(); scrollChatToBottom(); }
557
572
  else if (ev.type === 'result') { /* terminal usage/summary block — already reflected via text */ }
558
573
  else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
559
574
  }
@@ -585,11 +600,10 @@ function historyMain() {
585
600
  return [
586
601
  reconnectAlert(),
587
602
  PageHeader({
588
- title: '§ history',
603
+ title: 'history',
589
604
  lede: 'pick a session from the sidebar — events stream live from ccsniff /v1/history.',
590
605
  }),
591
606
  h('div', { key: 'histempty', class: 'history-empty', role: 'status' },
592
- h('div', { key: 'ge', class: 'history-empty-glyph', 'aria-hidden': 'true' }, '§'),
593
607
  h('p', { key: 'gt', class: 'history-empty-title' },
594
608
  count ? 'Select a session to view its events' : 'No sessions yet'),
595
609
  h('p', { key: 'gs', class: 'lede history-empty-sub' },
@@ -605,13 +619,13 @@ function historyMain() {
605
619
  : state.selectedSid;
606
620
 
607
621
  const head = PageHeader({
608
- title: '§ ' + truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || state.selectedSid, 40, 80),
622
+ title: truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || state.selectedSid, 40, 80),
609
623
  lede,
610
624
  });
611
625
 
612
626
  const actions = h('div', { key: 'acts', class: 'history-actions' },
613
- Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
614
- Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
627
+ Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
628
+ Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
615
629
  );
616
630
 
617
631
  if (state.events.length === 0) {
@@ -641,8 +655,8 @@ function historyMain() {
641
655
  const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (total - shown.length + i);
642
656
  const role = e.role || '?';
643
657
  const type = e.type || '?';
644
- const tool = e.tool ? ' · ' + e.tool : '';
645
- const errMark = e.isError ? ' · ' : '';
658
+ const tool = e.tool ? ' · tool: ' + e.tool : '';
659
+ const errMark = e.isError ? ' · error' : '';
646
660
  const raw = e.text || '';
647
661
  const text = raw.replace(/\s+/g, ' ').trim();
648
662
  const expanded = state.expandedEvents.has(key);
@@ -664,9 +678,9 @@ let copyToast = null;
664
678
  function copySid() {
665
679
  const sid = state.selectedSid;
666
680
  if (!sid) return;
667
- const done = () => { copyToast = 'copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
681
+ const done = () => { copyToast = 'copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
668
682
  if (navigator.clipboard?.writeText) {
669
- navigator.clipboard.writeText(sid).then(done).catch(() => { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); });
683
+ navigator.clipboard.writeText(sid).then(done).catch(() => { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); });
670
684
  } else {
671
685
  // Fallback for insecure (http) origins where navigator.clipboard is absent.
672
686
  try {
@@ -674,7 +688,7 @@ function copySid() {
674
688
  ta.value = sid; ta.style.position = 'fixed'; ta.style.opacity = '0';
675
689
  document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
676
690
  done();
677
- } catch { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); }
691
+ } catch { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); }
678
692
  }
679
693
  }
680
694
 
@@ -746,7 +760,7 @@ function historySide() {
746
760
  Row({
747
761
  key: 'sess' + s.sid,
748
762
  rank: String(i + 1).padStart(3, '0'),
749
- title: (s.isSubagent ? ' ' : '') + (projectLabel(s.title) || projectLabel(s.project) || s.sid),
763
+ title: (s.isSubagent ? '- ' : '') + (projectLabel(s.title) || projectLabel(s.project) || s.sid),
750
764
  sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools' + (s.errors ? ' · ' + s.errors + ' err' : ''),
751
765
  rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
752
766
  active: s.sid === state.selectedSid,
@@ -761,13 +775,14 @@ function historySide() {
761
775
  running.length
762
776
  ? Panel({
763
777
  key: 'runningPanel',
764
- title: 'running · ' + running.length,
778
+ title: 'running · ' + running.length,
765
779
  children: running.map((r, i) => {
766
780
  const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
767
781
  const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
768
782
  return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
769
- h('span', { class: 'lede' }, '● ' + agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
770
- Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: ' stop' }));
783
+ h('span', { key: 'rd', class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
784
+ h('span', { class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
785
+ Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: 'stop' }));
771
786
  }),
772
787
  })
773
788
  : null,
@@ -796,7 +811,7 @@ function historySide() {
796
811
  ? h('p', { key: 'min2', class: 'lede empty-state' }, 'type at least 2 characters to search')
797
812
  : null,
798
813
  state.searchQ
799
- ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: '× clear search' })
814
+ ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: 'clear search' })
800
815
  : null,
801
816
  !searching && projects.length > 1
802
817
  ? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
@@ -810,10 +825,10 @@ function historySide() {
810
825
  'show subagents (' + subagentCount + ')')
811
826
  : null,
812
827
  state.historyError
813
- ? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, '⚠ ' + state.historyError)
828
+ ? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, state.historyError)
814
829
  : (rows.length ? h('div', { key: 'rows' }, ...rows) : h('p', { key: 'empty', class: 'lede empty-state' }, 'no sessions yet')),
815
830
  !searching && truncatedBy > 0
816
- ? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: 'show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
831
+ ? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: 'show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
817
832
  : null,
818
833
  ],
819
834
  }),
@@ -842,9 +857,8 @@ async function saveBackend() {
842
857
  function healthSummary() {
843
858
  const hh = state.health || {};
844
859
  const ok = hh.status === 'ok';
845
- const dot = ok ? '●' : (hh.status === 'unknown' ? '◌' : '○');
846
860
  const bits = [];
847
- bits.push(dot + ' ' + (hh.status || 'unknown'));
861
+ bits.push(hh.status || 'unknown');
848
862
  if (hh.version) bits.push('v' + hh.version);
849
863
  if (typeof hh.agents === 'number') bits.push(hh.agents + ' agents');
850
864
  if (typeof hh.activeExecutions === 'number') bits.push(hh.activeExecutions + ' active');
@@ -858,7 +872,7 @@ function settingsMain() {
858
872
  const isValid = isValidUrl(state.backendDraft);
859
873
  return [
860
874
  PageHeader({
861
- title: 'settings',
875
+ title: 'settings',
862
876
  lede: 'point agentgui at any backend. blank = same-origin (ccsniff in-process). ?backend=… or the field below persists via localStorage.',
863
877
  }),
864
878
  h('div', { key: 'settings-grid', class: 'settings-grid' }, [
@@ -878,10 +892,10 @@ function settingsMain() {
878
892
  title: isValid ? 'Enter a valid URL or leave blank for same-origin' : 'Invalid URL format',
879
893
  onInput: (v) => { state.backendDraft = v; render(); },
880
894
  }),
881
- !isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
882
- state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connecting…') : null,
883
- state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connected') : null,
884
- state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, 'connection failed — check the URL') : null,
895
+ !isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
896
+ state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connecting…') : null,
897
+ state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connected') : null,
898
+ state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, 'connection failed — check the URL') : null,
885
899
  healthSummary(),
886
900
  Btn({
887
901
  key: 'savebtn',