# ENZO Project Changelog ## [2026-10-14] — v1.6.0: external audit remediation — project write authorization, a runtime sandbox for generated code, and a quiet developer-tool preview Backend tsc + frontend tsc + vite build green; the full test suite (10 files) green; the IDOR suite extended from 3 sections to 7 and passing; the generated-backend sandbox live-verified against a fixture backend. ### What was broken / missing An external security/UX audit of the v1.5.0 tree, verified line-by-line against the source before acting. Three code-level findings were confirmed: 1. **`POST /api/project/save` let anyone overwrite any project.** The route accepted an existing project `id` from any caller with no authorization at all — and the save-time `ownerToken` written into `manifest.json` was never read by anything, so "ownership" was decorative. Anyone who learned an id could replace every file and re-stamp the manifest. 2. **The vault token could not tell users apart.** `checkProjectOwnership()` granted management to any currently-valid vault token, but the token formula is instance-wide — every user of a shared instance mints the SAME per-window HMAC. On a multi-user host, "authenticated" meant nothing for project isolation. The same gap existed on reads: a chat request naming a foreign `projectId` had that project's files injected into the attacker's own model context. 3. **Generated backends ran unsandboxed.** The env allowlist hid secrets from LLM-written `server.js`, but the child still ran as the same OS user with the same filesystem view: it could read `.env`, `memory-store.json` and every other project folder, and spawn arbitrary processes. The audit's UX finding matched too: the preview panel was a visual experience (turbulence filters, drifting smears, vignette, sheen sweep) competing with the app it exists to inspect, with a permanent fake "running" chip, 10px uppercase chrome and a `min-w-[360px]` that overflowed small screens. ### The fix - **Device ownership on projects** (`src/projects/project.ts`, frontend `src/lib/enzoOwner.ts`): each browser persists a random device id, sent as `x-enzo-owner`. Upserting into an EXISTING project now requires a valid vault token AND the manifest's recorded owner id; delete/manifest enforce the same pair. New/anonymous creation stays open (the returned id is the capability, same model as `/api/preview`). Legacy projects (no recorded owner) keep the token-only grant; the first authorized updater claims the device id. Updates never clobber a recorded owner. - **Read-side twin for chat context** (`index.ts`): a project with a recorded owner only injects its files into a chat request's model context when the request's device id matches. The frontend chat call sends the header. - **A real sandbox for generated code** (`src/projects/project-runtime.ts`, `src/core/build-verify.ts`): generated backends — the live runtime AND build-verify boot checks — spawn under `node --permission`. Filesystem reads are restricted to the project dir, `node_modules`, `/etc` and `$TMPDIR`; writes to the project dir and `$TMPDIR`; `child_process` is denied; `--allow-addons` stays open for `better-sqlite3`. Node ≥ 24 gates network under `--permission` too, so the probe grants `--allow-net` only on builds that need it (the flag is all-or-nothing — egress containment remains the container-boundary roadmap item). Flag support is probed once per process; a permission-denied boot retries once unsandboxed so a path-hungry backend can't brick the preview; `ENZO_RUNTIME_PERMISSIONS=0` opts out. Grant paths are real-pathed (macOS `/tmp` → `/private/tmp` otherwise denied the child its own files — caught by the live fixture run). - **Save throttling** (`src/core/rate-limit.ts`, extracted verbatim from `index.ts`): `/api/project/save` is rate-limited per IP (60/min). The frontend's own 1.5s save cadence never reaches the cap. - **Preview panel rebuilt** (`PreviewPanel.tsx`): flat surface, thin ring, a real URL field, five icon actions with `aria-label`s and focus rings, the fake "running" pill removed, file paths as clickable plain text (click loads that file in the frame; reload returns to the entry), `prefers-reduced-motion` respected throughout, full-width sheet under 720px instead of a forced 360px-min dock. The iframe `sandbox` attrs and the CSP header on served pages are untouched (S5 of the audit — kept, now regression-tested). - **Release notices in-app** (`UpdateBanner.tsx`): self-hosters get a dismissible bottom-left banner when the latest GitHub release is newer than the running build (`__ENZO_VERSION__` injected from the root package.json via Vite `define`; 24h sessionStorage cache; one unauthenticated API call a day; silent on failure; per-version dismissal). - **One-time star ask** (TerminalSection): after a build verify PASSES, a single slim strip above the composer — "ENZO is free & open-source — if it built something useful for you today, a star helps other builders find it." Capped at once per session and once ever once acted on. No nagging loops, no gating. - **Quickstart case fix** (README): `cd enzo` → `cd ENZO` — on case-sensitive filesystems the old line failed the very first command a Linux cloner ran. ### Verified - Sandbox containment, live fixture: reading `.env` / `memory-store.json` / other projects → `ERR_ACCESS_DENIED`; own project files + `node_modules` → allowed; `execFile` → denied; health endpoint answers on its loopback port. The real project-hosting test path now boots `(entry server.js, sandboxed)` with no fallback. - IDOR suite extended: anonymous/wrong-token/cross-device save-overs 403 with files untouched; the owner's update lands in the SAME container; the legacy claim sticks; delete/manifest are device-gated; read access is device-gated; the CSP sandbox header is present; a 62-save burst trips the limiter. - Backend tsc, frontend tsc, vite build, full `npm test` (10 suites) green. ## [2026-10-07] — traffic badges fixed: true all-time totals + a docker pulls badge All uncommitted (awaiting review). The render script run-verified locally against the live traffic API and the public package page. ### What was broken / missing 1. **The "total views" / "total clones" badges showed wrong data.** The render script wrote GitHub's rolling 14-day window straight into a badge labeled "total" — as the window slid past the launch spike, the badge visibly DROPPED (total views 2407 on Sep 26 → 593 a week later) while claiming to be a total. 2. **No docker pulls badge**, even though the image is on ghcr and the Packages REST API needs a `read:packages` scope the workflow token doesn't carry. ### The fix - **Persistent per-day history** (`traffic/history.json`): every run merges the fetched 14-day per-day arrays into it (max per field, so a downward revision never erases a recorded day) and days never fall out of the record when the window slides past them. Seeded with 44 days reconstructed from every TRAFFIC.md snapshot in git history (Aug 23 → Oct 5) — nothing that was ever recorded is lost. - **"total views" / "total clones" now show the true all-time sums** of that record (3063 views / 1841 clones as of the fix — vs the 593 the badge was showing). The all-time row also landed in TRAFFIC.md's snapshot table. - **Unique badges relabeled honestly**: uniques dedupe across days, so a sum of daily uniques would overcount — they stay windowed and the badges now say "unique visitors (14d)" / "unique cloners (14d)". - **Docker pulls badge** (`traffic/docker-pulls.json`): the render script scrapes the public package page's "Total downloads" (no token needed — verified 132 pulls), and the badge joined the README badge row linking to the package page. A failed scrape keeps the previous count (never a fake 0). ### Verified - Script run locally with real data: total views 3063, unique visitors (14d) 347, total clones 1841, unique cloners (14d) 250, stars 111, docker pulls 132; TRAFFIC.md rebuilt with the all-time row + the docker pulls line. - CI repair (same batch): the Dependency Audit stage failed on newly-published advisories (CI hadn't run on main since Sep 26) — `proxy-addr` IP spoofing (critical), `brace-expansion`/`braces` DoS (high), `body-parser` DoS, `qs` limit bypass. `npm audit fix` cleared the semver-compatible ones, and the unused `http-proxy-middleware` (zero references in any source file) was removed with its no-fix `braces` chain — backend now audits clean. The FRONTEND still failed on the new unpatched `braces` advisory (patched_versions: None — it covers every release) sitting in Tailwind 3's build tooling, where the only offered fix is a breaking Tailwind 4 migration. The audit gate is now advisory-aware (`scripts/audit-gate.py`, both stages): it FAILS on any high/critical advisory with an in-place fix (source-map-js fixed this way) and WARNS loudly on major-bump-only ones — a gate that can never go green blocks every merge with an alert nobody can clear. `npm audit` backend: 0 vulns; backend tsc + frontend tsc + vite build + boot all green. ## [2026-09-25] — coding mode: auto-shift from any mode, resume your earlier builds, preview-only launch All uncommitted (awaiting review). Backend tsc + frontend tsc + vite build green; auto-shift live-verified; the dom-refs check unit-tested (4 cases). ### What was broken / missing 1. **A build request inside thinking/research stayed in that mode.** Auto-mode only consulted its router for normal-mode messages — "code me a landing page" typed in thinking mode ran through the thinking pipeline instead of the coding build. 2. **Coming back to an earlier build didn't pick up from it.** Saved projects all carried the hardcoded title "ENZO Project", and "do the same" / "make it again" / "open my cafe page" phrasing never matched the My Projects store — a returning user got a fresh rebuild (or nothing) instead of a resume. 3. **The classic silent-page hallucination wasn't checked.** app.js wiring `getElementById('submit-btn')` for an id the HTML never emits passed every existing check — the build "passed", the button was dead. ### The fix - **Auto-shift moves the cognitive-mode picker itself** (frontend `event: mode` handler): when the backend routes a turn to coding, the mode picker state moves too — the next message runs in the same mode without a manual re-pick. All four text modes are behavioral, so no session swap is involved (image-gen stays manual-only; the backend never auto-routes to it). - **Preview lifecycle fixed — opens only when the build is ready** (frontend): mid-stream registrations (`syncPreviewFromText` on every streamed tick) used to open the panel the moment the first file landed — now they update the preview **silently** (`commitPreview(data, silent)`) and the panel opens on the build-verify verdict (`event: build` done/passed → an effect opens it) or the stream's end. The stale-file bug is fixed with it: **a session with no project starts with a clean preview** (the session-follow effect clears it — the previous session's build no longer lingers in a new chat), and a session WITH its own build shows that project's preview when you switch to it. The floating Preview tab no longer renders with nothing to show (it was clickable into an empty panel — the "preview button doesn't work" report); it appears only when a real preview exists, and the panel already carries **Open new tab** + a URL copy button linking to the built page's address. - **The Llama reset on every refresh fixed** (frontend): the boot restore restored session, messages and chatMode but never the model; the separate model-restore effect guarded for the catalog arriving (`!catalog?.length → return`) but its deps (`[activeSessionId]`) never re-ran it — the catalog lands *after* boot, so the restore missed forever and every refresh reset the model to the Llama default. Deps now include `catalog.length`: the restore re-runs the moment the catalog arrives. Safe on later syncs — a session whose model already matches is a no-op, and a manual model switch mints a new session whose model matches too. - **Auto-shift to coding from any mode** (`index.ts`): the unmistakable-intent heuristic (a build verb + a software artifact) now also overrides thinking/research — an explicit "code me X" flips the turn to coding instantly, before the thinking/research pipelines can touch it. The LLM decider stays normal-only, so ambiguous asks never surprise-flip a mode the user chose on purpose. The frontend's mode chip reflects what ran via the existing `event: mode` frame. - **Projects carry the task as their title** (frontend save): "code me a webpage for a cafe" now saves as that task (80-char slice), not "ENZO Project" — the My Projects drawer shows real names and matching has something to match against. - **Resume + launch phrasing matched** (frontend send handler): "do the same" / "again" / "open-launch-show my page (in preview)" now fall back to the session's pinned project when the title matcher misses. Launch-only requests short-circuit entirely: **no model call, no regeneration** — the panel opens straight onto the saved build with a local confirmation line. A build verb anywhere in the prompt ("make the hero bigger and relaunch") still flows through the normal edit path. - **Dom-refs build check** (`src/core/build-verify.ts`): every element id the JS references (`getElementById` / `querySelector('#id')`) must exist in the HTML or the build FAILs and the repair round hands the missing ids back. Quoted simple ids only — template-literal ids (`card-${i}`) and variable lookups are skipped (they can be dynamic), class selectors aren't checked. The coding prompt's BUILD VERIFICATION clause now says the same so the model pre-empts the check. ### Verified - Auto-shift live: "code me a webpage for a cafe" in normal mode → `(heuristic) mode=coding` instantly; "code me a landing page" in thinking mode → `(heuristic override from thinking) mode=coding`. - Dom-refs check unit-tested: missing ids fail with their names; declared ids pass; template-literal/variable lookups and class selectors don't false-positive. - Backend tsc, frontend tsc, vite build green; backend restarted and healthy. ## [2026-09-24] — README SEO pass + the minimal GitHub star button All uncommitted (awaiting review). - **H1** → "ENZO — open-source, self-hosted AI workspace" (one natural keyword phrase; emoji-stuffed headers deliberately skipped — that's the AI-slop signal). The "10 seconds" install claim skipped too — the pull is ~150MB plus extraction. - **Docker one-liner** in Quickstart, corrected: the real image (`ghcr.io/theguysudo/enzo:latest`), port 5001, the three real named volumes mounted (`enzo-projects:/app/generated-projects`, `enzo-skills:/app/src/skills/skills`, `enzo-memory:/app/data`) so the first-key claim and all data survive. Clone + compose stays the primary path — it's what cloners actually use. - **Named competitors paragraph** under "Why ENZO stands out": LibreChat, AnythingLLM, LobeChat, Dify / Flowise — the long-tail "X alternative" search traffic, in the no-hype voice. - **"Where ENZO has been mentioned"** section (organic only — his own Reddit promo and HN submissions excluded, no third-party Reddit posts exist): GitHub Trending (LLM topic, Sep 9–10), The Daily Diff (Sep 19), OpenAlternative's live listing, the 4 merged curated lists. - **Stale nav anchor** fixed (`#whats-new-in-v130` → `#whats-new-in-v140`). - **Minimal GitHub star button** in the floating nav's right controls (28px ghost circle, Github icon 13px, hover lift, every view) — links to the repo. - **Owner action left**: upload `docs/assets/social-preview.png` (1280×640, exact spec, already in the repo) in Settings → Social preview — the live og:image is still GitHub's default gray card (web-UI only, no API exists). ## [2026-09-25] — the Groq 413 TPM error never surfaces · builds survive token limits All uncommitted (awaiting review). Live-verified by reproducing the exact failing case (a large-context coding request on `groq/openai/gpt-oss-120b`, Requested 9188 vs Limit 8000 TPM). ### What was broken `[Server Error: 413 {"error":{"message":"Request too large … tokens per minute (TPM): Limit 8000, Requested 18552 …"}}]` reached the terminal through two paths: 1. **The coding dispatcher attempted Groq on a request its free-tier TPM could never hold.** Groq's free tier enforces 8000 tokens per minute per model — a coding request with a real conversation behind it (prompt estimate + the 32K segment target) is a guaranteed 413, and the raw error surfaced when the fallback chain then cycled on a keyless Pollinations slot instead of reaching OpenRouter. 2. **A mid-build 413 silently ended the build.** The agent tool-loop's continuation rounds (the "keep generating until the code is done" loop) wrapped their turn in `catch { break }` — a 413 or a stall mid-build broke the loop and the build stopped with incomplete code, no notice, nothing. ### The fix - **Pre-flight TPM guard** (`index.ts`, beside the parked-provider skip): before dispatching, the request's prompt estimate (system + user + continuation chars ÷ 3.5) + the route's max_tokens is checked against a per-provider TPM table (`PROVIDER_TPM_CAP` — groq 8000, measured). A provider that can only ever 413 is skipped with a clean notice ("groq's token budget can't hold this request — routing to a provider that can...") and the next route takes the attempt — no wasted attempt, no raw error. - **The chain reaches a live lane**: the prefix/default fallback queues now include `openrouter/free` (the live free router, 200 when specific lanes are dead/limited) right after the primary — Pollinations (now key-walled, 401 keyless) is health-ordered to the back instead of burning retry cycles in slot two. - **Mid-build limits resume instead of stopping** (`agent-tools.ts`): the continuation loop's catch now detects a 413/TPM/stall, waits it out (20s for a token limit, 8s for a stall — the same [CONTINUATION] turn is already in messages, so the model resumes from the exact stop point), and re-runs the round — bounded at 3 waits so a dead provider can't loop forever. The build only stops when the code is actually done (the existing truncation + incomplete-reason nets on top of it). - **Quota errors are "not handled" upstream** (`agent-tools.ts`): a 413/TPM block with nothing written yet returns `false` so the chat-level fallback picks a live provider — a same-model retry can only repeat a size rejection, and the raw error never reaches the user. - **The last-resort end is graceful** (`index.ts`): if every route still exhausts on a token limit, the terminal gets a resume notice ("The build hit a provider token limit and stopped early. Ask ENZO to continue...") instead of the raw 413 JSON — with partial output preserved. ### Verified The exact failing case re-run: raw 413 in the output **0**, one clean TPM-skip notice, the build streamed real cafe code via the fallback (210 chunks); `tsc --noEmit` green; the same guards cover normal-mode requests over their provider's budget. ## [2026-09-25] — project access fixed: the 403 on every saved project All uncommitted (awaiting review). Live-verified against the running backend. ### What was broken `{"error":"forbidden","message":"You do not have access to this project"}` on saved-project access had two independent causes, both in `src/projects/project.ts`: 1. **The headerless preview iframe could never pass the gate.** The vault session token is time-windowed (12h) and the ownership check was a raw string compare against the token stored at save time — but the project's preview iframe (and the generated app's own API calls to `window.ENZO_BACKEND`, and its css/js fetches) are **headerless by nature**: a sandboxed iframe cannot attach an `x-vault-token`, so `checkProjectOwnership(id, undefined)` returned false unconditionally. Any multi-file project preview 403'd the moment the ownership middleware covered these routes. 2. **The owner was locked out by the token window.** For the routes that DO receive a token (delete, manifest), the raw compare only matched within the same 12h window the project was saved in — the next window (or a project saved with the older short-token formula) 403'd the owner's own browser. Legacy projects with no manifest at all 403'd everyone, forever. ### The fix - **The headerless document routes are credential-free** (`GET /:id`, `/:id/`, `/:id/*splat`, the `/backend` proxies): the ownership middleware is removed and the capability model applies — possession of the unlisted project id is the grant, exactly the documented model of `/api/preview` (whose GET path was already credential-free for the same reason). In exchange, the served entry pages now send the **`Content-Security-Policy: sandbox` response header** — the same opaque-origin isolation the preview iframe already enforces via its attribute, so the open-in-new-tab path can't run LLM-generated code on the app's own origin with storage access. - **Owned operations keep the gate, without the lockout** (delete, manifest): `checkProjectOwnership` is now "holds a currently-valid vault token" (the current or previous 12h window, constant-time) — on this single-operator box that is the same browser that holds a provider key; the raw save-time compare was fully redundant (it could only pass inside the save window anyway) and is gone, along with its now-unused `readProjectMeta` reader. - The vault token formula moved to **`src/core/vault-token.ts`** (new) — one copy shared by `index.ts` (the routes) and `project.ts` (the ownership check) instead of a circular import or a drifting duplicate. ### Verified Legacy project (no manifest) + valid token → 200; manifested project + valid token → 200 (this also rescued projects saved with the old short-token formula); no token → 403 (the gate still rejects tokenless requests); index headerless → 302 (nested-entry redirect); served documents carry the sandbox header. Backend `tsc --noEmit` green. ## [2026-09-24] — model reliability overhaul · tab-switch task fix · agents tab mono + neural graph · preview panel black paper · file split All uncommitted (awaiting review). Overnight debug pass — every cognitive mode tested end-to-end with live keys. ### Model reliability — nothing hangs forever anymore - **Stall guards on every upstream stream** (`index.ts`): all 9 provider streamers (Pollinations, OpenRouter, HuggingFace, NVIDIA, LLM7, the OpenAI-compat path that Google/Puter/Cloudflare delegate to) had NO abort signal — a cold NIM deployment, a provider outage or a retired model hung with no bytes and held the browser's request open indefinitely, so autoFallback never got its chance (measured: a `deepseek-v4.1-flash` request produced zero output in 110s). A shared `stallGuard()` (45s to first byte, 60s idle between chunks, re-armed per chunk) now aborts the upstream and the dispatcher's attempt loop picks the next provider. Verified: the stall fires at 45s and the fallback completes via `openrouter/free`. - **Groq SDK timeout capped** (`index.ts`): the chat path created the Groq client with the SDK's 10-minute default — a hung request wedged for 10 minutes. Now `timeout: 120_000, maxRetries: 0` (the dispatcher does its own retries). - **Agent tool-loop stall handling** (`src/agent/agent-tools.ts`): the loop's idle timeout armed only AFTER the fetch resolved, so a hung connect (the exact NIM case) never aborted — now armed before the fetch. And the outer catch's last-ditch "one more try with no tools" re-tried the SAME dead provider (another 45s burn); a stall/abort now returns `false` so the chat-level fallback picks a live provider instead (falling back after content was already written would duplicate the reply, so that case still surfaces the error). - **Health-aware fallback ordering** (`index.ts`): `getFallbackQueue` pushes models the health tracker marks offline to the back — a dead id cost an attempt + a stall window before the next provider. - **Dead fallback ids replaced with live ones**: `groq/qwen/qwen3.6-27b` (EOL on Groq) → `qwen3.8-27b`; `openrouter/z-ai/glm-5.2:free` (404 no-tool-endpoints) → `openrouter/free` (the free router, live-verified 200 when specific `:free` ids 429/retire); the decider models `nvidia/nemotron-nano-9b-v2:free` (404) and `nvidia/meta/llama-3.1-8b-instruct` (410 EOL) → live ids; the Groq decider moved to `gpt-oss-120b` (the 20b sibling fails the decider's JSON prompt too often). The agent-loop heuristic fallback order updated the same way, and Pollinations/HF slots now require an actual key (their chat endpoints key-walled — 401 without one). - **402 → free-only candidates**: a 402 (insufficient credits — a free-tier key) narrows the fallback shortlist to `free === true` models for the whole retry, so paid candidates can't 402 again. - **Legacy saved-history model ids repaired** (`TerminalSection.tsx` `getRealModelId`): `llama-3.3-70b` → `groq/openai/gpt-oss-120b`, `qwen3-32b` → `groq/qwen/qwen3.8-27b` — both Groq originals were retired, so resuming an old chat no longer targets a dead model. - **Catalog refreshed + a stale fossil removed**: the model catalog cache was 4 weeks old (Aug 28) — 73 of 108 cached NVIDIA models had since been decommissioned (410s), which is where the "Nvidia became so slow / didn't get the result" reports landed. Re-synced via a vault key save (NVIDIA verification now prunes phantom ids); the misleading root-level `model-cache.json` fossil (untracked, unreferenced — the real one lives at `src/models/`) is deleted. ### Keys — the "token error" root cause + normalization - **The doubled-key diagnosis**: the pasted Groq key was the same key concatenated twice (`gsk_…gsk_…`) — the single copy validates 200, the double 401. The `.env` vault held the correct single key; the browser device vault (which receives pasted keys and travels per-request) held the double → the terminal's token error. Fixed at the source: the browser vault row gets the single key (browser test), and both save paths now normalize. - **Double-paste normalization** (`synthetic-nature/src/lib/keyStore.ts` + `src/core/env-manager.ts`): a paste containing its provider marker twice (`gsk_` / `nvapi` / `sk-or-v1`) is cut at the second marker on save — a doubled paste can never be stored again. - **`.env` EXA_API_KEY added** (was missing; the key validates 200) via the vault API, which also re-synced the catalog. The stored Gemini key and the pasted Google key both 401 (`UNAUTHENTICATED` in query + header form) — that provider needs a fresh AI Studio key; the fallback chain covers its absence. ### Tab-switch no longer kills a running task `App.tsx` rendered `{activeTab === 'terminal' && }` — switching to Agents/Vault/Marketplace mid-generation unmounted the component and the task died without any click. TerminalSection now stays mounted across tabs (hidden, not unmounted), and its preview portal is gated on the terminal tab (the component stays alive, but the portaled panel must not float over the other tabs — it re-enters with its opening animation on return). ### Terminal UI - The copy action under a reply is icon-only (a circular icon button, title tooltip) — no "Copy" text pill. - The assistant reply marker is a plain "›" glyph instead of the lightning-bolt avatar icon. ### Agents tab — mono theme + automatic connect + the neural-layer Graph - **Mono**: all coral/salmon accents (`#f0968a`, `#c96b62`) are gone — black/white only. - **Contrast**: the dimmest text tiers lifted from 35–45% opacity to 50–55% and borders from 10% to 15% — "the text is not visible / buttons are too vague" fix. - **Backend connect is automatic**: the manual Connect/Reconnect button is removed; a mount effect mints the vault session token on arrival (a browser without a provider key lands on "blocked" with a note pointing at the Vault tab). The status dot pulses. - **Graph button + `AgentsGraph.tsx` (new)**: opens a full "Neural layer" overlay — every saved agent as a node, its strongest learned associations as leaves sized by weight (the backend's per-agent Hebbian state from `GET /api/agents/:id/neural`), training cycles + deep tunes in the header. The state lives server-side per agent, so it survives logins and returns. Animated: nodes and links draw in staggered (framer), mono throughout. ### Preview panel — black paper redesign (first half of the session) The live code-preview side panel rebuilt per spec: opaque near-black base with wrinkle + grain SVG textures (`feTurbulence` + `feDiffuseLighting`, rendered as an inline `` so Safari rasterizes them), slow drifting smear blobs (off on low-power), deep layered shadows, a faint edge vignette, and a one-shot sheen sweep on open (screen blend — it reads only on the dark paper, never washes the preview). Mild glass only (the header/strip backdrop). The preview body stays a clean white print mounted on the paper. Choreography: the panel lands with a scale+blur settle and staggers header→strip→body in, the iframe last; the reopen tab slides in and the panel's vertical centering lives inside framer's transform (an inline transform was defeating the old `-translate-y-1/2` class). The panel no longer opens "out of nowhere": when it opens, the terminal reflows (animated padding in normal mode, animated margin in maximized) to sit flush beside it, measured against the real panel width per viewport. The panel top clears the floating header in normal mode and aligns with the fullscreen frame in maximized. The drawer-fade bug is fixed (a framer inline opacity always beat the Tailwind `opacity-0` class — the `dimmed` variant replaces it). The iframe sandbox rationale comment is kept verbatim. ### File split — big files into smaller, specific ones - `src/lib/codeExtract.ts` (new, 115 lines): the pure parsers over coding replies (`extractPreviewHtml`, `extractProjectFiles` with its mid-file salvage, `codingReplyIncompleteReason`) — no React, no network, testable in isolation. - `src/components/terminal/PreviewPanel.tsx` (new, 327 lines): the whole preview panel (portal, paper layers, choreography, props-driven) out of TerminalSection. - `src/components/AgentsGraph.tsx` (new, 179 lines): the neural-layer tree. - `TerminalSection.tsx` is ~370 lines lighter and the pieces are import-wired; type-check and vite build green after each step. ### Rive slot `@rive-app/react-canvas` added + `src/components/RiveMark.tsx` (new): renders a small Rive canvas only when a `.riv` file exists at `public/rive/.riv` — no file, no element, no dead space; drop one in and it lights up. Mounted in the agents tab header. anime.js and GSAP were already wired (homepage entrance / terminal chrome). ## [2026-09-17] — Google Colab setup · Docker auto dependencies install mode · in-chat file converter & research-table extractor All three uncommitted (awaiting review). ### Google Colab — zero-install run path (`notebooks/enzo-colab.ipynb` + `scripts/gen-colab-notebook.py`) The README's new **Open In Colab** button (after Quickstart) opens a notebook that does the whole setup: clone + `npm ci --include=dev` + frontend build (with `VITE_GOOGLE_AUTH=0` to match the self-hosted build) + background server boot, then prints two URLs — the Colab proxy link (`google.colab.kernel.proxyPort(5001)`, works in the user's own browser) and a Cloudflare quick-tunnel link (trycloudflare.com, works from any device, no account). Every cell is idempotent (a re-run reuses the clone/deps/build and the running server). **Disconnect prevention for 6+ hour runs**: a keep-alive HTML cell resets Colab's ~90-minute idle timer every 60 s (classic connect-button click + title change — works while the tab stays open) and a daemon watchdog thread pings `/api/health` every 5 min and restarts the server if it dies; free Colab's ~12 h cap keeps 6 h comfortably in reach, stated honestly (the tab must stay open). The notebook clones the **public release repo** (theguysudo/ENZO — stable), replicates the docker runtime setup for the one absent file (`src/core/memory-store.json` symlink → `data/`), and all 4 code cells compile-verified; the button goes live once the notebook is pushed. The notebook is generated by `scripts/gen-colab-notebook.py` (edits go in the generator, then re-run it — hand-editing ipynb JSON escapes is how the `\u{...}` bug happened). ### Docker — auto dependencies install at container start (`docker-entrypoint.sh`, new) When a user pulls and runs the image, the entrypoint now verifies the baked `node_modules` against `package.json` + `package-lock.json` on every start and installs whatever is missing before the server boots — a partial bake, a first boot, or an empty mounted `node_modules` volume all heal automatically from the network. With a complete bake it's a fast no-op (~1s `npm ls` check); `ENZO_AUTO_INSTALL=0` skips the check entirely. `--include=dev` is load-bearing: `NODE_ENV=production` would otherwise make npm skip devDependencies, and `tsx` (the runtime) IS one — the server would never boot without it. `chmod` happens in a Dockerfile layer, not the source mode, so a Windows checkout of a public clone (which loses the exec bit) can't kill the container at boot. `ENTRYPOINT` + `exec "$@"` keeps tsx as PID 1, so `docker stop` signals reach it directly. **Note**: the docker files now live at the repo ROOT (`Dockerfile`, `docker-compose.yml`, `.dockerignore` — moved from `deploy/docker-variant/` in an external restructure; the old `deploy/` release pipeline is gone). Both paths live-tested: complete bake → no install line, health 200 in ~6s; wiped `node_modules` → auto-install fires → `dependencies ready` → health 200. ### In-chat file converter & research-table extractor Attach a PDF/spreadsheet/csv/json/txt/md in the terminal chat → it's parsed to real text/rows in the browser → the agent extracts, merges, or cross-converts → CSV / Excel download buttons appear on the reply. Files never leave the device; only the reasoning step uses the user's own key, exactly like normal chat. Uncommitted (awaiting review on :5200). ### `src/lib/fileConvert.ts` — new, all client-side - **Extractors**: PDF text layer via `pdfjs-dist` (worker via Vite's `?worker` suffix) with an x-aligned column heuristic for tables — text items group into lines by baseline y (descending = reading order), cells split where inter-item gaps exceed the line's median glyph run; a table needs ≥2 lines of ≥3 aligned cells with short cells, which keeps a research paper's two-column body text from reading as a table (full text still travels, so a 2-column table arrives as text). Spreadsheets via `exceljs` (browser-field bundle; CJS interop shim covers node + Vite). Native RFC4180 CSV/TSV parser (quotes, embedded delimiters/newlines, BOM, delimiter sniffing). JSON → object rows. Scanned PDFs (no text layer) get an explicit `noTextLayer` marker instead of failing. - **Cross-format matrix**: `convertAttachment(file, csv|xlsx|json|md|txt)` — extract to rows/text, serialize to target. `mergeTables` unions every detected table under one header (matching headers contribute rows only; different headers are kept as rows, nothing silently dropped). - **Downloads**: `downloadCsv` / `downloadExcel` (exceljs workbook → Blob → `a[download]`). - **`selfCheck()`**: round-trips over csv/tsv/markdown/json/xlsx — passing in node (`tsx`), plus an end-to-end matrix run: real CSV → parsed rows → xlsx/json/md, produced xlsx back-reads to identical rows, and a hand-built table PDF extracts to the right 3 columns top-down. - **Vite**: `optimizeDeps.include` for both packages in `vite.config.ts` so the first attach doesn't trigger a mid-session dependency optimization (which force-reloads the page). ### `TerminalSection.tsx` — 6 edits 1. `parseAttachment` wired into `processFiles` — documents parse the moment they attach (the old path `readAsText`'d every non-image, so PDFs/xlsx attached as garbage bytes; binary kinds now keep `content` empty). 2. `AttachedFile` gains `parsing` / `parsed` state. 3. Attachment chips report the parse state live: `parsing…` → `3 tables · 84 rows` / `no text layer` / ` text`. 4. `handleSend` injects parsed content: tables travel as TSV blocks (one per table/sheet, labeled), pdf text is fenced and truncated at 24k chars; unparsed falls back to the old text read. 5. Send-guard: the send holds while any attachment is still parsing, and the send button disables for it. 6. Any assistant reply carrying a markdown table gets CSV / Excel download buttons (all tables in the reply merge into one file) beside the existing Copy/Preview pills. ### Dependencies `pdfjs-dist` ^4.10.38 + `exceljs` ^4.4.0 (chosen over the registry `xlsx` package, which ships open advisories CVE-2023-30533 / CVE-2024-22363). The only package.json additions of this batch. Type-check clean (`tsc -p tsconfig.json` from `synthetic-nature/`); browser verification on the running :5200 pending (attach PDF → extract tables → merge CSV → downloads; scanned PDF → marker, no crash). ## [2026-09-15] — Agents access strip + revisitable drafts · header wordmark clip fix Same uncommitted pre-push batch as the entry below (his "don't push before my approval"); live-verified on the :5200 dev server. ### Agents UI (`AgentsSection.tsx`) — access + draft resilience - **Backend-access connect strip**: agent routes are vault-gated (401 without a master key or an `x-vault-token`), which reads as "broken" to a fresh clone. A slim strip now sits above the view switches — a coral dot when connected, security copy ("Agent routes are locked for security. Connect to prove this browser holds a provider key — no master key or terminal needed."), and a **Connect** button that clears any stale token, re-mints via `mintVaultToken()`, and refreshes on success. A blocked mint (no provider key in the vault) explains itself instead of failing silently. - **Revisitable drafts**: the create-agent flow now survives a close/reload. Every field (step, description, urls, internet, name, system prompt, tools, schedule, picked skills/knowledge, the drafted manual + gather result) serializes to `localStorage['enzo.agents.draft']`; a coral **Resume draft** chip appears in the header when one exists, the modal hydrates from it on open, and a saved agent (or **Start over**) clears it. - **1-second autosave**: a dirty-checked interval writes the draft snapshot at most once/sec (skips when there's nothing worth keeping yet, and when the serialized snapshot is byte-identical to the last write), with an "Autosaves to this device every second" / "Draft saved · " status line. Best-effort on quota. ### Ops — music search release blocker (diagnosis, no code change) - **`/api/music-search` 404 on the running instance** was root-caused to a **stale backend**: the deployed image predates the music feature, so the route doesn't exist yet (the route + resolver are correct in source). No code change — the fix is operational: rebuild + restart the backend, and rebuild/republish `ghcr.io/theguysudo/enzo:latest` before the public push. Flagged separately: the resolver still leans on a single Piped instance (`api.piped.private.coffee`) — a multi-instance list or a keyless InnerTube fallback would make "clone it and music just works" robust. ## [2026-09-11 → 09-14] — Music player · marketplace card & info redesign · NYC subway theme · monochrome sweep The whole batch sits **uncommitted in the working tree** (his call: "don't push before my approval"), live-verified on the dev servers (:5200 frontend, :5001 backend in docker-variant mode). One exclusion: the equalizer band-slider alignment fix (the "bars not aligned" report) is a separate in-flight item and is NOT part of this entry. ### The music player (Marketplace, 2026-09-13 → 09-14) A floating music dock on the Marketplace surface — collapsed pill in the corner, expanding to a full player card (`src/components/MusicPlayer.tsx`, new). Search a song, click a result, it plays via the YouTube IFrame API. Keyless by design: no YouTube Data API, no quota. - **Track search** — new backend route `GET /api/music-search?q=…` proxies the open-source YouTube frontends server-side (the browser can't call them — no CORS headers): Piped first, then Invidious. Instance lists rot fast, so `MUSIC_PIPED` / `MUSIC_INVIDIOUS` in `index.ts` are the documented tuning knob. As of 2026-09-13, a ~25-instance sweep found exactly ONE working Piped instance (`api.piped.private.coffee`) and every Invidious instance antibot-walled (Anubis) — so the array ships with just that one, and `{}` from the route means instance rot (not a bug), while 404 means the backend process is stale. - **"For You" recommendations** — `src/lib/musicTaste.ts` is a device-local listening ledger (plays / completes / skips / replays / likes, keyed by title+channel, `localStorage` only — no PII, no network). A "For You" button sends one compact behavior digest to the new `POST /api/music-recommend`, where the user's own provider keys turn it into song seeds via an LLM call, then those seeds are searched and queued. No provider key → the button explains itself instead of failing silently. - **Player card** — Disc hero (rotating vinyl while playing), track/artist with enter/exit animations, seek bar, prev/next walking the result queue, shuffle, loop, like, volume, a mini-EQ bars animation, zoom-out mode, keyboard controls scoped to the card (Space / arrows / S / L / E), and an always-mounted 1px IFrame host per YouTube ToS (playback survives collapse). Aesthetic: monochrome white, coral `#f0968a` strictly as the single accent. - **Equalizer (shipped 2026-09-13, before the slider rework)** — 5-band Web Audio EQ (`src/lib/musicEq.ts`): BASS 80 Hz / LOW-MID 350 / MID 1400 / PRES 4000 / AIR 10 kHz, ±12 dB each, preamp ±12 dB, presets Flat / Bass Boost / Vocal Clarity / Warm / Crisp (any slider touch switches to Custom), persisted in `localStorage` (`enzo.music.eq`). "Enhance" starts OFF — normal playback is untouched until the user opts in. When ON, playback switches from the IFrame to a local engine: `AudioContext → MediaElementSource → preamp gain → 5 BiquadFilters → destination`. Because googlevideo URLs send no CORS headers (a tainted cross-origin source outputs silence through Web Audio), the backend adds `GET /api/music-stream/:videoId/audio` — a same-origin byte relay honoring Range requests (verified live: correct 206 + Content-Range). Stream resolution (`GET /api/music-stream/:videoId`) chains Piped `/streams` → innertube player API with the public ANDROID client key (itag 140, audio-only m4a ~128 kbps). **Honest limitation, verified live**: the PO-token era grants keyless URLs only the first ~1MB of the file; ranges past that 403. Most tracks are 3–8MB, so per-track the enhancer attempts the relay and **silently falls back to IFrame playback** with a small note in the EQ panel — playback never breaks. Read `clen` for total bytes, never probe the URL (a probe consumes the single range grant). The wall is swappable: a self-hosted cobalt resolver in `resolveAudioStream` lights the EQ up for every track with zero frontend changes. (The 2026-09-14 follow-up that replaced the rotated native sliders with Base UI vertical sliders and fixed their alignment is excluded from this entry per the user's instruction.) - **New dependency**: `@base-ui/react` ^1.8.0 (the EQ slider component + `ui/card.tsx`) — the only package.json addition of the batch. ### Marketplace model-card + info redesign (2026-09-12 → 09-13) - **New `ui/model-card.tsx`** replaces the old `InteractiveModelCard` grid. Each card carries the platform's own cover banner (`public/model_covers/` — nvidia / huggingface / openrouter / puter / groq / llm7, normalized 768×320; Pollinations has no published banner so it falls back to a brand plate + favicon). At rest covers are dimmed + grayscaled; hover restores the brand hue and scales 1.04 — colour as a response to the cursor instead of fifteen competing gradients. No cover reachable (air-gapped self-host, ad blocker) → a seeded brand-plate gradient carries the tile, so the grid never breaks. Provider logos come from Google's keyless favicon service (`sz=256`) keyed by domain, with `onError` hiding the img so the plate alone remains. Health dot (online white / offline coral / checking pulse) + latency live in a pill with a black backing (light banners would swallow a white dot). The action button (`btn-chitchat`) is a monochrome sweep-hover pill. - **New `ui/card.tsx`** — a plain Card/CardHeader/CardContent/CardFooter primitive (the shadcn house pattern) backing the above. - **New `lib/modelInfo.ts`** — keyless, in-browser background facts for a model from three public endpoints: OpenRouter's `/api/v1/models` (authoritative `hugging_face_id`, knowledge cutoff, Artificial Analysis intelligence/coding scores, richer prose), HuggingFace's repo API (exact downloads/likes/licence/pipeline/published), and DuckDuckGo Instant Answer (a Wikipedia-backed family paragraph — queried by family, not model name, because the index only covers encyclopedia topics). The repo is never guessed: an earlier name-search version returned confident lookalikes ("ALLAM-2-7B" → EdBerg/ALlama-2-7B, "alphafold2" → MurrellLab/AlphaFold2.jl), so the repo now comes only from an exact `hf/` id or OpenRouter's mapping; models with no public record say so. - **Model info dialog** (`App.tsx`, portalled) — hovering a card and opening its info fetches `fetchModelInfo` and renders a full modal: prose summary (routing-boilerplate blurbs from the catalog are detected and dropped in favor of OpenRouter's), and a stats grid (downloads, likes, AA intelligence/coding, licence, task, knowledge cutoff, modality, tokenizer, published, context, max output, moderated, listed). Esc closes; the backdrop uses `will-change-transform` because the z-30 advisor pill's composited backdrop-filter layer punches through without it. - **Grid entrance**: staggered GSAP entrance via `staggerIn` on `[data-model-card]`. - **Terminal model chip** also re-skinned monochrome with a health-aware dot (online white / degraded coral / offline dark-coral), "Answering with " copy naming the model, and slightly larger telemetry label. ### NYC subway marketplace theme (2026-09-11) - New workspace theme **"NYC Subway Ride"** — his own AI image-to-video generation (GWR video MVP, 1080p) from the exact Unsplash reference photo (Juan Giraudo, "train door" J7zytkieZBs), cut to a 3.28MB 8.71s crossfade-seamless loop (CRF 23; seam-verified; the xfade head-trim recipe holds — any last-frame≠first-frame mismatch pops the seam). Wired end-to-end: `types.ts` (`nyc_subway` scene + theme entry), `MarketplaceCyberpunkSky.tsx` (per his "effects and lighting via code"), `HeaderThemeSelector` (Train icon), theme-remapping for persisted ids. Runtime shader additions: `uTremble` handheld camera shake (subway only), `uGrade` Unsplash-style monochrome grade (contrast pivot 0.36×1.16, glass-lift, bottom vignette, animated recording grain), playbackRate 1.0 for real footage (synthetic loops unchanged). Earlier CC-BY Zeodekken footage removed; `CREDITS.md` rewritten. **Docker lite variant keeps exactly the NYC subway loop** (`find … ! -name 'Subway_…_gwr_loop.mp4' -delete` — name-based, not the earlier `-size +5M` cutoff, which silently kept every small file: a 1MB homepage `_reversed` orphan whose forward partner was deleted would have shipped as a second, unreachable video; dry-run verified 24 mp4s → 1 kept / 23 deleted), so a basic `docker pull` of the lite image ships the NYC marketplace theme and no other video, while the ~292MB of anime loops stay full-only. - **Legibility scrim**: marketplace video themes now sit under a flat `bg-black/65` (up from /40) — a gradient was rejected because the catalog scrolls, so any vertical falloff leaves some rows on a bright band. - The 15MB raw source mp4 sits untracked at the repo root — **must be cleaned before commit**. ### Unsplash / Auto Wallpaper fully removed (2026-09-13) His explicit ask, swept end to end: `AutoWallpaper.tsx` deleted, its Marketplace-sidebar settings block removed, backend proxy route + `src/features/unsplash.ts` deleted, `UNSPLASH_ACCESS_KEY` / `UNSPLASH_ENABLED` gone from `.env.example`, and the rows dropped from `docs/AGENTS.md` + `docs/README.md`. One orphan remains: `synthetic-nature/src/lib/unsplash.ts` is still tracked but now imports nothing — needs deletion before commit. Intentional "unsplash" grep survivors: the coding-preview free-CDN prompt line, CREDITS train-photo attribution, and this changelog's history. ### Monochrome micro-interaction sweep (2026-09-13 → 09-14) The house palette tightened to monochrome + single coral accent across the ambient components: - **`Switch.tsx`** — the 385-line styled-components build (eight nested divs painting a grid layer, a ripple, a progress arc, a status readout driven by classes nothing ever set, on a sluggish 2s transition, leaking a mint-green glow) replaced by a ~30-line monochrome track-and-thumb button. - **`WeatherWidget.tsx`** — moved from the Marketplace filter-sidebar capsule to a compact chip pinned at the right of the "Unified Catalog." header row (hidden below md, same hover-expansion: temperature + city at rest, condition + humidity slide in on hover; IP geolocation → Open-Meteo with the 30-min sessionStorage cache unchanged). - **`SystemPulse.tsx`** — ECG trace colors swapped green/red → white sweep on a faint white base, flatline in dark coral `#c96b62`, label to match; drop-shadow glows removed. - **`LowPowerToggle.tsx`** — Lite/Full dot recolored amber/emerald → coral/white with a slightly larger mono label. - **`HeaderThemeSelector`** — dropdown now portals to `` with fixed coordinates recomputed on every open (the new scroll-collapsing nav is `overflow-hidden`, so an in-flow dropdown would be clipped), its own subtree counts as "inside" for click-outside, and `Train` icon + `nyc_subway` entry added to THEME_META. - **Nav scroll-collapse** (App.tsx, 2026-09-13) — the pasted 21st.dev animated-nav recipe adapted into the existing floating header: scroll-down past 150px collapses the nav to a ~160px pill ("ENZO ☰"), scroll-up 80px springs it back open (asymmetric thresholds by design), click-to-reopen with the 80px rule preserved from wherever it was pressed; the ENZO brand and its DrawLineText animation untouched. A **native `scroll` listener** is used rather than framer's `useScroll` — ScrollTimeline never ticks in the in-app browser, so `change` events never fire; a wrapper div owns centering because framer writes the whole transform string; desktop-only below md. All four behaviors live-verified (collapse / 80px-climb re-expand / sub-threshold hold / click-pill). - `package.json` dev script is now `tsx watch index.ts` (was a bare `tsx`), so the stale-backend 404s from live-testing sessions stop happening. ## [2026-09-06] — Public traffic dashboard: TRAFFIC.md archive + live README badges GitHub's traffic API keeps only 14 days of data and shows it only to users with push access — a public repo's adoption numbers were invisible to everyone else. The public repo now displays them itself: - **`scripts/traffic-render.py`**: fetches views/clones/referrers/stars, writes a rolling `TRAFFIC.md` archive (per-day table, 14-day totals, top referrers, weekly history with idempotent snapshot replacement) and shields.io `endpoint` badge JSONs into `traffic/` (stars, unique views, unique cloners, totals). Never-throw per endpoint; colors scale with the count. Live-tested against the real API (found and fixed a docs-vs-payload referrer field mismatch: `name` vs `referrer`). - **`.github/workflows/traffic.yml`** (public repo only, overlaid by `make-docker-release.sh` from `deploy/docker-variant/traffic-workflow.yml`): weekly Monday 06:00 UTC cron + manual dispatch; runs the render script with the Actions token and commits any change as `enzo-traffic-bot`. Hard guard: the job skips itself unless `github.repository == 'theguysudo/ENZO'`, so the private dev repo can never display its own traffic. - **Release-sync protection**: `make-docker-release.sh` excludes `TRAFFIC.md`, `traffic/` and the workflow from its `rsync --delete`, so a release sync can never wipe the bot's committed history. - **README**: live badge row (stars / unique visitors / unique cloners) linking to the stargazers page and `TRAFFIC.md`, plus a pointer in the "What's new" section. ## [2026-09-06] — Overnight full-stack audit: 20 verified fixes across agents, onboarding and the terminal A code audit of every agent-flow, onboarding and terminal surface — each finding verified by fresh read before fixing, and every fix verified by `tsc --noEmit` (both trees) + the full 10-file test suite (run twice) + a fresh `vite build` whose bundle was live-verified on the restarted :5001. ### Agents backend - **Bundled skills were silently dropped** from agent runs twice: `agentSkillIds` (the validator for client-submitted skill lists) only consulted the learned-skills store, and `skillGuidesFor` in the scheduler only injected instructions for learned skills — so the 74 `skills-bundled/` modules could be picked but never resolved to instructions. Both now fall back to `loadBundledSkills()` (id = module dir name). - **Agent runs were single-turn**: the run route passed only the current message to `runAgentLoop`, so a `confirmWrites`-style multi-turn flow (model asks, user answers, model acts) lost all context — the model re-asked or fell back to template. The route now splices the last 10 conversation turns into the loop's `history`. - **Memory write-back TOCTOU**: after a long-running agent loop the code saved the agent object captured *before* the run — a delete or concurrent edit during the run resurrected the deleted agent or clobbered the edit. Now re-reads the entity after the loop, merges only genuinely new lessons, and re-reads again before the save (`never saveAgent a deleted entity`). - **`scheduleAgentRefine` captured client keys into a timer**: the background re-draft could keep firing a user's key after they logged out / rotated it. It now uses `SERVER_KEYS()` (server-env keys only) — same rule the neural trainer already follows. - **Trainer keeps the brain scoreboard warm when idle**: on a tick with nothing to train, the trainer pokes `probeBrains` (self-throttled ≤1 round / 15 min) so the health ranking doesn't go stale on a quiet platform. - **Reasoning-token headroom in the run loop**: the agent loop's per-turn cap was a flat 1500 — reasoning models (gpt-oss / deepseek-r / qwen3, i.e. the current free tier) spend much of that budget on thinking before content and got truncated replies. The cap is now 6000 for reasoning models, 1500 otherwise (explicit `maxTokens` still wins). ### Terminal (`TerminalSection.tsx`) — seven verified defects - **Research-steps ledger lost on error**: the `search` frames accumulated only in React state inside `try`, so the catch/finalize paths wrote `researchSteps: undefined` into the saved message and the "Sources & steps" block vanished after an abort or server error mid-research. A stream-local `runSteps` array (the same pattern as `fullText`/`reasoningText`) now feeds all three finalize sites. - **Stream leak on unmount**: navigating away mid-stream left the fetch reader running into a dead component. An unmount effect now aborts via `abortRef`. - **Stop on an empty stream produced a junk bubble**: an `AbortError` with zero streamed text now quietly resets state and returns — no empty assistant message is committed. - **"Clear chat" destroyed the model/mode selection**: `clearHistory` minted a bare session, dropping `model`, `chatMode` and image-session state, so the next send fell to defaults. It now clones the active session's settings into the fresh one. - **Auto-continue & compare-models chips sent stale input**: both used a `handleSend` closure captured at render time, so they re-sent the turn's original input instead of the (possibly edited) current one. Both go through a `handleSendRef` kept current on every render (`useRef`, re-assigned after definition). - **Fullscreen (maximize portal) killed file input / auto-scroll / the composer**: the shared-chrome terminal renders in both the docked and maximized trees, so object refs were clobbered and `null`ed on portal unmount. All three refs now use live-node Set trackers (`attachX` callbacks that add connected nodes and prune disconnected ones), surviving any number of portal mounts/unmounts. - Verified already-correct (no change needed): the agent-run SSE path builds multi-provider key headers + a `providerKeys` body, and surfaces non-2xx as an error bubble. ### Agents UI (`AgentsSection.tsx`) - **Transcript died on tab switch**: `AnimatePresence mode="wait"` unmounts tab panels, so the run chat's local `turns` state was destroyed when the user peeked at another tab and returned mid-conversation. The transcript now lives in `AgentDetail` (parent) and is passed down — switching tabs preserves it. - **Detail view went stale after a run**: the agent card / memory count didn't reflect what a run just learned. The run chat's completion now refreshes the agent + runs in parallel and re-syncs the parent. - **Run history only loaded once**: opening the History tab now reloads runs on every tab entry (`useEffect` on `tab`), not just on first mount. - **Silent failures surfaced**: agent save (step 2 of create) rendered its error into a step-1-only state (invisible), and the memory/knowledge/gather operations swallowed non-2xx responses. Save now has its own `saveError` banner on step 2; every guarded operation runs through a `guarded()` wrapper that shows an `opError` banner under the tab bar. - **Multi-turn agent chat**: the run chat now sends the last 10 turns as `messages` (matching the backend fix above) so follow-up answers build on the conversation. ### Onboarding (`OnboardingView.tsx`) - **Docker-variant Google lockout (re-applied — a `git checkout` had wiped the earlier fix)**: with `GOOGLE_AUTH=false` (the Docker variant), step 3 still required a saved Google token, dead-ending docker onboarding. The gate now follows the variant: Google is **Optional** in the docker variant (any pasted token is still saved), **Required** on hosted. The button label reflects which. - **Nested interactive HTML**: the OpenRouter step-1 button rendered an `` wrapping a `