agentgui 1.0.954 → 1.0.955

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.
@@ -0,0 +1,329 @@
1
+ # GUI Predictability + Polish punch-list (2026-06-05, workflow wf_980345e1-201)
2
+
3
+ 44 adversarially-confirmed gaps across 7 surfaces.
4
+
5
+ ## [high][kit+app] No roots-picker at the synthetic root despite server already returning `roots`
6
+ - where: app.js:566-578 (filesMain crumb/toolbar) + http-handler.js:209 (sendJSON includes `roots: allowRoots`)
7
+ - fix: The server already ships `roots` and the app captures `state.files.roots` (app.js:488) but never renders it - only crumb-walk-down exists. Add a kit `FileRootsPicker({roots, current, onPick})` (new export in files.js) rendering a horizontal segmented control of root basenames (real <button>s, role=group, aria-label 'filesystem roots', aria-pressed for the active root, 44x44 hit targets, tokens --accent-tint/--accent for active). Render it in the FileToolbar-left ABOVE the breadcrumb when `roots.length > 1`. App wires onPick -> loadDir(root). This makes lateral root navigation possible (e.g. STARTUP_CWD vs CLAUDE_PROJECTS_DIR vs FS_ROOTS) instead of only descending one root.
8
+
9
+ ## [high][kit+app] Preview is a full-screen MODAL, not an inline split/side pane - breaks the file-manager feel
10
+ - where: app.js:535-560 (filePreview -> FileViewer modal) + files-modals.js:171 (FileViewer goes through Backdrop/Modal)
11
+ - fix: Add a kit `FileBrowser({roots, current, segments, files, preview, previewBody, loading, error, columns, sort, filter, onNav, onOpen, onSort, onFilter, onPickRoot, onClosePreview, onUseAsCwd, onAction})` split-pane layout component in files.js: a CSS grid `.ds-file-browser` with a `.ds-file-main` (toolbar + grid) and a `.ds-file-aside` preview pane that appears in-flow (not a backdrop) when a file is selected; on <900px the aside becomes a bottom drawer / falls back to the existing FileViewer modal. Keep FileViewer for the mobile/modal path. The app replaces the modal `filePreview()`+toolbar+grid assembly with one FileBrowser call passing state + callbacks. Preview pane needs role=region aria-label 'file preview', a close button (44x44), and an explicit loading(Skeleton)/error(Alert)/empty('select a file to preview') state.
12
+
13
+ ## [high][kit+app] FileGrid has no sort (name/size/modified) or in-dir filter; renders server order only
14
+ - where: files.js:80-100 (FileGrid) + http-handler.js:206 (server sorts dir-first then name, fixed) + app.js:585 (FileGrid call, no sort/filter)
15
+ - fix: Extend kit FileGrid (or the new FileBrowser toolbar) with a `sort:{key:'name'|'size'|'modified', dir:'asc'|'desc'}` prop + `onSort(key)` and a `filter` string + `onFilter`. Render a kit `FileGridHeader` row of three sortable column buttons (name/size/modified) with aria-sort reflecting state, plus reuse the existing SearchInput (label 'filter files', placeholder 'filter in folder') wired to onFilter. Sort/filter is pure client-side over the already-fetched entries (dirs always pinned first to preserve the file-manager convention). App holds `state.files.sort`/`state.files.filter`, applies them in filesMain before passing `files`, and re-renders on change. No server change - the existing single sort stays as the default.
16
+
17
+ ## [medium][app] Bare Spinner loading state - no content-shaped skeleton (Skeleton primitive already exists, unused here)
18
+ - where: app.js:580-581 (f.loading -> Spinner 'loading…') and app.js:547-548 (preview loading -> Spinner) ; content.js:399 (Skeleton export exists)
19
+ - fix: Swap the bare `Spinner + 'loading…'` for the existing kit `Skeleton` primitive shaped like the grid: render ~8 `Skeleton({height:'44px', width:'100%'})` rows inside `.ds-file-grid` (or have FileGrid accept `loading` and render the skeleton itself as a kit default so the app passes `loading:true`). Preview loading likewise uses Skeleton lines. Keep role=status/aria-busy (Skeleton already sets these). This removes the layout jump from spinner to grid. Prefer adding `loading` to FileGrid/FileBrowser so the skeleton lives in the kit, not app markup.
20
+
21
+ ## [medium][kit+app] No keyboard navigation across the grid (arrows / Enter / Backspace) - each row is independently tabbable only
22
+ - where: files.js:46-78 (FileRow open is a lone <button>, no roster) ; app.js:585-592 (onOpen only)
23
+ - fix: Add roving-tabindex keyboard nav in the kit FileGrid/FileBrowser: container role=listbox (or grid) with arrow Up/Down (and Left/Right when multi-column) moving a roving focus, Enter/Space = onOpen, Backspace = onNavUp (go to parent dir). Implement via a keydown handler on the grid container + tabindex management (the focused row's open-button gets tabindex=0, others -1); respects the responsive column count. App passes `onNavUp` (loadDir of parent: reuse the crumb i = segments.length-1 reconstruction) and keeps onOpen. Must not trap Tab (Tab still exits the grid). 44x44 targets already satisfied by .ds-file-row.
24
+
25
+ ## [medium][app] Toolbar (breadcrumb + use-as-chat-cwd) is absent at the synthetic root because it is gated on `f.path`
26
+ - where: app.js:594-599 (`const toolbar = f.path ? ... : null`)
27
+ - fix: The toolbar (and thus the breadcrumb + 'use as chat cwd') only renders once `f.path` is set, so during the initial load and on any list error there is no toolbar at all and the root crumb button is unreachable. Always render the toolbar shell (move it into the kit FileBrowser which owns the toolbar), showing the breadcrumb with at least the root segment and disabling 'use as chat cwd' (aria-disabled) until a valid `path` exists, rather than dropping the whole bar. This also gives the loading/error states a stable toolbar to sit under.
28
+
29
+ ## [low][kit+app] FileRow onAction (download/rename/delete) is built in the kit but never wired - and rename/delete are infeasible read-only
30
+ - where: files.js:72-77 (onAction renders download/rename/delete) ; files.js:97 + app.js:585 (FileGrid called with no onAction) ; http-handler.js (only GET /api/list, /api/file, /api/image - no write/delete/download endpoint)
31
+ - fix: Scope the action set to what the read-only server supports: only DOWNLOAD is feasible (it is a GET of bytes). Add an `actions` allowlist prop to FileRow/FileGrid (default `['download']`) so rename/delete are NOT rendered unless the consumer opts in - this removes the dead/misleading rename+delete affordances rather than wiring no-ops. For download, the server has no streaming/download endpoint: /api/file caps at 512KB text-only and /api/image is type-limited, so add a `download:1` query branch to /api/file (or a new GET /api/download/ confined to the SAME allowlist roots) that sets Content-Disposition: attachment and streams the file without the 512KB/text-ext cap; app wires onAction('download', file) -> window.open(B.downloadUrl(...)). Rename/delete stay unimplemented (out of scope for a read-only browser) and must not appear.
32
+
33
+ ## [low][app] Crumb-walk path reconstruction is duplicated and Windows-fragile in the app instead of using server `path`
34
+ - where: app.js:573-576 (rebuilds drive-aware absolute path from segments by string-joining) - mirrors server logic at http-handler.js:208
35
+ - fix: Reconstructing absolute paths from breadcrumb segments in the client (`/^[A-Za-z]:$/` + manual backslash join) is brittle (UNC paths, mixed separators) and duplicates server knowledge. Have the kit BreadcrumbPath/FileBrowser pass the clicked segment INDEX to onNav, and the app derive the target from the server-authoritative `state.files.path` by trimming whole path segments off `f.path` (split on the actual separator present in `path`) rather than re-joining drive letters. Keeps a single source of truth for the path shape. Low severity - works today on plain drive paths - but is the kind of half-wired edge that breaks on UNC/non-Windows roots.
36
+
37
+ ## [high][kit+app] SessionCard counter is hardcoded null — live event/tool/error metrics are accumulated but never shown
38
+ - where: site/app/js/app.js:614-622 (liveMain sessions map sets counter:null) — data exists in state.sessionsBySid via openLiveStream (app.js:309-314 sets sess.events/tools/errors/last)
39
+ - fix: App: in liveMain's map, join each active row to its live metrics — const m = state.sessionsBySid && state.sessionsBySid.get(r.sessionId) — and pass discrete fields, NOT a pre-joined string: { events: m?.events||0, tools: m?.tools||0, errors: m?.errors||0, last: m?.last }. Set status:'error' when (m?.errors>0) so the is-error tone actually triggers. Kit: change SessionCard signature to read session.events/tools/errors and render a structured metrics row (replace the brittle elapsed+counter string concat at sessions.js:99-100) — discrete labelled spans 'N events', 'N tools', 'N errors' (errors span gets class ds-dash-stat-err -> color var(--flame); count of 0 omitted). Each metric span keyed. Add .ds-dash-metrics flex row CSS in chat.css using kit tokens (--fs-tiny, --ff-mono, --fg-2). Note state.sessionsBySid is only built from state.sessions (history list) — app must ensure sessions are loaded on live-tab entry (mirror chat-tab behaviour) or live sessions with no history row show zeroed metrics; load on navTo('live').
40
+
41
+ ## [high][app] Elapsed is computed once per render and frozen between 3s polls — and the relTime tick is 30s, far too coarse for an elapsed-seconds counter
42
+ - where: site/app/js/app.js:619 (elapsed computed inline at render) + app.js:277 (startRelTimeTick fires every 30000ms)
43
+ - fix: Add a 1s tick that calls scheduleRender() while state.tab==='live' AND state.active has running rows (gate it so it only runs on the live tab, started in navTo('live'), cleared on leave — do not globally tick at 1s). Then the inline elapsed at app.js:619 recomputes each second. Keep the existing 30s tick for history relative times. scheduleRender already rAF-coalesces so the cost is one cheap diff per second only while the live tab is foregrounded.
44
+
45
+ ## [medium][kit+app] Offline vs empty states exist in the kit but the offline path is fragile and the two are weakly distinguished; is-error card tone never triggers
46
+ - where: anentrypoint-design/src/components/sessions.js:113-119 (offline -> ds-dash-state-error text, empty -> plain ds-dash-state) + app.js:613 (offline = health.status not ok/unknown) + sessions.js:91-92 (is-error only when session.status==='error', which liveMain never sets)
47
+ - fix: Verify offline triggers: health.status transitions to a non-ok/non-unknown value on WS disconnect (app.js:1307/1336/1625 paths) — confirm the offline EmptyState renders when the server is killed. Distinguish the two states visually beyond color: kit should give the offline state its own .ds-dash-state-error with the status-dot-disc.status-dot-error glyph + a 'reconnecting' affordance, and the empty state a neutral .ds-dash-state with a 'start a chat' call-to-action (onNew prop -> Btn) rather than only text, so empty is actionable and offline is alarm-toned. Use EmptyState component if available for consistency with filesMain. Also wire status:'error' in liveMain (gap 1) so per-card is-error border (sessions.js:91, var(--flame)) actually fires when a session has errors. Both states keep role=status aria-live=polite.
48
+
49
+ ## [medium][kit] No inline typing caret at the stream head — the live assistant turn shows accumulating text but no blinking cursor marking where output is being written
50
+ - where: anentrypoint-design/src/components/agent-chat.js:136-154 (the streaming row build) + chat.js:176 (PART_RENDERERS.text); CSS chat.css
51
+ - fix: In agent-chat.js, when building the streaming row (isStreaming && msgHasBody), mark the LAST text/md-shell part as the live head: push it with a new flag {kind:'text', text, mdShell:true, streaming:true} for the final prose part only (track the last text part index in the parts loop). In chat.js PART_RENDERERS.text, when p.streaming is true append a caret node after the inline content: h('span',{class:'chat-caret','aria-hidden':'true'}). Add .chat-caret CSS: an inline-block 1px-wide, 1em-tall bar using background:var(--accent,var(--fg)) with a steplike blink @keyframes chat-caret-blink, gated by @media (prefers-reduced-motion: reduce){animation:none} (kept solid). No glyph — it is a CSS-drawn bar. This makes the stream head legible the way Claude Desktop shows a live cursor. App passes nothing new; busy+messages already drive it.
52
+
53
+ ## [high][kit+app] Assistant messages have NO per-message actions — no copy, no retry, no edit-and-resend; a settled turn is inert
54
+ - where: anentrypoint-design/src/components/chat.js:226-269 (ChatMessage — only meta row, no action affordances) + agent-chat.js:145-153 (row build passes no action callbacks)
55
+ - fix: Kit: extend ChatMessage to accept onCopy, onRetry, onEdit callbacks and a `text`/raw-source string for copy. Render an action row inside .chat-stack after `meta` ONLY for settled (non-typing, non-streaming) messages: a .chat-actions row of <button class='chat-action' type=button> elements — 'copy' (always, both roles), and for assistant 'retry', for user 'edit'. Each button is text-labeled ASCII words (no icons-as-only-affordance), aria-label set, min 44x44 hit target via padding, focus-visible outline using --accent. Default the row visually subtle (opacity via :hover/:focus-within of .chat-msg on pointer devices, but always visible/focusable for keyboard + touch — do NOT hide behind hover-only). AgentChat threads action callbacks per-message: add props onCopyMessage(id), onRetryMessage(id), onEditMessage(id) and pass them into each ChatMessage. App (app.js chatMain AgentChat props): onCopyMessage -> navigator.clipboard.writeText of the message's plain text (reconstruct from m.content or concatenated text/md parts); onRetryMessage(id) -> drop that assistant msg + everything after, re-run sendChat against the prior user turn (reuse resumeSid logic); onEditMessage(id) -> load that user message's content into state.chat.draft, truncate messages from that turn onward, render() (user then edits + re-sends). Add CSS .chat-actions/.chat-action in chat.css using tokens.
56
+
57
+ ## [high][kit] No jump-to-latest affordance when scrolled up during a stream; auto-scroll only fires when the sentinel is already visible, so a reading user who scrolled up gets no way back and no new-message signal
58
+ - where: anentrypoint-design/src/components/chat.js:80-101 (makeThreadAutoScroll — pins only when sentinel intersecting, no UI when detached) + agent-chat.js:199-206 (thread has no jump button)
59
+ - fix: In agent-chat.js wrap the thread in a positioned container (.agentchat-thread-wrap, position:relative) and render a 'jump to latest' button (.agentchat-jump, position:absolute bottom-center, text label 'Jump to latest' — ASCII words, no arrow glyph) that is hidden by default. Drive its visibility from the same IntersectionObserver: extend makeThreadAutoScroll to accept an onPinnedChange(pinned) callback (or expose a small thread controller) so when the sentinel leaves view the host shows the jump button, and clicking it scrolls el.scrollTop=scrollHeight and re-pins. The existing auto-scroll already correctly does NOT fight a scrolled-up reader (it only pins when sentinel visible) — this just surfaces that state instead of leaving it silent. Button: type=button, aria-label='Scroll to latest messages', min 44px tall, focus-visible outline, tokenized bg (--surface/--accent). Add @media (prefers-reduced-motion) -> behavior:auto vs smooth. CSS in chat.css. App passes nothing; it is self-contained in the kit.
60
+
61
+ ## [high][kit+app] The terminal `result` usage event is DROPPED — no tokens/turns/cost/duration surfaced even though the block carries total_cost_usd, duration_ms, subtype
62
+ - where: app.js:946 (`else if (ev.type === 'result') { /* ... already reflected via text */ }` — block discarded) <- backend.js:313 forwards it <- lib/stream-event-handler.js:58-60 builds {type:'result', total_cost_usd, duration_ms, subtype, is_error}
63
+ - fix: App: in sendChat's result branch, capture the block onto the assistant message, e.g. cur.meta = { costUsd: ev.block.total_cost_usd, durationMs: ev.block.duration_ms, subtype: ev.block.subtype, isError: ev.block.is_error }; persistChat(); render(). (Also extend backend.js streamChat result mapping if num_turns/usage become available, but with the current block cost+duration+subtype are enough.) Kit: ChatMessage accepts an optional `usage` prop ({costUsd,durationMs,subtype}); render a .chat-usage footnote line at the end of a settled assistant turn — a small muted row using the middot separator: e.g. 'opus · 4.2s · $0.0131' (KEEP the · middot — product typography). Format duration via a kit helper (ms -> Ns), cost via toFixed; omit any null field; use --fg-3 token, font-size .8em. aria-label='turn cost and duration'. Wire AgentChat to pass m.meta through as ChatMessage usage for settled assistant rows. CSS .chat-usage in chat.css.
64
+
65
+ ## [medium][kit+app] Suggestion chips appear ONLY on an empty transcript — no contextual follow-ups offered after a settled turn
66
+ - where: anentrypoint-design/src/components/agent-chat.js:172-184 (suggestions consumed only inside emptyState) + app.js:794 (`suggestions: state.chat.messages.length ? [] : [...]` — explicitly emptied once any message exists)
67
+ - fix: Kit: add a `followups` prop (array of strings/{label,prompt}) to AgentChat; when NOT busy and messages.length>0 and followups.length, render a .agentchat-followups row just above the composer (after the thread/working tail) reusing the same chip markup/CSS as .agentchat-empty-suggestion (factor a shared SuggestionChips(items,onClick) helper so empty + follow-up chips stay visually identical). Each chip: type=button, focus-visible, onSuggestionClick(prompt). Gate to hidden while busy and when followups empty. App: keep suggestions for the empty state, and additionally pass followups derived after a settled turn — a small static contextual set (e.g. ['Explain that in more detail','Show me the relevant files','What are the risks?']) gated on !state.chat.busy && last message is a settled assistant turn; onSuggestionClick already exists and sets draft+sendChat. Add .agentchat-followups CSS (flex-wrap, gap, top border-divider via --border token, scrolls horizontally on <640 via overflow-x:auto, no glyphs).
68
+
69
+ ## [high][app] Flat time-sorted rail never uses the kit's groups prop — no Today/Yesterday/This-week/Older buckets
70
+ - where: app.js sessionsColumn() (lines 443-468) builds a flat items[] and passes NO groups; kit ConversationList already supports groups (sessions.js:60-64)
71
+ - fix: In sessionsColumn(), after building items[], bucket each session by recency from s.last into ordered groups [{label:'Today',sids},{label:'Yesterday',sids},{label:'This week',sids},{label:'Earlier',sids}] using local-day boundaries (not the relative-string fmtRelTime); pass groups to ConversationList. Keep items[] for the bySid lookup the kit does internally. Drop empty buckets. No kit change needed — the groups branch (sessions.js:60-64) already renders .ds-session-group with role=group/aria-label and keyed rows.
72
+
73
+ ## [high][app] Running sessions are not pinned/sectioned to the top of the rail
74
+ - where: app.js sessionsColumn() sets running per-row (line 451) but visibleSessions() sorts purely by s.last (app.js:1131); a running session scrolled below the fold has no priority
75
+ - fix: When building groups[], prepend a synthetic 'Active' group whose sids are the sessions where state.active has a matching a.sessionId (the same predicate already computed at app.js:451), and exclude those sids from the time buckets so they appear once, pinned at top. Pure app wiring; the kit group renderer already de-dupes via bySid.get().filter(Boolean).
76
+
77
+ ## [high][app] Chat-tab inline search runs the server search but the rail keeps showing time-sorted sessions, never the hits
78
+ - where: app.js sessionsColumn() search.onInput (line 461) sets searchQ + calls runSearch() populating state.searchHits, but `sessions:items` is always visibleSessions()-derived; historySide() consumes searchHits (app.js:1180) yet the rail does not
79
+ - fix: In sessionsColumn(), branch on an active search: when state.searchHits && state.searchQ.trim().length>=2, map state.searchHits.results into ConversationList items (title:r.snippet, project:r.project, time via r.ts, rail from r.isError/r.isSubagent, sid:r.sid) and pass NO groups (flat hit list); set loading:state.searchBusy and an empty/error/'no matches' state from searchHits.error / empty results. onSelect for a hit row should loadSession/resumeInChat with {focusEventI:r.i,focusEventTs:r.ts} like historySide does (app.js:1195). De-dupe repeated sids in the key (kit already keys cs-+sid, so distinct hits on one sid collide — pass a position-disambiguated sid or extend the row key). Keep it app-side; ConversationList already accepts loading/error/emptyText.
80
+
81
+ ## [medium][kit] Kit renders .ds-session-group / .ds-session-group-rows but chat.css only styles .ds-session-group-label — grouped rows get no container styling and the scroll container class mismatches
82
+ - where: chat.css:227-251 — has .ds-session-list and .ds-session-group-label and a stray .ds-session-groups in the overflow rule (227), but no .ds-session-group or .ds-session-group-rows rules; kit emits role=list rows inside .ds-session-group-rows (sessions.js:64) which sits inside .ds-session-list
83
+ - fix: Add chat.css rules: .ds-session-group { display:flex; flex-direction:column; } and .ds-session-group-rows { display:flex; flex-direction:column; gap:2px; } so grouped rows lay out like the flat list; make the group label sticky within the scroll area (.ds-session-group-label { position:sticky; top:0; background:var(--bg-1); z-index:1; }) for Claude-Desktop section headers. Remove the dead .ds-session-groups selector or alias it. Use existing tokens (var(--space-*), var(--bg-*), var(--fg-3)) — no hardcoded hex, no glyphs.
84
+
85
+ ## [low][app] No agent badge surfaced on rail rows though the kit supports it
86
+ - where: app.js:449 sets agent:undefined; kit renders s.agent into .ds-session-agent (sessions.js:42, styled chat.css:248)
87
+ - fix: Populate agent from the session's recorded agent when available (or the running active record's agentId mapped via agentById) so the rail shows which agent owns each conversation, matching Claude-Desktop's per-chat affordance. App-only; kit + CSS already present.
88
+
89
+ ## [low][server] Rename/delete/archive are infeasible against read-only ccsniff — no mutation route exists; do NOT add affordances that 404
90
+ - where: backend.js only exposes GET /v1/history/{sessions,events,search,stream} (lines 87-113); lib/http-handler.js has no POST/DELETE/PUT for history (only /api/terminal/sessions POST and read-only /api/list,/api/image)
91
+ - fix: Scope decision: ccsniff reads ~/.claude/projects JSONL and exposes no write/delete API, so true rename/delete would require either editing Claude's own session files (out of scope, destructive, not ccsniff's contract) or a new agentgui-side overlay store. Recommended now: do NOT add rename/delete to the rail (would mislead). If archive is wanted, implement it as a CLIENT-SIDE hidden-sids set in localStorage (agentgui.hiddenSessions) with an 'archive'/'unarchive' row action filtered out of visibleSessions() — no server mutation, reversible, honest about ccsniff being read-only. Defer real rename/delete until ccsniff (or an agentgui sidecar) grows a write endpoint.
92
+
93
+ ## [high][kit+app] Mobile (<900px) sessions/pane drawers are dead — `.ws-sessions-open` is never toggled, no scrim, no Esc-close, no focus-trap
94
+ - where: c:\dev\anentrypoint-design\src\components\shell.js:324-368 (WorkspaceShell) + app-shell.css:2835-2850 (@media max-width:900px); app.js:421 passes narrow but no drawer control
95
+ - fix: WorkspaceShell is half-wired for mobile: CSS positions `.ws-sessions` fixed+translateX(-110%) and only reveals it under `.ws-shell.ws-sessions-open`, but NOTHING ever adds that class (grep across app.js/kit = 0 matches), and there is no backdrop element (AppShell has `.app-side-scrim`, WorkspaceShell does not). On mobile the rail-toggle calls toggleWs('rail') which toggles `ws-rail-collapsed` (a desktop width var), not the drawer. FIX in kit shell.js: (1) add a `toggleWsDrawer(which)` pure-DOM helper that toggles `.ws-sessions-open`/`.ws-pane-open` on `.ws-shell`; (2) render a `.ws-scrim` div (role/aria-hidden, onclick closes any open drawer) inside WorkspaceShell, shown only when a drawer is open; (3) when `narrow`, the rail-toggle and a new sessions hamburger drive the DRAWER not the width-collapse; (4) trap focus inside the open drawer and bind a keydown(Esc) -> close (mirror a focus-trap like the existing modal pattern, or at minimum Esc-close + return focus to the toggle); (5) backdrop-dismiss via the scrim onclick. CSS: add `.ws-shell.ws-pane-open .ws-pane { transform: translateX(0) }` (today pane reveal keys off `:not(.ws-pane-collapsed)` which is wrong on mobile since pane defaults collapsed) and `.ws-scrim` (fixed inset:0, z below drawers, bg tonal var). App keeps only wiring: pass nothing new — drawer state lives in the kit DOM-class like the existing toggleWs.
96
+
97
+ ## [medium][kit] Collapsed-rail toggle announces a stale, direction-wrong accessible name
98
+ - where: c:\dev\anentrypoint-design\src\components\shell.js:342-346 (ws-rail-toggle)
99
+ - fix: The rail toggle's `aria-label` is hardcoded `'collapse navigation'` even when the rail is already collapsed, so a screen-reader user hears "collapse" when the action will expand. `aria-expanded` flips correctly but the name does not. FIX: set `aria-label` to `railIsCollapsed ? 'expand navigation' : 'collapse navigation'` (kit-only). Same stale-name issue on the pane toggle (line 361-365): label is the generic `'toggle context pane'` — make it `paneIsCollapsed ? 'show context pane' : 'hide context pane'`. Both already keep `aria-expanded` so this is purely the announced name.
100
+
101
+ ## [low][kit] Collapsed icon-only rail: New-chat action and nav items lose their accessible affordance label visually but the toggle has no keyboard-reachable announced state change confirmation; rail-action label is display:none with no title fallback
102
+ - where: c:\dev\anentrypoint-design\src\components\shell.js:380-407 (WorkspaceRail) + app-shell.css:2789-2796
103
+ - fix: In the collapsed rail, `.ws-rail-action-label` and `.ws-rail-item-label` are display:none. Nav items keep `title` + `aria-label` (good). But the New-chat `ws-rail-action` (shell.js:385-389) has `aria-label` only, no `title`, so collapsed it shows a bare icon with no hover tooltip (items get one via `title`). FIX: add `title: action.label` to the ws-rail-action button so the collapsed primary action is hover-discoverable like the nav items. Keep aria-label. Kit-only; no app change.
104
+
105
+ ## [medium][kit] Collapsed pane leaves a 0px grid track + an absolutely-positioned reopen toggle floating over content with no labelled, in-flow reopen affordance
106
+ - where: c:\dev\anentrypoint-design\app-shell.css:2727-2738,2820-2830 + shell.js:331,359-366
107
+ - fix: When pane is present but collapsed, `.ws-pane-collapsed` sets `--ws-pane-w:0px` but the base grid template still declares a `var(--ws-pane-w)` track (0px) and `.ws-pane-toggle` is `position:absolute; left:calc(-1 * var(--space-5))` — it hangs into the content column with no in-flow anchor, and on the chat tab when collapsed it is the ONLY reopen handle. Verify there is no dead non-zero column (there is not — track is 0px), but the floating toggle is a rough edge: give the collapsed pane a thin visible gutter (e.g. `--ws-pane-w: var(--ws-rail-w-collapsed)` when collapsed instead of 0px, OR keep the toggle but anchor it to the content's right edge with a clear `aria-label='show context pane'` and a visible chevron). Recommend: collapsed pane = narrow gutter strip holding only the toggle (predictable, no overlap), matching the rail's collapse-to-icon pattern. Kit CSS + shell.js only.
108
+
109
+ ## [medium][kit+app] ContextPane is static (agent/model/cwd/toolCount) — missing live session metadata and recent files that the app already has in state
110
+ - where: c:\dev\anentrypoint-design\src\components\context-pane.js:25-52; app.js:414-420 wiring + state.active rows carry startedAt/pid/elapsed (app.js:619,1165)
111
+ - fix: ContextPane surfaces only 4 static rows. The app already polls `state.active` (3s, app.js:261) with `startedAt`/`pid`/`cwd`/`model` per running session and has `B.listDir` for files. ENRICH the kit component with OPTIONAL prop-driven sections (all render only when the prop is passed, so existing callers are unchanged): (1) `session: {startedAt, elapsed, pid, status}` -> a 'session' Panel with elapsed (use the typographic ellipsis/middot set, NO new glyphs) and a live/idle status via the kit's existing status semantics; (2) `recentFiles: [{name, path, onOpen}]` -> a 'recent files' Panel of Rows using the kit Icon('file') set, with empty state 'no files yet'; (3) `tokens: {input, output}` -> optional usage Panel only if the app can supply it (today it cannot — mark as future, do NOT fabricate). Add explicit empty/loading states per new section. App wiring (app.js:414): pass `session` from the matching `state.active` row for the resumed sid, and `recentFiles` from a small recents list (cwd browse via existing listDir). Keep all visuals in the kit (Panel/Row/Icon, tokens only); app passes state + onOpen callbacks. Scope guard: token usage is out of scope until the server emits it — ship the prop shape but leave it unwired.
112
+
113
+ ## [medium][kit+app] Empty-state copy register diverges across the new + old surfaces: ConversationList 'No conversations yet' (Sentence), SessionDashboard 'No live sessions' / app passes 'No live sessions - start a chat or run a local agent.' (Sentence), Files FileGrid 'empty directory' + EmptyState default 'no files here yet' (lowercase), history 'No sessions yet' (Sentence) vs sidebar 'no sessions yet' (lowercase), ContextPane 'none'/'idle'/'server default' (lowercase). Same product, four casing conventions for the same kind of empty state.
114
+ - where: sessions.js:23,113; files.js:80,150; context-pane.js:33-44; app.js:467,629,983,1259,1468
115
+ - fix: Pick ONE register (Sentence case, no trailing period for short labels) as the kit default for every emptyText/state default: ConversationList emptyText 'No conversations yet', SessionDashboard emptyText 'No live sessions', FileGrid emptyText 'This folder is empty', EmptyState default 'Nothing here yet'. App must pass matching Sentence-case strings for Files ('This folder is empty'), Live ('No live sessions - start a chat or run a local agent') and align history sidebar 'no sessions yet' -> 'No sessions yet'.
116
+
117
+ ## [medium][app] Relative-time format is inconsistent: history rows + ConversationList rows use fmtRelTime() -> '5m ago' / '2h ago', but liveMain builds elapsed as raw seconds '42s' (no 'ago', seconds-only, overflows past 60s) and runningPanel does the same (elapsed + 's'). The SessionCard 'elapsed' and the conversation row 'time' read as two different time vocabularies in adjacent surfaces.
118
+ - where: app.js:619 (liveMain elapsed), app.js:1165-1170 (runningPanel), app.js:58-65 fmtRelTime; consumed by SessionCard (sessions.js:99-100)
119
+ - fix: Introduce one duration formatter fmtElapsed(sec) -> '42s'/'3m 5s'/'1h 4m' for in-flight age and keep fmtRelTime for last-activity, and document which surface uses which: live/running = elapsed-since-start (fmtElapsed), conversation list/history = last-activity-ago (fmtRelTime). Pass the formatted string into SessionCard.elapsed so the kit stays format-agnostic.
120
+
121
+ ## [high][kit] Running/status indicator carries no text word, breaking the connected/connecting/offline + running vocab the crumb dot establishes. ConversationList running shows only a bare .status-dot-disc with aria-label 'running'; SessionCard head shows a disc whose tone (live/error) is conveyed only by color class with NO visible or screen-reader status word ('aria-hidden' on the disc, no sibling text). The crumb status dot DOES carry a words-only label. Color-only status fails WCAG 1.4.1 for the dashboard cards.
122
+ - where: sessions.js:43-45 (ConversationList running disc), sessions.js:92-94 (SessionCard head disc aria-hidden, no status word)
123
+ - fix: In SessionCard add a visible/AT status word next to the disc: render h('span',{class:'ds-dash-status'}, s.status==='error'?'error':'running') with the disc aria-hidden, so status is words + disc (matching the crumb pattern). For ConversationList running disc keep aria-label but ensure tone is not the SOLE signal — add an sr-only or title 'running' is present (it is) — acceptable, but align the wording to 'running' everywhere.
124
+
125
+ ## [medium][kit] ConversationList session New-chat button (40px) and inline search input (36px) fall below the 44x44 touch target the app enforces elsewhere (index.html @max-width:640 bumps .history-actions buttons to 44px, .pill to 36px). On a phone the rail's primary 'New chat' and search are under-sized and there is no <640/420px bump for them.
126
+ - where: C:\dev\anentrypoint-design\chat.css:214 (.ds-session-new min-height:40px), :222 (.ds-session-search min-height:36px); no @media block for these (chat.css:269 starts a 640 block but does not raise them)
127
+ - fix: In chat.css add to the existing @media (max-width:640px) block: .ds-session-new,.ds-session-search,.ds-session-row{min-height:44px} and ensure 420px keeps no horizontal scroll. Land as kit default; no index.html override.
128
+
129
+ ## [low][kit] SessionDashboard offline state vocab diverges from app offlineBanner: dashboard says 'Backend offline — live sessions unavailable' while the app-wide offline banner says 'Backend unreachable' and the crumb dot says 'offline'. Three phrasings for one backend-down state.
130
+ - where: sessions.js:115 (SessionDashboard offline), app.js:842 (offlineBanner 'Backend unreachable'), app.js:357 (crumb 'offline')
131
+ - fix: Align the SessionDashboard offline copy to the shared register: 'Backend offline - live sessions unavailable' using a hyphen separator OR keep em-dash (kept set) but match the leading noun phrase to the app banner ('Backend unreachable - live sessions unavailable'). Standardize on the single word 'offline' as the state token used in the crumb.
132
+
133
+ ## [medium][kit] ContextPane working-dir Row uses rail:'green' for a set cwd and null for default, but the GUI-wide rail semantics reserve green for ok/selected; a cwd being set is not an ok/error status, so green here is semantically off-key vs the rest of the rail vocabulary. Similarly 'running tools' uses rail:'purple' (purple = subagent in the documented semantics), conflating tool-activity with subagent.
134
+ - where: context-pane.js:37-44 (rail green for cwd, rail purple for running tools)
135
+ - fix: Drop the rail from the cwd Row (a value, not a status) and use the green rail only when it conveys an ok state; for 'running tools' prefer the live/accent treatment (or no rail) rather than purple, since purple is documented as the subagent tone. Keep rail tones reserved: green=ok/selected, purple=subagent, flame=error.
136
+
137
+ ## [low][kit+app] Files preview loading/empty/error states are present but the loading copy 'loading…' (lowercase) diverges from history 'loading events…' and Files toolbar; minor but part of the same casing drift. Files also has TWO empty strings (FileGrid emptyText 'empty directory' passed by app at app.js:587 AND the kit EmptyState default 'no files here yet') that can both surface depending on path.
138
+ - where: app.js:548,581 ('loading…'), app.js:587 ('empty directory'), files.js:80,150
139
+ - fix: Unify loading copy to a single 'Loading…' default in the kit Spinner-adjacent empty-state usage and pass one canonical Files empty string; align FileGrid emptyText default and the app-passed value to the same 'This folder is empty'.
140
+
141
+ ## [high][kit+app] Mobile: sessions column (conversation list) is unreachable on chat + history at <=900px
142
+ - where: app-shell.css:2844-2845 (.ws-sessions fixed + translateX(-110%); .ws-sessions-open never set) + app.js workspaceRail/view (no toggle wires ws-sessions-open)
143
+ - fix: WorkspaceShell already drives a left rail toggle but the SESSIONS drawer has no open path: .ws-sessions is position:fixed translateX(-110%) at <=900px and only .ws-shell.ws-sessions-open reveals it, yet nothing ever adds that class. Add a kit-owned sessions drawer toggle (a button in .ws-crumb or rail visible only <=900px) that flips .ws-sessions-open on .ws-shell plus a tap scrim (mirror the AppShell toggleSide/.app-side-scrim pattern already in shell.js), and auto-close it on onSelect. App passes narrow + wires nothing extra. Without this, mobile users on chat/history cannot open or switch conversations at all.
144
+
145
+ ## [high][kit] Mobile: right ContextPane drawer opens with no scrim and no host-reachable toggle
146
+ - where: app-shell.css:2846-2847 (.ws-pane fixed; :not(.ws-pane-collapsed) shows it) + shell.js:359-366 (ws-pane-toggle pinned at left:-var(--space-5))
147
+ - fix: At <=900px the pane becomes a full-height fixed overlay (width min(320px,85vw)) revealed whenever it is not collapsed, but the only toggle (.ws-pane-toggle) is absolutely positioned at left:calc(-1*--space-5) hanging off the pane's own left edge -> off-screen/overlapping content on mobile, and there is no backdrop scrim to dismiss it. Add a <=900px scrim element + reposition the toggle inside the viewport (e.g. a fixed affordance), and default paneIsCollapsed=true on narrow so chat doesn't boot with the overlay covering the thread.
148
+
149
+ ## [low][kit] Collapsed rail has no flyout/title for icon-only items; labels are display:none so the rail is unlabeled visually when collapsed
150
+ - where: app-shell.css:2790-2796 (.ws-rail-collapsed hides .ws-rail-item-label) + shell.js:398 (title=it.label set, good for AT/hover but no visible affordance)
151
+ - fix: title=it.label gives a native tooltip and aria-label is present (AT ok), but a collapsed rail shows only icons with no on-hover flyout label, so sighted mouse users must hover-and-wait. Add a CSS hover flyout label (.ws-rail-collapsed .ws-rail-item:hover .ws-rail-item-label { position:absolute; display:block; ... }) so collapsed nav is discoverable without relying on the OS tooltip delay. Low risk, pure kit CSS.
152
+
153
+ ## [medium][app] ConversationList rebuilds the entire session list on every 3s active poll, and each row re-scans state.active
154
+ - where: app.js:260-263 refreshActive()->render(); app.js:445-454 sessionsColumn maps every session and runs (state.active).some(a=>a.sessionId===s.sid) per row (O(sessions x active) each frame)
155
+ - fix: Every 3s refreshActive() calls full render(), which rebuilds sessionsColumn() -> ConversationList for ALL sessions (default 60, growable) even when no session's running state changed. Precompute a Set of running sids once per render (const runningSids = new Set(state.active.map(a=>a.sessionId))) and look up running:runningSids.has(s.sid) instead of .some() per row, and skip the active-poll render when the running-sid set is unchanged (diff prev vs next set) so an idle poll costs zero DOM work. Keeps the 3s liveness without per-poll O(n) churn.
156
+
157
+ ## [low][kit] ConversationList search input is 36px tall — below the 44px touch-target minimum used everywhere else
158
+ - where: chat.css:221-222 (.ds-session-search min-height:36px) vs ws-rail-item/file-row/composer all min-height:44px
159
+ - fix: The inline conversation search box is 36px; the rest of the kit standardizes on 44px touch targets (ws-rail-item:2777, file-row:865, composer:1872). Bump .ds-session-search min-height to 44px (or add a <=640px override) so the primary conversation filter is tappable on mobile.
160
+
161
+ ## [medium][kit+app] Files view FileGrid has no row virtualization or cap — a large directory builds hundreds/thousands of FileRow nodes every render
162
+ - where: files.js:80-99 FileGrid maps ALL files; app.js:585-592 filesMain passes f.entries with no slice; server returns full readdir (http-handler.js:200-209)
163
+ - fix: A directory with thousands of entries renders one FileRow (each with an open button + 3 action buttons when onAction is set) per file on every render, and loadDir replaces the whole list (no incremental). Add an initial cap + 'show N more' in FileGrid (mirror the history sessionsLimit pattern at app.js:1260) or windowed rendering, so node_modules-sized dirs don't freeze the frame. App caps entries; kit exposes a limit/onMore prop.
164
+
165
+ ## [low][app] EventList builds full title/sub strings for every shown row each frame; expanded-body guard exists but base row work is still O(rows) per render mid-stream
166
+ - where: app.js:1045-1081 (shown.map builds key/title/sub/raw.replace(/\s+/g,' ') for up to eventsLimit=300 rows every render); chat.css streaming triggers frequent renders
167
+ - fix: The expanded JSON.stringify is correctly gated (app.js:1064), but every render still recomputes title slicing + raw.replace regex + sub concatenation for all ~300 shown rows, and during a live history stream scheduleRender fires often. Memoize the per-event row descriptor keyed by event index + expanded-state so unchanged rows are not rebuilt each frame; only the newly-appended/ toggled rows recompute.
168
+
169
+ ## [medium][app] Files view first-load triggers loadDir from inside the render function (filesMain), a side-effect during render
170
+ - where: app.js:565 (if (!f.path && !f.loading && !f.error) { loadDir(''); }) — also filePreview at app.js:546 (loadPreviewContent during render)
171
+ - fix: loadDir('') and loadPreviewContent() are kicked off synchronously inside the view-building functions, which run during render; loadDir immediately sets loading + calls render() re-entrantly. It works only because the guard flips f.loading, but a render side-effect is fragile (a future double-render or StrictMode-like double-invoke double-fetches). Move the initial dir load to navTo('files') (like history's refreshHistory on navTo) and the preview fetch to the onOpen handler, so render stays pure.
172
+
173
+ ## [high][server] /api/list and /api/file allowlist confinement holds, but symlinks inside an allowed root can escape it (TOCTOU/symlink traversal)
174
+ - where: http-handler.js:174-178 (/api/list prefix check on normalizedPath) + 236-240 (/api/file) + 200-205 (readdir reports symlink type but full path is path.join, not realpath)
175
+ - fix: The normalize-then-resolved-prefix guard correctly blocks ../ traversal (path.normalize collapses segments and the lowercased prefix+sep check is sound). The hole: a symlink living inside an allowed root that points OUTSIDE it passes the string-prefix check (the link's own path is within-root) but fs.statSync/readSync follow it to arbitrary host files. For /api/file (raw bytes) and /api/list, fs.realpathSync the normalized path and re-run the within-allowed prefix check against the resolved real path before stat/read. /api/image (http-handler.js:286-295) has the same gap.
176
+
177
+ ## [medium][app] Files breadcrumb path reconstruction is brittle for POSIX absolute paths and assumes drive-letter or leading-slash only
178
+ - where: app.js:573-576 (filesMain onNav rebuilds joined path: drive-aware else '/'+segs.join('/')) + files.js:157-167 BreadcrumbPath onNav(i) returns segment index
179
+ - fix: BreadcrumbPath emits a segment index and the app reconstructs the absolute path from state.files.segments, joining with '\' for Windows-drive or '/' otherwise. UNC paths (\\server\share) and any path whose first segment is not a drive letter rebuild incorrectly (a leading '/' is prepended, breaking the click-to-ancestor nav). Have the server return per-segment absolute paths in the /api/list segments payload (e.g. [{name,path}]) so the breadcrumb navigates by exact path instead of client-side reconstruction.
180
+
181
+
182
+ ---
183
+ ## Synthesized plan
184
+
185
+ I'll synthesize these confirmed gaps into a dependency-ordered, kit-vs-app build plan. Let me reason through the grouping by component and fixLocation.
186
+
187
+ This is a synthesis task — the data is all provided. No need to re-read source. I'll produce the ordered plan directly.
188
+
189
+ # AgentGUI + anentrypoint-design kit — dependency-ordered build plan (28 confirmed gaps)
190
+
191
+ Build order is **kit components first** (they unblock app wiring), then **app-only wiring**, then **server-only**, ending with the **build + vendor + witness** step. Within the kit, each shared component is edited ONCE with all its gaps batched so there is a single diff per file.
192
+
193
+ ---
194
+
195
+ ## PHASE 0 — Server (independent, do first or in parallel; unblocks kit/app file features)
196
+
197
+ These touch only `lib/http-handler.js` (+ tiny `backend.js` for the download URL). No kit dependency. Fully parallelizable with Phase 1.
198
+
199
+ - **S1 [high] Symlink/TOCTOU escape on /api/list, /api/file, /api/image** — `fs.realpathSync` the normalized path and re-run the within-allowed-root prefix check against the resolved real path before any stat/read on all three routes. (`http-handler.js:174-178, 236-240, 286-295`)
200
+ - **S2 [low] Download endpoint** — add a `download:1` branch to `/api/file` (or new `GET /api/download/`) confined to the SAME allowlist roots, sets `Content-Disposition: attachment`, streams without the 512KB/text-ext cap. Add `B.downloadUrl(...)` in `backend.js`. (Needed by K-FILES action wiring A-FILE7.)
201
+ - **S3 [medium] Per-segment breadcrumb paths** — `/api/list` returns segments as `[{name, path}]` (server-authoritative absolute path per segment) instead of bare name strings. **Unblocks** the kit BreadcrumbPath `onNav` signature change (K-FILES) and retires the brittle client reconstruction (A-FILE8/crumb gap). (`http-handler.js:208`)
202
+
203
+ > S3 has a downstream contract dependency: it changes the `segments` payload shape, which the kit `BreadcrumbPath` (K-FILES) and the app onNav (A-FILE8) both consume. Land S3's payload + kit + app together as one logical change set, but the server edit itself is independent.
204
+
205
+ ---
206
+
207
+ ## PHASE 1 — KIT (edit each component once, all its gaps batched)
208
+
209
+ GUI lives in the kit. These are the bottleneck — everything in Phase 2 wires into them. The five components are mutually independent files, so **all five can be built in parallel** by different hands.
210
+
211
+ ### K-FILES — `src/components/files.js` (+ files-modals.js retained) — BATCH 8 gaps
212
+ This is the largest batch and the critical path for the Files surface. Land as one coherent rewrite introducing the split-pane `FileBrowser`:
213
+ 1. **[high] `FileBrowser` split-pane** component (`.ds-file-browser` grid: `.ds-file-main` + in-flow `.ds-file-aside` preview pane; `<900px` → bottom drawer / fall back to retained `FileViewer` modal). Owns toolbar. Preview pane gets `role=region`, close button (44x44), explicit loading/error/empty states.
214
+ 2. **[high] `FileRootsPicker`** export — segmented `<button>` control of root basenames, `role=group`, `aria-label`, `aria-pressed`, 44x44; rendered in toolbar-left above breadcrumb when `roots.length>1`.
215
+ 3. **[high] FileGrid sort/filter** — `sort:{key,dir}`+`onSort`, `filter`+`onFilter`; new `FileGridHeader` (sortable name/size/modified column buttons with `aria-sort`); reuse `SearchInput(label:'filter files')`. Dirs pinned first.
216
+ 4. **[medium] FileGrid keyboard nav** — container `role=listbox/grid`, roving-tabindex, arrows move focus, Enter/Space=`onOpen`, Backspace=`onNavUp`, Tab still exits.
217
+ 5. **[medium] FileGrid `loading` prop → Skeleton** — render ~8 `Skeleton` rows shaped like the grid (kit default, app passes `loading:true`).
218
+ 6. **[low] FileRow `actions` allowlist prop** (default `['download']`) — rename/delete NOT rendered unless opted in (removes dead/infeasible affordances).
219
+ 7. **[medium] BreadcrumbPath onNav carries per-segment path** (consumes S3 payload) — navigates by exact server path, not index-reconstruction.
220
+ 8. **[low/medium] FileGrid `limit`/`onMore` cap** — initial cap + "show N more" (mirror history sessionsLimit) so huge dirs don't freeze the frame.
221
+
222
+ > **Conflict note:** the gap data describes two homes for sort/filter and the toolbar — "new FileBrowser toolbar" vs "FileGrid header" vs app-local `.ds-file-toolbar`. **Resolve once:** sort/filter UI lives in `FileGridHeader` inside `FileGrid`; the root-picker + breadcrumb + use-as-cwd live in `FileBrowser`'s toolbar. The app's old inline `.ds-file-toolbar` div is deleted (its always-render-shell fix, A-FILE6, is absorbed into FileBrowser owning a stable toolbar). Do these 8 as ONE edit to avoid churning the same map/markup repeatedly.
223
+
224
+ ### K-SESSIONS — `src/components/sessions.js` + `chat.css` — BATCH 6 gaps
225
+ ConversationList + SessionDashboard/SessionCard share this file; batch all:
226
+ 1. **[high] SessionCard discrete metrics** — read `session.events/tools/errors/last`; render structured keyed `.ds-dash-metrics` spans ('N events', 'N tools', 'N errors' with `.ds-dash-stat-err`→`--flame`, 0 omitted), replacing the brittle elapsed+counter concat. `chat.css` adds `.ds-dash-metrics`.
227
+ 2. **[high] SessionCard status word + WCAG 1.4.1** — visible/AT status word (`error`/`running`) next to the aria-hidden disc; fixes color-only status. (`is-error` tone now reachable once app sets `status:'error'`.)
228
+ 3. **[medium] SessionDashboard offline/empty distinction** — alarm-toned offline (`.ds-dash-state-error` + `status-dot-error` + reconnecting affordance) vs neutral actionable empty (`onNew`→`Btn` CTA); both `role=status aria-live=polite`.
229
+ 4. **[low] SessionDashboard offline copy** — align to "Backend unreachable — live sessions unavailable" (kept em-dash).
230
+ 5. **[medium] chat.css `.ds-session-group`/`.ds-session-group-rows`** styling (flex column, gap), sticky `.ds-session-group-label`, remove dead `.ds-session-groups` selector. **Unblocks** the app's grouping/active-pinning gaps (A-RAIL1/2).
231
+ 6. **[medium] chat.css mobile touch targets** — extend `@media(max-width:640px)`: `.ds-session-new,.ds-session-search,.ds-session-row{min-height:44px}` and bump `.ds-session-search` base min-height 36→44. (Two gaps about the same control → one rule.)
232
+
233
+ > ConversationList groups + agent badge + search-hits are **app-only** (the kit already supports `groups`/`s.agent`) — they go in Phase 2 but DEPEND on K-SESSIONS #5 (group CSS) shipping.
234
+
235
+ ### K-CHAT — `src/components/agent-chat.js` + `chat.js` + chat CSS — BATCH 5 gaps
236
+ Chat thread shares these two kit files; batch:
237
+ 1. **[high] Per-message actions** — `ChatMessage` accepts `onCopy/onRetry/onEdit` + raw `text`; render `.chat-actions` row (settled msgs only) of ASCII-word `<button>`s (copy both roles; assistant=retry, user=edit), 44x44, focus-visible, always keyboard/touch-focusable (not hover-only). `AgentChat` threads `onCopyMessage/onRetryMessage/onEditMessage`.
238
+ 2. **[medium] Inline stream caret** — mark the LAST streaming text part `{streaming:true}`; `PART_RENDERERS.text` appends a CSS-drawn `.chat-caret` bar (no glyph), `prefers-reduced-motion` gated.
239
+ 3. **[high] Jump-to-latest** — positioned `.agentchat-thread-wrap`; `.agentchat-jump` button (ASCII "Jump to latest", hidden by default); extend `makeThreadAutoScroll` with `onPinnedChange(pinned)` to surface the detached state; click scrolls + re-pins. Self-contained (app passes nothing).
240
+ 4. **[high] `usage` footnote** — `ChatMessage` optional `usage:{costUsd,durationMs,subtype}` → muted `.chat-usage` line at end of settled assistant turn (kept middot separator, e.g. `opus · 4.2s · $0.0131`); `AgentChat` passes `m.meta` through.
241
+ 5. **[medium] Follow-up chips** — `AgentChat` `followups` prop → `.agentchat-followups` row above composer when `!busy && messages.length>0`; factor shared `SuggestionChips(items,onClick)` helper so empty + follow-up chips stay identical.
242
+
243
+ > **CSS file note:** the gap data says `chat.css`, but the verifier flagged chat CSS actually lives in the kit's source CSS / `app-shell.css`. Put caret/actions/usage/followups/jump rules wherever the existing `.chat-typing`/`.agentchat-*` rules live (kit source CSS), NOT a non-existent `chat.css`.
244
+
245
+ ### K-SHELL — `src/components/shell.js` + `app-shell.css` — BATCH 5 gaps
246
+ WorkspaceShell/WorkspaceRail/ContextPane all live here; batch all shell gaps:
247
+ 1. **[high] Mobile sessions + pane drawers** (this is TWO confirmed gaps describing the same dead `ws-sessions-open` mechanism + the pane-overlay-no-scrim — treat as one fix): add `toggleWsDrawer(which)` toggling `.ws-sessions-open`/`.ws-pane-open`; render `.ws-scrim` (onclick closes, role/aria-hidden); narrow rail-toggle + new sessions hamburger drive the DRAWER not width-collapse; Esc-close + focus-trap + return-focus; CSS `.ws-shell.ws-pane-open .ws-pane{transform:translateX(0)}` (fix the wrong `:not(.ws-pane-collapsed)` mobile reveal); `paneIsCollapsed` defaults true on narrow so chat doesn't boot covered.
248
+ 2. **[medium] Direction-correct toggle aria-labels** — rail: `railIsCollapsed?'expand navigation':'collapse navigation'`; pane: `paneIsCollapsed?'show context pane':'hide context pane'`.
249
+ 3. **[low] Collapsed rail-action `title`** — add `title:action.label` to the New-chat `.ws-rail-action` button (parity with nav items).
250
+ 4. **[low] Collapsed-rail hover flyout label** — pure CSS `.ws-rail-collapsed .ws-rail-item:hover .ws-rail-item-label{position:absolute;display:block;...}` so collapsed nav is discoverable without OS tooltip delay.
251
+ 5. **[medium] Collapsed-pane gutter** — collapsed pane = narrow gutter strip (`--ws-pane-w: var(--ws-rail-w-collapsed)`) holding only the toggle, anchored in-flow, instead of 0px track + absolutely-positioned floating toggle over content.
252
+
253
+ ### K-CONTEXT — `src/components/context-pane.js` — BATCH 2 gaps
254
+ 1. **[medium] Prop-driven enrichment sections** (backward-compatible, render only when prop passed): `session:{startedAt,elapsed,pid,status}` Panel; `recentFiles:[{name,path,onOpen}]` Panel; `tokens` prop shape SHIPPED-BUT-UNWIRED (server emits no usage). Empty/loading states per section.
255
+ 2. **[medium] Rail-tone semantics fix** — drop `rail:'green'` from the cwd Row (a value, not ok-status); drop `rail:'purple'` from running-tools (purple is reserved for subagent) — use accent/no-rail. Keep green=ok/selected, purple=subagent, flame=error.
256
+
257
+ ### K-EMPTY — cross-component empty-text defaults (touches sessions.js, files.js — partly absorbed above)
258
+ - **[medium] Empty-state register** — unify on Sentence-case, no trailing period: kit defaults `ConversationList 'No conversations yet'`, `SessionDashboard 'No live sessions'`, `FileGrid 'This folder is empty'`, `EmptyState 'Nothing here yet'`. Fold the sessions.js/files.js portions INTO the K-SESSIONS and K-FILES batches (same files) to avoid a second pass; the app-string half is in Phase 2.
259
+
260
+ ---
261
+
262
+ ## PHASE 2 — APP WIRING (`site/app/js/app.js`, + `backend.js` for download) — after the kit ships its props
263
+
264
+ All depend on Phase-1 component props/CSS existing. The sub-groups below are **mutually independent** (different state slices / view functions) and parallelizable, except where noted.
265
+
266
+ **Files wiring** (depends K-FILES, S2, S3):
267
+ - **A-FILE-COMPOSE** — replace the modal `filePreview()`+toolbar+grid assembly with ONE `FileBrowser` call passing `roots/current/segments/files/preview/loading/error/sort/filter` + callbacks (`onPick→loadDir(root)`, `onNav` by server path, `onSort/onFilter` over fetched entries with dirs pinned, `onClosePreview`, `onUseAsCwd`, `onAction('download')→window.open(B.downloadUrl)`). Render `FileRootsPicker` (renders `state.files.roots`, finally used). Hold `state.files.sort/filter`. Pass `loading:true` (Skeleton). Toolbar always renders (A-FILE6 absorbed). Reconstruct breadcrumb target from server `path` (A-FILE8/crumb absorbed). — collapses ~7 file gaps into one wiring call.
268
+ - **A-FILE-LIFECYCLE [medium]** — move `loadDir('')` to `navTo('files')` and `loadPreviewContent()` to the `onOpen` handler so render stays pure (no render-side-effect fetch).
269
+
270
+ **Rail wiring** (depends K-SESSIONS #5 group CSS):
271
+ - **A-RAIL-GROUPS [high]** — `sessionsColumn()` buckets by recency (Today/Yesterday/This week/Earlier via local-day boundaries) into `groups`, drop empties, keep `items[]` for kit bySid lookup.
272
+ - **A-RAIL-ACTIVE [high]** — prepend synthetic 'Active' group (running sids from `state.active`), exclude those from time buckets (kit de-dupes). *Depends on A-RAIL-GROUPS (same `groups[]` build).*
273
+ - **A-RAIL-SEARCH [high]** — branch `sessionsColumn()` on active search: map `state.searchHits.results` to flat hit items (no groups), `loading:searchBusy`, no-matches state; `onSelect` → `loadSession/resumeInChat` with `{focusEventI,focusEventTs}`; disambiguate duplicate sids in the row key. *Same function as A-RAIL-GROUPS — land A-RAIL-GROUPS/ACTIVE/SEARCH as one `sessionsColumn()` rewrite to avoid conflicts.*
274
+ - **A-RAIL-AGENT [low]** — populate `agent:` from recorded/active `agentId` (kit+CSS already present). Also in `sessionsColumn()` — fold into the same rewrite.
275
+ - **A-RAIL-PERF [medium]** — precompute `runningSids = new Set(...)` once per render (O(1) per-row); skip idle active-poll render when running-sid set unchanged. Touches `refreshActive()` + `sessionsColumn()` — coordinate with the rewrite.
276
+
277
+ **Live dashboard wiring** (depends K-SESSIONS #1/#2/#3):
278
+ - **A-LIVE-METRICS [high]** — `liveMain` joins each row to `state.sessionsBySid` metrics; pass discrete `{events,tools,errors,last}`; set `status:'error'` when errors>0; ensure sessions load on `navTo('live')` (mirror chat-tab).
279
+ - **A-LIVE-TICK [high]** — 1s tick gated to `state.tab==='live'` + running rows (started/cleared in `navTo('live')`), calls `scheduleRender()`; keep 30s history tick. *Independent of A-LIVE-METRICS but both in navTo('live')/liveMain — coordinate.*
280
+ - **A-LIVE-OFFLINE [medium]** — wire `status:'error'` (overlaps A-LIVE-METRICS) + harden offline detection (WS disconnect must flip `state.health.status`, currently only sets `ws:'reconnecting'`).
281
+
282
+ **Chat wiring** (depends K-CHAT):
283
+ - **A-CHAT-ACTIONS [high]** — `onCopyMessage`(clipboard plain text), `onRetryMessage`(truncate + re-run sendChat vs prior user turn, reuse resumeSid), `onEditMessage`(load into draft + truncate).
284
+ - **A-CHAT-USAGE [high]** — capture the dropped `result` block at `app.js:946` onto `cur.meta={costUsd,durationMs,subtype,isError}`; `persistChat()`; pass `m.meta` as `ChatMessage.usage`.
285
+ - **A-CHAT-FOLLOWUPS [medium]** — pass static contextual `followups` gated on settled assistant turn (keep empty-state `suggestions`).
286
+
287
+ **ContextPane wiring** (depends K-CONTEXT):
288
+ - **A-CTX [medium]** — pass `session` from matching `state.active` row + `recentFiles` from `B.listDir` recents; leave `tokens` unwired.
289
+
290
+ **Cross-cutting copy** (depends K-EMPTY defaults):
291
+ - **A-COPY [medium/low]** — pass Sentence-case empty strings (Files 'This folder is empty', Live, history sidebar 'no sessions yet'→'No sessions yet'); add `fmtElapsed(sec)→'42s'/'3m 5s'/'1h 4m'` for live/running (keep `fmtRelTime` for last-activity); unify 'loading…'→'Loading…'. Independent, low-risk, parallelizable.
292
+
293
+ **App-only perf** (no kit dep):
294
+ - **A-EVENTS-PERF [low]** — memoize EventList per-row descriptor keyed by event index + expanded-state. Fully independent.
295
+
296
+ ---
297
+
298
+ ## PHASE 3 — Build + vendor + witness (single terminal step, after ALL kit edits)
299
+
300
+ Per AGENTS.md flow, run ONCE after every kit batch is merged:
301
+ 1. `node scripts/build.mjs` in `c:\dev\anentrypoint-design` (kit `lint-glyphs` + `lint-tokens` must pass).
302
+ 2. Copy `dist/247420.{js,css}` → `site/app/vendor/anentrypoint-design/`.
303
+ 3. Browser-witness `http://localhost:3000/gm/` (`bun server.js`): WorkspaceShell renders, rail nav, sessions groups + Active pin + search hits, Files split-pane + roots-picker + sort/filter + keyboard nav, Live metrics + 1s tick + status words, chat actions/caret/usage/followups/jump, mobile drawers + scrim + Esc, 0 console errors, `decorativeGlyphs=[]` (middot intact), a11y roles/aria, 420px no h-scroll.
304
+ 4. Push the kit so unpkg stays in sync; commit agentgui app/server changes.
305
+
306
+ ---
307
+
308
+ ## Parallelization & conflict summary
309
+
310
+ - **Fully parallel, no shared file:** S1, S2, S3 (server) ∥ each of K-FILES, K-SESSIONS, K-CHAT, K-SHELL, K-CONTEXT (distinct kit files) ∥ A-EVENTS-PERF.
311
+ - **Batch-once-per-file (must NOT be split into separate diffs):**
312
+ - `files.js` → all 8 K-FILES gaps + the FileGrid/EmptyState empty-string defaults.
313
+ - `sessions.js`+`chat.css` → all 6 K-SESSIONS gaps + ConversationList/SessionDashboard empty-string defaults + both touch-target gaps (one media rule).
314
+ - `agent-chat.js`+`chat.js` → all 5 K-CHAT gaps.
315
+ - `shell.js`+`app-shell.css` → all 5 K-SHELL gaps (the two "mobile drawer" gaps are one fix).
316
+ - `context-pane.js` → both K-CONTEXT gaps.
317
+ - `app.js sessionsColumn()` → A-RAIL-GROUPS + A-RAIL-ACTIVE + A-RAIL-SEARCH + A-RAIL-AGENT + A-RAIL-PERF as ONE rewrite (they all mutate the same item/group build; doing them separately conflicts).
318
+ - `app.js navTo()/liveMain` → A-LIVE-METRICS + A-LIVE-TICK + A-LIVE-OFFLINE + A-FILE-LIFECYCLE all add enter/leave hooks in `navTo` — coordinate the navTo edits.
319
+ - **Hard dependency chains:**
320
+ - S3 (server segment payload) → K-FILES BreadcrumbPath onNav → A-FILE-COMPOSE crumb wiring (land together).
321
+ - S2 (download endpoint + `B.downloadUrl`) → K-FILES FileRow `actions` → A-FILE-COMPOSE onAction.
322
+ - K-SESSIONS group CSS → A-RAIL-GROUPS/ACTIVE/SEARCH (kit `groups` already supported, but unstyled until the CSS ships).
323
+ - K-CHAT props → A-CHAT-* ; K-CONTEXT props → A-CTX ; K-SESSIONS SessionCard → A-LIVE-METRICS — all "kit prop must exist before app passes it."
324
+ - ALL kit edits → Phase 3 build/vendor (single gate; the app only sees kit changes after the vendored bundle is rebuilt+copied).
325
+ - **Resolved spec conflicts in the gap data:**
326
+ 1. Files toolbar home — gaps variously say "FileBrowser toolbar" / "FileGrid toolbar" / app-local `.ds-file-toolbar`. **Resolution:** sort/filter → `FileGridHeader` in FileGrid; root-picker/breadcrumb/use-as-cwd → `FileBrowser` toolbar; delete the app inline toolbar div (absorbs A-FILE6 always-render-shell).
327
+ 2. A-FILE6 says "move toolbar into kit FileBrowser" — verifier notes no FileBrowser exists today; it's CREATED in K-FILES, so this becomes valid once that lands (sequence, not conflict).
328
+ 3. `chat.css` (K-CHAT) doesn't exist — put rules in the kit's real chat/`app-shell.css` source.
329
+ 4. A-CHAT-USAGE + A-LIVE-OFFLINE both touch `status`/`result` plumbing in distinct branches — no real conflict, just adjacent edits in app.js.
@@ -4,6 +4,39 @@ import os from 'os';
4
4
  import crypto from 'crypto';
5
5
  import * as term from './terminal.js';
6
6
 
7
+ // Confine a requested filesystem path to an allowlist of roots. Two layers:
8
+ // 1. normalize + resolved-prefix check on the LEXICAL path (blocks `../`
9
+ // traversal, which path.normalize collapses so a literal `..` test is a
10
+ // no-op);
11
+ // 2. fs.realpathSync + the SAME prefix check on the REAL path, so a symlink
12
+ // that lives inside an allowed root but points outside it cannot escape
13
+ // (the lexical path passes layer 1, the resolved target fails layer 2).
14
+ // Returns { ok, realPath, reason }. realPath is the symlink-resolved absolute
15
+ // path to stat/read; callers use it, never the raw input. A non-existent path
16
+ // has no realpath yet, so it fails closed with reason 'not found'.
17
+ function confineToRoots(inputPath, allowRoots) {
18
+ const isWindows = os.platform() === 'win32';
19
+ const norms = allowRoots.map(r => path.normalize(r));
20
+ const expanded = inputPath && inputPath.startsWith('~') ? inputPath.replace('~', os.homedir()) : inputPath;
21
+ const normalizedPath = path.normalize(expanded || '');
22
+ const isAbsolute = isWindows ? /^[A-Za-z]:[\\/]/.test(normalizedPath) : normalizedPath.startsWith('/');
23
+ const within = (p) => {
24
+ const np = isWindows ? p.toLowerCase() : p;
25
+ return norms.some(root => {
26
+ const r = isWindows ? root.toLowerCase() : root;
27
+ return np === r || np.startsWith(r + path.sep);
28
+ });
29
+ };
30
+ if (!isAbsolute || !within(normalizedPath)) return { ok: false, reason: 'path outside allowed roots' };
31
+ // realpath the target and re-confine, defeating symlink escape (TOCTOU/link
32
+ // traversal). If realpath throws (missing path / broken link) fail closed.
33
+ let realPath;
34
+ try { realPath = fs.realpathSync(normalizedPath); }
35
+ catch (e) { return { ok: false, reason: e && e.code === 'ENOENT' ? 'not found' : 'cannot resolve path', code: e && e.code }; }
36
+ if (!within(realPath)) return { ok: false, reason: 'symlink target outside allowed roots' };
37
+ return { ok: true, realPath };
38
+ }
39
+
7
40
  export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
8
41
  return async function httpHandler(req, res) {
9
42
  // CORS: when PASSWORD is set the server holds credentials a cross-origin
@@ -149,13 +182,118 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
149
182
  if (h) { await h(req, res); return; }
150
183
  } catch (_) {}
151
184
  }
185
+ // Directory listing for the Files / folder-browser view (mirrors fsbrowse
186
+ // /api/list). Confined to an allowlist root exactly like /api/image - the
187
+ // normalize-then-prefix-check is the real guard (a `..` test after
188
+ // path.normalize is a no-op). Returns {path, segments, entries} so the kit
189
+ // FileGrid + BreadcrumbPath render directly. Allowed roots default to the
190
+ // server cwd + Claude projects dir; widen via FS_ROOTS (path-separated).
191
+ if (routePath.startsWith('/api/list')) {
192
+ const rawQ = routePath.split('?')[0].slice('/api/list'.length).replace(/^\//, '');
193
+ const decodedPath = rawQ ? decodeURIComponent(rawQ) : '';
194
+ const allowRoots = [
195
+ process.env.STARTUP_CWD || process.cwd(),
196
+ process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
197
+ ...(process.env.FS_ROOTS ? process.env.FS_ROOTS.split(path.delimiter) : []),
198
+ ].map(r => path.normalize(r));
199
+ // Empty path = the first allow-root (a sane default landing dir).
200
+ const reqPath = !decodedPath ? allowRoots[0] : decodedPath;
201
+ const conf = confineToRoots(reqPath, allowRoots);
202
+ if (!conf.ok) {
203
+ const code = conf.reason === 'not found' ? 404 : 403;
204
+ sendJSON(req, res, code, { error: 'forbidden: ' + conf.reason }); return;
205
+ }
206
+ // Use the symlink-resolved real path for all reads.
207
+ const normalizedPath = conf.realPath;
208
+ try {
209
+ const st = fs.statSync(normalizedPath);
210
+ if (!st.isDirectory()) { sendJSON(req, res, 400, { error: 'not a directory' }); return; }
211
+ // Classify by extension into the kit's data-file-type buckets so
212
+ // FileGrid renders the right rail/icon. dir/symlink come from stat.
213
+ const EXT_TYPE = {
214
+ image: ['png','jpg','jpeg','gif','webp','svg','bmp','ico','avif'],
215
+ video: ['mp4','webm','mov','mkv','avi','m4v'],
216
+ audio: ['mp3','wav','ogg','flac','m4a','aac'],
217
+ code: ['js','mjs','cjs','ts','tsx','jsx','rs','go','py','rb','java','c','cpp','h','hpp','cs','php','sh','css','html','json','yml','yaml','toml','sql'],
218
+ text: ['txt','md','log','csv','env'],
219
+ archive: ['zip','tar','gz','tgz','rar','7z','bz2','xz'],
220
+ document: ['pdf','doc','docx','xls','xlsx','ppt','pptx','odt'],
221
+ };
222
+ const typeFor = (name, dirent) => {
223
+ if (dirent.isSymbolicLink()) return 'symlink';
224
+ if (dirent.isDirectory()) return 'dir';
225
+ const ext = path.extname(name).slice(1).toLowerCase();
226
+ for (const [t, exts] of Object.entries(EXT_TYPE)) if (exts.includes(ext)) return t;
227
+ return 'other';
228
+ };
229
+ const dirents = fs.readdirSync(normalizedPath, { withFileTypes: true });
230
+ const entries = dirents.map((d) => {
231
+ const full = path.join(normalizedPath, d.name);
232
+ let size = null, modified = null;
233
+ try { const s = fs.statSync(full); size = s.isDirectory() ? null : s.size; modified = s.mtime.toISOString(); } catch (_) {}
234
+ return { name: d.name, type: typeFor(d.name, d), size, modified, path: full };
235
+ }).sort((a, b) => (a.type === 'dir' ? 0 : 1) - (b.type === 'dir' ? 0 : 1) || a.name.localeCompare(b.name));
236
+ // Breadcrumb segments from the absolute path (drive/root aware).
237
+ const segments = normalizedPath.split(/[\\\/]+/).filter(Boolean);
238
+ sendJSON(req, res, 200, { path: normalizedPath, segments, entries, roots: allowRoots });
239
+ } catch (err) {
240
+ const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
241
+ sendJSON(req, res, code, { error: err.message });
242
+ }
243
+ return;
244
+ }
245
+
246
+ // Raw file bytes for the Files preview pane. Confined to the SAME
247
+ // allowlist roots as /api/list (server cwd + Claude projects dir, widened
248
+ // via FS_ROOTS). Same normalize-then-prefix-check guard. Capped at 512KB
249
+ // and limited to reasonable text/code/image types so this is never a
250
+ // generic arbitrary-file reader. Images are served via /api/image; this
251
+ // returns text/* with utf-8.
252
+ if (routePath.startsWith('/api/file/')) {
253
+ const rawF = routePath.split('?')[0].slice('/api/file/'.length);
254
+ const decodedPath = decodeURIComponent(rawF);
255
+ const allowRoots = [
256
+ process.env.STARTUP_CWD || process.cwd(),
257
+ process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
258
+ ...(process.env.FS_ROOTS ? process.env.FS_ROOTS.split(path.delimiter) : []),
259
+ ].map(r => path.normalize(r));
260
+ const conf = confineToRoots(decodedPath, allowRoots);
261
+ if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end('Forbidden'); return; }
262
+ const normalizedPath = conf.realPath;
263
+ // Only known text/code extensions (images go through /api/image). An
264
+ // unknown/binary extension is rejected, never served as octet-stream.
265
+ const TEXT_EXTS = new Set([
266
+ 'js','mjs','cjs','ts','tsx','jsx','rs','go','py','rb','java','c','cpp','h','hpp','cs','php','sh','css','html','json','yml','yaml','toml','sql',
267
+ 'txt','md','log','csv','env','xml','ini','conf','cfg','gitignore','dockerfile','svg',
268
+ ]);
269
+ const ext = path.extname(normalizedPath).slice(1).toLowerCase()
270
+ || path.basename(normalizedPath).toLowerCase();
271
+ if (!TEXT_EXTS.has(ext)) { res.writeHead(403); res.end('Forbidden: unsupported type'); return; }
272
+ try {
273
+ const st = fs.statSync(normalizedPath);
274
+ if (!st.isFile()) { res.writeHead(400); res.end('Not a file'); return; }
275
+ const MAX = 512 * 1024;
276
+ const fd = fs.openSync(normalizedPath, 'r');
277
+ const len = Math.min(st.size, MAX);
278
+ const buf = Buffer.alloc(len);
279
+ fs.readSync(fd, buf, 0, len, 0);
280
+ fs.closeSync(fd);
281
+ res.writeHead(200, {
282
+ 'Content-Type': 'text/plain; charset=utf-8',
283
+ 'Cache-Control': 'no-cache',
284
+ 'X-File-Truncated': st.size > MAX ? '1' : '0',
285
+ });
286
+ res.end(buf);
287
+ } catch (err) {
288
+ const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
289
+ res.writeHead(code); res.end(err.message);
290
+ }
291
+ return;
292
+ }
293
+
152
294
  if (routePath.startsWith('/api/image/')) {
153
295
  const imagePath = routePath.slice('/api/image/'.length);
154
296
  const decodedPath = decodeURIComponent(imagePath);
155
- const expandedPath = decodedPath.startsWith('~') ? decodedPath.replace('~', os.homedir()) : decodedPath;
156
- const normalizedPath = path.normalize(expandedPath);
157
- const isWindows = os.platform() === 'win32';
158
- const isAbsolute = isWindows ? /^[A-Za-z]:[\\\/]/.test(normalizedPath) : normalizedPath.startsWith('/');
159
297
  // Confine reads to an allowlist root. Without this the route is an
160
298
  // arbitrary-file-read of any image-extensioned path on the host (the
161
299
  // prior `includes('..')` guard is a no-op after path.normalize resolves
@@ -163,18 +301,16 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
163
301
  // only; add more via IMAGE_ROOTS (path-separated). The user home is NOT
164
302
  // a default root - it covers ~/.ssh, ~/.aws, dotfiles etc., so an image
165
303
  // route reaching all of home is a broad read of anything image-shaped.
304
+ // confineToRoots also realpath-resolves so a symlink inside a root can't
305
+ // point an image read at an out-of-root file.
166
306
  const allowRoots = [
167
307
  process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
168
308
  ...(process.env.IMAGE_ROOTS ? process.env.IMAGE_ROOTS.split(path.delimiter) : []),
169
309
  ].map(r => path.normalize(r));
170
- const norm = isWindows ? normalizedPath.toLowerCase() : normalizedPath;
171
- const withinAllowed = allowRoots.some(root => {
172
- const r = isWindows ? root.toLowerCase() : root;
173
- return norm === r || norm.startsWith(r + path.sep);
174
- });
175
- if (!isAbsolute || !withinAllowed) { res.writeHead(403); res.end('Forbidden'); return; }
310
+ const conf = confineToRoots(decodedPath, allowRoots);
311
+ if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end(conf.reason === 'not found' ? 'Not found' : 'Forbidden'); return; }
312
+ const normalizedPath = conf.realPath;
176
313
  try {
177
- if (!fs.existsSync(normalizedPath)) { res.writeHead(404); res.end('Not found'); return; }
178
314
  const ext = path.extname(normalizedPath).toLowerCase();
179
315
  const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' };
180
316
  // Only serve known image types - never an octet-stream fallback, which
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.954",
3
+ "version": "1.0.955",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",