agentgui 1.0.973 → 1.0.975

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.
Files changed (46) hide show
  1. package/AGENTS.md +14 -6
  2. package/lib/http-handler.js +27 -17
  3. package/package.json +1 -1
  4. package/scripts/validate-mutations.mjs +3 -0
  5. package/site/app/js/app.js +28 -15
  6. package/site/app/vendor/anentrypoint-design/247420.css +70 -26
  7. package/site/app/vendor/anentrypoint-design/247420.js +11 -11
  8. package/site/theme.mjs +2 -2
  9. package/test.js +0 -89
  10. package/scripts/harvest-fixtures.mjs +0 -219
  11. package/site/app/vendor/cdn/dompurify.js +0 -9
  12. package/site/app/vendor/cdn/fonts/1291de6d401a.woff2 +0 -0
  13. package/site/app/vendor/cdn/fonts/1ba89a87e0b8.woff2 +0 -0
  14. package/site/app/vendor/cdn/fonts/3644d51c507b.woff2 +0 -0
  15. package/site/app/vendor/cdn/fonts/4b91d2650dc2.woff2 +0 -0
  16. package/site/app/vendor/cdn/fonts/530d036ba64a.woff2 +0 -0
  17. package/site/app/vendor/cdn/fonts/570a2bdd8f8b.woff2 +0 -0
  18. package/site/app/vendor/cdn/fonts/5dd6d880fee9.woff2 +0 -0
  19. package/site/app/vendor/cdn/fonts/62de9143afe3.woff2 +0 -0
  20. package/site/app/vendor/cdn/fonts/64884efa2f11.woff2 +0 -0
  21. package/site/app/vendor/cdn/fonts/68cd7063be2e.woff2 +0 -0
  22. package/site/app/vendor/cdn/fonts/6c252abcf99b.woff2 +0 -0
  23. package/site/app/vendor/cdn/fonts/71e69e06516a.woff2 +0 -0
  24. package/site/app/vendor/cdn/fonts/9ea68c62083f.woff2 +0 -0
  25. package/site/app/vendor/cdn/fonts/c010f9b7d6b2.woff2 +0 -0
  26. package/site/app/vendor/cdn/fonts/d69723fc74be.woff2 +0 -0
  27. package/site/app/vendor/cdn/fonts/fonts.css +0 -459
  28. package/site/app/vendor/cdn/marked.js +0 -8
  29. package/site/app/vendor/cdn/prismjs/components/prism-bash.min.js +0 -1
  30. package/site/app/vendor/cdn/prismjs/components/prism-clike.min.js +0 -1
  31. package/site/app/vendor/cdn/prismjs/components/prism-core.min.js +0 -1
  32. package/site/app/vendor/cdn/prismjs/components/prism-css.min.js +0 -1
  33. package/site/app/vendor/cdn/prismjs/components/prism-diff.min.js +0 -1
  34. package/site/app/vendor/cdn/prismjs/components/prism-go.min.js +0 -1
  35. package/site/app/vendor/cdn/prismjs/components/prism-javascript.min.js +0 -1
  36. package/site/app/vendor/cdn/prismjs/components/prism-json.min.js +0 -1
  37. package/site/app/vendor/cdn/prismjs/components/prism-jsx.min.js +0 -1
  38. package/site/app/vendor/cdn/prismjs/components/prism-markdown.min.js +0 -1
  39. package/site/app/vendor/cdn/prismjs/components/prism-markup.min.js +0 -1
  40. package/site/app/vendor/cdn/prismjs/components/prism-python.min.js +0 -1
  41. package/site/app/vendor/cdn/prismjs/components/prism-rust.min.js +0 -1
  42. package/site/app/vendor/cdn/prismjs/components/prism-sql.min.js +0 -1
  43. package/site/app/vendor/cdn/prismjs/components/prism-toml.min.js +0 -1
  44. package/site/app/vendor/cdn/prismjs/components/prism-tsx.min.js +0 -1
  45. package/site/app/vendor/cdn/prismjs/components/prism-typescript.min.js +0 -1
  46. package/site/app/vendor/cdn/prismjs/components/prism-yaml.min.js +0 -1
package/AGENTS.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # AgentGUI — Agent Notes
2
2
 
3
+ ## Design-maturity sweep + dead-code + secret-hardening (2026-06-18) — sixteenth run
4
+
5
+ Same mandate ("fix every aspect, all design lives in ../design, fan out subagents, track with a workflow"). One tracking Workflow **`gui-design-16` (run `wf_6479308b-24f`, 12 lenses chat-thread/composer-input/files-browser/sessions-dashboard/shell-chrome/tokens-theme/a11y-motion/history-settings/marketing-site/glyph-residue/dead-code/server-security): 54 agents -> hunt -> adversarial verify -> 40 confirmed findings, ~2.7M tokens.** Applied via a clean per-file subagent fan-out (one agent per physical file, zero edit conflicts).
6
+
7
+ **Kit fixes (ALL in `/config/workspace/design`):** chat.js ToolCallNode now gates the `args` section on real args (`hasArgs`) so result-only/arg-less cards no longer show `args {}` + composer `<textarea>` binds the `disabled` prop; agent-chat.js cwd input emits `aria-busy` while checking; content.js EventList now forwards `detail/actions/highlight/meta` to Row (expanded history events were dropping toolInput detail + copy + search-highlight) + TextField gained a full invalid/error state (`error`/`aria-invalid`/`aria-describedby`/`.ds-field-error` role=alert); interaction-primitives.js ShortcutList splits combos into discrete `.ds-kbd` caps (`.ds-kbd-caps`/`.ds-kbd-sep` + CSS) instead of one wide cap; sessions.js ConversationList rail filter migrated from bespoke `.ds-session-search` to the shared `SearchInput`/`.ds-search-input` (one filter control across rail + dashboard); shell.js WsResizer adds `aria-valuetext "<n> pixels"`; voice.js `×`->`Icon('x')`, community.js `⋯`->`'more'` (glyph residue). chat.css: cwd `.is-checking` hint tone + `aria-busy` field cue + cwd-btn `6px`->`var(--r-1)`, breakdown idle disc moved off the connecting-amber to canonical `--stale` double-inset, breakdown running disc shape-ring, `.ds-dash-clear` hover/focus-visible, `.chat-tool-copy:focus-visible`, errors-toggle AA fix, ds-session-search base block removed. app-shell.css: composer + WorkspaceShell (`.ws-rail/.ws-pane/.ws-sessions/.ws-scrim`) reduced-motion guards, `.ds-file-row.is-locked/.is-restricted` + `.ds-file-perm-tag` chip, `.ws-drawer-toggle` 44px coarse floor, `.ds-density-btn.active`/`.ds-filter-pill.active` AA contrast (`color:var(--fg)`), ShortcutList legend rules. editor-primitives.css `#000` dock shadow->`var(--shadow-3)` + checkbox `#fff` fallback dropped. app-surfaces.css `@media print` re-asserts paper-tuned signal tokens. community.css `:focus-visible` for ThreadPanel/Forum/Page + forum-search. marketing.css NEW `.site-footer` family (theme.mjs footers migrated off the in-app `.app-status` strip that suppressed content <=1100px) + `.site-cli` baseline+card-chrome + token paddings + `100dvh` embed.
8
+
9
+ **Server secret-hardening (`lib/http-handler.js`, the high-sev cluster):** the module-level `SECRET_RE` (already blocking `/api/file`+`/api/download`) is now ALSO applied to `/api/upload-file`, `/api/mkdir`, `/api/rename` (403 on a secret/dotfile target name — was an overwrite-a-secret hole) and `/api/list` (filters secret-named dirents from the listing — was enumerating `.env`/`.pem` the preview/download routes block); `/api/download` Content-Disposition is now RFC-5987 encoded (ASCII `filename=` fallback + `filename*=UTF-8''<pct>`) so a CJK/emoji name no longer throws `ERR_INVALID_CHAR`. `validate-mutations.mjs` 26/26 PASS, no regression.
10
+
11
+ **Dead-code removal:** agentgui `site/app/vendor/cdn/` (36 dead files — marked/dompurify/prismjs/fonts, zero refs; markdown stack fetches jsDelivr at runtime), `scripts/harvest-fixtures.mjs` (zero refs), and the 3 `node scripts/copy-vendor.js` lines in `.github/workflows/build-platforms.yml` (script deleted in the 15th run — CI was broken); kit `scripts/bundle-markdown.mjs` (orphaned, superseded by src/markdown.js).
12
+
13
+ **Witnessed** (localhost:3009/gm/?token, PASSWORD=`123,slam,123,slam`, fresh server + re-vendored dist, via gm `browser` verb): readyState complete, `ds-247420` dark body `rgb(19,19,24)`, 3 resizers, hScroll 0, rail `ds-search-input`=1 / bespoke `ds-session-search`=0 (consolidation landed), `ds-file-perm-tag`/`site-footer`/`ds-kbd-caps` rules all resolve, **0 console errors**. Kit build all 4 lints PASS. `test.js` 10 pass/0 fail. Re-vendored `dist/247420.{css,js}`.
14
+
3
15
  ## Design-maturity sweep + dead-code + server-hardening (2026-06-18) — fifteenth run
4
16
 
5
17
  Mandate: "fix every aspect, update ../design, all design content lives there, fan out subagents, track with a workflow." Two tracking Workflows ran. **`gui-design-15` (run `wf_7ba315a1-9a9`, 8 lenses composer-input/chat-thread/files/sessions/shell/tokens-theme/a11y-motion/glyph-residue): 59 agents -> 42 findings -> 35 confirmed -> 33 applied across 9 kit files** + 2 deferred fixes implemented after (shell pane-toggle relocated into `.ws-crumb` as a `ws-desktop-toggle`; files density picker made icon-led with 3 NEW shell.js icons `rows`/`rows-tight`/`grid` + `DENSITY_ICONS` + `.ds-density-btn` 28px square). **`gui-design-15b` (run `wf_5b2861b2-717`, 3 lenses history-settings/server-boundary/security): 25 agents -> 22 findings -> 18 confirmed**, all landed. Total ~95 agents, ~4M tokens.
@@ -14,15 +26,11 @@ Mandate: "fix every aspect, update ../design, all design content lives there, fa
14
26
 
15
27
  ## Design-maturity sweep + marketing-site consolidation (2026-06-18) — fourteenth run
16
28
 
17
- Continues the 13th run's "all design lives in the kit" mandate to the LAST residue surface + a kit design-maturity sweep. **`site/theme.mjs` (the flatspace marketing/landing renderer, NOT the SPA) was the remaining residue**: it carried an inline `<style>` with hardcoded hex (`#FBF6EB`/`#1F1B16`), ~12 inline `style=` props, deprecated `C.Btn({primary})`, and two `↗` arrow glyphs. ALL migrated: a new kit **`marketing.css`** `.site-*` family (`.site-panel`/`.site-hero`/`.site-hero-h`/`.site-hero-body`/`.site-chip-row`/`.site-cta-row`/`.site-cli`+`.cli`/`.prompt`/`.cmd`/`.site-embed`, pre-scoped `.ds-247420`, wired into `build.mjs` cssParts + `lint-glyphs` `SCAN_ROOT_FILES`); kit `Panel`/`Heading` gained a **`class` prop** (they only accepted `style`) so consumers attach classes instead of inline styles; `Btn({primary})` -> `Btn({variant:'primary'})`; `↗` -> `->`. theme.mjs head now render-blocking-`<link>`s the kit `247420.css` (it loaded the kit via JS importmap only, so the inline `<style>` was its pre-JS FOUC guard) — the kit already owns the base surface (`app-surfaces.css:27` `.ds-247420 body { background:var(--bg); color:var(--fg); font-family:var(--ff-display) }`). flatspace is NOT a dep here; theme.mjs is a pure render fn witnessed by loading its `renderHtml()` output. **The marketing site tracks unpkg@latest (importmap+CSS link); the SPA ships the pinned vendored dist — two intentional load strategies.**
18
-
19
- **Workflow `gui-design-14` (via the Workflow tool, run `wf_50751bd9-a43`, 7 lenses: chat-thread/files/sessions/shell-chrome/tokens-theme/a11y-motion/marketing-residue): 46 agents -> 39 findings -> 28 adversarially confirmed** (`PUNCHLIST-DESIGN-14.md`; 2 lens connection-failures: tokens-theme hunt + marketing-residue verify). ALL landed in the kit. **chat:** ThinkingNode base CSS (`.chat-thinking`/`-text`/`-dots` had ZERO base rules outside `.agentchat-working`), markdown `table`/`th`/`td`/`hr` (were unstyled -> bare cells), dead `.chat-tick` inline-code class given a rule, composer toolbar unified on 32px/`--r-1` (was 40px-round vs 36px-square), flat `.chat-md` rhythm+inset, streaming `.chat-stream-pre` persistent chrome, tool/code `:has()` measure-breakout. **files:** FileSkeleton container was a single 48px shimmer bar collapsing all rows (now inner `.ds-skel`-only), filter folded into one `.ds-file-controls` toolbar row, per-type cell icon tints, dead `data-columns` card-mode removed (+ `FILE_GRID_MIN_COL`/`columns` param), `.ds-file-check` dead font/color decls dropped + `.ds-check-box` rest border -> `--fg-3`. **sessions:** stale disc own tone (was sharing `--amber` with connecting) + connecting now a hollow ring, dead duplicate `.ds-dash-status.is-running` removed + running unified on `--accent` across disc/breakdown/card, `.seg.is-idle` weight 600, reduced-motion live-disc static-ring fallback, mixed-tick thickened 1.5px->2px, card cost as emphasized `.ds-dash-stat-cost`, conv-list unread -> hollow ring (shape-distinct from running). **shell:** resizers hidden past their staging breakpoints (`@media` 1480/1100/900 — were dead handles dragging vars into a fixed drawer), coarse-pointer 16px hit target, `WS_RESIZE_CLAMP` reconciled to the CSS `clamp()` floors/ceilings, localStorage committed on pointerup (not per-move), separator `aria-valuemin/max/now`. **a11y:** voice `vx-*` added to `community.css` reduced-motion guard, status discs given non-colour SHAPE channels, `.ds-input-bare` `:focus`->`:focus-visible`+ring. **Status-disc shape rule:** live=solid+pulse(or static ring under reduced-motion), error=solid+halo, connecting=hollow ring, stale=muted solid — four channels, not colour-only. Witnessed live (localhost:3009/gm/?token, PASSWORD=`123,slam,123,slam`): 0 console/page errors, dark body surface painted by kit, 3 resizers, no h-scroll, migrated classes resolve. Kit pushed `3704876`; rebased onto a concurrent CI `v0.0.211` bump.
29
+ `site/theme.mjs` (marketing renderer, NOT the SPA) was the last design residue -> migrated to a NEW kit `marketing.css` `.site-*` family; `Panel`/`Heading` gained a `class` prop; `Btn({primary})`->`Btn({variant:'primary'})`. Workflow `gui-design-14` (`wf_50751bd9-a43`, 7 lenses) 46 agents -> 28 confirmed, all in the kit (ThinkingNode/markdown-table/status-disc shape channels/shell resizer breakpoints). **Status-disc shape rule: live=solid+pulse, error=solid+halo, connecting=hollow ring, stale=muted solid.** Full detail in rs-learn (recall "agentgui 14th run marketing-site consolidation").
20
30
 
21
31
  ## Design-content consolidation — ALL design lives in the kit now (2026-06-18) — thirteenth run
22
32
 
23
- The mandate: every design decision must live in the kit (`../design` = `/config/workspace/design`), none in the agentgui app. **`site/app/index.html` no longer carries an inline `<style>` block** — the ~240-line block (28 classes: `.pill`/`.cwd-bar`/`.resume-banner`/`.health-chip`/`.settings-grid`/`.history-empty`/`.boot-splash`/`.chat-controls`/`.status-dot`/`.ds-event-list` overrides + `event-flash` keyframe + scrollbar theming + focus rings + responsive + print) moved to a NEW kit file **`../design/app-surfaces.css`** (wired into `scripts/build.mjs` CSS_FILES, scoped `.ds-247420`, reads kit tokens directly with NO `--agentgui-*` fallbacks — those local color vars are deleted). `app.js` carries **zero inline `style=` props** (the 6 margin literals -> `.agentgui-field-mb`/`.agentgui-field-my` kit utilities; the `mainStyle` layout string -> `.agentgui-main`/`.agentgui-main-chat` kit rules). **Rule going forward: no design content in agentgui — new surface styling is a kit CSS rule, never an inline `<style>` or `style=`.** `app-surfaces.css` selectors are written pre-scoped `.ds-247420 ...` so the build's selector-prefixer (handles `:root`/`html`/`body`, compounds `[attr]`/`*`) leaves them untouched. Because `247420.css` is a render-blocking `<link>` in head and `<html class="ds-247420">` is static, there is NO FOUC from moving the base/boot-splash styling into the kit.
24
-
25
- **Workflow `.claude/workflows/gui-design-consolidation.js`** (residue-first 13th run, 6 lenses: design-residue/chat-thread/files-live/shell-chrome/tokens-theme/a11y-motion) ran 44 agents -> 19 confirmed findings (`PUNCHLIST-DESIGN.md`), ALL landed in the kit: composer send-row `.chat-composer-toolbar` (was unstyled), role-monotonic chat hover tints, tool-card `done` success tone, base-`Chat` `.chat-empty-suggestion` styling, error-card flame inset rail, `.ds-check-box` CSS-drawn multi-select checkbox (replaced the `[x]/[ ]` bracket text in files.js+sessions.js with a bordered box that fills on `.is-marked`/`[aria-checked]` — border-drawn tick, NOT a glyph), thin 26px status strip, status-item ellipsis, `.ws-crumb` gutter align, fluid `clamp()` shell width tokens, `:focus`->`:focus-visible` (no ring on mouse click), `--warn`/`--sky` dark-theme pairs, unified `--focus-*` tokens, reduced-motion skeleton static-fill, color-blind-safe rail SHAPE differentiation + sr-only status word in `Row()`. Amber/purple hardcoded `var(--token, #hex)` fallbacks stripped (the dark-hex could wrongly paint the light theme). **`lint-glyphs` was extended** with `SCAN_ROOT_FILES` (app-shell/chat/colors_and_type/community/community-app/editor-primitives/app-surfaces.css) — the root bundled CSS previously escaped the glyph lint, which is how 39 box-drawing comment dividers in app-shell.css slipped in (now converted to ASCII). Witnessed live (localhost:3009/gm/, fresh bundle): 0 console/page errors, migrated classes resolve from the kit bundle (`.pill` cursor:pointer/radius 999px, `.ds-check-box` 15px, cwd mono), no h-scroll.
33
+ The mandate's origin: every design decision lives in the kit (`../design`), none in agentgui. `site/app/index.html` lost its ~240-line inline `<style>` (moved to NEW kit `app-surfaces.css`); `app.js` carries zero inline `style=` props. **Rule going forward: no design content in agentgui — new surface styling is a kit CSS rule, never an inline `<style>` or `style=`.** `lint-glyphs` extended with `SCAN_ROOT_FILES` so root bundled CSS no longer escapes the glyph lint. Workflow `gui-design-consolidation.js` (6 lenses) 44 agents -> 19 confirmed. Full detail in rs-learn (recall "agentgui 13th run design-content consolidation").
26
34
 
27
35
  ## CRITICAL — `authedFetch` must NOT set `Authorization: Bearer` behind an nginx Basic-Auth proxy (2026-06-18)
28
36
 
@@ -37,6 +37,12 @@ export function confineToRoots(inputPath, allowRoots) {
37
37
  return { ok: true, realPath };
38
38
  }
39
39
 
40
+ // Secret-bearing basenames that must never be readable through ANY confined
41
+ // raw-bytes route (preview or download), even when they sit inside an allowed
42
+ // root: dotfiles, env/key/cert material, and credential stores. One const so
43
+ // /api/file and /api/download can never drift apart.
44
+ export const SECRET_RE = /(^\.|\.(env|pem|key|crt|p12|pfx)$|secret|credential|\.npmrc$|\.netrc$)/i;
45
+
40
46
  // The allowlist the Files surface operates within: server cwd + Claude
41
47
  // projects dir, widened via FS_ROOTS (path-separated). One construction so
42
48
  // /api/list,file,download and the mutation routes can never drift apart.
@@ -95,19 +101,15 @@ function isAllowRoot(realPath, allowRoots) {
95
101
 
96
102
  export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
97
103
  return async function httpHandler(req, res) {
98
- // CORS: when PASSWORD is set the server holds credentials a cross-origin
99
- // page must not be able to spend, so we do NOT advertise a wildcard origin
100
- // (a wildcard + a leaked Bearer/token would let any site the user visits
101
- // drive this server). Reflect an explicitly-allowed origin (CORS_ORIGIN)
102
- // when configured, else same-origin only. With no PASSWORD the server is
103
- // already open, so the permissive wildcard is harmless and kept for tools.
104
+ // CORS: emit ACAO only when CORS_ORIGIN is explicitly set. A wildcard would
105
+ // let any webpage the user visits make credentialless fetches to /api/list,
106
+ // /api/file/*, etc. and read ~/.claude/projects content even on a no-
107
+ // PASSWORD localhost deploy. Set CORS_ORIGIN=<origin> for cross-origin tools.
104
108
  const _corsOrigin = process.env.CORS_ORIGIN;
105
109
  if (_corsOrigin) {
106
110
  res.setHeader('Access-Control-Allow-Origin', _corsOrigin);
107
111
  res.setHeader('Vary', 'Origin');
108
- } else if (!process.env.PASSWORD) {
109
- res.setHeader('Access-Control-Allow-Origin', '*');
110
- } // else: no ACAO header -> browsers block cross-origin reads (same-origin still works)
112
+ } // no ACAO header -> browsers enforce same-origin by default
111
113
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
112
114
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
113
115
  // The password can ride in a ?token= query param (EventSource/deep-links
@@ -403,7 +405,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
403
405
  for (const [t, exts] of Object.entries(EXT_TYPE)) if (exts.includes(ext)) return t;
404
406
  return 'other';
405
407
  };
406
- const dirents = fs.readdirSync(normalizedPath, { withFileTypes: true });
408
+ const dirents = fs.readdirSync(normalizedPath, { withFileTypes: true }).filter((d) => !SECRET_RE.test(d.name));
407
409
  const entries = dirents.map((d) => {
408
410
  const full = path.join(normalizedPath, d.name);
409
411
  let size = null, modified = null, permissions;
@@ -449,7 +451,6 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
449
451
  // material, and credential stores must never be readable through the
450
452
  // Files preview even when they sit inside an allowed root.
451
453
  const base = path.basename(normalizedPath);
452
- const SECRET_RE = /(^\.|\.(env|pem|key|crt|p12|pfx)$|secret|credential|\.npmrc$|\.netrc$)/i;
453
454
  if (SECRET_RE.test(base)) { res.writeHead(403); res.end('Forbidden'); return; }
454
455
  // Only known text/code extensions (images go through /api/image). An
455
456
  // unknown/binary extension is rejected, never served as octet-stream.
@@ -493,15 +494,19 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
493
494
  const conf = confineToRoots(decodedPath, allowRoots);
494
495
  if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end('Forbidden'); return; }
495
496
  const normalizedPath = conf.realPath;
497
+ // Same secret-name block as /api/file: a download must not exfiltrate
498
+ // .env/.pem/.key/credential material just because it streams bytes.
499
+ if (SECRET_RE.test(path.basename(normalizedPath))) { res.writeHead(403); res.end('Forbidden'); return; }
496
500
  try {
497
501
  const st = fs.statSync(normalizedPath);
498
502
  if (!st.isFile()) { res.writeHead(400); res.end('Not a file'); return; }
499
503
  const MAX = 50 * 1024 * 1024; // 50MB cap so a download can't exhaust memory
500
504
  if (st.size > MAX) { res.writeHead(413); res.end('File too large to download'); return; }
501
- const name = path.basename(normalizedPath).replace(/["\r\n]/g, '');
505
+ const base = path.basename(normalizedPath);
506
+ const asciiName = base.replace(/[\\"\r\n]/g, '').replace(/[^\x20-\x7e]/g, '_');
502
507
  res.writeHead(200, {
503
508
  'Content-Type': 'application/octet-stream',
504
- 'Content-Disposition': 'attachment; filename="' + name + '"',
509
+ 'Content-Disposition': 'attachment; filename="' + asciiName + '"; filename*=UTF-8\'\'' + encodeURIComponent(base),
505
510
  'Content-Length': String(st.size),
506
511
  'Cache-Control': 'no-cache',
507
512
  });
@@ -533,6 +538,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
533
538
  if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot rename an allowed root' }); return; }
534
539
  const newName = sanitizeEntryName(body.newName);
535
540
  if (!newName) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
541
+ if (SECRET_RE.test(newName)) { sendJSON(req, res, 403, { error: 'forbidden: secret/dotfile name' }); return; }
536
542
  const target = path.join(path.dirname(conf.realPath), newName);
537
543
  // The target stays in the same (already-confined) directory by
538
544
  // construction, but re-check anyway so the invariant is local.
@@ -622,6 +628,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
622
628
  if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
623
629
  const name = sanitizeEntryName(body.name);
624
630
  if (!name) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
631
+ if (SECRET_RE.test(name)) { sendJSON(req, res, 403, { error: 'forbidden: secret/dotfile name' }); return; }
625
632
  const target = path.join(conf.realPath, name);
626
633
  if (fs.existsSync(target)) { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
627
634
  try { fs.mkdirSync(target); sendJSON(req, res, 200, { ok: true, path: target }); }
@@ -641,6 +648,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
641
648
  if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
642
649
  const name = sanitizeEntryName(qs.get('name'));
643
650
  if (!name) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
651
+ if (SECRET_RE.test(name)) { sendJSON(req, res, 403, { error: 'forbidden: secret/dotfile name' }); return; }
644
652
  const target = path.join(conf.realPath, name);
645
653
  if (fs.existsSync(target) && qs.get('overwrite') !== '1') { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
646
654
  let buf;
@@ -672,12 +680,14 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
672
680
  const normalizedPath = conf.realPath;
673
681
  try {
674
682
  const ext = path.extname(normalizedPath).toLowerCase();
675
- const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' };
676
- // Only serve known image types - never an octet-stream fallback, which
677
- // would turn this into a generic file reader for any extension.
683
+ const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
684
+ // SVG is intentionally excluded: browsers render SVG as a live document
685
+ // in the app's origin, so an agent-written SVG with a <script src=CDN>
686
+ // would execute in the agentgui origin (CSP allows unpkg/jsdelivr).
687
+ // Files preview uses /api/file/download (attachment) for SVG.
678
688
  const contentType = mimeTypes[ext];
679
689
  if (!contentType) { res.writeHead(403); res.end('Forbidden'); return; }
680
- res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' });
690
+ res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' });
681
691
  res.end(fs.readFileSync(normalizedPath));
682
692
  } catch (err) { sendJSON(req, res, 400, { error: err.message }); }
683
693
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.973",
3
+ "version": "1.0.975",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -28,6 +28,9 @@ const TRAVERSAL = join(ROOT, '..', '..', ...(WIN ? ['Windows', 'win.ini'] : ['et
28
28
  await check('rename-traversal', 403, await post('/api/rename', { path: TRAVERSAL, newName: 'x.txt' }));
29
29
  await check('delete-out-of-roots', 403, await post('/api/delete', { path: OUTSIDE }));
30
30
  await check('delete-root-refused', 403, await post('/api/delete', { path: ROOT }));
31
+ await check('rename-to-secret-403', 403, await post('/api/rename', { path: join(ROOT, 'package.json'), newName: '.env' }));
32
+ await check('upload-secret-403', 403, await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(ROOT) + '&name=.env', { method: 'PUT', headers: AUTH, body: 'secret' }).then(async r => ({ status: r.status, body: await r.text() })));
33
+ await check('mkdir-secret-403', 403, await post('/api/mkdir', { dir: ROOT, name: '.env' }));
31
34
  await check('mkdir-reserved-name', 400, await post('/api/mkdir', { dir: ROOT, name: 'CON' }));
32
35
  await check('mkdir-name-with-separator', 400, await post('/api/mkdir', { dir: ROOT, name: 'a/b' }));
33
36
  await check('mkdir-name-trailing-dot', 400, await post('/api/mkdir', { dir: ROOT, name: 'evil.' }));
@@ -415,6 +415,7 @@ function openLiveStream() {
415
415
  // Dedupe against the snapshot/prior pushes by event index - a
416
416
  // reconnect or overlap would otherwise double-append the same event.
417
417
  if (ev.i == null || !state.events.some(e => e.i === ev.i)) {
418
+ ev._idx = state.events.length;
418
419
  state.events.push(ev);
419
420
  // Cap retained events so a long live session can't grow unbounded.
420
421
  if (state.events.length > 2000) state.events.splice(0, state.events.length - 2000);
@@ -1699,7 +1700,9 @@ function appendText(parts, text) {
1699
1700
  // standalone tool_result part. Sets the card's result + done/error status.
1700
1701
  function applyToolResult(parts, block) {
1701
1702
  const id = block.tool_use_id || block.id || null;
1702
- const content = block?.content ?? block?.output ?? block;
1703
+ const raw = block?.content ?? block?.output ?? block;
1704
+ // Claude API delivers content as an array of {type,text} objects; flatten to plain text.
1705
+ const content = Array.isArray(raw) ? (raw.filter(b => b.type === 'text').map(b => b.text).join('\n') || JSON.stringify(raw, null, 2)) : raw;
1703
1706
  const isError = !!(block?.is_error);
1704
1707
  const byId = id ? [...parts].reverse().find(p => p && p.kind === 'tool' && p._id === id) : null;
1705
1708
  const target = byId || [...parts].reverse().find(p => p && p.kind === 'tool' && p.status === 'running');
@@ -2323,6 +2326,9 @@ async function sendChat(textArg) {
2323
2326
  } finally {
2324
2327
  state.chat.busy = false;
2325
2328
  state.chat.abort = null;
2329
+ // Prune an empty assistant shell (WS-drop before any content arrived).
2330
+ const msgs = state.chat.messages;
2331
+ if (msgs.length && isEmptyTurn(msgs[msgs.length - 1])) msgs.pop();
2326
2332
  persistChat();
2327
2333
  refreshActive(); // settle the running panel/dashboard now, not at the next poll
2328
2334
  render();
@@ -2479,6 +2485,13 @@ function historyMain() {
2479
2485
  onSelect: (id) => { state.eventFilter = id && id.id ? id.id : id; render(); },
2480
2486
  label: 'Filter events by type',
2481
2487
  });
2488
+ // Single pass over state.events for all three counters (replaces three separate .filter() calls).
2489
+ const evCounters = state.events.reduce((c, e) => {
2490
+ if (e.role === 'user') c.turns++;
2491
+ if (e.type === 'tool_use') c.tools++;
2492
+ if (e.isError) c.errors++;
2493
+ return c;
2494
+ }, { turns: 0, tools: 0, errors: 0 });
2482
2495
  const meta = SessionMeta({
2483
2496
  items: [
2484
2497
  sess && sess.cwd ? { label: 'cwd', value: sess.cwd, title: sess.cwd } : null,
@@ -2487,9 +2500,9 @@ function historyMain() {
2487
2500
  // Spelled counter vocabulary in the detail strip (events/turns/tools/
2488
2501
  // errors); the abbreviated 'ev/tools/err' triple stays compact-row-only.
2489
2502
  { label: 'events', value: String(state.events.length) },
2490
- { label: 'turns', value: String(sess?.userTurns ?? state.events.filter(e => e.role === 'user').length) },
2491
- { label: 'tools', value: String(state.events.filter(e => e.type === 'tool_use').length) },
2492
- { label: 'errors', value: String(state.events.filter(e => e.isError).length) },
2503
+ { label: 'turns', value: String(sess?.userTurns ?? evCounters.turns) },
2504
+ { label: 'tools', value: String(evCounters.tools) },
2505
+ { label: 'errors', value: String(evCounters.errors) },
2493
2506
  ].filter(Boolean),
2494
2507
  });
2495
2508
  if (filteredEvents.length === 0) {
@@ -2506,10 +2519,7 @@ function historyMain() {
2506
2519
  const shown = filteredEvents.slice(-limit);
2507
2520
  const hiddenCount = total - shown.length;
2508
2521
  // Keys of the currently-shown rows, so expand-all toggles only what's rendered.
2509
- const shownKeys = shown.map((e, i) => {
2510
- const absIdx = total - shown.length + i;
2511
- return e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + absIdx;
2512
- });
2522
+ const shownKeys = shown.map((e, i) => e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + (e._idx ?? (total - shown.length + i)));
2513
2523
  const allExpanded = shownKeys.length > 0 && shownKeys.every(k => state.expandedEvents.has(k));
2514
2524
  const eventControls = h('div', { key: 'evctrl', class: 'history-actions', role: 'group', 'aria-label': 'event controls' },
2515
2525
  Btn({ key: 'expall', onClick: () => {
@@ -2535,8 +2545,7 @@ function historyMain() {
2535
2545
  // Stable key: server event index when present, else ts + the event's
2536
2546
  // ABSOLUTE position in state.events (not the sliced-view index, which
2537
2547
  // shifts when live events append and would collide loaded vs live rows).
2538
- const absIdx = total - shown.length + i;
2539
- const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + absIdx;
2548
+ const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + (e._idx ?? (total - shown.length + i));
2540
2549
  const role = e.role || '?';
2541
2550
  const type = e.type || '?';
2542
2551
  const tool = e.tool ? ' · tool: ' + e.tool : '';
@@ -2890,8 +2899,7 @@ function settingsMain() {
2890
2899
  label: 'backend url',
2891
2900
  value: state.backendDraft,
2892
2901
  placeholder: '(blank = same origin)',
2893
- 'aria-describedby': !isValid ? 'backend-url-error' : undefined,
2894
- 'aria-invalid': !isValid ? 'true' : 'false',
2902
+ error: !isValid ? 'Invalid URL format' : undefined,
2895
2903
  title: isValid ? 'Enter a valid URL or leave blank for same-origin' : 'Invalid URL format',
2896
2904
  onInput: (v) => {
2897
2905
  state.backendDraft = v;
@@ -2900,7 +2908,6 @@ function settingsMain() {
2900
2908
  render();
2901
2909
  },
2902
2910
  }),
2903
- !isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
2904
2911
  state.backendStatus === 'connecting' ? h('p', { key: 'bst-connecting', class: 'lede', role: 'status' }, 'connecting…') : null,
2905
2912
  state.backendStatus === 'ok' ? h('p', { key: 'bst-ok', class: 'lede', role: 'status' }, 'connected') : null,
2906
2913
  state.backendStatus === 'failed' ? h('p', { key: 'bst-failed', class: 'lede field-error', role: 'alert' }, 'connection failed - check the URL') : null,
@@ -2977,6 +2984,8 @@ function clearLocalData() {
2977
2984
  // defaults with the keys gone.
2978
2985
  state.chat.abort?.abort(); // stop any in-flight stream before we drop the page
2979
2986
  for (const k of ['agentgui.chat', 'agentgui.agent', 'agentgui.model', 'agentgui.cwd', 'agentgui.backend', 'agentgui.live', 'agentgui.files']) lsRemove(k);
2987
+ // Also wipe kit WorkspaceShell layout keys (collapse state + resizer widths).
2988
+ try { for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && k.startsWith('ds.ws.')) localStorage.removeItem(k); } } catch {}
2980
2989
  state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, confirmingEdit: null, totalCost: 0 };
2981
2990
  location.reload();
2982
2991
  }
@@ -3013,7 +3022,7 @@ function preferencesPanel() {
3013
3022
  state.confirmingClearData
3014
3023
  ? Alert({ key: 'cld', kind: 'warn', title: 'Clear all local data?',
3015
3024
  children: [
3016
- h('span', { key: 'cldtxt' }, 'Removes saved chat, agent/model/cwd, and backend from this browser. This cannot be undone. '),
3025
+ h('span', { key: 'cldtxt' }, 'Removes saved chat, agent/model/cwd, backend, and layout preferences from this browser. This cannot be undone. '),
3017
3026
  Btn({ key: 'cldno', onClick: () => { state.confirmingClearData = false; render(); }, children: 'cancel' }),
3018
3027
  Btn({ key: 'cldyes', danger: true, onClick: clearLocalData, children: 'clear' })] })
3019
3028
  : Btn({ key: 'cldbtn', onClick: clearLocalData, children: 'clear local data' }),
@@ -3046,7 +3055,9 @@ function agentsPanel() {
3046
3055
  const bits = [PROTOCOL_WORDS[a.protocol] || 'agent'];
3047
3056
  if (!avail) bits.push(a.npxInstallable ? 'runs via npx' : 'not installed');
3048
3057
  if (acp) bits.push(acp.healthy ? 'running healthy' : (acp.running ? 'running' : 'stopped'));
3049
- if (acp && acp.restartCount > 1) bits.push('restarted ' + acp.restartCount + ' times');
3058
+ if (acp && acp.restartCount >= 1) bits.push('restarted ' + acp.restartCount + (acp.restartCount === 1 ? ' time' : ' times'));
3059
+ if (acp && !acp.healthy && acp.providerInfo?.error) bits.push(acp.providerInfo.error);
3060
+ if (acp && acp.idleMs > 3_600_000) bits.push(fmtDuration(acp.idleMs) + ' idle');
3050
3061
  return Row({
3051
3062
  key: 'ag' + a.id,
3052
3063
  rank: String(i + 1).padStart(3, '0'),
@@ -3192,6 +3203,8 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromH
3192
3203
  // whole session) - cap in-memory state at the most-recent 5000 so a
3193
3204
  // monster session can't pin the tab; the render window stays 300+load-older.
3194
3205
  if (state.events.length > 5000) state.events = state.events.slice(-5000);
3206
+ // Stamp stable _idx so EventList keys are stable regardless of slice/cap.
3207
+ state.events.forEach((e, i) => { if (e._idx == null) e._idx = i; });
3195
3208
  clearTimeout(slowTimer);
3196
3209
  state.eventsSlow = false;
3197
3210
  state.eventsLoaded = true;
@@ -1809,7 +1809,7 @@
1809
1809
  transition: background var(--dur-snap) var(--ease), color var(--dur-snap) var(--ease);
1810
1810
  }
1811
1811
  .ds-247420 .ds-density-btn:hover { color: var(--fg); }
1812
- .ds-247420 .ds-density-btn.active { background: var(--accent-tint); color: var(--accent); }
1812
+ .ds-247420 .ds-density-btn.active { background: var(--accent-tint); color: var(--fg); }
1813
1813
  .ds-247420 .ds-density-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1814
1814
 
1815
1815
  /* Compact density — tighter rows for long listings. */
@@ -1834,6 +1834,10 @@
1834
1834
  .ds-247420 .ds-file-cell.active { border-color: var(--accent); background: var(--accent-tint); }
1835
1835
  .ds-247420 .ds-file-cell.is-marked { border-color: var(--accent); background: var(--accent-tint); }
1836
1836
  .ds-247420 .ds-file-cell.is-locked { opacity: 0.6; }
1837
+ .ds-247420 .ds-file-row.is-locked { opacity: 0.6; }
1838
+ .ds-247420 .ds-file-row.is-restricted .title { color: var(--fg-2); }
1839
+ .ds-247420 .ds-file-perm-tag { font-family: var(--ff-mono); font-size: var(--fs-micro); color: var(--fg-3); padding: 1px 6px; border: var(--bw-hair) solid var(--rule); border-radius: var(--r-1); white-space: nowrap; }
1840
+ .ds-247420 .ds-file-perm-tag.is-noaccess { color: var(--flame); border-color: color-mix(in srgb, var(--flame) 40%, transparent); }
1837
1841
  .ds-247420 .ds-file-cell-open {
1838
1842
  display: flex; flex-direction: column; align-items: stretch; gap: var(--space-1);
1839
1843
  width: 100%; padding: var(--space-1); margin: 0;
@@ -2230,6 +2234,10 @@
2230
2234
  border-radius: var(--r-2); font-size: var(--fs-xs);
2231
2235
  }
2232
2236
  .ds-247420 .ds-shortcut-row { display: flex; align-items: center; gap: var(--space-2); }
2237
+ .ds-247420 .ds-shortcuts-hint .ds-kbd-label { flex: 1 1 auto; opacity: .85; }
2238
+ .ds-247420 .ds-shortcuts-hint .ds-kbd { white-space: normal; max-width: 100%; }
2239
+ .ds-247420 .ds-kbd-caps { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px; }
2240
+ .ds-247420 .ds-kbd-sep { color: var(--fg-3); font-size: var(--fs-micro); padding: 0 2px; }
2233
2241
  .ds-247420 .ds-kbd {
2234
2242
  display: inline-block; min-width: 0;
2235
2243
  padding: 2px 7px; border-radius: 6px;
@@ -2667,9 +2675,10 @@
2667
2675
  re-declare it; the sizing block now states the truth). */
2668
2676
  line-height: 1.5; resize: none;
2669
2677
  min-height: 28px; max-height: 200px;
2670
- box-sizing: border-box; overflow-y: auto;
2678
+ box-sizing: border-box; overflow-y: hidden;
2671
2679
  scrollbar-width: thin;
2672
2680
  }
2681
+ .ds-247420 .chat-composer textarea:focus { overflow-y: auto; }
2673
2682
  .ds-247420 .chat-composer textarea::placeholder { color: var(--fg-3); }
2674
2683
  .ds-247420 .chat-composer textarea:focus { background: none; border: none; box-shadow: none; outline: none; }
2675
2684
  .ds-247420 .chat-composer .send {
@@ -2702,6 +2711,12 @@
2702
2711
  .ds-247420 .chat-composer .send:disabled {
2703
2712
  background: var(--bg-3); color: var(--fg-3); cursor: not-allowed; transform: none;
2704
2713
  }
2714
+ @media (prefers-reduced-motion: reduce) {
2715
+ .ds-247420 .chat-composer,
2716
+ .ds-247420 .chat-composer textarea,
2717
+ .ds-247420 .chat-composer .send,
2718
+ .ds-247420 .composer-btn { transition: none; }
2719
+ }
2705
2720
 
2706
2721
  .ds-247420 .aicat-portrait { display: inline-flex; align-items: center; gap: 10px; padding: 4px 0; }
2707
2722
  .ds-247420 .aicat-face {
@@ -3694,6 +3709,8 @@
3694
3709
  .ds-247420 .ws-pane-collapsed .ws-pane > * { display: none; }
3695
3710
  @media (pointer: coarse) {
3696
3711
  .ds-247420 .ws-rail-toggle { width: 44px; height: 44px; }
3712
+ .ds-247420 .ws-drawer-toggle { width: 44px; height: 44px; }
3713
+ .ds-247420 .ws-desktop-toggle { width: 44px; height: 44px; }
3697
3714
  }
3698
3715
 
3699
3716
  /* Drawer toggles and the scrim are hidden by default and revealed by the staged
@@ -3711,6 +3728,7 @@
3711
3728
  pane becomes a mobile overlay drawer at <=1480px, reached via its own
3712
3729
  drawer-toggle), so hide this crumb control past that breakpoint. */
3713
3730
  @media (max-width: 1480px) { .ds-247420 .ws-pane-toggle { display: none; } }
3731
+ @media (max-width: 480px) { .ds-247420 .ws-crumb { padding-left: var(--space-2); padding-right: var(--space-2); } }
3714
3732
  .ds-247420 .ws-scrim { display: none; }
3715
3733
 
3716
3734
  /* Responsive: the columns yield to the CONTENT in stages - the main column is
@@ -3819,6 +3837,11 @@
3819
3837
  .ds-247420 .ws-drawer-toggle:hover { background: var(--bg-2); color: var(--fg); }
3820
3838
  .ds-247420 .ws-drawer-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
3821
3839
 
3840
+ @media (prefers-reduced-motion: reduce) {
3841
+ .ds-247420 .ws-shell, .ds-247420 .ws-rail, .ds-247420 .ws-pane, .ds-247420 .ws-sessions, .ds-247420 .ws-scrim { transition: none; }
3842
+ .ds-247420 .ws-resizer::after { transition: none; }
3843
+ }
3844
+
3822
3845
  /* ============================================================
3823
3846
  Row title highlight, expanded-row actions, filter pills.
3824
3847
  ============================================================ */
@@ -3849,7 +3872,7 @@
3849
3872
  transition: background var(--dur-snap) var(--ease), color var(--dur-snap) var(--ease);
3850
3873
  }
3851
3874
  .ds-247420 .ds-filter-pill:hover { background: var(--bg-3); color: var(--fg); }
3852
- .ds-247420 .ds-filter-pill.active { background: var(--accent-tint); color: var(--accent); border-color: var(--accent); }
3875
+ .ds-247420 .ds-filter-pill.active { background: var(--accent-tint); color: var(--fg); border-color: var(--accent); }
3853
3876
  .ds-247420 .ds-filter-pill:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
3854
3877
 
3855
3878
  /* Touch floor for the new small controls. */
@@ -4907,6 +4930,16 @@
4907
4930
  border-radius: var(--r-1);
4908
4931
  }
4909
4932
 
4933
+ /* ThreadPanel / Forum / Page interactive elements */
4934
+ .ds-247420 .cm-tp-item:focus-visible,
4935
+ .ds-247420 .cm-forum-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--r-1); }
4936
+ .ds-247420 .cm-tp-new:focus-visible,
4937
+ .ds-247420 .cm-tp-close:focus-visible,
4938
+ .ds-247420 .cm-forum-new:focus-visible,
4939
+ .ds-247420 .cm-forum-sort:focus-visible,
4940
+ .ds-247420 .cm-page-edit:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--r-1); }
4941
+ .ds-247420 .cm-forum-search:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent); }
4942
+
4910
4943
  /* ---------- mobile header ---------- */
4911
4944
  .ds-247420 .cm-mobile-header {
4912
4945
  display: flex;
@@ -5487,7 +5520,7 @@
5487
5520
  background: none;
5488
5521
  border: 1px solid var(--rule);
5489
5522
  color: inherit;
5490
- border-radius: 6px;
5523
+ border-radius: var(--r-1);
5491
5524
  padding: 2px 8px;
5492
5525
  cursor: pointer;
5493
5526
  font: inherit;
@@ -5506,6 +5539,7 @@
5506
5539
  }
5507
5540
  .ds-247420 .agentchat-cwd-input:focus-visible { outline: none; box-shadow: var(--focus-ring-inset); }
5508
5541
  .ds-247420 .agentchat-cwd-input[aria-invalid='true'] { border-color: var(--flame); box-shadow: inset 0 0 0 var(--bw-hair) var(--flame); }
5542
+ .ds-247420 .agentchat-cwd-input[aria-busy='true'] { border-color: var(--fg-3); box-shadow: inset 0 0 0 var(--bw-hair) var(--fg-3); }
5509
5543
 
5510
5544
  /* head + thread */
5511
5545
  .ds-247420 .agentchat-head {
@@ -5756,18 +5790,14 @@
5756
5790
  }
5757
5791
  .ds-247420 .ds-session-new > span { display: none; }
5758
5792
  .ds-247420 .ds-session-new:hover { background: var(--bg-2); color: var(--fg); }
5759
- .ds-247420 .ds-session-search { order: 1; flex: 1 1 auto; min-width: 0; }
5793
+ /* Rail filter uses the shared .ds-search-input primitive (provides bg/border/
5794
+ radius/focus-ring); only the rail layout + touch floor live here. */
5795
+ .ds-247420 .ds-session-head .ds-search-input { order: 1; flex: 1 1 auto; min-width: 0; }
5760
5796
  .ds-247420 .ds-session-new:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
5761
- .ds-247420 .ds-session-search {
5762
- width: 100%; padding: var(--space-1) var(--space-3); min-height: 36px;
5763
- background: var(--bg-2); border: var(--bw-hair) solid var(--bg-3); color: var(--fg);
5764
- border-radius: var(--r-1); font-family: var(--ff-body); font-size: var(--fs-sm);
5765
- }
5766
- .ds-247420 .ds-session-search:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent); }
5767
5797
  /* Touch floor (must FOLLOW the base rules - same specificity, order decides). */
5768
5798
  @media (pointer: coarse) {
5769
5799
  .ds-247420 .ds-session-new { width: 44px; min-height: 44px; }
5770
- .ds-247420 .ds-session-search { min-height: 44px; }
5800
+ .ds-247420 .ds-session-head .ds-search-input { min-height: 44px; }
5771
5801
  }
5772
5802
  .ds-247420 .ds-session-list, .ds-247420 .ds-session-groups { flex: 1; min-height: 0; overflow-y: auto; padding: var(--space-2); }
5773
5803
  /* Grouped rows (Today/Yesterday/...) lay out like the flat list; the section
@@ -5946,7 +5976,7 @@
5946
5976
  border-radius: var(--r-1); background: var(--bg-2); color: var(--fg-2);
5947
5977
  cursor: pointer; font-family: var(--ff-body); font-size: var(--fs-tiny);
5948
5978
  }
5949
- .ds-247420 .ds-dash-errors-toggle.active { background: var(--accent-tint); color: var(--accent); border-color: var(--accent); }
5979
+ .ds-247420 .ds-dash-errors-toggle.active { background: var(--accent-tint); color: var(--fg); border-color: var(--accent); }
5950
5980
  .ds-247420 .ds-dash-errors-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
5951
5981
 
5952
5982
  /* --- C3: per-card select checkbox. --- */
@@ -6114,6 +6144,7 @@
6114
6144
  /* Inline cwd validation line (checking / error) under the cwd input. */
6115
6145
  .ds-247420 .agentchat-cwd-hint { font-size: var(--fs-tiny); color: var(--fg-3); }
6116
6146
  .ds-247420 .agentchat-cwd-hint.is-error { color: var(--flame); }
6147
+ .ds-247420 .agentchat-cwd-hint.is-checking { color: var(--fg-2); font-style: italic; }
6117
6148
 
6118
6149
  /* Dashboard: shared session title heading (same string as the rails). */
6119
6150
  .ds-247420 .ds-dash-title {
@@ -6213,6 +6244,7 @@
6213
6244
  }
6214
6245
  .ds-247420 .chat-msg .chat-tool .chat-tool-head::-webkit-details-marker { display: none; }
6215
6246
  .ds-247420 .chat-msg .chat-tool .chat-tool-head:hover { background: var(--bg-2); }
6247
+ .ds-247420 .chat-msg .chat-tool .chat-tool-head:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
6216
6248
  .ds-247420 .chat-tool-icon { display: inline-flex; color: var(--fg-3); }
6217
6249
  .ds-247420 .chat-tool-name { font-family: var(--ff-mono); font-weight: 600; color: var(--fg); }
6218
6250
  .ds-247420 .chat-tool-label { color: var(--fg-3); font-size: var(--fs-tiny); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
@@ -6233,6 +6265,7 @@
6233
6265
  .ds-247420 .chat-tool-section-label { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); font-size: var(--fs-tiny); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tr-caps); color: var(--fg-3); }
6234
6266
  .ds-247420 .chat-tool-copy { position: static; opacity: 0; }
6235
6267
  .ds-247420 .chat-tool-section:hover .chat-tool-copy, .ds-247420 .chat-tool-section:focus-within .chat-tool-copy { opacity: 1; }
6268
+ .ds-247420 .chat-tool-copy:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; opacity: 1; }
6236
6269
  .ds-247420 .chat-tool-pre { margin: 0; padding: var(--space-2); background: var(--bg-2); border-radius: var(--r-1); font-family: var(--ff-mono); font-size: var(--fs-tiny); line-height: 1.45; overflow-x: auto; max-height: 320px; overflow-y: auto; }
6237
6270
  .ds-247420 .chat-tool-pre.is-error { color: var(--flame); }
6238
6271
  .ds-247420 .chat-tool-pre.chat-tool-empty { color: var(--fg-3); }
@@ -6288,15 +6321,17 @@
6288
6321
  .ds-247420 .ds-dash-breakdown .seg { display: inline-flex; align-items: center; gap: var(--space-1); font-weight: 600; }
6289
6322
  .ds-247420 .ds-dash-breakdown .seg::before { content: ''; width: 7px; height: 7px; border-radius: 50%; flex: none; }
6290
6323
  .ds-247420 .ds-dash-breakdown .seg.is-running { color: var(--accent); }
6291
- .ds-247420 .ds-dash-breakdown .seg.is-running::before { background: var(--accent); }
6324
+ .ds-247420 .ds-dash-breakdown .seg.is-running::before { background: var(--accent); box-shadow: 0 0 0 1.5px color-mix(in oklab, var(--accent) 30%, transparent); }
6292
6325
  .ds-247420 .ds-dash-breakdown .seg.is-error { color: var(--flame); }
6293
6326
  .ds-247420 .ds-dash-breakdown .seg.is-error::before { background: var(--flame); box-shadow: 0 0 0 1.5px color-mix(in oklab, var(--flame) 38%, transparent); }
6294
- .ds-247420 .ds-dash-breakdown .seg.is-idle { color: var(--amber); }
6295
- .ds-247420 .ds-dash-breakdown .seg.is-idle::before { background: transparent; box-shadow: inset 0 0 0 2px var(--amber); }
6327
+ .ds-247420 .ds-dash-breakdown .seg.is-idle { color: var(--stale); }
6328
+ .ds-247420 .ds-dash-breakdown .seg.is-idle::before { background: transparent; box-shadow: inset 0 0 0 1px var(--stale), inset 0 0 0 3px var(--bg); }
6296
6329
  .ds-247420 .ds-dash-stream-disc { display: inline-flex; align-items: center; gap: var(--space-1); }
6297
6330
  .ds-247420 .ds-dash-selectall { display: inline-flex; align-items: center; gap: var(--space-1); font-size: var(--fs-tiny); color: var(--fg-2); cursor: pointer; background: none; border: none; padding: var(--space-1); }
6298
6331
  .ds-247420 .ds-dash-selectall:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
6299
6332
  .ds-247420 .ds-dash-clear { background: none; border: none; color: var(--fg-3); cursor: pointer; font-size: var(--fs-tiny); text-decoration: underline dotted; padding: var(--space-1); }
6333
+ .ds-247420 .ds-dash-clear:hover { color: var(--fg); }
6334
+ .ds-247420 .ds-dash-clear:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: var(--r-1); }
6300
6335
 
6301
6336
  /* Conversation-rail loading skeleton (cold ccsniff walk). */
6302
6337
  .ds-247420 .ds-session-row-skeleton { display: flex; flex-direction: column; gap: 6px; padding: var(--space-2) var(--space-3); }
@@ -7504,7 +7539,7 @@
7504
7539
  .ds-247420 .ds-input-check:checked { background: var(--accent); border-color: var(--accent); }
7505
7540
  .ds-247420 .ds-input-check:checked::after {
7506
7541
  content: ''; position: absolute; left: 4px; top: 1px;
7507
- width: 4px; height: 8px; border: solid var(--accent-fg, #fff);
7542
+ width: 4px; height: 8px; border: solid var(--accent-fg);
7508
7543
  border-width: 0 2px 2px 0; transform: rotate(45deg);
7509
7544
  }
7510
7545
  .ds-247420 .ds-input-check:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@@ -7546,7 +7581,7 @@
7546
7581
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
7547
7582
  border: 1px solid var(--rule);
7548
7583
  border-radius: var(--r-2, 8px);
7549
- box-shadow: 0 6px 24px color-mix(in oklab, #000 28%, transparent);
7584
+ box-shadow: var(--shadow-3);
7550
7585
  color: var(--panel-text);
7551
7586
  overflow: hidden;
7552
7587
  }
@@ -7870,9 +7905,6 @@
7870
7905
  .ds-247420 .ds-event-list .row[role="button"]:hover { background: color-mix(in srgb, var(--fg) 5%, transparent); }
7871
7906
  .ds-247420 .ds-event-list .row.event-flash { animation: agentgui-event-flash 2s ease-out; }
7872
7907
 
7873
- /* Chat composer: hide the idle scrollbar on the (empty/short) textarea. */
7874
- .ds-247420 .chat-composer textarea { overflow-y: auto; scrollbar-width: thin; }
7875
- .ds-247420 .chat-composer textarea:not(:focus) { overflow-y: hidden; }
7876
7908
 
7877
7909
  /* Generic interactive focus ring for app-emitted controls. */
7878
7910
  .ds-247420 button:focus-visible,
@@ -7913,6 +7945,10 @@
7913
7945
  }
7914
7946
 
7915
7947
  @media print {
7948
+ /* Re-assert the paper-tuned signal tokens so token-derived foregrounds
7949
+ (status discs, error text, tool cards) print with light-theme colours
7950
+ rather than the dark-theme-derived values inherited under auto-dark. */
7951
+ .ds-247420 { --flame:#C53E00; --amber:#8A6512; --warn:#E0241A; --sky:#3A6EFF; --bg:var(--paper); --fg:var(--ink); }
7916
7952
  .ds-247420 #app { min-height: auto; display: block; height: auto; }
7917
7953
  .ds-247420 .skip-link, .ds-247420 .status-dot, .ds-247420 .history-actions, .ds-247420 .chat-composer { display: none !important; }
7918
7954
  .ds-247420 .app, .ds-247420 .app-main, .ds-247420 .panel, .ds-247420 .chat, .ds-247420 .chat-thread {
@@ -7930,20 +7966,28 @@
7930
7966
  .ds-247420 .site-panel { margin: var(--space-2); }
7931
7967
 
7932
7968
  /* Hero block */
7933
- .ds-247420 .site-hero { padding: 24px 22px; }
7969
+ .ds-247420 .site-hero { padding: var(--space-4); }
7934
7970
  .ds-247420 .site-hero-h { margin: 0 0 var(--space-2); }
7935
7971
  .ds-247420 .site-hero-body { margin: var(--space-2) 0 var(--space-3); color: var(--fg-2); max-width: 64ch; }
7936
7972
  .ds-247420 .site-chip-row { display: flex; gap: 6px; flex-wrap: wrap; margin: 0 0 var(--space-3); }
7937
7973
  .ds-247420 .site-cta-row { display: flex; gap: var(--space-2); flex-wrap: wrap; }
7938
7974
 
7939
7975
  /* Quickstart CLI block — these .cli/.prompt/.cmd nodes were previously unstyled. */
7940
- .ds-247420 .site-cli { padding: 16px 22px; }
7941
- .ds-247420 .site-cli .cli { display: flex; gap: var(--space-2); font-family: var(--ff-mono); font-size: var(--fs-sm); padding: 2px 0; }
7976
+ .ds-247420 .site-cli { padding: var(--space-3); background: var(--bg); border: var(--bw-hair, 1px) solid var(--rule); border-radius: var(--r-1); }
7977
+ .ds-247420 .site-cli .cli { display: flex; align-items: baseline; gap: var(--space-2); font-family: var(--ff-mono); font-size: var(--fs-sm); padding: 2px 0; }
7942
7978
  .ds-247420 .site-cli .prompt { color: var(--fg-3); user-select: none; flex: 0 0 auto; }
7943
7979
  .ds-247420 .site-cli .cmd { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
7944
7980
 
7945
- /* Embedded legacy doc (iframe wrapper) */
7946
- .ds-247420 .site-embed { width: 100%; height: calc(100vh - 180px); min-height: 520px; border: 0; border-radius: var(--r-1); background: var(--bg-2); display: block; }
7981
+ /* Embedded legacy doc (iframe wrapper). 180px = the page chrome (topbar +
7982
+ * crumb + footer) sitting above the embed; dvh keeps it mobile-safe. */
7983
+ .ds-247420 .site-embed { width: 100%; height: calc(100dvh - 180px); min-height: 320px; border: 0; border-radius: var(--r-1); background: var(--bg-2); display: block; }
7984
+
7985
+ /* Marketing footer family (the in-app .app-status strip suppresses content
7986
+ * below 1100px; the site footer always shows its credits row). */
7987
+ .ds-247420 .site-footer { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2) var(--space-3); width: 100%; padding: var(--space-3) var(--space-4); font-family: var(--ff-body); font-size: var(--fs-sm); line-height: 1.4; color: var(--fg-3); border-top: 1px solid var(--rule); }
7988
+ .ds-247420 .site-footer .item { color: inherit; }
7989
+ .ds-247420 .site-footer .item:first-of-type { color: var(--accent); }
7990
+ .ds-247420 .site-footer .spread { flex: 1; }
7947
7991
 
7948
7992
  /* spoint/loading-screen.css */
7949
7993
  /* Loading-screen kit styles. Scoped under .ds-247420 at build time.