agentgui 1.0.942 → 1.0.944

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,15 @@
1
+ ## [Unreleased] - live e2e chat verified + composer caret fix + ccsniff memory cap
2
+
3
+ - verified the full GUI chat round-trip end-to-end in a real browser: compose -> WS chat.sendMessage -> claude-code -> streaming_progress -> transcript renders the streamed reply (not just the runner harness).
4
+ - site/app/js/app.js: composer onInput re-renders only on the empty<->non-empty draft transition (the only change that toggles the send button), not on every keystroke. Why: a synchronous re-render per character fought the caret on the controlled textarea.
5
+ - vendored anentrypoint-design DS rebuilt: ChatComposer reads the live textarea value on send and syncs the controlled value into the DOM only when it differs, so a mid-type re-render no longer resets the caret or drops keystrokes. Why: DS override belongs in the kit; the composer is shared.
6
+ - ccsniff (sibling repo, pushed): bounded the in-memory history store (event cap + eviction + per-event text cap) and bounded loadOnce to read newest events first up to the cap. Why: the store grew unbounded to 6GB+ (OOM) and parsed the entire JSONL backlog at load (~2.4GB transient peak); heap now settles ~47MB and load peak ~570MB.
7
+
8
+ - 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.
9
+ - 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.
10
+ - 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.
11
+ - 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.
12
+
1
13
  ## [Unreleased] - site/app live client migrated to 247420 SDK components
2
14
 
3
15
  - 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.944",
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) {
@@ -419,15 +434,24 @@ function chatMain() {
419
434
  value: state.chat.draft,
420
435
  disabled: !canSend(),
421
436
  placeholder,
422
- onInput: (v) => { state.chat.draft = v; render(); },
437
+ // The DS textarea is controlled and reads its live DOM value on send, so we
438
+ // do NOT re-render on every keystroke (that fights the cursor and is wasteful).
439
+ // Re-render only when the draft crosses empty<->non-empty, since that is the
440
+ // only transition that changes the send button's disabled state.
441
+ onInput: (v) => {
442
+ const was = !!(state.chat.draft && state.chat.draft.trim());
443
+ const now = !!(v && v.trim());
444
+ state.chat.draft = v;
445
+ if (was !== now) render();
446
+ },
423
447
  onSend: (v) => { state.chat.draft = v; sendChat(); },
424
448
  });
425
449
 
426
450
  const banners = [];
427
451
  if (state.chat.resumeSid) {
428
452
  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' })));
453
+ h('span', { key: 'rbtxt', class: 'lede' }, 'resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'),
454
+ Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
431
455
  if (state.chat.resumeNote) {
432
456
  banners.push(Alert({ key: 'rnote', kind: 'info', title: 'Agent switched', children: state.chat.resumeNote }));
433
457
  }
@@ -488,9 +512,9 @@ function cwdBanner() {
488
512
  }
489
513
  return h('div', { key: 'cwdb', class: 'cwd-bar', role: 'group', 'aria-label': 'Working directory' },
490
514
  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'),
515
+ state.chatCwd ? 'cwd: ' + truncate(state.chatCwd, 28, 60) : 'cwd: server default'),
492
516
  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);
517
+ state.chatCwd ? h('button', { key: 'cwdclr', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); } }, 'use default') : null);
494
518
  }
495
519
 
496
520
  function newChat() {
@@ -553,7 +577,7 @@ async function sendChat() {
553
577
  })) {
554
578
  if (ev.type === 'text') { cur.content += ev.text; render(); scrollChatToBottom(); }
555
579
  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(); }
580
+ else if (ev.type === 'tool_result') { cur.parts.push('-> ' + toolResultSummary(ev.block)); render(); scrollChatToBottom(); }
557
581
  else if (ev.type === 'result') { /* terminal usage/summary block — already reflected via text */ }
558
582
  else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
559
583
  }
@@ -585,11 +609,10 @@ function historyMain() {
585
609
  return [
586
610
  reconnectAlert(),
587
611
  PageHeader({
588
- title: '§ history',
612
+ title: 'history',
589
613
  lede: 'pick a session from the sidebar — events stream live from ccsniff /v1/history.',
590
614
  }),
591
615
  h('div', { key: 'histempty', class: 'history-empty', role: 'status' },
592
- h('div', { key: 'ge', class: 'history-empty-glyph', 'aria-hidden': 'true' }, '§'),
593
616
  h('p', { key: 'gt', class: 'history-empty-title' },
594
617
  count ? 'Select a session to view its events' : 'No sessions yet'),
595
618
  h('p', { key: 'gs', class: 'lede history-empty-sub' },
@@ -605,13 +628,13 @@ function historyMain() {
605
628
  : state.selectedSid;
606
629
 
607
630
  const head = PageHeader({
608
- title: '§ ' + truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || state.selectedSid, 40, 80),
631
+ title: truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || state.selectedSid, 40, 80),
609
632
  lede,
610
633
  });
611
634
 
612
635
  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' }),
636
+ Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
637
+ Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
615
638
  );
616
639
 
617
640
  if (state.events.length === 0) {
@@ -641,8 +664,8 @@ function historyMain() {
641
664
  const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (total - shown.length + i);
642
665
  const role = e.role || '?';
643
666
  const type = e.type || '?';
644
- const tool = e.tool ? ' · ' + e.tool : '';
645
- const errMark = e.isError ? ' · ' : '';
667
+ const tool = e.tool ? ' · tool: ' + e.tool : '';
668
+ const errMark = e.isError ? ' · error' : '';
646
669
  const raw = e.text || '';
647
670
  const text = raw.replace(/\s+/g, ' ').trim();
648
671
  const expanded = state.expandedEvents.has(key);
@@ -664,9 +687,9 @@ let copyToast = null;
664
687
  function copySid() {
665
688
  const sid = state.selectedSid;
666
689
  if (!sid) return;
667
- const done = () => { copyToast = 'copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
690
+ const done = () => { copyToast = 'copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
668
691
  if (navigator.clipboard?.writeText) {
669
- navigator.clipboard.writeText(sid).then(done).catch(() => { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); });
692
+ navigator.clipboard.writeText(sid).then(done).catch(() => { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); });
670
693
  } else {
671
694
  // Fallback for insecure (http) origins where navigator.clipboard is absent.
672
695
  try {
@@ -674,7 +697,7 @@ function copySid() {
674
697
  ta.value = sid; ta.style.position = 'fixed'; ta.style.opacity = '0';
675
698
  document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
676
699
  done();
677
- } catch { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); }
700
+ } catch { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); }
678
701
  }
679
702
  }
680
703
 
@@ -746,7 +769,7 @@ function historySide() {
746
769
  Row({
747
770
  key: 'sess' + s.sid,
748
771
  rank: String(i + 1).padStart(3, '0'),
749
- title: (s.isSubagent ? ' ' : '') + (projectLabel(s.title) || projectLabel(s.project) || s.sid),
772
+ title: (s.isSubagent ? '- ' : '') + (projectLabel(s.title) || projectLabel(s.project) || s.sid),
750
773
  sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools' + (s.errors ? ' · ' + s.errors + ' err' : ''),
751
774
  rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
752
775
  active: s.sid === state.selectedSid,
@@ -761,13 +784,14 @@ function historySide() {
761
784
  running.length
762
785
  ? Panel({
763
786
  key: 'runningPanel',
764
- title: 'running · ' + running.length,
787
+ title: 'running · ' + running.length,
765
788
  children: running.map((r, i) => {
766
789
  const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
767
790
  const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
768
791
  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' }));
792
+ h('span', { key: 'rd', class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
793
+ h('span', { class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
794
+ Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: 'stop' }));
771
795
  }),
772
796
  })
773
797
  : null,
@@ -796,7 +820,7 @@ function historySide() {
796
820
  ? h('p', { key: 'min2', class: 'lede empty-state' }, 'type at least 2 characters to search')
797
821
  : null,
798
822
  state.searchQ
799
- ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: '× clear search' })
823
+ ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: 'clear search' })
800
824
  : null,
801
825
  !searching && projects.length > 1
802
826
  ? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
@@ -810,10 +834,10 @@ function historySide() {
810
834
  'show subagents (' + subagentCount + ')')
811
835
  : null,
812
836
  state.historyError
813
- ? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, '⚠ ' + state.historyError)
837
+ ? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, state.historyError)
814
838
  : (rows.length ? h('div', { key: 'rows' }, ...rows) : h('p', { key: 'empty', class: 'lede empty-state' }, 'no sessions yet')),
815
839
  !searching && truncatedBy > 0
816
- ? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: 'show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
840
+ ? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: 'show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
817
841
  : null,
818
842
  ],
819
843
  }),
@@ -842,9 +866,8 @@ async function saveBackend() {
842
866
  function healthSummary() {
843
867
  const hh = state.health || {};
844
868
  const ok = hh.status === 'ok';
845
- const dot = ok ? '●' : (hh.status === 'unknown' ? '◌' : '○');
846
869
  const bits = [];
847
- bits.push(dot + ' ' + (hh.status || 'unknown'));
870
+ bits.push(hh.status || 'unknown');
848
871
  if (hh.version) bits.push('v' + hh.version);
849
872
  if (typeof hh.agents === 'number') bits.push(hh.agents + ' agents');
850
873
  if (typeof hh.activeExecutions === 'number') bits.push(hh.activeExecutions + ' active');
@@ -858,7 +881,7 @@ function settingsMain() {
858
881
  const isValid = isValidUrl(state.backendDraft);
859
882
  return [
860
883
  PageHeader({
861
- title: 'settings',
884
+ title: 'settings',
862
885
  lede: 'point agentgui at any backend. blank = same-origin (ccsniff in-process). ?backend=… or the field below persists via localStorage.',
863
886
  }),
864
887
  h('div', { key: 'settings-grid', class: 'settings-grid' }, [
@@ -878,10 +901,10 @@ function settingsMain() {
878
901
  title: isValid ? 'Enter a valid URL or leave blank for same-origin' : 'Invalid URL format',
879
902
  onInput: (v) => { state.backendDraft = v; render(); },
880
903
  }),
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,
904
+ !isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
905
+ state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connecting…') : null,
906
+ state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connected') : null,
907
+ state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, 'connection failed — check the URL') : null,
885
908
  healthSummary(),
886
909
  Btn({
887
910
  key: 'savebtn',
@@ -518,14 +518,43 @@
518
518
 
519
519
  .ds-247420 .app-topbar {
520
520
  position: sticky; top: 0; z-index: var(--z-header);
521
- display: grid; grid-template-columns: auto 1fr auto;
521
+ display: flex; flex-wrap: wrap;
522
522
  align-items: center; gap: var(--space-4);
523
523
  min-height: var(--app-topbar-h);
524
524
  padding: 10px var(--pad-x);
525
525
  background: color-mix(in oklab, var(--bg) 88%, transparent);
526
526
  backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
527
527
  }
528
- .ds-247420 .app-topbar nav { display: flex; gap: 4px; font-size: var(--fs-sm); }
528
+
529
+ /* Merged chrome: when AppShell gets both a topbar and a crumb it wraps them
530
+ in .app-chrome and they share ONE sticky band instead of stacking as two
531
+ bars. The breadcrumb provides the left identity (it already begins with the
532
+ brand), so the topbar's standalone brand is hidden to avoid showing the name
533
+ twice; nav and the crumb's right slot sit together on the right. */
534
+ .ds-247420 .app-chrome {
535
+ position: sticky; top: 0; z-index: var(--z-header);
536
+ display: flex; flex-wrap: wrap; align-items: center;
537
+ gap: var(--space-2) var(--space-4);
538
+ padding: 8px var(--pad-x);
539
+ background: color-mix(in oklab, var(--bg) 88%, transparent);
540
+ backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
541
+ }
542
+ .ds-247420 .app-chrome > .app-topbar,
543
+ .ds-247420 .app-chrome > .app-crumb {
544
+ position: static; background: none; backdrop-filter: none;
545
+ -webkit-backdrop-filter: none; padding: 0; min-height: 0;
546
+ flex: 1 1 auto;
547
+ }
548
+ .ds-247420 .app-chrome > .app-crumb { order: 1; flex: 1 1 auto; }
549
+ .ds-247420 .app-chrome > .app-topbar { order: 2; flex: 0 0 auto; }
550
+ .ds-247420 .app-chrome > .app-topbar > .brand { display: none; }
551
+ .ds-247420 .app-chrome > .app-topbar > nav { margin-left: 0; }
552
+ .ds-247420 .app-chrome > .app-crumb > .crumb-right { margin-left: auto; }
553
+ .ds-247420 .app-topbar > .brand { flex: 0 0 auto; }
554
+ .ds-247420 .app-topbar > .app-search { flex: 1 1 auto; }
555
+ .ds-247420 .app-topbar > nav { margin-left: auto; }
556
+ .ds-247420 .app-topbar nav { display: flex; gap: 4px; font-size: var(--fs-sm); flex-wrap: wrap; }
557
+ .ds-247420 .app-topbar nav a { flex: 0 0 auto; }
529
558
  .ds-247420 .app-topbar nav a {
530
559
  color: var(--fg-2);
531
560
  padding: 12px 14px;
@@ -1083,25 +1112,21 @@
1083
1112
  /* App Layout: single-column + drawer is handled once in the ≤900px block;
1084
1113
  no need to re-declare grid-template-columns here (was a redundant !important). */
1085
1114
 
1086
- /* Topbar Navigation */
1115
+ /* Topbar Navigation: stack the nav onto its own full-width row below the
1116
+ brand and let it scroll horizontally. The brand stays on the first row;
1117
+ nav wraps to a second row at 100% width (flex-wrap on .app-topbar). */
1087
1118
  .ds-247420 .app-topbar {
1088
- grid-template-columns: 1fr auto;
1089
1119
  gap: var(--space-2); padding: 12px var(--space-3);
1090
1120
  }
1091
- .ds-247420 .app-topbar nav a {
1092
- padding: 12px 10px;
1093
- min-height: 44px;
1094
- }
1095
- /* Keep primary nav reachable on small screens (apps without a sidebar have
1096
- no other entry point). Compact it and let it scroll horizontally rather
1097
- than hiding it entirely. */
1098
- .ds-247420 .app-topbar nav {
1099
- display: flex; gap: 2px;
1121
+ .ds-247420 .app-topbar > nav {
1122
+ flex: 1 1 100%; margin-left: 0; order: 3;
1123
+ display: flex; gap: 2px; flex-wrap: nowrap;
1100
1124
  overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch;
1101
- max-width: 60vw;
1102
1125
  }
1103
1126
  .ds-247420 .app-topbar nav::-webkit-scrollbar { display: none; }
1104
- .ds-247420 .app-topbar nav a { padding: 10px 8px; white-space: nowrap; }
1127
+ .ds-247420 .app-topbar nav a {
1128
+ flex: 0 0 auto; padding: 10px 12px; min-height: 44px; white-space: nowrap;
1129
+ }
1105
1130
  .ds-247420 .brand { font-size: var(--fs-tiny); font-weight: 600; }
1106
1131
 
1107
1132
  /* Search */