agentgui 1.0.948 → 1.0.950
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/workflows/gui-audit.js +86 -0
- package/AGENTS.md +15 -1
- package/AUDIT-PUNCHLIST.md +62 -0
- package/lib/http-handler.js +54 -3
- package/lib/ws-handlers-util.js +17 -0
- package/package.json +2 -2
- package/site/app/index.html +20 -16
- package/site/app/js/app.js +300 -83
- package/site/app/js/backend.js +5 -1
- package/site/app/vendor/anentrypoint-design/247420.css +963 -51
- package/site/app/vendor/anentrypoint-design/247420.js +89 -42
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
name: 'gui-audit',
|
|
3
|
+
description: 'Sweep every agentgui GUI surface for jank, verify findings adversarially, and emit kit-update tasks',
|
|
4
|
+
whenToUse: 'Run a maximum-effort GUI audit of agentgui: review chat, history, settings, a11y, security, performance, and glyph hygiene across the client (site/app) and the design kit (c:\\dev\\anentrypoint-design). Fans out one reviewer per surface, verifies each finding with an independent skeptic, then synthesizes a prioritized punch-list. GUI fixes land in the design kit.',
|
|
5
|
+
phases: [
|
|
6
|
+
{ title: 'Review', detail: 'one reviewer agent per GUI surface/aspect' },
|
|
7
|
+
{ title: 'Verify', detail: 'independent skeptic confirms each finding is real' },
|
|
8
|
+
{ title: 'Synthesize', detail: 'dedupe + prioritize into a punch-list with kit-vs-app split' },
|
|
9
|
+
],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Every GUI surface and cross-cutting aspect the audit must cover. Each becomes
|
|
13
|
+
// one reviewer agent. Add a row to widen coverage; the fan-out scales with it.
|
|
14
|
+
const SURFACES = [
|
|
15
|
+
{ key: 'chat', prompt: 'Audit the agentgui CHAT surface for jank. Read site/app/js/app.js (chatMain, sendChat, toolPart, applyToolResult), site/app/js/backend.js (streamChat), and the kit AgentChat at c:\\dev\\anentrypoint-design/src/components/agent-chat.js + chat.js. Check: tool_use/tool_result render as structured collapsible cards (not flattened text); markdown/text/tool parts interleave in arrival order; empty state; composer stop button while streaming; thinking/working indicator during silent tool runs; aborted-empty turn pruning; multi-turn resume correctness. Report each issue with file:line, why it is jank, and whether the fix is in the kit or the app.' },
|
|
16
|
+
{ key: 'history', prompt: 'Audit the agentgui HISTORY surface (site/app/js/app.js historyMain, historySide, loadSession, EventList usage). Check: event rendering consistency with chat (same event same way), expand/collapse + keyboard activation, the silent 300-event cap with no load-older, search-result click landing on the matched event, live SSE wiring, session list filtering/subagent toggle. Report each issue with file:line and kit-vs-app.' },
|
|
17
|
+
{ key: 'settings', prompt: 'Audit the agentgui SETTINGS surface (site/app/js/app.js settingsMain, agentsPanel, preferencesPanel, saveBackend). Check: ACP agent start/stop/restart/health controls (read-only today), cwd surfacing + server-side validation, backend URL validation, clear-local-data flow, health summary clarity. Report each issue with file:line and kit-vs-app.' },
|
|
18
|
+
{ key: 'a11y', prompt: 'Audit agentgui ACCESSIBILITY across site/app and the kit. Check: skip-link target reachability/focus, tab order on each tab, agent/model select labels, EventList row role=button keyboard activation + expand announcement, aria-live over/under-announcing, color contrast on the dark theme (WCAG AA 4.5:1 text / 3:1 UI), focus-visible rings, reduced-motion. Report each issue with file:line.' },
|
|
19
|
+
{ key: 'security', prompt: 'Audit agentgui GUI-reachable SECURITY. Read lib/http-handler.js. Check: Access-Control-Allow-Origin:* with Authorization + ?token= cross-origin/leak risk; /api/image arbitrary-file-read (the .. check runs after path.normalize); markdown XSS (kit marked+dompurify) — does DOMPurify actually neutralize script/javascript: URLs in agent output. Report each issue with file:line, severity, and the concrete exploit path.' },
|
|
20
|
+
{ key: 'perf', prompt: 'Audit agentgui GUI PERFORMANCE. Check: per-token render+scroll thrash on the chat stream (should be rAF-coalesced via scheduleStreamRender), history EventList rebuild cost on burst SSE loads (last-300 map per rAF), webjsx keying stability, markdown/Prism cache warmup, resize/relTime tick cost. Report each hotspot with file:line and a concrete fix.' },
|
|
21
|
+
{ key: 'glyph', prompt: 'Sweep EVERY GUI source file (site/app/**, c:\\dev\\anentrypoint-design/src/**, *.css) for decorative tell-tale glyphs: arrows, box-drawing, bullets, status dots, checks, emoji, any non-ASCII decorative symbol. KEEP the typographic set (middot, ellipsis, em/en-dash in prose), SVG line-icons via Icon(), CSS-drawn discs, and intentional ascii-art (AICAT_FACE). Report every decorative glyph found with file:line and its ASCII replacement.' },
|
|
22
|
+
{ key: 'responsive', prompt: 'Audit agentgui RESPONSIVE behavior from site/app/index.html media queries + the kit AppShell. Check: chat controls cluster wrap at 360/640/900px, cwd path overflow, history sidebar drawer (open/close/scrim/select-closes-it), touch-target sizes, settings two-column collapse. Report each issue with the breakpoint and file:line.' },
|
|
23
|
+
{ key: 'consistency', prompt: 'Audit agentgui PREDICTABILITY/CONSISTENCY. Check that the same state reads the same way everywhere: connection/activity status vocabulary across crumb dot, chat status, AgentControls status, settings health, agentsPanel; ellipsis convention (… vs ...); rail tone semantics (green=ok/selected, purple=subagent, flame=error); button labels. Report each divergence with file:line.' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const FINDINGS_SCHEMA = {
|
|
27
|
+
type: 'object',
|
|
28
|
+
required: ['surface', 'findings'],
|
|
29
|
+
properties: {
|
|
30
|
+
surface: { type: 'string' },
|
|
31
|
+
findings: {
|
|
32
|
+
type: 'array',
|
|
33
|
+
items: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
required: ['title', 'where', 'why', 'fixLocation'],
|
|
36
|
+
properties: {
|
|
37
|
+
title: { type: 'string' },
|
|
38
|
+
where: { type: 'string', description: 'file:line' },
|
|
39
|
+
why: { type: 'string' },
|
|
40
|
+
fixLocation: { type: 'string', enum: ['kit', 'app', 'server', 'both'] },
|
|
41
|
+
severity: { type: 'string', enum: ['low', 'medium', 'high'] },
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const VERDICT_SCHEMA = {
|
|
49
|
+
type: 'object',
|
|
50
|
+
required: ['isReal', 'reason'],
|
|
51
|
+
properties: {
|
|
52
|
+
isReal: { type: 'boolean' },
|
|
53
|
+
reason: { type: 'string' },
|
|
54
|
+
correctedFix: { type: 'string' },
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
phase('Review');
|
|
59
|
+
|
|
60
|
+
// Pipeline: each surface is reviewed, then every finding it produced is
|
|
61
|
+
// independently verified — a surface's findings start verifying the moment that
|
|
62
|
+
// surface's review returns, without waiting for slower surfaces.
|
|
63
|
+
const reviewed = await pipeline(
|
|
64
|
+
SURFACES,
|
|
65
|
+
(s) => agent(s.prompt, { label: 'review:' + s.key, phase: 'Review', schema: FINDINGS_SCHEMA }),
|
|
66
|
+
(review, surface) =>
|
|
67
|
+
parallel((review.findings || []).map((f) => () =>
|
|
68
|
+
agent(
|
|
69
|
+
'Adversarially verify this GUI audit finding. Default to isReal=false if you cannot confirm it from the actual source. Finding: "' + f.title + '" at ' + f.where + ' — ' + f.why,
|
|
70
|
+
{ label: 'verify:' + surface.key, phase: 'Verify', schema: VERDICT_SCHEMA }
|
|
71
|
+
).then((v) => ({ ...f, surface: surface.key, verdict: v }))
|
|
72
|
+
))
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const confirmed = reviewed.flat().filter(Boolean).filter((f) => f.verdict && f.verdict.isReal);
|
|
76
|
+
log(confirmed.length + ' confirmed findings across ' + SURFACES.length + ' surfaces');
|
|
77
|
+
|
|
78
|
+
phase('Synthesize');
|
|
79
|
+
|
|
80
|
+
const punchList = await agent(
|
|
81
|
+
'Synthesize these confirmed agentgui GUI audit findings into a prioritized punch-list. Group by fixLocation (kit vs app vs server), order by severity then user impact, dedupe overlaps, and for each give a one-line concrete remedy. Findings JSON:\n' +
|
|
82
|
+
JSON.stringify(confirmed, null, 2),
|
|
83
|
+
{ label: 'synthesize', phase: 'Synthesize' }
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return { confirmedCount: confirmed.length, confirmed, punchList };
|
package/AGENTS.md
CHANGED
|
@@ -6,7 +6,7 @@ One surface. `server.js` serves `site/app/` under `BASE_URL` (default `/gm`) and
|
|
|
6
6
|
|
|
7
7
|
When `PASSWORD` env var is set, every HTTP route is gated by `lib/http-handler.js` accepting **Basic auth**, **`Authorization: Bearer <pwd>`**, OR **`?token=<pwd>`** query param (added 2026-05-26 so `EventSource` and direct deep-links work — neither can set headers). WS `/sync` requires `?token=` only. The HTML head script injects `window.__BASE_URL`, `window.__SERVER_VERSION`, and `window.__WS_TOKEN`; `site/app/js/backend.js` reads `__WS_TOKEN` and threads it onto every fetch (Bearer header) / EventSource (qs) / WebSocket (qs).
|
|
8
8
|
|
|
9
|
-
- `site/app/index.html` — shell + CSS; imports `anentrypoint-design` from
|
|
9
|
+
- `site/app/index.html` — shell + CSS; imports `anentrypoint-design` from the **local vendored copy** `./vendor/anentrypoint-design/247420.{js,css}` (re-vendored 2026-06-04 for a PREDICTABLE shipped UI that does not shift when upstream publishes, and offline operation — the markdown stack marked/dompurify/prismjs still fetches from jsdelivr on first chat render). Update flow: edit the kit at `c:\dev\anentrypoint-design`, `node scripts/build.mjs`, copy `dist/247420.{js,css}` into `site/app/vendor/anentrypoint-design/`, then publish the kit so unpkg stays in sync.
|
|
10
10
|
- `site/app/js/backend.js` — same-origin WS/HTTP client (`DEFAULT_BACKEND = ''`); the transport glue that wires the kit; `?backend=` query override for cross-origin debugging
|
|
11
11
|
- `site/app/js/app.js` — webjsx view + state; renders the `AgentChat` kit (from `anentrypoint-design`) for the chat surface and wires agentgui's WS/ccsniff state as kit callbacks; history/settings remain agentgui-local. Exposes `window.__agentgui`
|
|
12
12
|
|
|
@@ -18,6 +18,20 @@ Dependencies:
|
|
|
18
18
|
- `ccsniff` (>=1.1.0) — exports `createHistoryRouter({projectsDir})` mountable on Express; serves `/v1/history/{sessions,sessions/:sid/events,search,snapshot,reindex,stream}`. Reads `~/.claude/projects` (override via `CLAUDE_PROJECTS_DIR`).
|
|
19
19
|
- `anentrypoint-design` (>=0.0.119) — kit library, single-file ESM from unpkg
|
|
20
20
|
|
|
21
|
+
## GUI audit pass (2026-06-04) — structured chat, predictable presentation
|
|
22
|
+
|
|
23
|
+
A maximum-effort GUI sweep landed these, GUI changes in the kit `c:\dev\anentrypoint-design` (built+vendored locally), correctness/security in agentgui:
|
|
24
|
+
|
|
25
|
+
- **Structured tool cards in chat.** `app.js sendChat` now emits structured parts — `toolPart()` builds `{kind:'tool',name,label,args,status,_id}` and `applyToolResult()` matches a `tool_result` back to its `tool_use` by id (sets `result`+done/error status). The kit `AgentChat` passes structured `{kind,...}` parts straight through to `ChatMessage.renderPart` (only bare strings are wrapped as text), so the kit's collapsible `ToolCallNode` (args/result sections, status icons) renders instead of the old flattened `'-> '+text`. Full args/result live in the card (no 120/160-char truncation).
|
|
26
|
+
- **AgentChat kit additions** (`src/components/agent-chat.js`): empty state (`.agentchat-empty*`, title/sub/`suggestions`+`onSuggestionClick`), composer stop button while streaming (`busy`+`onCancel:onStop` into `ChatComposer`), a `working…` tail (`.agentchat-working`) when a content-bearing turn is still streaming (silent tool runs no longer read as frozen). CSS in `chat.css`.
|
|
27
|
+
- **Streaming render is rAF-coalesced** via `scheduleStreamRender()` (was render+scroll per token).
|
|
28
|
+
- **Multi-turn resume wired end-to-end.** Server `chat.sendMessage` is one-shot (new ephemeral `chat-` id per call); claude's real session id is `parsed.session_id` in the stream. `ws-handlers-util.js onEvent` now broadcasts `streaming_session{claudeSessionId}` once; `backend.js` yields `{type:'session'}`; `app.js` captures it into `state.chat.resumeSid` (claude-code only) + persists, so the next turn `--resume`s.
|
|
29
|
+
- **Skip link works.** Kit `AppShell` `<main id=app-main>` now has `tabindex=-1` so the skip-link moves focus, not just scrolls.
|
|
30
|
+
- **History:** `state.eventsLimit` (default 300, reset per session) + `load N older` control (no more silently-unreachable older events); expand-all/collapse-all; per-event rail tone (green=normal, purple=tool_use, flame=error) matching the GUI-wide rail semantics; search hits carry `{focusEventI,focusEventTs}` so a click scrolls to + flashes (`.event-flash`) the matched event; mobile drawer auto-closes on session select (`loadSession` removes `.side-open` since DS only auto-closes on `<a>` clicks).
|
|
31
|
+
- **Security** (`lib/http-handler.js`): `/api/image` confined to an allowlist root (`CLAUDE_PROJECTS_DIR`, home, `IMAGE_ROOTS`) — was arbitrary-file-read (the `..` check was a no-op after `path.normalize`); CORS no longer advertises `Access-Control-Allow-Origin:*` when `PASSWORD` is set (reflects `CORS_ORIGIN` if set, else same-origin only). Markdown XSS verified inert (DOMPurify strips script/onerror/`javascript:`).
|
|
32
|
+
- **Consistency:** unified ellipsis to `…` (kept set); settings health chip uses connection vocab (`connected/connecting/offline`) matching the crumb dot; running-chats panel now shows on chat tab too (extracted `runningPanel()`); aborted-empty turns pruned from persisted/restored chat; restored transcript reopens under its own agent (`restoredAgent`).
|
|
33
|
+
- **Workflow:** `.claude/workflows/gui-audit.js` — a reusable multi-agent GUI-audit workflow (per-surface reviewers -> adversarial verify -> synthesize, kit-vs-app fixLocation).
|
|
34
|
+
|
|
21
35
|
## Orchestration agents
|
|
22
36
|
|
|
23
37
|
The four flagship agents the GUI drives are **Claude Code, OpenCode, Kilo, and Antigravity (`agy`)**; the agent picker (`site/app/js/app.js`, `PRIMARY_AGENTS`) sorts these first, then other-available, then npx-installable, then not-installed.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
All findings are pre-confirmed (isReal: true). Here is the deduped, prioritized punch-list grouped by fixLocation, ordered by severity then user impact.
|
|
2
|
+
|
|
3
|
+
## SERVER (lib/) — highest impact: security correctness
|
|
4
|
+
|
|
5
|
+
1. **[HIGH · security] /api/image broad arbitrary-file-read** — `lib/http-handler.js:144-162`: drop `os.homedir()` from `allowRoots` (restrict to `CLAUDE_PROJECTS_DIR` + `IMAGE_ROOTS`) and 403 any path whose extension isn't in the image `mimeTypes` map instead of falling back to `application/octet-stream`.
|
|
6
|
+
2. **[HIGH · security] ?token= credential leak, no Referrer-Policy/CSP** — `lib/http-handler.js:56-61`: always `res.setHeader('Referrer-Policy','no-referrer')`, add a CSP restricting img/connect/script-src to self + unpkg/jsdelivr, and migrate token→httpOnly SameSite=Strict cookie so the password leaves the URL/history/logs.
|
|
7
|
+
3. **[MEDIUM · robustness] cwd unvalidated before spawn** — `lib/ws-handlers-util.js:70`: when `p?.cwd` is set, `fs.statSync(cwd).isDirectory()` check and `err(400,'no such directory')` before `runClaudeWithStreaming`; optionally confine to an allowlist root.
|
|
8
|
+
|
|
9
|
+
## APP (site/app/) — chat correctness, then history, then settings
|
|
10
|
+
|
|
11
|
+
4. **[HIGH · chat] Live SSE event read flat but arrives nested** — `app.js:281-293`: unwrap `const ev = data.payload || data;` and use `ev` for `state.events.push` and the tool_use/isError counters (keep `data.sid` for routing). Core live-history regression.
|
|
12
|
+
5. **[MEDIUM · chat] resumeSid persists across agent switch** — `app.js:149-168`: clear `resumeSid`/`resumeNote` in `selectAgent` when `id !== 'claude-code'`, and gate the banner (474), status sub (527), and forwarded arg (662) on `selectedAgent === 'claude-code'` (stale sid wrongly triggers agy `--continue`).
|
|
13
|
+
6. **[MEDIUM · chat] scrollChatToBottom queries dead `.chat-thread`** — `app.js:133-142`: prefer `.agentchat-thread` (the kit's real scroller), keep `.chat-thread`/`#agentgui-main` as fallbacks.
|
|
14
|
+
7. **[MEDIUM · history] Live events not deduped vs snapshot** — `app.js:282-285`: after the unwrap (#4), guard `state.events.push` with `ev.i != null && state.events.some(e => e.i === ev.i)` and count each `ev.i` once via a per-session Set.
|
|
15
|
+
8. **[MEDIUM · settings] Schemeless backend URL validated-but-stored-raw** — `app.js:998-1013`: normalize on save (`new URL(...).origin`), validate/store/use the same canonical string so `localhost:3000` can't resolve against the page origin.
|
|
16
|
+
9. **[MEDIUM · settings] clear-local-data leaves in-memory state that re-persists** — `app.js:1090-1098`: zero `selectedAgent`/`selectedModel`/`chatCwd`/`backend`/`backendDraft`/`agentModels` (don't re-call `B.setBackend('')` which rewrites the key), or `location.reload()` after clearing.
|
|
17
|
+
10. **[LOW · chat] Aborted-empty turn not pruned from in-memory array** — `app.js:599-604`: in `cancelChat`, `state.chat.messages = state.chat.messages.filter(m => !isEmptyTurn(m))` so `messages.length`-derived UI isn't off-by-one (persist only filters its snapshot, never the live array).
|
|
18
|
+
11. **[LOW · history] Search-hit focus scroll races live append** — `app.js:1228-1234`: snapshot the target event's `.i`/`.ts`, re-derive `rowPos` inside the rAF from current `state.events`, bail if it moved.
|
|
19
|
+
12. **[LOW · history] Search results ignore subagent/project filters** — `app.js:904-921`: apply the same `showSubagents`/`projectFilter` gating as `visibleSessions` before slicing (or label the header "incl. hidden").
|
|
20
|
+
13. **[LOW · settings] preferencesPanel cwd "(current: X)" can misrepresent running chats** — `app.js:1108-1109`: relabel as "default for new chats" or derive the displayed cwd from in-flight `chat.active` ctrl.cwd.
|
|
21
|
+
|
|
22
|
+
## APP — consistency / vocabulary (low, batch together)
|
|
23
|
+
|
|
24
|
+
14. **[MEDIUM · consistency] Status bar says 'live' where crumb/health say 'connected'** — `app.js:387`: use `ok ? 'connected' : 'offline'`; reserve 'live' for the history SSE label (335).
|
|
25
|
+
15. **[MEDIUM · consistency] agentsPanel rail inverts purple=subagent contract** — `app.js:1152`: `rail: avail ? 'green' : 'flame'` (selection already shown via `active`); purple must stay subagent-only.
|
|
26
|
+
16. **[LOW · settings] db chip absent on partial /health + 'N active' is dead text** — `app.js:1022-1040`: always render `db unknown` when `hh.db` absent; make the active-executions chip a button routing to history's running panel.
|
|
27
|
+
17. **[LOW · consistency] Resume label noun vs gerund** — `app.js:527`: `'resuming…'` to match banner + sibling gerund statuses.
|
|
28
|
+
18. **[LOW · consistency] Literal '...' in resume banner** — `app.js:479`: drop the trailing `...` (it's a fixed 8-char slice, not a truncation) or use `…`.
|
|
29
|
+
19. **[LOW · consistency] ACP 'running·healthy' tight middot vs spaced separator** — `app.js:1144`: `'running healthy'` so `·` keeps one meaning.
|
|
30
|
+
20. **[LOW · consistency] db 'ok/down' reuses vocabulary the connection chip translated away** — `app.js:1037`: `db online/offline` to match the connection register.
|
|
31
|
+
|
|
32
|
+
## KIT (c:\dev\anentrypoint-design) — requires build + publish to unpkg
|
|
33
|
+
|
|
34
|
+
21. **[HIGH · perf] Streaming re-parses full growing markdown every rAF (O(n²))** — `chat.js:82-90` MdNode: parse markdown once on turn-complete; render the live turn as cheap inline/plain text while streaming (gate on `isStreaming`), promote to `{kind:'md'}` only when settled.
|
|
35
|
+
22. **[HIGH · responsive] Chat control cluster + cwd bar have no media queries** — `chat.css`: add `@media` breakpoints for `.agentchat-controls`/`.agentchat-cwd-text` (the 360/640/900 rules in index.html target dead `.chat-controls`/`.cwd-bar`; also delete that dead CSS in `index.html`).
|
|
36
|
+
23. **[MEDIUM · a11y] EventList Row toggle never emits aria-expanded** — `content.js:23,39-50`: add an `expanded` prop → `aria-expanded` on the button branch; app passes the boolean at `app.js:796-806`.
|
|
37
|
+
24. **[MEDIUM · responsive] cwd `max-width:60ch` overflows phones** — `chat.css:59-64`: `max-width: min(60ch, 60vw)` (+ optional 640px 42vw clamp).
|
|
38
|
+
25. **[MEDIUM · responsive] Hamburger 36px + drawer rows ~40px + send button under 44px** — `247420.css` source: raise `.app-side-toggle` and `.app-side a` and the small/landscape composer `send` to min 44×44 (WCAG 2.5.5).
|
|
39
|
+
26. **[MEDIUM · security] marked/dompurify imported from unpinned CDN** — `markdown.js:9-26`: vendor marked+dompurify locally (dynamic import can't carry SRI) and ship a `script-src 'self'` CSP; success/fallback sanitization paths are already safe.
|
|
40
|
+
27. **[LOW · perf] CodeNode highlight cache key uses code LENGTH not content** — `chat.js:95-97`: key on full `p.code` (or export+use `simpleHash`) so same-length blocks re-highlight.
|
|
41
|
+
28. **[LOW · a11y] `.agentchat-cwd-btn` has no :focus-visible** — `chat.css`: add `:focus-visible` ring mirroring `.agentchat-empty-suggestion`.
|
|
42
|
+
29. **[LOW · a11y] Native select pickers have title-only accessible name** — `agent-chat.js:36-48`: pass `label:` (visible or sr-only) to the agent/model `Select`.
|
|
43
|
+
30. **[LOW · consistency] Head sub-line re-derives 'streaming…' ignoring status prop** — `agent-chat.js:175`: derive from the same `status` prop (`busy ? (status||'streaming…') : …`) so reconnecting-while-streaming reads one word.
|
|
44
|
+
31. **[LOW · glyph] Box-drawing dividers in OS-kit comments** — `src/kits/os/app-panes.css:4,51,114`: replace `──` with `---` (comment-only; not the shipped AgentChat surface).
|
|
45
|
+
|
|
46
|
+
## BOTH (coordinated app + kit + publish)
|
|
47
|
+
|
|
48
|
+
32. **[HIGH · chat] Text and tool parts don't interleave — all prose renders before every tool card** — `app.js:673-675` + `agent-chat.js:114-124`: model the assistant turn as one ordered `parts[]` (append/extend `{kind:'md'}` segments and tool parts in arrival order; tool boundary closes the md segment); drop the forced `m.content` prepend in the kit and render `parts` strictly in order. This is the core chat-jank fix and supersedes the standalone perf concern in #21 about ordering.
|
|
49
|
+
33. **[MEDIUM · chat] Orphan tool_result renders as detached bottom card** — `app.js:446-457`: ids already match on both runner paths, so harden the client fallback — attach an unmatched result to the most recent running tool part (or insert after the last tool part) rather than pushing to array end. Compounds #32.
|
|
50
|
+
34. **[MEDIUM · history] Same event kinds render differently in chat vs history** — `app.js:773-808` + kit ToolCallNode: generalize the kit's ToolCallNode so EventList rows accept structured `{kind:'tool',...}` parts, then route history tool_use/tool_result through the same `toolPart`/`applyToolResult` pairing chat uses (preserve green/purple/flame rails).
|
|
51
|
+
|
|
52
|
+
## PERF — confirmed no-fix (mitigating context)
|
|
53
|
+
|
|
54
|
+
- `app.js:264,1306` relTime/resize full re-render: acceptable (rAF-coalesced); resolves once #36 row-build is lazy.
|
|
55
|
+
- `app.js` webjsx keying: stable, no collision — this is why the perf findings stay at vnode-build cost, not DOM teardown.
|
|
56
|
+
|
|
57
|
+
## APP — remaining perf (high/medium)
|
|
58
|
+
|
|
59
|
+
35. **[HIGH · perf] Full app tree re-renders per rAF during stream** — `app.js:78-86`: scope the streaming re-render to the active transcript/bubble rather than rebuilding Topbar/Crumb/Status/panels every token (pairs with kit #21).
|
|
60
|
+
36. **[HIGH · perf] EventList computes `full`+JSON.stringify for all ~300 rows every frame** — `app.js:790`: build `full` (and `JSON.stringify(e.toolInput,...)`) only when the row is `expanded`.
|
|
61
|
+
37. **[MEDIUM · perf] O(sessions) linear scan per live event** — `app.js:287-299`: build `state.sessionsBySid = new Map(...)` on each refresh, `.get(data.sid)` instead of `arr.find`.
|
|
62
|
+
38. **[MEDIUM · perf] scrollChatToBottom nests a 2nd rAF + re-querySelector per frame** — `app.js:133-142`: cache the scroller (re-query only if detached), drop the inner rAF (caller already in a frame). Folds into #6.
|
package/lib/http-handler.js
CHANGED
|
@@ -6,9 +6,41 @@ import * as term from './terminal.js';
|
|
|
6
6
|
|
|
7
7
|
export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
|
|
8
8
|
return async function httpHandler(req, res) {
|
|
9
|
-
|
|
9
|
+
// CORS: when PASSWORD is set the server holds credentials a cross-origin
|
|
10
|
+
// page must not be able to spend, so we do NOT advertise a wildcard origin
|
|
11
|
+
// (a wildcard + a leaked Bearer/token would let any site the user visits
|
|
12
|
+
// drive this server). Reflect an explicitly-allowed origin (CORS_ORIGIN)
|
|
13
|
+
// when configured, else same-origin only. With no PASSWORD the server is
|
|
14
|
+
// already open, so the permissive wildcard is harmless and kept for tools.
|
|
15
|
+
const _corsOrigin = process.env.CORS_ORIGIN;
|
|
16
|
+
if (_corsOrigin) {
|
|
17
|
+
res.setHeader('Access-Control-Allow-Origin', _corsOrigin);
|
|
18
|
+
res.setHeader('Vary', 'Origin');
|
|
19
|
+
} else if (!process.env.PASSWORD) {
|
|
20
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
21
|
+
} // else: no ACAO header -> browsers block cross-origin reads (same-origin still works)
|
|
10
22
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
11
23
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
24
|
+
// The password can ride in a ?token= query param (EventSource/deep-links
|
|
25
|
+
// can't set headers), so a navigation away from the app would leak it in
|
|
26
|
+
// the Referer header to the destination. Strip the referrer on every
|
|
27
|
+
// response so the credential never crosses an origin boundary that way.
|
|
28
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
29
|
+
// CSP: the markdown stack (marked/dompurify/prismjs) and the DS kit load
|
|
30
|
+
// from unpkg/jsdelivr; everything else is self. connect-src also allows
|
|
31
|
+
// self for the same-origin WS/SSE. 'unsafe-inline' on style/script is
|
|
32
|
+
// required by the inlined head bootstrap + DS runtime <style> injection.
|
|
33
|
+
res.setHeader('Content-Security-Policy', [
|
|
34
|
+
"default-src 'self'",
|
|
35
|
+
"script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net",
|
|
36
|
+
"style-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net https://fonts.googleapis.com",
|
|
37
|
+
"img-src 'self' data: blob:",
|
|
38
|
+
"font-src 'self' data: https://unpkg.com https://cdn.jsdelivr.net https://fonts.gstatic.com",
|
|
39
|
+
"connect-src 'self' https://unpkg.com https://cdn.jsdelivr.net ws: wss:",
|
|
40
|
+
"object-src 'none'",
|
|
41
|
+
"base-uri 'self'",
|
|
42
|
+
"frame-ancestors 'self'",
|
|
43
|
+
].join('; '));
|
|
12
44
|
if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
|
|
13
45
|
if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') return;
|
|
14
46
|
|
|
@@ -124,12 +156,31 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
124
156
|
const normalizedPath = path.normalize(expandedPath);
|
|
125
157
|
const isWindows = os.platform() === 'win32';
|
|
126
158
|
const isAbsolute = isWindows ? /^[A-Za-z]:[\\\/]/.test(normalizedPath) : normalizedPath.startsWith('/');
|
|
127
|
-
|
|
159
|
+
// Confine reads to an allowlist root. Without this the route is an
|
|
160
|
+
// arbitrary-file-read of any image-extensioned path on the host (the
|
|
161
|
+
// prior `includes('..')` guard is a no-op after path.normalize resolves
|
|
162
|
+
// the segments). Allowed roots: the Claude projects dir (history images)
|
|
163
|
+
// only; add more via IMAGE_ROOTS (path-separated). The user home is NOT
|
|
164
|
+
// a default root - it covers ~/.ssh, ~/.aws, dotfiles etc., so an image
|
|
165
|
+
// route reaching all of home is a broad read of anything image-shaped.
|
|
166
|
+
const allowRoots = [
|
|
167
|
+
process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
|
|
168
|
+
...(process.env.IMAGE_ROOTS ? process.env.IMAGE_ROOTS.split(path.delimiter) : []),
|
|
169
|
+
].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; }
|
|
128
176
|
try {
|
|
129
177
|
if (!fs.existsSync(normalizedPath)) { res.writeHead(404); res.end('Not found'); return; }
|
|
130
178
|
const ext = path.extname(normalizedPath).toLowerCase();
|
|
131
179
|
const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' };
|
|
132
|
-
|
|
180
|
+
// Only serve known image types - never an octet-stream fallback, which
|
|
181
|
+
// would turn this into a generic file reader for any extension.
|
|
182
|
+
const contentType = mimeTypes[ext];
|
|
183
|
+
if (!contentType) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
133
184
|
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' });
|
|
134
185
|
res.end(fs.readFileSync(normalizedPath));
|
|
135
186
|
} catch (err) { sendJSON(req, res, 400, { error: err.message }); }
|
package/lib/ws-handlers-util.js
CHANGED
|
@@ -70,6 +70,14 @@ export function register(router, deps) {
|
|
|
70
70
|
const cwd = p?.cwd || STARTUP_CWD;
|
|
71
71
|
const resumeSessionId = p?.resumeSid || p?.resumeSessionId || undefined;
|
|
72
72
|
if (!registry.has(agentId)) err(404, `Unknown agentId: ${agentId}`);
|
|
73
|
+
// A client-supplied cwd that doesn't exist would have the agent CLI spawn
|
|
74
|
+
// fail opaquely (or inherit STARTUP_CWD on some runners). Validate up front
|
|
75
|
+
// so the caller gets a clear 400 instead of a confusing mid-stream error.
|
|
76
|
+
if (p?.cwd) {
|
|
77
|
+
let st = null;
|
|
78
|
+
try { st = fs.statSync(cwd); } catch (_) {}
|
|
79
|
+
if (!st || !st.isDirectory()) err(400, `cwd is not an existing directory: ${cwd}`);
|
|
80
|
+
}
|
|
73
81
|
|
|
74
82
|
const sessionId = 'chat-' + crypto.randomBytes(8).toString('hex');
|
|
75
83
|
// Auto-subscribe the originating ws so it receives its own broadcasts.
|
|
@@ -85,8 +93,17 @@ export function register(router, deps) {
|
|
|
85
93
|
(async () => {
|
|
86
94
|
let eventCount = 0;
|
|
87
95
|
broadcastSync({ type: 'streaming_start', sessionId, agentId, timestamp: Date.now() });
|
|
96
|
+
let claudeSessionBroadcast = false;
|
|
88
97
|
const onEvent = (parsed) => {
|
|
89
98
|
eventCount++;
|
|
99
|
+
// Surface claude's REAL session id (from the stream) once, so the client
|
|
100
|
+
// can --resume this conversation on its next turn. The ephemeral
|
|
101
|
+
// 'chat-...' sessionId is not a claude session id and cannot be resumed.
|
|
102
|
+
if (!claudeSessionBroadcast && parsed?.session_id) {
|
|
103
|
+
claudeSessionBroadcast = true;
|
|
104
|
+
ctrl.claudeSessionId = parsed.session_id;
|
|
105
|
+
broadcastSync({ type: 'streaming_session', sessionId, claudeSessionId: parsed.session_id, agentId, timestamp: Date.now() });
|
|
106
|
+
}
|
|
90
107
|
if (parsed?.type === 'assistant' && parsed.message?.content) {
|
|
91
108
|
for (const block of parsed.message.content) {
|
|
92
109
|
broadcastSync({ type: 'streaming_progress', sessionId, block, blockRole: 'assistant', seq: eventCount, timestamp: Date.now() });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentgui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.950",
|
|
4
4
|
"description": "Multi-agent ACP client with real-time communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "electron/main.js",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"webjsx": "^0.0.73",
|
|
44
44
|
"webtalk": "^1.0.31",
|
|
45
45
|
"ws": "^8.14.2",
|
|
46
|
-
"xstate": "^5.
|
|
46
|
+
"xstate": "^5.32.0",
|
|
47
47
|
"zod": "^4.3.6"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
package/site/app/index.html
CHANGED
|
@@ -5,15 +5,16 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
6
|
<title>agentgui</title>
|
|
7
7
|
<meta name="description" content="agentgui - multi-agent client with same-origin server, in-process ccsniff history, and ACP chat.">
|
|
8
|
-
<!-- The GUI lives in the anentrypoint-design kit and is
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
<!-- The GUI lives in the anentrypoint-design kit and is vendored locally under
|
|
9
|
+
./vendor/anentrypoint-design so the shipped UI is PREDICTABLE: it does not
|
|
10
|
+
silently shift when the upstream package publishes, and it works offline
|
|
11
|
+
(the markdown stack still fetches marked/dompurify/prismjs on first chat
|
|
12
|
+
render). Update flow: edit the kit at c:\dev\anentrypoint-design, run
|
|
13
|
+
`node scripts/build.mjs`, copy dist/247420.{js,css} here, then publish the
|
|
14
|
+
kit so unpkg stays in sync. -->
|
|
15
|
+
<link rel="stylesheet" href="./vendor/anentrypoint-design/247420.css">
|
|
15
16
|
<script type="importmap">
|
|
16
|
-
{ "imports": { "anentrypoint-design": "
|
|
17
|
+
{ "imports": { "anentrypoint-design": "./vendor/anentrypoint-design/247420.js" } }
|
|
17
18
|
</script>
|
|
18
19
|
<style>
|
|
19
20
|
:root {
|
|
@@ -72,14 +73,8 @@
|
|
|
72
73
|
.ds-247420 [data-prog-focus]:focus-visible,
|
|
73
74
|
[data-prog-focus]:focus, [data-prog-focus]:focus-visible { outline: none !important; box-shadow: none !important; }
|
|
74
75
|
|
|
75
|
-
/* skip link
|
|
76
|
-
|
|
77
|
-
position: absolute; left: -9999px; top: 0; z-index: 1000;
|
|
78
|
-
padding: .5em .9em; border-radius: 6px;
|
|
79
|
-
background: var(--accent, var(--agentgui-accent)); color: #06120a;
|
|
80
|
-
text-decoration: none; font-weight: 600;
|
|
81
|
-
}
|
|
82
|
-
.skip-link:focus { left: 8px; top: 8px; outline: 2px solid #fff; outline-offset: 2px; }
|
|
76
|
+
/* (The skip link is rendered + styled by the design system's AppShell;
|
|
77
|
+
agentgui carries no local .skip-link rule.) */
|
|
83
78
|
|
|
84
79
|
/* status connection dot - a CSS-drawn disc (real UI affordance, not a text
|
|
85
80
|
glyph). The .status-dot-disc child is always present; the parent's state
|
|
@@ -260,6 +255,15 @@
|
|
|
260
255
|
them a pointer cursor, so the affordance is invisible. */
|
|
261
256
|
.ds-event-list .row[role="button"] { cursor: pointer; }
|
|
262
257
|
.ds-event-list .row[role="button"]:hover { background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 5%, transparent); }
|
|
258
|
+
/* Flash the row a search result landed on, so the match is obvious. */
|
|
259
|
+
.ds-event-list .row.event-flash { animation: agentgui-event-flash 2s ease-out; }
|
|
260
|
+
@keyframes agentgui-event-flash {
|
|
261
|
+
0% { background: color-mix(in srgb, var(--accent, var(--agentgui-accent)) 40%, transparent); }
|
|
262
|
+
100% { background: transparent; }
|
|
263
|
+
}
|
|
264
|
+
@media (prefers-reduced-motion: reduce) {
|
|
265
|
+
.ds-event-list .row.event-flash { animation: none; outline: 2px solid var(--accent, var(--agentgui-accent)); }
|
|
266
|
+
}
|
|
263
267
|
|
|
264
268
|
/* Chat composer: hide the idle scrollbar on the (empty/short) textarea. */
|
|
265
269
|
.chat-composer textarea { overflow-y: auto; scrollbar-width: thin; }
|