{"owner":"nesquena","repo":"hermes-webui","hasSkills":true,"totalSkillsCount":2,"totalTokensCount":12859,"categories":["project-spec"],"hasMcp":false,"mcpConfig":null,"found":["DESIGN.md","ARCHITECTURE.md"],"skills":{"DESIGN.md":"---\nversion: alpha\nname: Hermes Calm Console\ndescription: \"A restrained agent control surface: conversational content first, tool traces as quiet metadata, minimal chrome.\"\ncolors:\n  primary: \"#EAE0D5\"\n  secondary: \"#C6AC8F\"\n  tertiary: \"#C6AC8F\"\n  neutral: \"#0A0908\"\n  surface: \"#22333B\"\n  surfaceSubtle: \"#11100E\"\n  borderSubtle: \"#3B4A50\"\n  ink: \"#0A0908\"\n  success: \"#86C08B\"\n  warning: \"#E0B15D\"\n  error: \"#F87171\"\ntypography:\n  body-md:\n    fontFamily: \"Georgia, Times New Roman, serif\"\n    fontSize: 15px\n    fontWeight: 400\n    lineHeight: 1.68\n  body-sm:\n    fontFamily: \"-apple-system, BlinkMacSystemFont, Segoe UI, Inter, system-ui, sans-serif\"\n    fontSize: 12px\n    fontWeight: 400\n    lineHeight: 1.45\n  user-message:\n    fontFamily: \"-apple-system, BlinkMacSystemFont, Segoe UI, Inter, system-ui, sans-serif\"\n    fontSize: 14px\n    fontWeight: 400\n    lineHeight: 1.55\n  mono-xs:\n    fontFamily: \"SF Mono, ui-monospace, monospace\"\n    fontSize: 11px\n    fontWeight: 500\n    lineHeight: 1.55\nrounded:\n  sm: 4px\n  md: 8px\n  lg: 12px\n  pill: 999px\nspacing:\n  xs: 4px\n  sm: 8px\n  md: 12px\n  lg: 16px\ncomponents:\n  app-shell:\n    backgroundColor: \"{colors.neutral}\"\n    textColor: \"{colors.primary}\"\n    rounded: \"{rounded.sm}\"\n    padding: 16px\n  panel:\n    backgroundColor: \"{colors.surface}\"\n    textColor: \"{colors.primary}\"\n    rounded: \"{rounded.lg}\"\n    padding: 16px\n  border-line:\n    backgroundColor: \"{colors.borderSubtle}\"\n    textColor: \"{colors.primary}\"\n    rounded: \"{rounded.sm}\"\n    padding: 4px\n  state-success:\n    backgroundColor: \"{colors.success}\"\n    textColor: \"{colors.ink}\"\n    rounded: \"{rounded.sm}\"\n    padding: 4px\n  state-warning:\n    backgroundColor: \"{colors.warning}\"\n    textColor: \"{colors.ink}\"\n    rounded: \"{rounded.sm}\"\n    padding: 4px\n  state-error:\n    backgroundColor: \"{colors.error}\"\n    textColor: \"{colors.ink}\"\n    rounded: \"{rounded.sm}\"\n    padding: 4px\n  tool-call-group:\n    backgroundColor: \"{colors.neutral}\"\n    textColor: \"{colors.secondary}\"\n    rounded: \"{rounded.md}\"\n    padding: 4px\n  tool-card:\n    backgroundColor: \"{colors.surfaceSubtle}\"\n    textColor: \"{colors.secondary}\"\n    rounded: \"{rounded.md}\"\n    padding: 8px\n  user-message:\n    backgroundColor: \"{colors.tertiary}\"\n    textColor: \"{colors.ink}\"\n    rounded: \"{rounded.lg}\"\n    padding: 12px\n---\n\n## Overview\n\nHermes WebUI should feel like a calm developer console, not a demo page assembled from colorful cards. The primary artifact is the conversation. Tool calls, thinking traces, context compaction records, token usage, and runtime status are useful, but they are transcript metadata and should sit below the visual priority of user and assistant prose.\n\nThe desired direction is Linear/Vercel precision with a little Claude-style conversational warmth: quiet surfaces, clear spacing, restrained accent use, and progressive disclosure for debugging detail.\n\n## Colors\n\n- **Primary (#EAE0D5):** main text on dark surfaces. The warm parchment should feel readable and grounded, not like bright white terminal text.\n- **Secondary/Tertiary (#C6AC8F):** metadata and restrained accent. Use sparingly for active state, focus, user bubbles, and quiet emphasis.\n- **Neutral (#0A0908):** app background and ink. This gives the WebUI depth without returning to the previous navy/gold theme.\n- **Surface (#22333B):** panels, sidebar, and stronger interactive surfaces. It should carry the structure while the conversation remains primary.\n- **Light surfaces (#EAE0D5 / #F4EEE7):** light mode uses the palette's parchment as the field and a slightly lifted derived surface for panels.\n- **Semantic colors:** success/warning/error/info are state colors only, not decorative palette choices.\n\n## Typography\n\nUse Claude-like split typography: assistant prose gets an editorial serif stack (Georgia as the available substitute for Anthropic Serif), while user bubbles and functional UI stay in a crisp sans stack. This keeps the bot voice calmer and more readable without making controls feel bookish. Use monospace only for code, file paths, commands, tool names, and compact metadata. Avoid making whole cards feel like terminal output unless they actually are logs.\n\nScale should stay tight: 11px metadata, 12px labels, 14px body, 16–18px headings. Do not proliferate 10px/10.5px/12.5px one-offs unless there is a real layout constraint.\n\n## Layout\n\nConversation rhythm:\n\n1. User message — right aligned, compact bubble.\n2. Assistant content — left aligned, prose-first, no heavy bubble.\n3. Tool/thinking/context traces — quiet disclosure rows inside the assistant turn.\n4. Raw logs/details — hidden until explicitly expanded.\n\nMetadata should not break the reading flow. A turn that used ten tools should read as one assistant turn with one compact `Used 10 tools` disclosure, not ten content cards.\n\n## Elevation & Depth\n\nUse almost no shadows in the transcript. Shadows are reserved for popovers, dropdowns, modal dialogs, and floating controls. Cards inside chat should use either a subtle border or a subtle tint, not both aggressively.\n\n## Shapes\n\n- Rows/list items: `4–8px` radius.\n- Cards/panels: `8–12px` radius.\n- Pills: only true chips/badges use `999px`.\n- Avoid stacks of nested rounded rectangles. If a card contains another card, one of them is probably unnecessary.\n\n## Components\n\n### Tool/thinking activity group\n\nCollapsed by default in settled history and during live runs unless the user has explicitly opened that Activity row before. Persist open/closed disclosure state per chat and per turn, so switching away from a chat and coming back preserves the mode the user left it in. Summary line uses one disclosure for internals and stays intentionally terse, e.g. `Activity: 4 tools`. It should not repeat the always-present thinking area, list individual tool names, or add a second trailing count badge. Expanding reveals thinking and individual tool cards together. Thinking and tools should not create separate transcript rows unless there is an error or approval state that needs attention.\n\n### Tool card\n\nA tool card is a debug event row, not a chat message. Show icon, name, short target/preview, and status. Arguments and result snippets stay behind expansion. Result snippets should be truncated; full logs belong behind “show more”.\n\n### Thinking/context cards\n\nSame visual family as tool-call metadata. They should be quieter than assistant prose and should not use bright tinted full cards unless the user expands them.\n\nAutomatic compression follows a quiet live-only divider treatment rather than a\ntool-card row. Use `Compressing context` for the active barrier and\n`Context auto-compressed` after continuation/completion; render both as centered\nnon-interactive text with horizontal rules. Do not give it a caret, click\ntarget, distinct accent color, special leading dot, or separate card identity.\nOnce the final answer is settled, omit the live-only compression row unless it is\nneeded to explain a visible recovery or error state.\n\n### Composer\n\nThe composer is the command surface. Keep it legible and focused: modest radius, subtle border, transparent inactive chips, no theatrical hover scaling.\n\n## Do's and Don'ts\n\nDo:\n\n- Collapse noisy agent internals by default.\n- Use one accent color at a time.\n- Prefer neutral borders and restrained surfaces.\n- Make debug traces accessible and inspectable without making them visually dominant.\n- Add stable class/data hooks for future visual regression tests.\n\nDon't:\n\n- Render every tool call as a first-class chat card.\n- Mix gold, cyan, purple, orange, red, and green as decorative colors in the same viewport.\n- Add new hardcoded radius/color values when a token exists.\n- Use shadows, gradients, and hover transforms for routine controls.\n- Hide important error or approval states; those are allowed to be prominent because they require action.\n","ARCHITECTURE.md":"# Hermes Web UI: Developer and Architecture Guide\n\n> This document is the canonical reference for anyone (human or agent) working on the\n> Hermes Web UI. It covers the exact current state of the code, every design decision and\n> quirk discovered during development, and a phased architecture improvement roadmap that\n> runs in parallel with the feature roadmap in ROADMAP.md.\n>\n> Keep this document updated as architecture changes are made.\n\n> Current shipped build: `v0.51.792` (July 1, 2026).\n> Automated coverage: ~11,500 tests via `pytest tests/ --collect-only -q`. CI runs on\n> Python 3.11, 3.12, and 3.13 (3 parallel shards each) against every PR, plus a ruff\n> lint gate, a headless browser smoke test, and a Docker smoke test.\n>\n> Notable architecture state: the bootstrap and first-run onboarding flow own setup discovery; the default WebUI state directory is `~/.hermes/webui`; `ctl.sh` provides a daemon wrapper for homelab installs; chat streaming is still WebUI-owned SSE with stream-ownership guards, cancellation, async manual compression, and turn-journal audit plumbing; provider/model discovery is profile-aware with live-model cache invalidation and custom-provider scoping. (Version/test-count numbers above are a periodic snapshot — the authoritative source is the latest git tag and `pytest --collect-only`.)\n\n---\n\n## 1. Overview and Purpose\n\nThe Hermes Web UI is a lightweight web application that gives you a browser-based\ninterface to the Hermes agent that is functionally equivalent to the CLI. It is modeled on\nthe Claude-style interface: a sidebar for session management, a central chat area,\nand a demand-driven right panel used for workspace browsing and preview surfaces.\nThe right panel is closed by default on desktop and opens only when it is actively\nbeing used for browsing or previewing content.\n\nTo prevent a visible first-paint mismatch on refresh, `static/index.html` preloads the\nsaved workspace panel state into `document.documentElement.dataset.workspacePanel`\nbefore the main stylesheet loads. Desktop CSS honors that preload marker immediately,\nand `static/boot.js` keeps the dataset synchronized with the runtime panel state machine.\n\nThe design philosophy is deliberately minimal. There is no build step, no bundler, no\nfrontend framework. The Python server is split into a routing shell (server.py) and\nbusiness logic modules (api/). The frontend is seven vanilla JS modules loaded from static/.\nThis makes the code easy to modify from a terminal or by an agent.\n\nHermes-level chrome is intentionally consolidated: the sidebar has no dedicated brand header.\nInstead, the footer exposes a single \"Hermes WebUI\" launch button that opens one tabbed\ncontrol-center modal for global preferences, conversation import/export, and clear-conversation\nactions. The topbar remains focused on conversation context and the workspace/files toggle.\n\n---\n\n## 2. File Inventory\n\n    <repo>/\n    server.py              Thin routing shell + HTTP Handler + auth middleware.\n                           Delegates all route handling to api/routes.py.\n    bootstrap.py           One-shot launcher: optional agent install, deps, health wait, browser open.\n    start.sh               Thin wrapper around bootstrap.py for shell-based startup.\n    ctl.sh                 Daemon lifecycle wrapper (start/stop/restart/status/logs) for homelab installs.\n    pyproject.toml         Standard build metadata plus the Ruff lint gate; source-checkout launch surface still centers on bootstrap.py / start.sh / ctl.sh.\n    Dockerfile             python:3.12-slim container image\n    docker-compose.yml     Compose config with named volume and optional auth\n    .dockerignore          Excludes .git, tests/, .env* from Docker builds\n    api/\n      __init__.py          Package marker\n      agent_compat.py      Resolver for Hermes Agent names moved to sibling modules (compatibility-only)\n      auth.py              Optional password authentication, signed cookies, passkeys/WebAuthn\n      config.py            Discovery, globals, model detection, reloadable config\n      helpers.py           HTTP helpers: j(), bad(), require(), safe_resolve(), security headers\n      goals.py             Persistent-goal commands and profile-scoped native GoalManager bridge\n      models.py            Session model + CRUD, per-session profile tracking, CLI/state.db bridge\n      profiles.py          Profile state management, hermes_cli wrapper\n      onboarding.py        First-run onboarding status, real provider config writes, OAuth linking, readiness detection\n      routes.py            All GET + POST route handlers (if/elif dispatch, no decorators)\n      startup.py           Startup helpers: auto_install_agent_deps()\n      state_sync.py        state.db bridge — opt-in /insights usage/title sync; always mirrors the session workspace into sessions.cwd\n      streaming.py         SSE engine, run_agent, cancel, compression, HERMES_HOME save/restore\n      updates.py           Self-update check and release notes\n      upload.py            Multipart parser, file upload handler\n      workspace.py         File ops: list_dir, read_file_content, git detection, workspace helpers\n    static/\n      index.html           HTML template\n      style.css            All CSS incl. mobile responsive, themes + skins, KaTeX\n      ui.js                DOM helpers, renderMd, tool cards, context indicator, file tree\n      workspace.js         File preview, file ops, git badge, central api() fetch wrapper\n      sessions.js          Session CRUD, list rendering, collapsible groups, search, SSE sync\n      messages.js          send(), SSE event handlers, approval/clarify, transcript, recovery\n      panels.js            Cron, skills, memory, profiles, todo, settings (Control Center)\n      commands.js          Slash command registry, parser, autocomplete dropdown\n      boot.js              Event wiring, mobile nav, voice input, theme/skin boot, bfcache handler\n      onboarding.js        First-run wizard overlay, provider setup flow\n      i18n.js              Localization catalog (en, es, de, zh, zh-Hant, ru, …)\n      login.js             Login page + open-redirect guard\n      icons.js             Lucide icon path registry\n      sw.js                Service worker: offline shell cache, version-pinned assets\n    tests/\n      conftest.py          Isolated test server/state fixtures\n      ~1,150 test files    ~11,500 tests collected via pytest (run `pytest --collect-only -q` for exact)\n      test_regressions.py  Permanent regression gate\n    CONTRIBUTING.md        Contributor workflow and PR expectations.\n    ROADMAP.md             Feature and product roadmap document.\n    SPRINTS.md             Forward sprint plan with CLI + Claude parity targets.\n    ARCHITECTURE.md        THIS FILE.\n    TESTING.md             Manual browser test plan and automated coverage reference.\n    CHANGELOG.md           Release notes per version.\n    CONTRIBUTORS.md        Community credit roll (regenerated via the maintainer workspace script).\n    requirements.txt       Python dependencies.\n    .env.example           Sample environment variable overrides.\n\n> Per-file line counts intentionally omitted — they drift every release. Use\n> `git ls-files | xargs wc -l` (or your editor) for current sizes; the role of\n> each file above is the durable part.\n\nState directory (runtime data, separate from source):\n\n    ~/.hermes/webui/\n    sessions/          One JSON file per session: {session_id}.json\n    workspaces.json    Registered workspaces list\n    last_workspace.txt Last-used workspace path\n    settings.json      User settings (default model, workspace, send key, password hash)\n    projects.json      Session project groups (name, color, id)\n\nLog file:\n\n    ~/.hermes/webui/bootstrap-8787.log   start.sh/bootstrap background server log\n    ~/.hermes/webui.log                  ctl.sh daemon log\n\n---\n\n## 3. Runtime Environment\n\n### Context replay matching\n\n`api/streaming.py::_strip_replayed_context_items` filters replayed model-context\nblocks using longest-match statistics from a suffix automaton over reversed\nreplay keys. Its greedy walk still removes only blocks of at least three rows;\nshort repeats, row order, and provider-facing `api_content` identity are preserved.\nLong arc-summary identity is normalized once per row and indexed by role, provider\npayload and the existing 1,500-character prefix rule. Malformed unhashable roles\nretain an equality-scan fallback. For ordinary hashable roles/keys, matching uses\nO(existing + candidate) key operations and auxiliary space, in addition to payload\nnormalization. All indexes are call-local; no session cache, durable schema, writer\nownership, or SSE contract changes. Inputs are not mutated and retained rows are\nreturned unchanged. All three context-reconciliation branches share this helper.\n\n- Python interpreter: <agent-dir>/venv/bin/python\n- The venv has all Hermes agent dependencies (run_agent, tools/*, cron/*)\n- Server binds to 127.0.0.1:8787 (localhost only, not public internet)\n- Access from Mac: SSH tunnel: ssh -N -L 8787:127.0.0.1:8787 <user>@<your-server>\n- The server imports Hermes modules via sys.path.insert(0, parent_dir)\n\nSuffix/prefix replay matching uses a linear prefix-function scan and constructs\neach comparison key once, preserving the existing replay equality contract.\nRead-side stale-stream maintenance defers when the session writer lock is busy;\nit leaves stream ownership and pending input intact for a later repair pass.\n\nEnvironment variables controlling behavior:\n\n    HERMES_WEBUI_HOST              Bind address (default: 127.0.0.1)\n    HERMES_WEBUI_PORT              Port (default: 8787)\n    HERMES_WEBUI_DEFAULT_WORKSPACE Default workspace path for new sessions\n    HERMES_WEBUI_STATE_DIR         Where sessions/ folder lives\n    HERMES_CONFIG_PATH             Path to ~/.hermes/config.yaml\n    HERMES_WEBUI_DEFAULT_MODEL     Optional model override; unset means provider default\n    HERMES_WEBUI_PASSWORD          Optional: enable password auth (off by default)\n    HERMES_WEBUI_SKIP_ONBOARDING   Optional: bypass the first-run onboarding wizard\n    HERMES_PREFILL_MESSAGES_FILE   Optional JSON message list for browser-turn prefill context\n    HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT Optional command that prints JSON messages or plain-text user prefill context\n    HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT Optional script timeout in seconds (default 5, max 30)\n    HERMES_WEBUI_PREFILL_CONTEXT_MAX_CHARS Optional parsed prefill budget in characters (default 12000, 0 disables)\n    HERMES_HOME                    Base directory for Hermes state (~/.hermes by default)\n\nTest isolation environment variables (set by conftest.py):\n\n    HERMES_WEBUI_TEST_PORT=...                         Optional pinned test port\n    HERMES_WEBUI_TEST_STATE_DIR=/tmp/hermes-webui-tests/* Optional pinned test state (default: OS temp dir; must be outside ~/.hermes)\n    HERMES_WEBUI_DEFAULT_WORKSPACE=.../test-workspace  Isolated test workspace\n\nTests NEVER talk to the production server (port 8787).\nThe test state dir is wiped before each test session and deleted after.\nSee: <repo>/tests/conftest.py\n\nPer-request environment variables (set by chat handler, restored after):\n\n    TERMINAL_CWD         Set to session.workspace before running agent.\n                         The terminal tool reads this to default cwd.\n    HERMES_EXEC_ASK      Set to \"1\" to enable approval gate for dangerous commands.\n    HERMES_SESSION_KEY   Set to session_id. The approval tool keys pending entries\n                         by this value, enabling per-session approval state.\n    HERMES_HOME          Set to the active profile's directory before running agent.\n                         Saved and restored around each agent run.\n\nWARNING: These env vars are process-global. Two concurrent chat requests will clobber\neach other. This is safe only for single-user, single-concurrent-request use.\nSee Architecture Phase B for the fix.\n\n---\n\n## 4. Server Architecture: Current State\n\n### 4.1 HTTP Server Layer\n\nPython stdlib ThreadingHTTPServer (from http.server). Each HTTP request runs in its own\nthread. The Handler class subclasses BaseHTTPRequestHandler with two methods:\n\n    do_GET    Routes: /, /health, /api/session, /api/sessions, /api/list,\n                      /api/chat/stream, /api/file, /api/approval/pending,\n                      /api/session/worktree/status\n    do_POST   Routes: /api/upload, /api/session/new, /api/session/update,\n                      /api/session/delete, /api/chat/start, /api/chat,\n                      /api/approval/respond, /api/session/worktree/remove\n\nRouting is a flat if/elif chain inside each method. No routing framework.\n\nHelper functions used by all handlers:\n\n    j(handler, payload, status=200)     Sends JSON response with correct headers\n    t(handler, payload, status=200, ct) Sends plain text or HTML response\n    read_body(handler)                  Reads and JSON-parses the POST body\n\nCRITICAL ORDERING RULE in do_POST:\nThe /api/upload check MUST appear BEFORE calling read_body(). read_body() calls\nhandler.rfile.read() which consumes the HTTP body stream. The upload handler also\nneeds rfile (to read the multipart payload). If read_body() runs first on a multipart\nrequest, the upload handler receives an empty body and the upload silently fails.\n\n### 4.2 Session Model\n\nSession is a plain Python class (not a dataclass, not SQLAlchemy):\n\n    Fields:\n      session_id    hex string, 12 chars (uuid4().hex[:12])\n      title         string, auto-set from first user message\n      workspace     absolute path string, resolved at creation\n      model         model ID string (e.g. \"anthropic/claude-sonnet-4.6\")\n      messages      list of OpenAI-format message dicts\n      created_at    float Unix timestamp\n      updated_at    float Unix timestamp, updated on every save()\n      pinned        bool, default False (Sprint 12)\n      archived      bool, default False (Sprint 14)\n      project_id    string or null, FK to projects.json (Sprint 15)\n      tool_calls    list of tool call dicts (Sprint 10)\n\n    Key methods:\n      path (property)  Returns SESSION_DIR/{session_id}.json\n      save()           Writes __dict__ as pretty JSON to path, updates updated_at\n      load(cls, sid)   Class method: reads JSON from disk, returns Session or None\n      compact()        Returns metadata-only dict (no messages) for the session list\n\n    In-memory cache:\n      SESSIONS = {}    dict: session_id -> Session object\n      LOCK = threading.Lock()   defined but NOT currently used around SESSIONS access\n\n    get_session(sid): checks SESSIONS cache, loads from disk on miss, raises KeyError\n    new_session(workspace, model): creates Session, caches in SESSIONS, saves, returns\n    all_sessions(): scans SESSION_DIR/*.json + SESSIONS, deduplicates, sorts by updated_at,\n                    returns list of compact() dicts\n\n    all_sessions() does a full directory scan on every call.\n    With 10 sessions: negligible. With 1000+: will be slow.\n    See Architecture Phase C for the index file fix.\n\ntitle_from(): takes messages list, finds first user message, returns first 64 chars.\nCalled after run_conversation() completes to set the session title retroactively.\n\n#### Session transcript reconciliation with `state.db`\n\n`reconciled_state_db_messages_for_session()` uses\n`merge_session_messages_append_only()` to combine a WebUI sidecar or context\nprojection with active Agent `state.db` rows. Its explicit\n`incoming_provenance=\"state_db\"` fence permits a state-only row whose timestamp\npredates the sidecar tail to use a safe chronological slot. Paginated\n`msg_limit` consumers rely on this merged order directly rather than applying a\nlater timestamp sort.\n\nThe same merge helper also stitches ordered child sidecars onto archived\ncompression parents. Those calls leave incoming provenance unverified, so their\nstable message sequence remains append-only even when parent rows carry later,\nrestamped timestamps. Timestamp alone is never authority to move a continuation\ninside its parent transcript.\n\nIf an older row could only be placed before the first surviving sidecar/context\nrow, the insertion helper declines it to avoid resurrecting compacted history.\nReconciliation then appends that row instead of dropping it; rows without a\nusable timestamp and rows at or after the sidecar tail also append normally.\nThe fallback therefore preserves an accepted state-only row when exact ordering\nis ambiguous, while safely placeable recovery rows remain chronological.\n\n#### Imported `state.db` sidebar projection\n\n`api.models.get_cli_sessions()` projects conversations from the active Hermes\nprofile's `state.db` into sidebar-shaped rows. The default projection keeps\ninteractive sources (CLI, TUI, ACP, messaging, and similar user-facing sessions)\nin a bounded 20-row candidate window. Background sources use independent recovery\npasses so a high-volume worker source cannot consume that interactive window:\n\n- Cron: up to `CRON_PROJECT_CHIP_LIMIT` rows.\n- Webhook: up to `WEBHOOK_PROJECT_CHIP_LIMIT` rows.\n- Kanban: up to `KANBAN_PROJECT_CHIP_LIMIT` rows.\n- One-shot (`hermes -z`): kept out of the interactive window and its\n  unassigned refill, with no default pass of its own. They keep normal project\n  chips, so the assigned recovery pass still reaches one filed under a project.\n  An explicit `source_filter=\"oneshot\"` lists the newest ones, and a session\n  the user opens by id resolves through `get_cli_session_by_id()`.\n\nSource-specific views still use their dedicated bounds, and the later sidebar\nvisibility stage decides whether recovered background rows are shown. In\n`all_profiles=True` mode the per-profile source bounds are disabled before rows\nare merged; cross-profile scoping, visibility, deduplication, and final route\nlimits remain downstream responsibilities.\n\n#### Compression lineage and session-list invalidation\n\n`api.agent_sessions._is_continuation_session()` is the shared classifier for\nsidebar projection, lineage metadata/reporting, and `state.db` transcript\nstitching. It uses the direct parent link, no conflicting non-empty source,\n`compression` or `cli_close` parent end reason, and the existing two-second\n`started_at` overlap allowance. A `source=\"tool\"` child is always a separate\nconversation, even when its parent is also a tool session or has no source.\nA direct `_branched_from`, `_delegate_from`, or `_reset_from` marker in the\nchild's `model_config` also makes a boundary; inherited ancestor markers do\nnot. Malformed or unverifiable marker evidence fails closed as a boundary.\nThe overlap allowance is the existing master policy, not a new window set by\nthis change.\n\nThe gateway watcher's cheap database fingerprint includes `model_config`, so\na marker-only update causes a fresh projection. Its published-payload hash\ncovers every emitted session field, not just ID, activity time and message\ncount; a changed projected title or lineage field can therefore emit\n`sessions_changed` even without message-row churn.\n\n### 4.3 SSE Streaming Engine\n\nThis is the most architecturally interesting part. Two endpoints cooperate:\n\n    POST /api/chat/start     Receives the user message. Creates a queue.Queue, stores it\n                             in STREAMS[stream_id], spawns a daemon thread running\n                             _run_agent_streaming(), returns {stream_id} immediately.\n\n    GET  /api/chat/stream    Long-lived SSE connection. Reads from STREAMS[stream_id]\n                             and forwards events to the browser until 'done' or 'error'.\n\nQueue registry:\n\n    STREAMS = {}               dict: stream_id -> queue.Queue\n    STREAMS_LOCK = threading.Lock()\n\nSSE event types and their data shapes:\n\n    token       {\"text\": \"...\"}                         LLM token delta\n    tool        {\"name\": \"...\", \"preview\": \"...\"}       Tool invocation started\n    approval    {\"command\": \"...\", \"description\": \"...\", \"pattern_keys\": [...]}\n    done        {\"session\": {compact_fields + messages}} Agent finished successfully\n    error       {\"message\": \"...\", \"trace\": \"...\"}       Agent threw exception\n\nThe SSE handler loop:\n    - Blocks on queue.get(timeout=30)\n    - On timeout (no events in 30s): sends a heartbeat comment (\": heartbeat\n\n\")\n      to keep the connection alive through proxies and firewalls\n    - On 'done' or 'error' event: breaks the loop and returns\n    - Catches BrokenPipeError and ConnectionResetError silently (browser disconnected)\n\nStream cleanup: _run_agent_streaming() pops its stream_id from STREAMS in a finally\nblock. If the browser disconnects mid-stream, the daemon thread runs to completion and\nthen cleans up. The queue fills and the put_nowait() calls fail silently (queue.Full\nis caught).\n\nFallback sync endpoint: POST /api/chat still exists and holds the connection open until\nthe agent finishes. The frontend never uses it but it can be useful for debugging.\n\n### 4.4 Agent Invocation (_run_agent_streaming)\n\n    def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id):\n\n1. Fetches session from SESSIONS (not from disk -- session was just updated by /api/chat/start)\n2. Sets TERMINAL_CWD, HERMES_EXEC_ASK, HERMES_SESSION_KEY env vars\n3. Creates AIAgent with:\n   - model=model, platform='cli', quiet_mode=True\n   - enabled_toolsets=CLI_TOOLSETS (from config.yaml or hardcoded default)\n   - session_id=session_id\n   - stream_delta_callback=on_token (fires per token)\n   - tool_progress_callback=on_tool (fires per tool invocation)\n4. Calls agent.run_conversation(user_message=msg_text, conversation_history=s.messages,\n                                 task_id=session_id)\n   NOTE: keyword is task_id NOT session_id (common mistake, documented in skill)\n5. On return: updates s.messages, calls title_from(), saves session\n6. Puts ('done', {session: ...}) into queue\n7. Finally block: restores env vars, pops stream_id from STREAMS\n\non_token callback:\n    if text is None: return  # end-of-stream sentinel from AIAgent\n    put('token', {'text': text})\n\non_tool callback:\n    put('tool', {'name': name, 'preview': preview})\n    # Also immediately surface any pending approval:\n    if has_pending(session_id):\n        with _lock: p = dict(_pending.get(session_id, {}))\n        if p: put('approval', p)\n\nThe approval surface-on-tool logic means approvals appear immediately after the tool\nfires (within the same SSE stream), without waiting for the next poll cycle.\n\n### 4.5 Approval System Integration\n\nThe approval system uses the existing Hermes gateway module at tools/approval.py.\nAll state lives in module-level variables in that file:\n\n    _pending = {}        dict: session_key -> pending_entry_dict\n    _lock = Lock()       protects _pending\n    _permanent_approved  set of permanently approved pattern keys\n\nBecause server.py imports tools.approval at module load time and everything runs in the\nsame process, this state IS shared between HTTP threads and agent daemon threads.\n\nImportant: this only works because Python imports are cached (sys.modules). The same\nmodule object is used everywhere. If the approval module were ever imported in a subprocess\nor via importlib.reload(), this would break.\n\nGET /api/approval/pending:\n    - Peeks at _pending[sid] without removing it\n    - Returns {pending: entry} or {pending: null}\n    - Called by the browser every 1500ms while S.busy is true (polling fallback)\n\nPOST /api/approval/respond:\n    - Pops _pending[sid] (removes it)\n    - For choice \"once\" or \"session\": calls approve_session(sid, pattern_key) for each key\n    - For choice \"always\": calls approve_session + approve_permanent + save_permanent_allowlist\n    - For choice \"deny\": just pops, does nothing (agent gets denied result)\n    - Returns {ok: true, choice: choice}\n\n### 4.6 File Upload Parser\n\nparse_multipart(rfile, content_type, content_length):\n    - Reads all content_length bytes from rfile into memory (up to MAX_UPLOAD_BYTES, default 20MB, env-overridable via HERMES_WEBUI_MAX_UPLOAD_MB)\n    - Extracts boundary from Content-Type header\n    - Splits raw bytes on b'--' + boundary\n    - For each part: parses MIME headers via email.parser.HeaderParser\n    - Returns (fields, files) where fields is {name: value} and files is {name: (filename, bytes)}\n\nhandle_upload(handler):\n    - Calls parse_multipart()\n    - Validates: file field present, filename present, session exists\n    - Sanitizes filename: replaces non-word chars with _, truncates to 200 chars\n    - Writes bytes to session.workspace / safe_name\n    - Returns {filename, path, size}\n\nWhy not cgi.FieldStorage:\n    - Deprecated in Python 3.11+\n    - Broken for binary files (silently corrupts or throws)\n    - The manual parser handles all file types correctly\n\n### 4.7 File System Operations\n\nsafe_resolve(root, requested):\n    - Resolves requested path relative to root\n    - Calls .relative_to(root) to assert the result is inside root\n    - Raises ValueError on path traversal (../../etc/passwd)\n\nlist_dir(workspace, rel='.'):\n    - Calls safe_resolve, then iterdir()\n    - Sorts: directories first, then files, case-insensitive alpha within each group\n    - Returns up to 200 entries with {name, path, type, size}\n\nread_file_content(workspace, rel):\n    - Calls safe_resolve\n    - Enforces MAX_FILE_BYTES = 200KB size limit\n    - Reads as UTF-8 with errors='replace' (binary files show replacement chars)\n    - Returns {path, content, size, lines}\n\n### 4.8 Persistent Goal Profile Boundary\n\n`api/goals.py` exposes the WebUI `/goal` command payloads and post-turn evaluation hook.\nHermes Agent's native `GoalManager` is the authoritative owner of goal evaluation,\ncontinuation decisions, wait barriers, failure counters, contracts, subgoals, and\n`state.db` persistence.\n\nFor a profile-scoped WebUI session, the bridge delegates only when the Agent exposes\nboth the context-local `set_hermes_home_override()` API and call-time default\n`SessionDB` path resolution. The bridge probes the resolved default path under the\nselected context before constructing the native manager, then binds that profile's\nHermes home before every native call. The override is reset in a `finally` block after\nevery operation, so concurrent sessions using the same session ID under different\nprofiles cannot cross-read or cross-write goal state. Goal snapshot rollback uses the\nsame scoped native persistence path.\n\nOlder Hermes Agent versions that lack either capability continue through\n`_LegacyProfileGoalManager`, which pins persistence to the selected profile's explicit\n`state.db` path. This includes intermediate versions whose context API is present but\nwhose default `SessionDB()` path remains frozen at module import. Keep this fallback\ncompatibility-only: new goal semantics belong in Hermes Agent's native manager rather\nthan a second WebUI implementation.\n\n\n### 4.9 Hermes Agent Moved-Name Compatibility\n\nHermes Agent owns its module layout. Its September 2026 decomposition moved names the\nWebUI uses (for example `tools.approval.set_current_session_key` to\n`tools.approval_context`) into `<stem>_<topic>` sibling modules. The old paths resolve\nonly through temporary PLUGIN-COMPAT `__getattr__` pointers that emit\n`HermesPluginCompatWarning` and are removed on schedule. The Agent's\n`compat_manifest.json` is the authoritative map of what moved where.\n\nWebUI code reaches a moved name only through\n`api.agent_compat.agent_attr(owner, name, home, default=...)`, which resolves in this\norder:\n\n1. the owner module's own namespace: pre-split Agents, and tests that stub the original\n   module in `sys.modules` or patch the name onto it;\n2. the `home` module: split Agents, with or without the old-path pointer (no warning);\n3. plain attribute access on the owner: non-module test doubles.\n\nIt raises like the import it replaces (or returns `default`), so each call site keeps its\nexisting fallback. Current users: approval session identity and MCP discovery\n(`streaming.py`), `/reload-mcp` (`commands.py`), MCP runtime status (`routes.py`), Claude\nCode credential linking (`oauth.py`), LM Studio reasoning options (`config.py`), and\nkanban connections and dispatch (`kanban_bridge.py`).\n\nImport names that are still native to their module directly. Never\n`from <old module> import <moved name>`, and never feature-detect a moved name with\n`hasattr`/`getattr` on the old module: once the pointers are removed those silently turn\n\"moved\" into \"missing\" behind the call sites' broad `except` blocks. When a later Agent\nsplit moves another name, route it through `agent_attr` and add pre-split and\npointer-removed cases to `tests/test_agent_compat.py`. The resolver is\ncompatibility-only: delete it, and import directly from the new homes, once the WebUI\nstops supporting Agents that predate the split.\n\n### 4.10 MCP Runtime Profile Boundary\n\nHermes Agent owns the in-process MCP ledger. It keys a connection by\n`(profile_home_key, name)` and registers its tools in that profile's registry overlay\nonly when the calling task serves a *routed* profile: the context-local Hermes-home\noverride differs from the process home. Otherwise the connection uses the bare server\nname and the global registry slot, which belong to the process profile.\n\n- `api.profiles._set_hermes_home()` is the single writer of the process-profile home:\n  startup (`init_profile_state()`) and `switch_profile(process_wide=True)` both go\n  through it, so `get_process_profile_home()` and the Agent pin\n  (`hermes_constants.pin_process_hermes_home()`, when the Agent provides it) are updated\n  in the same step as `HERMES_HOME`. Streaming turns still mirror their profile into\n  `os.environ['HERMES_HOME']` for legacy readers; without the pin, that mirror makes a\n  turn's own profile look like the process profile, so same-named servers of different\n  profiles share one bare-name connection.\n- `/api/mcp/servers`, `/api/mcp/tools`, `/api/notes/sources` and `/reload-mcp` run\n  their Agent calls inside `api.mcp_runtime.mcp_runtime_scope()`, which binds the\n  request profile's home and secret scope (root profile included) without touching\n  `os.environ`. Status, tool count and inventory are read from one scope; the inventory\n  lists only tools in that profile's own registry slot for servers it configures.\n- A connection *serves* a profile when that profile owns it (`_server_scope_keys`, else\n  the scope in the key) or adopted it (`_server_tool_scopes`, an identical shared\n  connection). `api.mcp_runtime.ledger_key_serves_view()` is the one predicate for\n  status, inventory and the reload summary. The Agent's launch-profile view (scope\n  `None`) is process-wide, so `filter_runtime_status_to_view()` downgrades\n  `get_mcp_status()` rows of servers the root profile does not serve to `configured`\n  rather than showing a routed profile's connection, tool count or connect error.\n- The scope is trusted only when the Agent's routing decision matches the profile WebUI\n  resolved. When it cannot be confirmed (for example a same-profile turn's mirror on an\n  Agent without the pin), status and inventory withhold runtime data\n  (`runtime_scope: \"unavailable\"`, shown as a notice in the MCP panel) and\n  `/reload-mcp` refuses instead of resetting another owner's connection.\n- `/reload-mcp` calls `shutdown_mcp_servers(scope=..., names=...)` with the profile's\n  own scope and the names of its live connections; `scope=None` without `names` is the\n  process-wide wildcard and is used only with Agents that predate profile-scoped MCP\n  (`runtime_scope: \"legacy_process\"`). A scoped shutdown only clears connect backoff for\n  the live keys it tears down, so WebUI also drops the profile's own cooldown/error\n  entries (`clear_profile_connect_cooldowns()`) before rediscovery: a server that failed\n  to spawn is retried by the reload, as the wildcard did, without touching another\n  owner's backoff.\n\nStatus and inventory stay passive: they never start or probe an MCP server. Ledger key\nhelpers resolve to Hermes Agent's `tools.mcp_tool_scope` when present so the key shape\nhas one owner; the local fallbacks only cover Agents that predate that module.\n\n#### MCP configuration writes\n\nMCP create/update, toggle and delete are separate from Agent runtime ownership.\nEach edit resolves the existing `_get_config_path()` once under `_cfg_lock`,\nreads a private raw YAML mapping, and commits to that same path with the existing\natomic writer. `HERMES_CONFIG_PATH` keeps its documented precedence; this does\nnot redirect operator-configured files or change profile resolution.\n\nRaw `${ENV_VAR}` references, unrelated configuration and masked credentials keep\ntheir original stored values. YAML-aliased containers and the selected server\nentry are detached before mutation so an edit cannot change a sibling or template. Runtime-expanded cache dictionaries are never the\nwrite source. An unreadable, malformed or non-mapping existing document aborts\nwithout overwriting it; a missing/empty document can be initialized. Failed disk\nwrites do not publish uncommitted mutations into the runtime cache. Reloading\nthat cache and sending HTTP responses happen after the lock is released.\n\nThis transaction serializes cooperating writes/reloads in one WebUI process.\nIt does not lock out other processes or provide Agent status/schema/dispatch\nisolation; those remain the runtime boundary described above.\n\n### 4.11 Model Catalog Rebuild Budget and Probe Scheduling\n\nThe model picker's catalog (`GET /api/models`) is produced by one cold rebuild that\nlive-probes providers. A probe is a network call to a provider the WebUI may not be able\nto reach — a LAN LM Studio/Ollama host is the usual case, because the probe runs in the\nserver process rather than the browser — so the rebuild runs on a daemon worker under a\nwall-clock budget:\n\n| Knob | Default | Meaning |\n| --- | --- | --- |\n| `HERMES_WEBUI_MODELS_REBUILD_BUDGET` | `4` (seconds) | Window a foreground caller waits for a cold rebuild. `0` restores the legacy synchronous unbounded rebuild. |\n| `CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS` | `5.0` | Per-endpoint cap for a custom provider `/v1/models` probe. |\n| `CUSTOM_MODELS_ATTEMPT_ONLY_TIMEOUT_SECONDS` | `0.01` | Attempt-only timeout for a probe reached after the window is already spent while the caller is still waiting. Not a per-probe minimum and not a slice — see below. |\n\nWithin the window the rebuild behaves normally; past it the caller is served a fallback\n(last-known disk cache, else a network-free static catalog) while the worker keeps going so\nthe refresh lands out-of-band for the next caller.\n\nCustom endpoints are probed **serially**: the active `model.base_url` first, then each\nnamed `custom_providers` entry in config order, then the LM Studio provider-group\nfallback. `_CustomProbeSchedule` in `api/config.py` hands each of those probes a fair\nslice of the *remaining* window instead of letting one probe take the whole per-endpoint\ncap, so an unreachable endpoint cannot leave the reachable providers behind it with no\nin-band probe at all (#7481).\n\n- Each slice is `min(cap, left / (remaining + 0.1))`, with **no lower bound**. The fractional\n  slot reserves headroom without halving a lone target's window. Unused time is lent to\n  later probes; the matching active/LM Studio target does not reserve a duplicate slot.\n  Wall-clock overhead can still cross the foreground deadline. A floor would make\n  the chain's cumulative spend grow with the endpoint count, and `custom_providers` has no\n  count limit, so a long enough chain of dead endpoints could again outspend the window and\n  push the reachable providers behind it out of the in-band rebuild.\n- The window belongs to the **caller, not to the chain**. One absolute deadline is captured\n  before the worker and the foreground wait start and is handed to `_CustomProbeSchedule`;\n  the foreground waits only `max(0, deadline - now)`. A schedule that minted its own deadline\n  at construction — deep inside the worker, after provider detection, the live id lookups and\n  the profile rebind — charged that discovery work to the caller's wait *and* granted it to\n  the chain again, so the chain could still be probing after the caller had already been\n  served the over-budget fallback, and the reachable provider landed out-of-band at best.\n- The two states that keep the unthrottled per-endpoint cap are stated explicitly rather\n  than inferred from the clock, because a spent window says nothing about whether anyone is\n  still waiting:\n  - the legacy unbounded path (budget `0`), which has no window to share;\n  - the out-of-band continuation — the worker still probing after the foreground caller\n    gave up and served its fallback. It is recognised through an explicit signal (the\n    caller sets the event when it stops waiting) and is the only in-process state allowed\n    to spend past the window. The narrow race where a probe finds the window spent while\n    the caller *is* still waiting is not that state: it gets the attempt-only timeout\n    above, never the cap.\n- A slice can truncate healthy endpoints even in short chains. Such timeouts are not\n  reported as unreachable and the partial catalog is never cached in memory or on disk.\n  The same worker retries truncated targets once at the full endpoint cap after the\n  foreground returns, retaining successful probes and publishing the complete catalog\n  through the existing generation, source-identity, ownership and durable fences.\n- An endpoint is probed **once per rebuild, except for that full-cap retry**. The three live-probe consumers\n  (active `model.base_url`, named `custom_providers`, LM Studio provider-group fallback)\n  share a per-rebuild memo keyed by the complete request URL (path slashes and query\n  preserved) plus the Authorization header sent, so the\n  common config where the active endpoint and `providers.lmstudio.base_url` are the same\n  LAN host no longer pays that host's connect timeout twice, and two named entries on one\n  endpoint probe it once. A different URL — or the same URL with a different key, which can\n  change the outcome — is still its own probe, and the memo is consulted only after the\n  per-endpoint SSRF/authentication checks, so a call that would have been refused still is.\n- Probe order, per-endpoint SSRF and authentication rules, and the per-endpoint cap are\n  preserved.\n\nPublication is ordered by **generation, not by wall clock**. Each cold rebuild takes the\nnext sequence number (`_allocate_models_rebuild_seq`), and a result is dropped when it is\nolder than the newest *allocated* generation (`_models_rebuild_seq`) — in which case it also\nleaves the build flag alone rather than clearing it, because that flag now belongs to the\nnewer rebuild. This matters for the out-of-band publisher, which outlives the\nforeground caller: without the guard it could resurrect a superseded\ncatalog over a newer one. A timestamp comparison cannot express the ordering, because an\nolder build can publish *after* a newer build has already started.\n\nThe fence is the latest **allocated** generation rather than the latest *published* one\nbecause `invalidate_models_cache()` clears `_cache_build_in_progress` without cancelling an\nin-flight worker: a newer rebuild can be allocated while an older one is still running. If\nthe older worker finishes first, a published-sequence comparison would let it publish the\ninvalidated catalog and release the flag that now belongs to the newer rebuild. Comparing\nagainst the allocated generation rejects it, so invalidation is a real freshness boundary,\nand a newer rebuild that then fails leaves the cache empty instead of resurrecting the\ninvalidated catalog.\n\nBoth `invalidate_models_cache()` and the provider-scoped invalidator used by\n`/api/models/refresh` share one epoch/owner reset. They advance the allocated generation,\nclear memory and provenance, evict the relevant credential pools before admission reopens,\nretire the in-flight owner, and delete the durable cache.\nEvery build already running becomes superseded *even when no successor rebuild is ever\nallocated*; otherwise a delayed worker could repopulate the cleared catalog under a\nfresh-looking fingerprint. Disk snapshots read before taking the catalog lock (including\nthe session-visit warmer) are published only if their captured epoch still matches: a\npreloaded fresh candidate whose epoch moved is dropped rather than published under the\ncurrent fingerprint. The *degraded* stale fallback is deliberately not fenced — it is\nreturned to a caller that already stopped waiting and is never published to memory, so\ndiscarding it would only swap a stale-but-real answer for a static catalog.\n\nA build's **identity is captured when it starts and re-validated when it publishes**, in\nmemory and at the durable commit: the source fingerprint (`_models_cache_source_fingerprint`\n— config, auth store and provider catalog; its `config_yaml` axis is the profile-specific\nconfig path, so it fences a foreign profile too) plus the profile name. A build that outlives\na config edit describes sources that no longer exist, so its result is dropped instead of\nbeing published under the *current* fingerprint. The check fails closed — an identity that\ncannot be read is not a match — and disk publication records the fingerprint the build READ,\nnever a recomputed one.\n\nOwnership of the single-flight slot is explicit too: the completion path clears\n`_cache_build_in_progress` only when the completing build is still the newest allocated\ngeneration, including on the error paths and after the disk I/O. A superseded publisher\ntherefore cannot admit a third rebuild beside the newer one it lost to.\n\nThe **durable commit is fenced separately from the in-memory one**, because the file write\nruns with the catalog lock released (a disk write must not hold it). Each build writes its\nown temp file — unique per build and thread, in the destination directory so the commit stays\na same-filesystem atomic rename — and the rename is serialized by\n`_models_cache_disk_commit_lock` after re-checking the accepted generation, the last\ncommitted generation (`_models_disk_committed_seq`) and the build's source identity.\nThe catalog lock remains held from that check **through** rename; invalidation takes the\nsame fixed order (disk-commit lock, then catalog lock) through deletion, so an admitted\nwriter cannot restore a file after invalidation returns. A superseded or re-sourced\ncommit discards its temp file and leaves the durable catalog to the build that won.\n\n**The two locks have one order everywhere: disk-commit then catalog.** The writer and the\ninvalidator both take them that way, so a caller that takes them in the opposite order is a\ndeadlock, not a latency bug — and both acquisitions are unbounded in production. A foreground\npublisher used to: `get_available_models` owns the catalog lock across its whole cold path,\nand its synchronous, within-budget and budget-boundary winners called\n`_save_models_cache_to_disk` from inside that ownership, i.e. catalog → commit. A publisher\nblocked on the commit mutex an invalidator held, with that invalidator then blocked on the\ncatalog lock the publisher owned, waited on each other forever, so a `/api/models` load and a\nconfig-save/`/api/models/refresh` invalidation could each hang (#7481 review). The foreground\npaths therefore do not commit inline: the catalog lock still spans the whole cold path,\nincluding the foreground wait and memory publication (owner, source identity and the\nabsolute deadline are read there too). Only the durable commit moves outside: it is\n**queued** and run by\n`_DeferredCatalogPublication` on its way out — after the catalog lock has been released — by\n`_commit_models_cache_to_disk_after_lock`. The caller still gets the cache and the durable\nfile populated before `get_available_models` returns, and the single-flight release rides\nwith the queued commit, so ownership still spans the durable write. The out-of-band worker\ncommits directly, which is correct because it holds no catalog lock by then.\n\n### 4.12 Insights Period Endpoint (`/api/insights`)\n\n`GET /api/insights` reports usage analytics for one time window. The window is\nselected by query parameters, and the response echoes the window it actually\nserved through `mode` (`\"trailing\"` or `\"custom\"`). Totals and the daily series\nare always computed over the same resolved interval. Data comes from the WebUI\nsession index plus non-WebUI rows in the active profile's `state.db`, when that\ndatabase exists. The optional `sync_to_insights` setting mirrors WebUI usage\ninto `state.db`; it does not control whether this endpoint includes CLI rows.\n\nWindow selection:\n\n- `days=N` — trailing window of the last `N` days. This is what the panel's\n  7/30/90/365 presets send, and the fallback when no absolute bound is usable.\n- `start=…` / `end=…` — an absolute window. Each bound is either an exact Unix\n  epoch timestamp in seconds or a `YYYY-MM-DD` calendar date. A supplied\n  `start` with no `end` runs to the server clock. A supplied `end` with no\n  `start` back-steps the start by 30 days using the bound's own arithmetic: a\n  `YYYY-MM-DD` end subtracts 30 calendar days (local midnight to local\n  midnight, DST-safe), while a numeric end subtracts exactly `30 * 86400`\n  seconds — so across a daylight-saving change a numeric end's default start can\n  land on a different local date than the calendar one would.\n- The **Custom range…** selection sends the two `<input type=date>` values as\n  raw `YYYY-MM-DD` strings, not epoch seconds, so the server reads them in its\n  own timezone and a browser in another timezone cannot shift the selected\n  calendar day. The panel defaults the inputs to the last 30 calendar days only\n  when no range has been picked yet, so preset → Custom never erases a chosen\n  range.\n\nBounds and fallbacks:\n\n- A `YYYY-MM-DD` bound selects a whole local calendar day (start = that day's\n  local midnight; an end date's exclusive stop is the next local midnight,\n  except a date equal to today, which clamps to the server clock because that\n  day is not over). An epoch bound keeps its exact `[start, end)` precision.\n  Bounds are swapped if supplied reversed.\n- The window never extends into the future: an `end` beyond the server clock\n  is clamped to `now`.\n- Windows are clamped to five calendar years so the daily series cannot grow\n  unbounded.\n- The request fails closed to the trailing `days` window (`mode: \"trailing\"`)\n  when an absolute bound cannot be served faithfully: a supplied bound that is\n  unparseable (e.g. `2026-02-31`) or out of the supported epoch range, a\n  pre-epoch date (Chromium's date input accepts `0001-…`, and no session\n  predates 1970), or a strictly-future start. A supplied-but-invalid bound is\n  never treated as an omitted one, so a rejected request cannot fabricate an\n  interval the caller did not ask for. An explicit zero-length window pinned to\n  the clock itself (`start == now`) stays an empty custom interval rather than\n  silently serving the trailing window.\n\n### 4.12 Project Bindings (`/api/projects/bind`)\n\nA session project can carry **bindings**: a set of workspaces (one of them the\ndefault), a model, and — API-only since the dialog row is hidden and no session\napplies it yet — a stored reasoning effort. The chip's quick-create (`+`) button, and the top-level New Chat button\nwhile that project filter is active, open a new session already configured for\nthe project's context. Right-click\na project chip and pick **Project settings…** to edit them: one row in the chip\nmenu (no inline summary, no per-axis unbind rows), a dialog built on the shared\n`.app-dialog*` classes (skins, Escape, focus trap) that stays below the shared\napp dialog's z-index so a prompt it opens stacks on top, and a menu clamped to\nthe viewport.\n\n`POST /api/projects/bind` updates a single project by `project_id`. Every body\nfield is optional and only the supplied ones are touched:\n\n- `workspaces: [str]` — replaces the full bound list. Each entry passes the\n  same trusted-path check as `/api/session/new` (`validate_workspace_to_add`\n  then `resolve_trusted_workspace`) and is auto-registered in the saved\n  workspace list, so a path outside the default root can be bound in one step.\n  Entries are de-duplicated, keeping first-seen order. `null` clears the list\n  (and the default with it).\n- `default_workspace: str` — the workspace a new session starts in. It must be\n  a member of `workspaces`; a value outside the list is auto-added so the\n  invariant \"`default_workspace` ∈ `workspaces`\" always holds. Removing the\n  workspace that was the default also drops the default.\n- `workspace: str` — the legacy single-workspace field from before\n  multi-workspace bindings. It is still accepted and kept in sync as\n  `workspaces: [w]`, so pre-existing rows that only set `workspace` keep\n  working; `_project_workspaces` reads the list first and falls back to it.\n- `auto_assign: bool` — accepted and **stored but dormant**. The backfill\n  sweep it used to drive, the dialog toggle that set it and the\n  workspace-keyed filing of new sessions were split out into a follow-up PR, so\n  this build reads the stored value nowhere. It is shape-checked (a JSON\n  boolean) in the request pre-flight, like `reasoning_effort`, and `null`/`false`\n  still clears it.\n- `model` / `model_provider` / `reasoning_effort` — single-value bindings per\n  project; `null` clears that axis (the project is then unbound on it). The\n  dialog renders the model row only: it never submits `reasoning_effort`, so\n  saving a binding PRESERVES an existing effort value instead of clearing it —\n  and, since the UX re-gate of 2026-10-08, no session applies that stored value\n  either (see below).\n\n`reasoning_effort` is **stored but dormant**. `/api/projects/bind` still accepts\nit (and `null` still clears it), and the dialog still renders no row for it\n(re-gate 2026-10-07) — but since the UX re-gate of 2026-10-08 nothing forwards\nit into a new session, so"},"files":{"DESIGN.md":"---\nversion: alpha\nname: Hermes Calm Console\ndescription: \"A restrained agent control surface: conversational content first, tool traces as quiet metadata, minimal chrome.\"\ncolors:\n  primary: \"#EAE0D5\"\n  secondary: \"#C6AC8F\"\n  tertiary: \"#C6AC8F\"\n  neutral: \"#0A0908\"\n  surface: \"#22333B\"\n  surfaceSubtle: \"#11100E\"\n  borderSubtle: \"#3B4A50\"\n  ink: \"#0A0908\"\n  success: \"#86C08B\"\n  warning: \"#E0B15D\"\n  error: \"#F87171\"\ntypography:\n  body-md:\n    fontFamily: \"Georgia, Times New Roman, serif\"\n    fontSize: 15px\n    fontWeight: 400\n    lineHeight: 1.68\n  body-sm:\n    fontFamily: \"-apple-system, BlinkMacSystemFont, Segoe UI, Inter, system-ui, sans-serif\"\n    fontSize: 12px\n    fontWeight: 400\n    lineHeight: 1.45\n  user-message:\n    fontFamily: \"-apple-system, BlinkMacSystemFont, Segoe UI, Inter, system-ui, sans-serif\"\n    fontSize: 14px\n    fontWeight: 400\n    lineHeight: 1.55\n  mono-xs:\n    fontFamily: \"SF Mono, ui-monospace, monospace\"\n    fontSize: 11px\n    fontWeight: 500\n    lineHeight: 1.55\nrounded:\n  sm: 4px\n  md: 8px\n  lg: 12px\n  pill: 999px\nspacing:\n  xs: 4px\n  sm: 8px\n  md: 12px\n  lg: 16px\ncomponents:\n  app-shell:\n    backgroundColor: \"{colors.neutral}\"\n    textColor: \"{colors.primary}\"\n    rounded: \"{rounded.sm}\"\n    padding: 16px\n  panel:\n    backgroundColor: \"{colors.surface}\"\n    textColor: \"{colors.primary}\"\n    rounded: \"{rounded.lg}\"\n    padding: 16px\n  border-line:\n    backgroundColor: \"{colors.borderSubtle}\"\n    textColor: \"{colors.primary}\"\n    rounded: \"{rounded.sm}\"\n    padding: 4px\n  state-success:\n    backgroundColor: \"{colors.success}\"\n    textColor: \"{colors.ink}\"\n    rounded: \"{rounded.sm}\"\n    padding: 4px\n  state-warning:\n    backgroundColor: \"{colors.warning}\"\n    textColor: \"{colors.ink}\"\n    rounded: \"{rounded.sm}\"\n    padding: 4px\n  state-error:\n    backgroundColor: \"{colors.error}\"\n    textColor: \"{colors.ink}\"\n    rounded: \"{rounded.sm}\"\n    padding: 4px\n  tool-call-group:\n    backgroundColor: \"{colors.neutral}\"\n    textColor: \"{colors.secondary}\"\n    rounded: \"{rounded.md}\"\n    padding: 4px\n  tool-card:\n    backgroundColor: \"{colors.surfaceSubtle}\"\n    textColor: \"{colors.secondary}\"\n    rounded: \"{rounded.md}\"\n    padding: 8px\n  user-message:\n    backgroundColor: \"{colors.tertiary}\"\n    textColor: \"{colors.ink}\"\n    rounded: \"{rounded.lg}\"\n    padding: 12px\n---\n\n## Overview\n\nHermes WebUI should feel like a calm developer console, not a demo page assembled from colorful cards. The primary artifact is the conversation. Tool calls, thinking traces, context compaction records, token usage, and runtime status are useful, but they are transcript metadata and should sit below the visual priority of user and assistant prose.\n\nThe desired direction is Linear/Vercel precision with a little Claude-style conversational warmth: quiet surfaces, clear spacing, restrained accent use, and progressive disclosure for debugging detail.\n\n## Colors\n\n- **Primary (#EAE0D5):** main text on dark surfaces. The warm parchment should feel readable and grounded, not like bright white terminal text.\n- **Secondary/Tertiary (#C6AC8F):** metadata and restrained accent. Use sparingly for active state, focus, user bubbles, and quiet emphasis.\n- **Neutral (#0A0908):** app background and ink. This gives the WebUI depth without returning to the previous navy/gold theme.\n- **Surface (#22333B):** panels, sidebar, and stronger interactive surfaces. It should carry the structure while the conversation remains primary.\n- **Light surfaces (#EAE0D5 / #F4EEE7):** light mode uses the palette's parchment as the field and a slightly lifted derived surface for panels.\n- **Semantic colors:** success/warning/error/info are state colors only, not decorative palette choices.\n\n## Typography\n\nUse Claude-like split typography: assistant prose gets an editorial serif stack (Georgia as the available substitute for Anthropic Serif), while user bubbles and functional UI stay in a crisp sans stack. This keeps the bot voice calmer and more readable without making controls feel bookish. Use monospace only for code, file paths, commands, tool names, and compact metadata. Avoid making whole cards feel like terminal output unless they actually are logs.\n\nScale should stay tight: 11px metadata, 12px labels, 14px body, 16–18px headings. Do not proliferate 10px/10.5px/12.5px one-offs unless there is a real layout constraint.\n\n## Layout\n\nConversation rhythm:\n\n1. User message — right aligned, compact bubble.\n2. Assistant content — left aligned, prose-first, no heavy bubble.\n3. Tool/thinking/context traces — quiet disclosure rows inside the assistant turn.\n4. Raw logs/details — hidden until explicitly expanded.\n\nMetadata should not break the reading flow. A turn that used ten tools should read as one assistant turn with one compact `Used 10 tools` disclosure, not ten content cards.\n\n## Elevation & Depth\n\nUse almost no shadows in the transcript. Shadows are reserved for popovers, dropdowns, modal dialogs, and floating controls. Cards inside chat should use either a subtle border or a subtle tint, not both aggressively.\n\n## Shapes\n\n- Rows/list items: `4–8px` radius.\n- Cards/panels: `8–12px` radius.\n- Pills: only true chips/badges use `999px`.\n- Avoid stacks of nested rounded rectangles. If a card contains another card, one of them is probably unnecessary.\n\n## Components\n\n### Tool/thinking activity group\n\nCollapsed by default in settled history and during live runs unless the user has explicitly opened that Activity row before. Persist open/closed disclosure state per chat and per turn, so switching away from a chat and coming back preserves the mode the user left it in. Summary line uses one disclosure for internals and stays intentionally terse, e.g. `Activity: 4 tools`. It should not repeat the always-present thinking area, list individual tool names, or add a second trailing count badge. Expanding reveals thinking and individual tool cards together. Thinking and tools should not create separate transcript rows unless there is an error or approval state that needs attention.\n\n### Tool card\n\nA tool card is a debug event row, not a chat message. Show icon, name, short target/preview, and status. Arguments and result snippets stay behind expansion. Result snippets should be truncated; full logs belong behind “show more”.\n\n### Thinking/context cards\n\nSame visual family as tool-call metadata. They should be quieter than assistant prose and should not use bright tinted full cards unless the user expands them.\n\nAutomatic compression follows a quiet live-only divider treatment rather than a\ntool-card row. Use `Compressing context` for the active barrier and\n`Context auto-compressed` after continuation/completion; render both as centered\nnon-interactive text with horizontal rules. Do not give it a caret, click\ntarget, distinct accent color, special leading dot, or separate card identity.\nOnce the final answer is settled, omit the live-only compression row unless it is\nneeded to explain a visible recovery or error state.\n\n### Composer\n\nThe composer is the command surface. Keep it legible and focused: modest radius, subtle border, transparent inactive chips, no theatrical hover scaling.\n\n## Do's and Don'ts\n\nDo:\n\n- Collapse noisy agent internals by default.\n- Use one accent color at a time.\n- Prefer neutral borders and restrained surfaces.\n- Make debug traces accessible and inspectable without making them visually dominant.\n- Add stable class/data hooks for future visual regression tests.\n\nDon't:\n\n- Render every tool call as a first-class chat card.\n- Mix gold, cyan, purple, orange, red, and green as decorative colors in the same viewport.\n- Add new hardcoded radius/color values when a token exists.\n- Use shadows, gradients, and hover transforms for routine controls.\n- Hide important error or approval states; those are allowed to be prominent because they require action.\n","ARCHITECTURE.md":"# Hermes Web UI: Developer and Architecture Guide\n\n> This document is the canonical reference for anyone (human or agent) working on the\n> Hermes Web UI. It covers the exact current state of the code, every design decision and\n> quirk discovered during development, and a phased architecture improvement roadmap that\n> runs in parallel with the feature roadmap in ROADMAP.md.\n>\n> Keep this document updated as architecture changes are made.\n\n> Current shipped build: `v0.51.792` (July 1, 2026).\n> Automated coverage: ~11,500 tests via `pytest tests/ --collect-only -q`. CI runs on\n> Python 3.11, 3.12, and 3.13 (3 parallel shards each) against every PR, plus a ruff\n> lint gate, a headless browser smoke test, and a Docker smoke test.\n>\n> Notable architecture state: the bootstrap and first-run onboarding flow own setup discovery; the default WebUI state directory is `~/.hermes/webui`; `ctl.sh` provides a daemon wrapper for homelab installs; chat streaming is still WebUI-owned SSE with stream-ownership guards, cancellation, async manual compression, and turn-journal audit plumbing; provider/model discovery is profile-aware with live-model cache invalidation and custom-provider scoping. (Version/test-count numbers above are a periodic snapshot — the authoritative source is the latest git tag and `pytest --collect-only`.)\n\n---\n\n## 1. Overview and Purpose\n\nThe Hermes Web UI is a lightweight web application that gives you a browser-based\ninterface to the Hermes agent that is functionally equivalent to the CLI. It is modeled on\nthe Claude-style interface: a sidebar for session management, a central chat area,\nand a demand-driven right panel used for workspace browsing and preview surfaces.\nThe right panel is closed by default on desktop and opens only when it is actively\nbeing used for browsing or previewing content.\n\nTo prevent a visible first-paint mismatch on refresh, `static/index.html` preloads the\nsaved workspace panel state into `document.documentElement.dataset.workspacePanel`\nbefore the main stylesheet loads. Desktop CSS honors that preload marker immediately,\nand `static/boot.js` keeps the dataset synchronized with the runtime panel state machine.\n\nThe design philosophy is deliberately minimal. There is no build step, no bundler, no\nfrontend framework. The Python server is split into a routing shell (server.py) and\nbusiness logic modules (api/). The frontend is seven vanilla JS modules loaded from static/.\nThis makes the code easy to modify from a terminal or by an agent.\n\nHermes-level chrome is intentionally consolidated: the sidebar has no dedicated brand header.\nInstead, the footer exposes a single \"Hermes WebUI\" launch button that opens one tabbed\ncontrol-center modal for global preferences, conversation import/export, and clear-conversation\nactions. The topbar remains focused on conversation context and the workspace/files toggle.\n\n---\n\n## 2. File Inventory\n\n    <repo>/\n    server.py              Thin routing shell + HTTP Handler + auth middleware.\n                           Delegates all route handling to api/routes.py.\n    bootstrap.py           One-shot launcher: optional agent install, deps, health wait, browser open.\n    start.sh               Thin wrapper around bootstrap.py for shell-based startup.\n    ctl.sh                 Daemon lifecycle wrapper (start/stop/restart/status/logs) for homelab installs.\n    pyproject.toml         Standard build metadata plus the Ruff lint gate; source-checkout launch surface still centers on bootstrap.py / start.sh / ctl.sh.\n    Dockerfile             python:3.12-slim container image\n    docker-compose.yml     Compose config with named volume and optional auth\n    .dockerignore          Excludes .git, tests/, .env* from Docker builds\n    api/\n      __init__.py          Package marker\n      agent_compat.py      Resolver for Hermes Agent names moved to sibling modules (compatibility-only)\n      auth.py              Optional password authentication, signed cookies, passkeys/WebAuthn\n      config.py            Discovery, globals, model detection, reloadable config\n      helpers.py           HTTP helpers: j(), bad(), require(), safe_resolve(), security headers\n      goals.py             Persistent-goal commands and profile-scoped native GoalManager bridge\n      models.py            Session model + CRUD, per-session profile tracking, CLI/state.db bridge\n      profiles.py          Profile state management, hermes_cli wrapper\n      onboarding.py        First-run onboarding status, real provider config writes, OAuth linking, readiness detection\n      routes.py            All GET + POST route handlers (if/elif dispatch, no decorators)\n      startup.py           Startup helpers: auto_install_agent_deps()\n      state_sync.py        state.db bridge — opt-in /insights usage/title sync; always mirrors the session workspace into sessions.cwd\n      streaming.py         SSE engine, run_agent, cancel, compression, HERMES_HOME save/restore\n      updates.py           Self-update check and release notes\n      upload.py            Multipart parser, file upload handler\n      workspace.py         File ops: list_dir, read_file_content, git detection, workspace helpers\n    static/\n      index.html           HTML template\n      style.css            All CSS incl. mobile responsive, themes + skins, KaTeX\n      ui.js                DOM helpers, renderMd, tool cards, context indicator, file tree\n      workspace.js         File preview, file ops, git badge, central api() fetch wrapper\n      sessions.js          Session CRUD, list rendering, collapsible groups, search, SSE sync\n      messages.js          send(), SSE event handlers, approval/clarify, transcript, recovery\n      panels.js            Cron, skills, memory, profiles, todo, settings (Control Center)\n      commands.js          Slash command registry, parser, autocomplete dropdown\n      boot.js              Event wiring, mobile nav, voice input, theme/skin boot, bfcache handler\n      onboarding.js        First-run wizard overlay, provider setup flow\n      i18n.js              Localization catalog (en, es, de, zh, zh-Hant, ru, …)\n      login.js             Login page + open-redirect guard\n      icons.js             Lucide icon path registry\n      sw.js                Service worker: offline shell cache, version-pinned assets\n    tests/\n      conftest.py          Isolated test server/state fixtures\n      ~1,150 test files    ~11,500 tests collected via pytest (run `pytest --collect-only -q` for exact)\n      test_regressions.py  Permanent regression gate\n    CONTRIBUTING.md        Contributor workflow and PR expectations.\n    ROADMAP.md             Feature and product roadmap document.\n    SPRINTS.md             Forward sprint plan with CLI + Claude parity targets.\n    ARCHITECTURE.md        THIS FILE.\n    TESTING.md             Manual browser test plan and automated coverage reference.\n    CHANGELOG.md           Release notes per version.\n    CONTRIBUTORS.md        Community credit roll (regenerated via the maintainer workspace script).\n    requirements.txt       Python dependencies.\n    .env.example           Sample environment variable overrides.\n\n> Per-file line counts intentionally omitted — they drift every release. Use\n> `git ls-files | xargs wc -l` (or your editor) for current sizes; the role of\n> each file above is the durable part.\n\nState directory (runtime data, separate from source):\n\n    ~/.hermes/webui/\n    sessions/          One JSON file per session: {session_id}.json\n    workspaces.json    Registered workspaces list\n    last_workspace.txt Last-used workspace path\n    settings.json      User settings (default model, workspace, send key, password hash)\n    projects.json      Session project groups (name, color, id)\n\nLog file:\n\n    ~/.hermes/webui/bootstrap-8787.log   start.sh/bootstrap background server log\n    ~/.hermes/webui.log                  ctl.sh daemon log\n\n---\n\n## 3. Runtime Environment\n\n### Context replay matching\n\n`api/streaming.py::_strip_replayed_context_items` filters replayed model-context\nblocks using longest-match statistics from a suffix automaton over reversed\nreplay keys. Its greedy walk still removes only blocks of at least three rows;\nshort repeats, row order, and provider-facing `api_content` identity are preserved.\nLong arc-summary identity is normalized once per row and indexed by role, provider\npayload and the existing 1,500-character prefix rule. Malformed unhashable roles\nretain an equality-scan fallback. For ordinary hashable roles/keys, matching uses\nO(existing + candidate) key operations and auxiliary space, in addition to payload\nnormalization. All indexes are call-local; no session cache, durable schema, writer\nownership, or SSE contract changes. Inputs are not mutated and retained rows are\nreturned unchanged. All three context-reconciliation branches share this helper.\n\n- Python interpreter: <agent-dir>/venv/bin/python\n- The venv has all Hermes agent dependencies (run_agent, tools/*, cron/*)\n- Server binds to 127.0.0.1:8787 (localhost only, not public internet)\n- Access from Mac: SSH tunnel: ssh -N -L 8787:127.0.0.1:8787 <user>@<your-server>\n- The server imports Hermes modules via sys.path.insert(0, parent_dir)\n\nSuffix/prefix replay matching uses a linear prefix-function scan and constructs\neach comparison key once, preserving the existing replay equality contract.\nRead-side stale-stream maintenance defers when the session writer lock is busy;\nit leaves stream ownership and pending input intact for a later repair pass.\n\nEnvironment variables controlling behavior:\n\n    HERMES_WEBUI_HOST              Bind address (default: 127.0.0.1)\n    HERMES_WEBUI_PORT              Port (default: 8787)\n    HERMES_WEBUI_DEFAULT_WORKSPACE Default workspace path for new sessions\n    HERMES_WEBUI_STATE_DIR         Where sessions/ folder lives\n    HERMES_CONFIG_PATH             Path to ~/.hermes/config.yaml\n    HERMES_WEBUI_DEFAULT_MODEL     Optional model override; unset means provider default\n    HERMES_WEBUI_PASSWORD          Optional: enable password auth (off by default)\n    HERMES_WEBUI_SKIP_ONBOARDING   Optional: bypass the first-run onboarding wizard\n    HERMES_PREFILL_MESSAGES_FILE   Optional JSON message list for browser-turn prefill context\n    HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT Optional command that prints JSON messages or plain-text user prefill context\n    HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT Optional script timeout in seconds (default 5, max 30)\n    HERMES_WEBUI_PREFILL_CONTEXT_MAX_CHARS Optional parsed prefill budget in characters (default 12000, 0 disables)\n    HERMES_HOME                    Base directory for Hermes state (~/.hermes by default)\n\nTest isolation environment variables (set by conftest.py):\n\n    HERMES_WEBUI_TEST_PORT=...                         Optional pinned test port\n    HERMES_WEBUI_TEST_STATE_DIR=/tmp/hermes-webui-tests/* Optional pinned test state (default: OS temp dir; must be outside ~/.hermes)\n    HERMES_WEBUI_DEFAULT_WORKSPACE=.../test-workspace  Isolated test workspace\n\nTests NEVER talk to the production server (port 8787).\nThe test state dir is wiped before each test session and deleted after.\nSee: <repo>/tests/conftest.py\n\nPer-request environment variables (set by chat handler, restored after):\n\n    TERMINAL_CWD         Set to session.workspace before running agent.\n                         The terminal tool reads this to default cwd.\n    HERMES_EXEC_ASK      Set to \"1\" to enable approval gate for dangerous commands.\n    HERMES_SESSION_KEY   Set to session_id. The approval tool keys pending entries\n                         by this value, enabling per-session approval state.\n    HERMES_HOME          Set to the active profile's directory before running agent.\n                         Saved and restored around each agent run.\n\nWARNING: These env vars are process-global. Two concurrent chat requests will clobber\neach other. This is safe only for single-user, single-concurrent-request use.\nSee Architecture Phase B for the fix.\n\n---\n\n## 4. Server Architecture: Current State\n\n### 4.1 HTTP Server Layer\n\nPython stdlib ThreadingHTTPServer (from http.server). Each HTTP request runs in its own\nthread. The Handler class subclasses BaseHTTPRequestHandler with two methods:\n\n    do_GET    Routes: /, /health, /api/session, /api/sessions, /api/list,\n                      /api/chat/stream, /api/file, /api/approval/pending,\n                      /api/session/worktree/status\n    do_POST   Routes: /api/upload, /api/session/new, /api/session/update,\n                      /api/session/delete, /api/chat/start, /api/chat,\n                      /api/approval/respond, /api/session/worktree/remove\n\nRouting is a flat if/elif chain inside each method. No routing framework.\n\nHelper functions used by all handlers:\n\n    j(handler, payload, status=200)     Sends JSON response with correct headers\n    t(handler, payload, status=200, ct) Sends plain text or HTML response\n    read_body(handler)                  Reads and JSON-parses the POST body\n\nCRITICAL ORDERING RULE in do_POST:\nThe /api/upload check MUST appear BEFORE calling read_body(). read_body() calls\nhandler.rfile.read() which consumes the HTTP body stream. The upload handler also\nneeds rfile (to read the multipart payload). If read_body() runs first on a multipart\nrequest, the upload handler receives an empty body and the upload silently fails.\n\n### 4.2 Session Model\n\nSession is a plain Python class (not a dataclass, not SQLAlchemy):\n\n    Fields:\n      session_id    hex string, 12 chars (uuid4().hex[:12])\n      title         string, auto-set from first user message\n      workspace     absolute path string, resolved at creation\n      model         model ID string (e.g. \"anthropic/claude-sonnet-4.6\")\n      messages      list of OpenAI-format message dicts\n      created_at    float Unix timestamp\n      updated_at    float Unix timestamp, updated on every save()\n      pinned        bool, default False (Sprint 12)\n      archived      bool, default False (Sprint 14)\n      project_id    string or null, FK to projects.json (Sprint 15)\n      tool_calls    list of tool call dicts (Sprint 10)\n\n    Key methods:\n      path (property)  Returns SESSION_DIR/{session_id}.json\n      save()           Writes __dict__ as pretty JSON to path, updates updated_at\n      load(cls, sid)   Class method: reads JSON from disk, returns Session or None\n      compact()        Returns metadata-only dict (no messages) for the session list\n\n    In-memory cache:\n      SESSIONS = {}    dict: session_id -> Session object\n      LOCK = threading.Lock()   defined but NOT currently used around SESSIONS access\n\n    get_session(sid): checks SESSIONS cache, loads from disk on miss, raises KeyError\n    new_session(workspace, model): creates Session, caches in SESSIONS, saves, returns\n    all_sessions(): scans SESSION_DIR/*.json + SESSIONS, deduplicates, sorts by updated_at,\n                    returns list of compact() dicts\n\n    all_sessions() does a full directory scan on every call.\n    With 10 sessions: negligible. With 1000+: will be slow.\n    See Architecture Phase C for the index file fix.\n\ntitle_from(): takes messages list, finds first user message, returns first 64 chars.\nCalled after run_conversation() completes to set the session title retroactively.\n\n#### Session transcript reconciliation with `state.db`\n\n`reconciled_state_db_messages_for_session()` uses\n`merge_session_messages_append_only()` to combine a WebUI sidecar or context\nprojection with active Agent `state.db` rows. Its explicit\n`incoming_provenance=\"state_db\"` fence permits a state-only row whose timestamp\npredates the sidecar tail to use a safe chronological slot. Paginated\n`msg_limit` consumers rely on this merged order directly rather than applying a\nlater timestamp sort.\n\nThe same merge helper also stitches ordered child sidecars onto archived\ncompression parents. Those calls leave incoming provenance unverified, so their\nstable message sequence remains append-only even when parent rows carry later,\nrestamped timestamps. Timestamp alone is never authority to move a continuation\ninside its parent transcript.\n\nIf an older row could only be placed before the first surviving sidecar/context\nrow, the insertion helper declines it to avoid resurrecting compacted history.\nReconciliation then appends that row instead of dropping it; rows without a\nusable timestamp and rows at or after the sidecar tail also append normally.\nThe fallback therefore preserves an accepted state-only row when exact ordering\nis ambiguous, while safely placeable recovery rows remain chronological.\n\n#### Imported `state.db` sidebar projection\n\n`api.models.get_cli_sessions()` projects conversations from the active Hermes\nprofile's `state.db` into sidebar-shaped rows. The default projection keeps\ninteractive sources (CLI, TUI, ACP, messaging, and similar user-facing sessions)\nin a bounded 20-row candidate window. Background sources use independent recovery\npasses so a high-volume worker source cannot consume that interactive window:\n\n- Cron: up to `CRON_PROJECT_CHIP_LIMIT` rows.\n- Webhook: up to `WEBHOOK_PROJECT_CHIP_LIMIT` rows.\n- Kanban: up to `KANBAN_PROJECT_CHIP_LIMIT` rows.\n- One-shot (`hermes -z`): kept out of the interactive window and its\n  unassigned refill, with no default pass of its own. They keep normal project\n  chips, so the assigned recovery pass still reaches one filed under a project.\n  An explicit `source_filter=\"oneshot\"` lists the newest ones, and a session\n  the user opens by id resolves through `get_cli_session_by_id()`.\n\nSource-specific views still use their dedicated bounds, and the later sidebar\nvisibility stage decides whether recovered background rows are shown. In\n`all_profiles=True` mode the per-profile source bounds are disabled before rows\nare merged; cross-profile scoping, visibility, deduplication, and final route\nlimits remain downstream responsibilities.\n\n#### Compression lineage and session-list invalidation\n\n`api.agent_sessions._is_continuation_session()` is the shared classifier for\nsidebar projection, lineage metadata/reporting, and `state.db` transcript\nstitching. It uses the direct parent link, no conflicting non-empty source,\n`compression` or `cli_close` parent end reason, and the existing two-second\n`started_at` overlap allowance. A `source=\"tool\"` child is always a separate\nconversation, even when its parent is also a tool session or has no source.\nA direct `_branched_from`, `_delegate_from`, or `_reset_from` marker in the\nchild's `model_config` also makes a boundary; inherited ancestor markers do\nnot. Malformed or unverifiable marker evidence fails closed as a boundary.\nThe overlap allowance is the existing master policy, not a new window set by\nthis change.\n\nThe gateway watcher's cheap database fingerprint includes `model_config`, so\na marker-only update causes a fresh projection. Its published-payload hash\ncovers every emitted session field, not just ID, activity time and message\ncount; a changed projected title or lineage field can therefore emit\n`sessions_changed` even without message-row churn.\n\n### 4.3 SSE Streaming Engine\n\nThis is the most architecturally interesting part. Two endpoints cooperate:\n\n    POST /api/chat/start     Receives the user message. Creates a queue.Queue, stores it\n                             in STREAMS[stream_id], spawns a daemon thread running\n                             _run_agent_streaming(), returns {stream_id} immediately.\n\n    GET  /api/chat/stream    Long-lived SSE connection. Reads from STREAMS[stream_id]\n                             and forwards events to the browser until 'done' or 'error'.\n\nQueue registry:\n\n    STREAMS = {}               dict: stream_id -> queue.Queue\n    STREAMS_LOCK = threading.Lock()\n\nSSE event types and their data shapes:\n\n    token       {\"text\": \"...\"}                         LLM token delta\n    tool        {\"name\": \"...\", \"preview\": \"...\"}       Tool invocation started\n    approval    {\"command\": \"...\", \"description\": \"...\", \"pattern_keys\": [...]}\n    done        {\"session\": {compact_fields + messages}} Agent finished successfully\n    error       {\"message\": \"...\", \"trace\": \"...\"}       Agent threw exception\n\nThe SSE handler loop:\n    - Blocks on queue.get(timeout=30)\n    - On timeout (no events in 30s): sends a heartbeat comment (\": heartbeat\n\n\")\n      to keep the connection alive through proxies and firewalls\n    - On 'done' or 'error' event: breaks the loop and returns\n    - Catches BrokenPipeError and ConnectionResetError silently (browser disconnected)\n\nStream cleanup: _run_agent_streaming() pops its stream_id from STREAMS in a finally\nblock. If the browser disconnects mid-stream, the daemon thread runs to completion and\nthen cleans up. The queue fills and the put_nowait() calls fail silently (queue.Full\nis caught).\n\nFallback sync endpoint: POST /api/chat still exists and holds the connection open until\nthe agent finishes. The frontend never uses it but it can be useful for debugging.\n\n### 4.4 Agent Invocation (_run_agent_streaming)\n\n    def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id):\n\n1. Fetches session from SESSIONS (not from disk -- session was just updated by /api/chat/start)\n2. Sets TERMINAL_CWD, HERMES_EXEC_ASK, HERMES_SESSION_KEY env vars\n3. Creates AIAgent with:\n   - model=model, platform='cli', quiet_mode=True\n   - enabled_toolsets=CLI_TOOLSETS (from config.yaml or hardcoded default)\n   - session_id=session_id\n   - stream_delta_callback=on_token (fires per token)\n   - tool_progress_callback=on_tool (fires per tool invocation)\n4. Calls agent.run_conversation(user_message=msg_text, conversation_history=s.messages,\n                                 task_id=session_id)\n   NOTE: keyword is task_id NOT session_id (common mistake, documented in skill)\n5. On return: updates s.messages, calls title_from(), saves session\n6. Puts ('done', {session: ...}) into queue\n7. Finally block: restores env vars, pops stream_id from STREAMS\n\non_token callback:\n    if text is None: return  # end-of-stream sentinel from AIAgent\n    put('token', {'text': text})\n\non_tool callback:\n    put('tool', {'name': name, 'preview': preview})\n    # Also immediately surface any pending approval:\n    if has_pending(session_id):\n        with _lock: p = dict(_pending.get(session_id, {}))\n        if p: put('approval', p)\n\nThe approval surface-on-tool logic means approvals appear immediately after the tool\nfires (within the same SSE stream), without waiting for the next poll cycle.\n\n### 4.5 Approval System Integration\n\nThe approval system uses the existing Hermes gateway module at tools/approval.py.\nAll state lives in module-level variables in that file:\n\n    _pending = {}        dict: session_key -> pending_entry_dict\n    _lock = Lock()       protects _pending\n    _permanent_approved  set of permanently approved pattern keys\n\nBecause server.py imports tools.approval at module load time and everything runs in the\nsame process, this state IS shared between HTTP threads and agent daemon threads.\n\nImportant: this only works because Python imports are cached (sys.modules). The same\nmodule object is used everywhere. If the approval module were ever imported in a subprocess\nor via importlib.reload(), this would break.\n\nGET /api/approval/pending:\n    - Peeks at _pending[sid] without removing it\n    - Returns {pending: entry} or {pending: null}\n    - Called by the browser every 1500ms while S.busy is true (polling fallback)\n\nPOST /api/approval/respond:\n    - Pops _pending[sid] (removes it)\n    - For choice \"once\" or \"session\": calls approve_session(sid, pattern_key) for each key\n    - For choice \"always\": calls approve_session + approve_permanent + save_permanent_allowlist\n    - For choice \"deny\": just pops, does nothing (agent gets denied result)\n    - Returns {ok: true, choice: choice}\n\n### 4.6 File Upload Parser\n\nparse_multipart(rfile, content_type, content_length):\n    - Reads all content_length bytes from rfile into memory (up to MAX_UPLOAD_BYTES, default 20MB, env-overridable via HERMES_WEBUI_MAX_UPLOAD_MB)\n    - Extracts boundary from Content-Type header\n    - Splits raw bytes on b'--' + boundary\n    - For each part: parses MIME headers via email.parser.HeaderParser\n    - Returns (fields, files) where fields is {name: value} and files is {name: (filename, bytes)}\n\nhandle_upload(handler):\n    - Calls parse_multipart()\n    - Validates: file field present, filename present, session exists\n    - Sanitizes filename: replaces non-word chars with _, truncates to 200 chars\n    - Writes bytes to session.workspace / safe_name\n    - Returns {filename, path, size}\n\nWhy not cgi.FieldStorage:\n    - Deprecated in Python 3.11+\n    - Broken for binary files (silently corrupts or throws)\n    - The manual parser handles all file types correctly\n\n### 4.7 File System Operations\n\nsafe_resolve(root, requested):\n    - Resolves requested path relative to root\n    - Calls .relative_to(root) to assert the result is inside root\n    - Raises ValueError on path traversal (../../etc/passwd)\n\nlist_dir(workspace, rel='.'):\n    - Calls safe_resolve, then iterdir()\n    - Sorts: directories first, then files, case-insensitive alpha within each group\n    - Returns up to 200 entries with {name, path, type, size}\n\nread_file_content(workspace, rel):\n    - Calls safe_resolve\n    - Enforces MAX_FILE_BYTES = 200KB size limit\n    - Reads as UTF-8 with errors='replace' (binary files show replacement chars)\n    - Returns {path, content, size, lines}\n\n### 4.8 Persistent Goal Profile Boundary\n\n`api/goals.py` exposes the WebUI `/goal` command payloads and post-turn evaluation hook.\nHermes Agent's native `GoalManager` is the authoritative owner of goal evaluation,\ncontinuation decisions, wait barriers, failure counters, contracts, subgoals, and\n`state.db` persistence.\n\nFor a profile-scoped WebUI session, the bridge delegates only when the Agent exposes\nboth the context-local `set_hermes_home_override()` API and call-time default\n`SessionDB` path resolution. The bridge probes the resolved default path under the\nselected context before constructing the native manager, then binds that profile's\nHermes home before every native call. The override is reset in a `finally` block after\nevery operation, so concurrent sessions using the same session ID under different\nprofiles cannot cross-read or cross-write goal state. Goal snapshot rollback uses the\nsame scoped native persistence path.\n\nOlder Hermes Agent versions that lack either capability continue through\n`_LegacyProfileGoalManager`, which pins persistence to the selected profile's explicit\n`state.db` path. This includes intermediate versions whose context API is present but\nwhose default `SessionDB()` path remains frozen at module import. Keep this fallback\ncompatibility-only: new goal semantics belong in Hermes Agent's native manager rather\nthan a second WebUI implementation.\n\n\n### 4.9 Hermes Agent Moved-Name Compatibility\n\nHermes Agent owns its module layout. Its September 2026 decomposition moved names the\nWebUI uses (for example `tools.approval.set_current_session_key` to\n`tools.approval_context`) into `<stem>_<topic>` sibling modules. The old paths resolve\nonly through temporary PLUGIN-COMPAT `__getattr__` pointers that emit\n`HermesPluginCompatWarning` and are removed on schedule. The Agent's\n`compat_manifest.json` is the authoritative map of what moved where.\n\nWebUI code reaches a moved name only through\n`api.agent_compat.agent_attr(owner, name, home, default=...)`, which resolves in this\norder:\n\n1. the owner module's own namespace: pre-split Agents, and tests that stub the original\n   module in `sys.modules` or patch the name onto it;\n2. the `home` module: split Agents, with or without the old-path pointer (no warning);\n3. plain attribute access on the owner: non-module test doubles.\n\nIt raises like the import it replaces (or returns `default`), so each call site keeps its\nexisting fallback. Current users: approval session identity and MCP discovery\n(`streaming.py`), `/reload-mcp` (`commands.py`), MCP runtime status (`routes.py`), Claude\nCode credential linking (`oauth.py`), LM Studio reasoning options (`config.py`), and\nkanban connections and dispatch (`kanban_bridge.py`).\n\nImport names that are still native to their module directly. Never\n`from <old module> import <moved name>`, and never feature-detect a moved name with\n`hasattr`/`getattr` on the old module: once the pointers are removed those silently turn\n\"moved\" into \"missing\" behind the call sites' broad `except` blocks. When a later Agent\nsplit moves another name, route it through `agent_attr` and add pre-split and\npointer-removed cases to `tests/test_agent_compat.py`. The resolver is\ncompatibility-only: delete it, and import directly from the new homes, once the WebUI\nstops supporting Agents that predate the split.\n\n### 4.10 MCP Runtime Profile Boundary\n\nHermes Agent owns the in-process MCP ledger. It keys a connection by\n`(profile_home_key, name)` and registers its tools in that profile's registry overlay\nonly when the calling task serves a *routed* profile: the context-local Hermes-home\noverride differs from the process home. Otherwise the connection uses the bare server\nname and the global registry slot, which belong to the process profile.\n\n- `api.profiles._set_hermes_home()` is the single writer of the process-profile home:\n  startup (`init_profile_state()`) and `switch_profile(process_wide=True)` both go\n  through it, so `get_process_profile_home()` and the Agent pin\n  (`hermes_constants.pin_process_hermes_home()`, when the Agent provides it) are updated\n  in the same step as `HERMES_HOME`. Streaming turns still mirror their profile into\n  `os.environ['HERMES_HOME']` for legacy readers; without the pin, that mirror makes a\n  turn's own profile look like the process profile, so same-named servers of different\n  profiles share one bare-name connection.\n- `/api/mcp/servers`, `/api/mcp/tools`, `/api/notes/sources` and `/reload-mcp` run\n  their Agent calls inside `api.mcp_runtime.mcp_runtime_scope()`, which binds the\n  request profile's home and secret scope (root profile included) without touching\n  `os.environ`. Status, tool count and inventory are read from one scope; the inventory\n  lists only tools in that profile's own registry slot for servers it configures.\n- A connection *serves* a profile when that profile owns it (`_server_scope_keys`, else\n  the scope in the key) or adopted it (`_server_tool_scopes`, an identical shared\n  connection). `api.mcp_runtime.ledger_key_serves_view()` is the one predicate for\n  status, inventory and the reload summary. The Agent's launch-profile view (scope\n  `None`) is process-wide, so `filter_runtime_status_to_view()` downgrades\n  `get_mcp_status()` rows of servers the root profile does not serve to `configured`\n  rather than showing a routed profile's connection, tool count or connect error.\n- The scope is trusted only when the Agent's routing decision matches the profile WebUI\n  resolved. When it cannot be confirmed (for example a same-profile turn's mirror on an\n  Agent without the pin), status and inventory withhold runtime data\n  (`runtime_scope: \"unavailable\"`, shown as a notice in the MCP panel) and\n  `/reload-mcp` refuses instead of resetting another owner's connection.\n- `/reload-mcp` calls `shutdown_mcp_servers(scope=..., names=...)` with the profile's\n  own scope and the names of its live connections; `scope=None` without `names` is the\n  process-wide wildcard and is used only with Agents that predate profile-scoped MCP\n  (`runtime_scope: \"legacy_process\"`). A scoped shutdown only clears connect backoff for\n  the live keys it tears down, so WebUI also drops the profile's own cooldown/error\n  entries (`clear_profile_connect_cooldowns()`) before rediscovery: a server that failed\n  to spawn is retried by the reload, as the wildcard did, without touching another\n  owner's backoff.\n\nStatus and inventory stay passive: they never start or probe an MCP server. Ledger key\nhelpers resolve to Hermes Agent's `tools.mcp_tool_scope` when present so the key shape\nhas one owner; the local fallbacks only cover Agents that predate that module.\n\n#### MCP configuration writes\n\nMCP create/update, toggle and delete are separate from Agent runtime ownership.\nEach edit resolves the existing `_get_config_path()` once under `_cfg_lock`,\nreads a private raw YAML mapping, and commits to that same path with the existing\natomic writer. `HERMES_CONFIG_PATH` keeps its documented precedence; this does\nnot redirect operator-configured files or change profile resolution.\n\nRaw `${ENV_VAR}` references, unrelated configuration and masked credentials keep\ntheir original stored values. YAML-aliased containers and the selected server\nentry are detached before mutation so an edit cannot change a sibling or template. Runtime-expanded cache dictionaries are never the\nwrite source. An unreadable, malformed or non-mapping existing document aborts\nwithout overwriting it; a missing/empty document can be initialized. Failed disk\nwrites do not publish uncommitted mutations into the runtime cache. Reloading\nthat cache and sending HTTP responses happen after the lock is released.\n\nThis transaction serializes cooperating writes/reloads in one WebUI process.\nIt does not lock out other processes or provide Agent status/schema/dispatch\nisolation; those remain the runtime boundary described above.\n\n### 4.11 Model Catalog Rebuild Budget and Probe Scheduling\n\nThe model picker's catalog (`GET /api/models`) is produced by one cold rebuild that\nlive-probes providers. A probe is a network call to a provider the WebUI may not be able\nto reach — a LAN LM Studio/Ollama host is the usual case, because the probe runs in the\nserver process rather than the browser — so the rebuild runs on a daemon worker under a\nwall-clock budget:\n\n| Knob | Default | Meaning |\n| --- | --- | --- |\n| `HERMES_WEBUI_MODELS_REBUILD_BUDGET` | `4` (seconds) | Window a foreground caller waits for a cold rebuild. `0` restores the legacy synchronous unbounded rebuild. |\n| `CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS` | `5.0` | Per-endpoint cap for a custom provider `/v1/models` probe. |\n| `CUSTOM_MODELS_ATTEMPT_ONLY_TIMEOUT_SECONDS` | `0.01` | Attempt-only timeout for a probe reached after the window is already spent while the caller is still waiting. Not a per-probe minimum and not a slice — see below. |\n\nWithin the window the rebuild behaves normally; past it the caller is served a fallback\n(last-known disk cache, else a network-free static catalog) while the worker keeps going so\nthe refresh lands out-of-band for the next caller.\n\nCustom endpoints are probed **serially**: the active `model.base_url` first, then each\nnamed `custom_providers` entry in config order, then the LM Studio provider-group\nfallback. `_CustomProbeSchedule` in `api/config.py` hands each of those probes a fair\nslice of the *remaining* window instead of letting one probe take the whole per-endpoint\ncap, so an unreachable endpoint cannot leave the reachable providers behind it with no\nin-band probe at all (#7481).\n\n- Each slice is `min(cap, left / (remaining + 0.1))`, with **no lower bound**. The fractional\n  slot reserves headroom without halving a lone target's window. Unused time is lent to\n  later probes; the matching active/LM Studio target does not reserve a duplicate slot.\n  Wall-clock overhead can still cross the foreground deadline. A floor would make\n  the chain's cumulative spend grow with the endpoint count, and `custom_providers` has no\n  count limit, so a long enough chain of dead endpoints could again outspend the window and\n  push the reachable providers behind it out of the in-band rebuild.\n- The window belongs to the **caller, not to the chain**. One absolute deadline is captured\n  before the worker and the foreground wait start and is handed to `_CustomProbeSchedule`;\n  the foreground waits only `max(0, deadline - now)`. A schedule that minted its own deadline\n  at construction — deep inside the worker, after provider detection, the live id lookups and\n  the profile rebind — charged that discovery work to the caller's wait *and* granted it to\n  the chain again, so the chain could still be probing after the caller had already been\n  served the over-budget fallback, and the reachable provider landed out-of-band at best.\n- The two states that keep the unthrottled per-endpoint cap are stated explicitly rather\n  than inferred from the clock, because a spent window says nothing about whether anyone is\n  still waiting:\n  - the legacy unbounded path (budget `0`), which has no window to share;\n  - the out-of-band continuation — the worker still probing after the foreground caller\n    gave up and served its fallback. It is recognised through an explicit signal (the\n    caller sets the event when it stops waiting) and is the only in-process state allowed\n    to spend past the window. The narrow race where a probe finds the window spent while\n    the caller *is* still waiting is not that state: it gets the attempt-only timeout\n    above, never the cap.\n- A slice can truncate healthy endpoints even in short chains. Such timeouts are not\n  reported as unreachable and the partial catalog is never cached in memory or on disk.\n  The same worker retries truncated targets once at the full endpoint cap after the\n  foreground returns, retaining successful probes and publishing the complete catalog\n  through the existing generation, source-identity, ownership and durable fences.\n- An endpoint is probed **once per rebuild, except for that full-cap retry**. The three live-probe consumers\n  (active `model.base_url`, named `custom_providers`, LM Studio provider-group fallback)\n  share a per-rebuild memo keyed by the complete request URL (path slashes and query\n  preserved) plus the Authorization header sent, so the\n  common config where the active endpoint and `providers.lmstudio.base_url` are the same\n  LAN host no longer pays that host's connect timeout twice, and two named entries on one\n  endpoint probe it once. A different URL — or the same URL with a different key, which can\n  change the outcome — is still its own probe, and the memo is consulted only after the\n  per-endpoint SSRF/authentication checks, so a call that would have been refused still is.\n- Probe order, per-endpoint SSRF and authentication rules, and the per-endpoint cap are\n  preserved.\n\nPublication is ordered by **generation, not by wall clock**. Each cold rebuild takes the\nnext sequence number (`_allocate_models_rebuild_seq`), and a result is dropped when it is\nolder than the newest *allocated* generation (`_models_rebuild_seq`) — in which case it also\nleaves the build flag alone rather than clearing it, because that flag now belongs to the\nnewer rebuild. This matters for the out-of-band publisher, which outlives the\nforeground caller: without the guard it could resurrect a superseded\ncatalog over a newer one. A timestamp comparison cannot express the ordering, because an\nolder build can publish *after* a newer build has already started.\n\nThe fence is the latest **allocated** generation rather than the latest *published* one\nbecause `invalidate_models_cache()` clears `_cache_build_in_progress` without cancelling an\nin-flight worker: a newer rebuild can be allocated while an older one is still running. If\nthe older worker finishes first, a published-sequence comparison would let it publish the\ninvalidated catalog and release the flag that now belongs to the newer rebuild. Comparing\nagainst the allocated generation rejects it, so invalidation is a real freshness boundary,\nand a newer rebuild that then fails leaves the cache empty instead of resurrecting the\ninvalidated catalog.\n\nBoth `invalidate_models_cache()` and the provider-scoped invalidator used by\n`/api/models/refresh` share one epoch/owner reset. They advance the allocated generation,\nclear memory and provenance, evict the relevant credential pools before admission reopens,\nretire the in-flight owner, and delete the durable cache.\nEvery build already running becomes superseded *even when no successor rebuild is ever\nallocated*; otherwise a delayed worker could repopulate the cleared catalog under a\nfresh-looking fingerprint. Disk snapshots read before taking the catalog lock (including\nthe session-visit warmer) are published only if their captured epoch still matches: a\npreloaded fresh candidate whose epoch moved is dropped rather than published under the\ncurrent fingerprint. The *degraded* stale fallback is deliberately not fenced — it is\nreturned to a caller that already stopped waiting and is never published to memory, so\ndiscarding it would only swap a stale-but-real answer for a static catalog.\n\nA build's **identity is captured when it starts and re-validated when it publishes**, in\nmemory and at the durable commit: the source fingerprint (`_models_cache_source_fingerprint`\n— config, auth store and provider catalog; its `config_yaml` axis is the profile-specific\nconfig path, so it fences a foreign profile too) plus the profile name. A build that outlives\na config edit describes sources that no longer exist, so its result is dropped instead of\nbeing published under the *current* fingerprint. The check fails closed — an identity that\ncannot be read is not a match — and disk publication records the fingerprint the build READ,\nnever a recomputed one.\n\nOwnership of the single-flight slot is explicit too: the completion path clears\n`_cache_build_in_progress` only when the completing build is still the newest allocated\ngeneration, including on the error paths and after the disk I/O. A superseded publisher\ntherefore cannot admit a third rebuild beside the newer one it lost to.\n\nThe **durable commit is fenced separately from the in-memory one**, because the file write\nruns with the catalog lock released (a disk write must not hold it). Each build writes its\nown temp file — unique per build and thread, in the destination directory so the commit stays\na same-filesystem atomic rename — and the rename is serialized by\n`_models_cache_disk_commit_lock` after re-checking the accepted generation, the last\ncommitted generation (`_models_disk_committed_seq`) and the build's source identity.\nThe catalog lock remains held from that check **through** rename; invalidation takes the\nsame fixed order (disk-commit lock, then catalog lock) through deletion, so an admitted\nwriter cannot restore a file after invalidation returns. A superseded or re-sourced\ncommit discards its temp file and leaves the durable catalog to the build that won.\n\n**The two locks have one order everywhere: disk-commit then catalog.** The writer and the\ninvalidator both take them that way, so a caller that takes them in the opposite order is a\ndeadlock, not a latency bug — and both acquisitions are unbounded in production. A foreground\npublisher used to: `get_available_models` owns the catalog lock across its whole cold path,\nand its synchronous, within-budget and budget-boundary winners called\n`_save_models_cache_to_disk` from inside that ownership, i.e. catalog → commit. A publisher\nblocked on the commit mutex an invalidator held, with that invalidator then blocked on the\ncatalog lock the publisher owned, waited on each other forever, so a `/api/models` load and a\nconfig-save/`/api/models/refresh` invalidation could each hang (#7481 review). The foreground\npaths therefore do not commit inline: the catalog lock still spans the whole cold path,\nincluding the foreground wait and memory publication (owner, source identity and the\nabsolute deadline are read there too). Only the durable commit moves outside: it is\n**queued** and run by\n`_DeferredCatalogPublication` on its way out — after the catalog lock has been released — by\n`_commit_models_cache_to_disk_after_lock`. The caller still gets the cache and the durable\nfile populated before `get_available_models` returns, and the single-flight release rides\nwith the queued commit, so ownership still spans the durable write. The out-of-band worker\ncommits directly, which is correct because it holds no catalog lock by then.\n\n### 4.12 Insights Period Endpoint (`/api/insights`)\n\n`GET /api/insights` reports usage analytics for one time window. The window is\nselected by query parameters, and the response echoes the window it actually\nserved through `mode` (`\"trailing\"` or `\"custom\"`). Totals and the daily series\nare always computed over the same resolved interval. Data comes from the WebUI\nsession index plus non-WebUI rows in the active profile's `state.db`, when that\ndatabase exists. The optional `sync_to_insights` setting mirrors WebUI usage\ninto `state.db`; it does not control whether this endpoint includes CLI rows.\n\nWindow selection:\n\n- `days=N` — trailing window of the last `N` days. This is what the panel's\n  7/30/90/365 presets send, and the fallback when no absolute bound is usable.\n- `start=…` / `end=…` — an absolute window. Each bound is either an exact Unix\n  epoch timestamp in seconds or a `YYYY-MM-DD` calendar date. A supplied\n  `start` with no `end` runs to the server clock. A supplied `end` with no\n  `start` back-steps the start by 30 days using the bound's own arithmetic: a\n  `YYYY-MM-DD` end subtracts 30 calendar days (local midnight to local\n  midnight, DST-safe), while a numeric end subtracts exactly `30 * 86400`\n  seconds — so across a daylight-saving change a numeric end's default start can\n  land on a different local date than the calendar one would.\n- The **Custom range…** selection sends the two `<input type=date>` values as\n  raw `YYYY-MM-DD` strings, not epoch seconds, so the server reads them in its\n  own timezone and a browser in another timezone cannot shift the selected\n  calendar day. The panel defaults the inputs to the last 30 calendar days only\n  when no range has been picked yet, so preset → Custom never erases a chosen\n  range.\n\nBounds and fallbacks:\n\n- A `YYYY-MM-DD` bound selects a whole local calendar day (start = that day's\n  local midnight; an end date's exclusive stop is the next local midnight,\n  except a date equal to today, which clamps to the server clock because that\n  day is not over). An epoch bound keeps its exact `[start, end)` precision.\n  Bounds are swapped if supplied reversed.\n- The window never extends into the future: an `end` beyond the server clock\n  is clamped to `now`.\n- Windows are clamped to five calendar years so the daily series cannot grow\n  unbounded.\n- The request fails closed to the trailing `days` window (`mode: \"trailing\"`)\n  when an absolute bound cannot be served faithfully: a supplied bound that is\n  unparseable (e.g. `2026-02-31`) or out of the supported epoch range, a\n  pre-epoch date (Chromium's date input accepts `0001-…`, and no session\n  predates 1970), or a strictly-future start. A supplied-but-invalid bound is\n  never treated as an omitted one, so a rejected request cannot fabricate an\n  interval the caller did not ask for. An explicit zero-length window pinned to\n  the clock itself (`start == now`) stays an empty custom interval rather than\n  silently serving the trailing window.\n\n### 4.12 Project Bindings (`/api/projects/bind`)\n\nA session project can carry **bindings**: a set of workspaces (one of them the\ndefault), a model, and — API-only since the dialog row is hidden and no session\napplies it yet — a stored reasoning effort. The chip's quick-create (`+`) button, and the top-level New Chat button\nwhile that project filter is active, open a new session already configured for\nthe project's context. Right-click\na project chip and pick **Project settings…** to edit them: one row in the chip\nmenu (no inline summary, no per-axis unbind rows), a dialog built on the shared\n`.app-dialog*` classes (skins, Escape, focus trap) that stays below the shared\napp dialog's z-index so a prompt it opens stacks on top, and a menu clamped to\nthe viewport.\n\n`POST /api/projects/bind` updates a single project by `project_id`. Every body\nfield is optional and only the supplied ones are touched:\n\n- `workspaces: [str]` — replaces the full bound list. Each entry passes the\n  same trusted-path check as `/api/session/new` (`validate_workspace_to_add`\n  then `resolve_trusted_workspace`) and is auto-registered in the saved\n  workspace list, so a path outside the default root can be bound in one step.\n  Entries are de-duplicated, keeping first-seen order. `null` clears the list\n  (and the default with it).\n- `default_workspace: str` — the workspace a new session starts in. It must be\n  a member of `workspaces`; a value outside the list is auto-added so the\n  invariant \"`default_workspace` ∈ `workspaces`\" always holds. Removing the\n  workspace that was the default also drops the default.\n- `workspace: str` — the legacy single-workspace field from before\n  multi-workspace bindings. It is still accepted and kept in sync as\n  `workspaces: [w]`, so pre-existing rows that only set `workspace` keep\n  working; `_project_workspaces` reads the list first and falls back to it.\n- `auto_assign: bool` — accepted and **stored but dormant**. The backfill\n  sweep it used to drive, the dialog toggle that set it and the\n  workspace-keyed filing of new sessions were split out into a follow-up PR, so\n  this build reads the stored value nowhere. It is shape-checked (a JSON\n  boolean) in the request pre-flight, like `reasoning_effort`, and `null`/`false`\n  still clears it.\n- `model` / `model_provider` / `reasoning_effort` — single-value bindings per\n  project; `null` clears that axis (the project is then unbound on it). The\n  dialog renders the model row only: it never submits `reasoning_effort`, so\n  saving a binding PRESERVES an existing effort value instead of clearing it —\n  and, since the UX re-gate of 2026-10-08, no session applies that stored value\n  either (see below).\n\n`reasoning_effort` is **stored but dormant**. `/api/projects/bind` still accepts\nit (and `null` still clears it), and the dialog still renders no row for it\n(re-gate 2026-10-07) — but since the UX re-gate of 2026-10-08 nothing forwards\nit into a new session, so"},"items":[{"name":"ARCHITECTURE.md","path":"ARCHITECTURE.md","rawUrl":"https://raw.githubusercontent.com/nesquena/hermes-webui/HEAD/ARCHITECTURE.md","title":"System Architecture & Module Boundaries","category":"project-spec","format":"markdown","content":"# Hermes Web UI: Developer and Architecture Guide\n\n> This document is the canonical reference for anyone (human or agent) working on the\n> Hermes Web UI. It covers the exact current state of the code, every design decision and\n> quirk discovered during development, and a phased architecture improvement roadmap that\n> runs in parallel with the feature roadmap in ROADMAP.md.\n>\n> Keep this document updated as architecture changes are made.\n\n> Current shipped build: `v0.51.792` (July 1, 2026).\n> Automated coverage: ~11,500 tests via `pytest tests/ --collect-only -q`. CI runs on\n> Python 3.11, 3.12, and 3.13 (3 parallel shards each) against every PR, plus a ruff\n> lint gate, a headless browser smoke test, and a Docker smoke test.\n>\n> Notable architecture state: the bootstrap and first-run onboarding flow own setup discovery; the default WebUI state directory is `~/.hermes/webui`; `ctl.sh` provides a daemon wrapper for homelab installs; chat streaming is still WebUI-owned SSE with stream-ownership guards, cancellation, async manual compression, and turn-journal audit plumbing; provider/model discovery is profile-aware with live-model cache invalidation and custom-provider scoping. (Version/test-count numbers above are a periodic snapshot — the authoritative source is the latest git tag and `pytest --collect-only`.)\n\n---\n\n## 1. Overview and Purpose\n\nThe Hermes Web UI is a lightweight web application that gives you a browser-based\ninterface to the Hermes agent that is functionally equivalent to the CLI. It is modeled on\nthe Claude-style interface: a sidebar for session management, a central chat area,\nand a demand-driven right panel used for workspace browsing and preview surfaces.\nThe right panel is closed by default on desktop and opens only when it is actively\nbeing used for browsing or previewing content.\n\nTo prevent a visible first-paint mismatch on refresh, `static/index.html` preloads the\nsaved workspace panel state into `document.documentElement.dataset.workspacePanel`\nbefore the main stylesheet loads. Desktop CSS honors that preload marker immediately,\nand `static/boot.js` keeps the dataset synchronized with the runtime panel state machine.\n\nThe design philosophy is deliberately minimal. There is no build step, no bundler, no\nfrontend framework. The Python server is split into a routing shell (server.py) and\nbusiness logic modules (api/). The frontend is seven vanilla JS modules loaded from static/.\nThis makes the code easy to modify from a terminal or by an agent.\n\nHermes-level chrome is intentionally consolidated: the sidebar has no dedicated brand header.\nInstead, the footer exposes a single \"Hermes WebUI\" launch button that opens one tabbed\ncontrol-center modal for global preferences, conversation import/export, and clear-conversation\nactions. The topbar remains focused on conversation context and the workspace/files toggle.\n\n---\n\n## 2. File Inventory\n\n    <repo>/\n    server.py              Thin routing shell + HTTP Handler + auth middleware.\n                           Delegates all route handling to api/routes.py.\n    bootstrap.py           One-shot launcher: optional agent install, deps, health wait, browser open.\n    start.sh               Thin wrapper around bootstrap.py for shell-based startup.\n    ctl.sh                 Daemon lifecycle wrapper (start/stop/restart/status/logs) for homelab installs.\n    pyproject.toml         Standard build metadata plus the Ruff lint gate; source-checkout launch surface still centers on bootstrap.py / start.sh / ctl.sh.\n    Dockerfile             python:3.12-slim container image\n    docker-compose.yml     Compose config with named volume and optional auth\n    .dockerignore          Excludes .git, tests/, .env* from Docker builds\n    api/\n      __init__.py          Package marker\n      agent_compat.py      Resolver for Hermes Agent names moved to sibling modules (compatibility-only)\n      auth.py              Optional password authentication, signed cookies, passkeys/WebAuthn\n      config.py            Discovery, globals, model detection, reloadable config\n      helpers.py           HTTP helpers: j(), bad(), require(), safe_resolve(), security headers\n      goals.py             Persistent-goal commands and profile-scoped native GoalManager bridge\n      models.py            Session model + CRUD, per-session profile tracking, CLI/state.db bridge\n      profiles.py          Profile state management, hermes_cli wrapper\n      onboarding.py        First-run onboarding status, real provider config writes, OAuth linking, readiness detection\n      routes.py            All GET + POST route handlers (if/elif dispatch, no decorators)\n      startup.py           Startup helpers: auto_install_agent_deps()\n      state_sync.py        state.db bridge — opt-in /insights usage/title sync; always mirrors the session workspace into sessions.cwd\n      streaming.py         SSE engine, run_agent, cancel, compression, HERMES_HOME save/restore\n      updates.py           Self-update check and release notes\n      upload.py            Multipart parser, file upload handler\n      workspace.py         File ops: list_dir, read_file_content, git detection, workspace helpers\n    static/\n      index.html           HTML template\n      style.css            All CSS incl. mobile responsive, themes + skins, KaTeX\n      ui.js                DOM helpers, renderMd, tool cards, context indicator, file tree\n      workspace.js         File preview, file ops, git badge, central api() fetch wrapper\n      sessions.js          Session CRUD, list rendering, collapsible groups, search, SSE sync\n      messages.js          send(), SSE event handlers, approval/clarify, transcript, recovery\n      panels.js            Cron, skills, memory, profiles, todo, settings (Control Center)\n      commands.js          Slash command registry, parser, autocomplete dropdown\n      boot.js              Event wiring, mobile nav, voice input, theme/skin boot, bfcache handler\n      onboarding.js        First-run wizard overlay, provider setup flow\n      i18n.js              Localization catalog (en, es, de, zh, zh-Hant, ru, …)\n      login.js             Login page + open-redirect guard\n      icons.js             Lucide icon path registry\n      sw.js                Service worker: offline shell cache, version-pinned assets\n    tests/\n      conftest.py          Isolated test server/state fixtures\n      ~1,150 test files    ~11,500 tests collected via pytest (run `pytest --collect-only -q` for exact)\n      test_regressions.py  Permanent regression gate\n    CONTRIBUTING.md        Contributor workflow and PR expectations.\n    ROADMAP.md             Feature and product roadmap document.\n    SPRINTS.md             Forward sprint plan with CLI + Claude parity targets.\n    ARCHITECTURE.md        THIS FILE.\n    TESTING.md             Manual browser test plan and automated coverage reference.\n    CHANGELOG.md           Release notes per version.\n    CONTRIBUTORS.md        Community credit roll (regenerated via the maintainer workspace script).\n    requirements.txt       Python dependencies.\n    .env.example           Sample environment variable overrides.\n\n> Per-file line counts intentionally omitted — they drift every release. Use\n> `git ls-files | xargs wc -l` (or your editor) for current sizes; the role of\n> each file above is the durable part.\n\nState directory (runtime data, separate from source):\n\n    ~/.hermes/webui/\n    sessions/          One JSON file per session: {session_id}.json\n    workspaces.json    Registered workspaces list\n    last_workspace.txt Last-used workspace path\n    settings.json      User settings (default model, workspace, send key, password hash)\n    projects.json      Session project groups (name, color, id)\n\nLog file:\n\n    ~/.hermes/webui/bootstrap-8787.log   start.sh/bootstrap background server log\n    ~/.hermes/webui.log                  ctl.sh daemon log\n\n---\n\n## 3. Runtime Environment\n\n### Context replay matching\n\n`api/streaming.py::_strip_replayed_context_items` filters replayed model-context\nblocks using longest-match statistics from a suffix automaton over reversed\nreplay keys. Its greedy walk still removes only blocks of at least three rows;\nshort repeats, row order, and provider-facing `api_content` identity are preserved.\nLong arc-summary identity is normalized once per row and indexed by role, provider\npayload and the existing 1,500-character prefix rule. Malformed unhashable roles\nretain an equality-scan fallback. For ordinary hashable roles/keys, matching uses\nO(existing + candidate) key operations and auxiliary space, in addition to payload\nnormalization. All indexes are call-local; no session cache, durable schema, writer\nownership, or SSE contract changes. Inputs are not mutated and retained rows are\nreturned unchanged. All three context-reconciliation branches share this helper.\n\n- Python interpreter: <agent-dir>/venv/bin/python\n- The venv has all Hermes agent dependencies (run_agent, tools/*, cron/*)\n- Server binds to 127.0.0.1:8787 (localhost only, not public internet)\n- Access from Mac: SSH tunnel: ssh -N -L 8787:127.0.0.1:8787 <user>@<your-server>\n- The server imports Hermes modules via sys.path.insert(0, parent_dir)\n\nSuffix/prefix replay matching uses a linear prefix-function scan and constructs\neach comparison key once, preserving the existing replay equality contract.\nRead-side stale-stream maintenance defers when the session writer lock is busy;\nit leaves stream ownership and pending input intact for a later repair pass.\n\nEnvironment variables controlling behavior:\n\n    HERMES_WEBUI_HOST              Bind address (default: 127.0.0.1)\n    HERMES_WEBUI_PORT              Port (default: 8787)\n    HERMES_WEBUI_DEFAULT_WORKSPACE Default workspace path for new sessions\n    HERMES_WEBUI_STATE_DIR         Where sessions/ folder lives\n    HERMES_CONFIG_PATH             Path to ~/.hermes/config.yaml\n    HERMES_WEBUI_DEFAULT_MODEL     Optional model override; unset means provider default\n    HERMES_WEBUI_PASSWORD          Optional: enable password auth (off by default)\n    HERMES_WEBUI_SKIP_ONBOARDING   Optional: bypass the first-run onboarding wizard\n    HERMES_PREFILL_MESSAGES_FILE   Optional JSON message list for browser-turn prefill context\n    HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT Optional command that prints JSON messages or plain-text user prefill context\n    HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT Optional script timeout in seconds (default 5, max 30)\n    HERMES_WEBUI_PREFILL_CONTEXT_MAX_CHARS Optional parsed prefill budget in characters (default 12000, 0 disables)\n    HERMES_HOME                    Base directory for Hermes state (~/.hermes by default)\n\nTest isolation environment variables (set by conftest.py):\n\n    HERMES_WEBUI_TEST_PORT=...                         Optional pinned test port\n    HERMES_WEBUI_TEST_STATE_DIR=/tmp/hermes-webui-tests/* Optional pinned test state (default: OS temp dir; must be outside ~/.hermes)\n    HERMES_WEBUI_DEFAULT_WORKSPACE=.../test-workspace  Isolated test workspace\n\nTests NEVER talk to the production server (port 8787).\nThe test state dir is wiped before each test session and deleted after.\nSee: <repo>/tests/conftest.py\n\nPer-request environment variables (set by chat handler, restored after):\n\n    TERMINAL_CWD         Set to session.workspace before running agent.\n                         The terminal tool reads this to default cwd.\n    HERMES_EXEC_ASK      Set to \"1\" to enable approval gate for dangerous commands.\n    HERMES_SESSION_KEY   Set to session_id. The approval tool keys pending entries\n                         by this value, enabling per-session approval state.\n    HERMES_HOME          Set to the active profile's directory before running agent.\n                         Saved and restored around each agent run.\n\nWARNING: These env vars are process-global. Two concurrent chat requests will clobber\neach other. This is safe only for single-user, single-concurrent-request use.\nSee Architecture Phase B for the fix.\n\n---\n\n## 4. Server Architecture: Current State\n\n### 4.1 HTTP Server Layer\n\nPython stdlib ThreadingHTTPServer (from http.server). Each HTTP request runs in its own\nthread. The Handler class subclasses BaseHTTPRequestHandler with two methods:\n\n    do_GET    Routes: /, /health, /api/session, /api/sessions, /api/list,\n                      /api/chat/stream, /api/file, /api/approval/pending,\n                      /api/session/worktree/status\n    do_POST   Routes: /api/upload, /api/session/new, /api/session/update,\n                      /api/session/delete, /api/chat/start, /api/chat,\n                      /api/approval/respond, /api/session/worktree/remove\n\nRouting is a flat if/elif chain inside each method. No routing framework.\n\nHelper functions used by all handlers:\n\n    j(handler, payload, status=200)     Sends JSON response with correct headers\n    t(handler, payload, status=200, ct) Sends plain text or HTML response\n    read_body(handler)                  Reads and JSON-parses the POST body\n\nCRITICAL ORDERING RULE in do_POST:\nThe /api/upload check MUST appear BEFORE calling read_body(). read_body() calls\nhandler.rfile.read() which consumes the HTTP body stream. The upload handler also\nneeds rfile (to read the multipart payload). If read_body() runs first on a multipart\nrequest, the upload handler receives an empty body and the upload silently fails.\n\n### 4.2 Session Model\n\nSession is a plain Python class (not a dataclass, not SQLAlchemy):\n\n    Fields:\n      session_id    hex string, 12 chars (uuid4().hex[:12])\n      title         string, auto-set from first user message\n      workspace     absolute path string, resolved at creation\n      model         model ID string (e.g. \"anthropic/claude-sonnet-4.6\")\n      messages      list of OpenAI-format message dicts\n      created_at    float Unix timestamp\n      updated_at    float Unix timestamp, updated on every save()\n      pinned        bool, default False (Sprint 12)\n      archived      bool, default False (Sprint 14)\n      project_id    string or null, FK to projects.json (Sprint 15)\n      tool_calls    list of tool call dicts (Sprint 10)\n\n    Key methods:\n      path (property)  Returns SESSION_DIR/{session_id}.json\n      save()           Writes __dict__ as pretty JSON to path, updates updated_at\n      load(cls, sid)   Class method: reads JSON from disk, returns Session or None\n      compact()        Returns metadata-only dict (no messages) for the session list\n\n    In-memory cache:\n      SESSIONS = {}    dict: session_id -> Session object\n      LOCK = threading.Lock()   defined but NOT currently used around SESSIONS access\n\n    get_session(sid): checks SESSIONS cache, loads from disk on miss, raises KeyError\n    new_session(workspace, model): creates Session, caches in SESSIONS, saves, returns\n    all_sessions(): scans SESSION_DIR/*.json + SESSIONS, deduplicates, sorts by updated_at,\n                    returns list of compact() dicts\n\n    all_sessions() does a full directory scan on every call.\n    With 10 sessions: negligible. With 1000+: will be slow.\n    See Architecture Phase C for the index file fix.\n\ntitle_from(): takes messages list, finds first user message, returns first 64 chars.\nCalled after run_conversation() completes to set the session title retroactively.\n\n#### Session transcript reconciliation with `state.db`\n\n`reconciled_state_db_messages_for_session()` uses\n`merge_session_messages_append_only()` to combine a WebUI sidecar or context\nprojection with active Agent `state.db` rows. Its explicit\n`incoming_provenance=\"state_db\"` fence permits a state-only row whose timestamp\npredates the sidecar tail to use a safe chronological slot. Paginated\n`msg_limit` consumers rely on this merged order directly rather than applying a\nlater timestamp sort.\n\nThe same merge helper also stitches ordered child sidecars onto archived\ncompression parents. Those calls leave incoming provenance unverified, so their\nstable message sequence remains append-only even when parent rows carry later,\nrestamped timestamps. Timestamp alone is never authority to move a continuation\ninside its parent transcript.\n\nIf an older row could only be placed before the first surviving sidecar/context\nrow, the insertion helper declines it to avoid resurrecting compacted history.\nReconciliation then appends that row instead of dropping it; rows without a\nusable timestamp and rows at or after the sidecar tail also append normally.\nThe fallback therefore preserves an accepted state-only row when exact ordering\nis ambiguous, while safely placeable recovery rows remain chronological.\n\n#### Imported `state.db` sidebar projection\n\n`api.models.get_cli_sessions()` projects conversations from the active Hermes\nprofile's `state.db` into sidebar-shaped rows. The default projection keeps\ninteractive sources (CLI, TUI, ACP, messaging, and similar user-facing sessions)\nin a bounded 20-row candidate window. Background sources use independent recovery\npasses so a high-volume worker source cannot consume that interactive window:\n\n- Cron: up to `CRON_PROJECT_CHIP_LIMIT` rows.\n- Webhook: up to `WEBHOOK_PROJECT_CHIP_LIMIT` rows.\n- Kanban: up to `KANBAN_PROJECT_CHIP_LIMIT` rows.\n- One-shot (`hermes -z`): kept out of the interactive window and its\n  unassigned refill, with no default pass of its own. They keep normal project\n  chips, so the assigned recovery pass still reaches one filed under a project.\n  An explicit `source_filter=\"oneshot\"` lists the newest ones, and a session\n  the user opens by id resolves through `get_cli_session_by_id()`.\n\nSource-specific views still use their dedicated bounds, and the later sidebar\nvisibility stage decides whether recovered background rows are shown. In\n`all_profiles=True` mode the per-profile source bounds are disabled before rows\nare merged; cross-profile scoping, visibility, deduplication, and final route\nlimits remain downstream responsibilities.\n\n#### Compression lineage and session-list invalidation\n\n`api.agent_sessions._is_continuation_session()` is the shared classifier for\nsidebar projection, lineage metadata/reporting, and `state.db` transcript\nstitching. It uses the direct parent link, no conflicting non-empty source,\n`compression` or `cli_close` parent end reason, and the existing two-second\n`started_at` overlap allowance. A `source=\"tool\"` child is always a separate\nconversation, even when its parent is also a tool session or has no source.\nA direct `_branched_from`, `_delegate_from`, or `_reset_from` marker in the\nchild's `model_config` also makes a boundary; inherited ancestor markers do\nnot. Malformed or unverifiable marker evidence fails closed as a boundary.\nThe overlap allowance is the existing master policy, not a new window set by\nthis change.\n\nThe gateway watcher's cheap database fingerprint includes `model_config`, so\na marker-only update causes a fresh projection. Its published-payload hash\ncovers every emitted session field, not just ID, activity time and message\ncount; a changed projected title or lineage field can therefore emit\n`sessions_changed` even without message-row churn.\n\n### 4.3 SSE Streaming Engine\n\nThis is the most architecturally interesting part. Two endpoints cooperate:\n\n    POST /api/chat/start     Receives the user message. Creates a queue.Queue, stores it\n                             in STREAMS[stream_id], spawns a daemon thread running\n                             _run_agent_streaming(), returns {stream_id} immediately.\n\n    GET  /api/chat/stream    Long-lived SSE connection. Reads from STREAMS[stream_id]\n                             and forwards events to the browser until 'done' or 'error'.\n\nQueue registry:\n\n    STREAMS = {}               dict: stream_id -> queue.Queue\n    STREAMS_LOCK = threading.Lock()\n\nSSE event types and their data shapes:\n\n    token       {\"text\": \"...\"}                         LLM token delta\n    tool        {\"name\": \"...\", \"preview\": \"...\"}       Tool invocation started\n    approval    {\"command\": \"...\", \"description\": \"...\", \"pattern_keys\": [...]}\n    done        {\"session\": {compact_fields + messages}} Agent finished successfully\n    error       {\"message\": \"...\", \"trace\": \"...\"}       Agent threw exception\n\nThe SSE handler loop:\n    - Blocks on queue.get(timeout=30)\n    - On timeout (no events in 30s): sends a heartbeat comment (\": heartbeat\n\n\")\n      to keep the connection alive through proxies and firewalls\n    - On 'done' or 'error' event: breaks the loop and returns\n    - Catches BrokenPipeError and ConnectionResetError silently (browser disconnected)\n\nStream cleanup: _run_agent_streaming() pops its stream_id from STREAMS in a finally\nblock. If the browser disconnects mid-stream, the daemon thread runs to completion and\nthen cleans up. The queue fills and the put_nowait() calls fail silently (queue.Full\nis caught).\n\nFallback sync endpoint: POST /api/chat still exists and holds the connection open until\nthe agent finishes. The frontend never uses it but it can be useful for debugging.\n\n### 4.4 Agent Invocation (_run_agent_streaming)\n\n    def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id):\n\n1. Fetches session from SESSIONS (not from disk -- session was just updated by /api/chat/start)\n2. Sets TERMINAL_CWD, HERMES_EXEC_ASK, HERMES_SESSION_KEY env vars\n3. Creates AIAgent with:\n   - model=model, platform='cli', quiet_mode=True\n   - enabled_toolsets=CLI_TOOLSETS (from config.yaml or hardcoded default)\n   - session_id=session_id\n   - stream_delta_callback=on_token (fires per token)\n   - tool_progress_callback=on_tool (fires per tool invocation)\n4. Calls agent.run_conversation(user_message=msg_text, conversation_history=s.messages,\n                                 task_id=session_id)\n   NOTE: keyword is task_id NOT session_id (common mistake, documented in skill)\n5. On return: updates s.messages, calls title_from(), saves session\n6. Puts ('done', {session: ...}) into queue\n7. Finally block: restores env vars, pops stream_id from STREAMS\n\non_token callback:\n    if text is None: return  # end-of-stream sentinel from AIAgent\n    put('token', {'text': text})\n\non_tool callback:\n    put('tool', {'name': name, 'preview': preview})\n    # Also immediately surface any pending approval:\n    if has_pending(session_id):\n        with _lock: p = dict(_pending.get(session_id, {}))\n        if p: put('approval', p)\n\nThe approval surface-on-tool logic means approvals appear immediately after the tool\nfires (within the same SSE stream), without waiting for the next poll cycle.\n\n### 4.5 Approval System Integration\n\nThe approval system uses the existing Hermes gateway module at tools/approval.py.\nAll state lives in module-level variables in that file:\n\n    _pending = {}        dict: session_key -> pending_entry_dict\n    _lock = Lock()       protects _pending\n    _permanent_approved  set of permanently approved pattern keys\n\nBecause server.py imports tools.approval at module load time and everything runs in the\nsame process, this state IS shared between HTTP threads and agent daemon threads.\n\nImportant: this only works because Python imports are cached (sys.modules). The same\nmodule object is used everywhere. If the approval module were ever imported in a subprocess\nor via importlib.reload(), this would break.\n\nGET /api/approval/pending:\n    - Peeks at _pending[sid] without removing it\n    - Returns {pending: entry} or {pending: null}\n    - Called by the browser every 1500ms while S.busy is true (polling fallback)\n\nPOST /api/approval/respond:\n    - Pops _pending[sid] (removes it)\n    - For choice \"once\" or \"session\": calls approve_session(sid, pattern_key) for each key\n    - For choice \"always\": calls approve_session + approve_permanent + save_permanent_allowlist\n    - For choice \"deny\": just pops, does nothing (agent gets denied result)\n    - Returns {ok: true, choice: choice}\n\n### 4.6 File Upload Parser\n\nparse_multipart(rfile, content_type, content_length):\n    - Reads all content_length bytes from rfile into memory (up to MAX_UPLOAD_BYTES, default 20MB, env-overridable via HERMES_WEBUI_MAX_UPLOAD_MB)\n    - Extracts boundary from Content-Type header\n    - Splits raw bytes on b'--' + boundary\n    - For each part: parses MIME headers via email.parser.HeaderParser\n    - Returns (fields, files) where fields is {name: value} and files is {name: (filename, bytes)}\n\nhandle_upload(handler):\n    - Calls parse_multipart()\n    - Validates: file field present, filename present, session exists\n    - Sanitizes filename: replaces non-word chars with _, truncates to 200 chars\n    - Writes bytes to session.workspace / safe_name\n    - Returns {filename, path, size}\n\nWhy not cgi.FieldStorage:\n    - Deprecated in Python 3.11+\n    - Broken for binary files (silently corrupts or throws)\n    - The manual parser handles all file types correctly\n\n### 4.7 File System Operations\n\nsafe_resolve(root, requested):\n    - Resolves requested path relative to root\n    - Calls .relative_to(root) to assert the result is inside root\n    - Raises ValueError on path traversal (../../etc/passwd)\n\nlist_dir(workspace, rel='.'):\n    - Calls safe_resolve, then iterdir()\n    - Sorts: directories first, then files, case-insensitive alpha within each group\n    - Returns up to 200 entries with {name, path, type, size}\n\nread_file_content(workspace, rel):\n    - Calls safe_resolve\n    - Enforces MAX_FILE_BYTES = 200KB size limit\n    - Reads as UTF-8 with errors='replace' (binary files show replacement chars)\n    - Returns {path, content, size, lines}\n\n### 4.8 Persistent Goal Profile Boundary\n\n`api/goals.py` exposes the WebUI `/goal` command payloads and post-turn evaluation hook.\nHermes Agent's native `GoalManager` is the authoritative owner of goal evaluation,\ncontinuation decisions, wait barriers, failure counters, contracts, subgoals, and\n`state.db` persistence.\n\nFor a profile-scoped WebUI session, the bridge delegates only when the Agent exposes\nboth the context-local `set_hermes_home_override()` API and call-time default\n`SessionDB` path resolution. The bridge probes the resolved default path under the\nselected context before constructing the native manager, then binds that profile's\nHermes home before every native call. The override is reset in a `finally` block after\nevery operation, so concurrent sessions using the same session ID under different\nprofiles cannot cross-read or cross-write goal state. Goal snapshot rollback uses the\nsame scoped native persistence path.\n\nOlder Hermes Agent versions that lack either capability continue through\n`_LegacyProfileGoalManager`, which pins persistence to the selected profile's explicit\n`state.db` path. This includes intermediate versions whose context API is present but\nwhose default `SessionDB()` path remains frozen at module import. Keep this fallback\ncompatibility-only: new goal semantics belong in Hermes Agent's native manager rather\nthan a second WebUI implementation.\n\n\n### 4.9 Hermes Agent Moved-Name Compatibility\n\nHermes Agent owns its module layout. Its September 2026 decomposition moved names the\nWebUI uses (for example `tools.approval.set_current_session_key` to\n`tools.approval_context`) into `<stem>_<topic>` sibling modules. The old paths resolve\nonly through temporary PLUGIN-COMPAT `__getattr__` pointers that emit\n`HermesPluginCompatWarning` and are removed on schedule. The Agent's\n`compat_manifest.json` is the authoritative map of what moved where.\n\nWebUI code reaches a moved name only through\n`api.agent_compat.agent_attr(owner, name, home, default=...)`, which resolves in this\norder:\n\n1. the owner module's own namespace: pre-split Agents, and tests that stub the original\n   module in `sys.modules` or patch the name onto it;\n2. the `home` module: split Agents, with or without the old-path pointer (no warning);\n3. plain attribute access on the owner: non-module test doubles.\n\nIt raises like the import it replaces (or returns `default`), so each call site keeps its\nexisting fallback. Current users: approval session identity and MCP discovery\n(`streaming.py`), `/reload-mcp` (`commands.py`), MCP runtime status (`routes.py`), Claude\nCode credential linking (`oauth.py`), LM Studio reasoning options (`config.py`), and\nkanban connections and dispatch (`kanban_bridge.py`).\n\nImport names that are still native to their module directly. Never\n`from <old module> import <moved name>`, and never feature-detect a moved name with\n`hasattr`/`getattr` on the old module: once the pointers are removed those silently turn\n\"moved\" into \"missing\" behind the call sites' broad `except` blocks. When a later Agent\nsplit moves another name, route it through `agent_attr` and add pre-split and\npointer-removed cases to `tests/test_agent_compat.py`. The resolver is\ncompatibility-only: delete it, and import directly from the new homes, once the WebUI\nstops supporting Agents that predate the split.\n\n### 4.10 MCP Runtime Profile Boundary\n\nHermes Agent owns the in-process MCP ledger. It keys a connection by\n`(profile_home_key, name)` and registers its tools in that profile's registry overlay\nonly when the calling task serves a *routed* profile: the context-local Hermes-home\noverride differs from the process home. Otherwise the connection uses the bare server\nname and the global registry slot, which belong to the process profile.\n\n- `api.profiles._set_hermes_home()` is the single writer of the process-profile home:\n  startup (`init_profile_state()`) and `switch_profile(process_wide=True)` both go\n  through it, so `get_process_profile_home()` and the Agent pin\n  (`hermes_constants.pin_process_hermes_home()`, when the Agent provides it) are updated\n  in the same step as `HERMES_HOME`. Streaming turns still mirror their profile into\n  `os.environ['HERMES_HOME']` for legacy readers; without the pin, that mirror makes a\n  turn's own profile look like the process profile, so same-named servers of different\n  profiles share one bare-name connection.\n- `/api/mcp/servers`, `/api/mcp/tools`, `/api/notes/sources` and `/reload-mcp` run\n  their Agent calls inside `api.mcp_runtime.mcp_runtime_scope()`, which binds the\n  request profile's home and secret scope (root profile included) without touching\n  `os.environ`. Status, tool count and inventory are read from one scope; the inventory\n  lists only tools in that profile's own registry slot for servers it configures.\n- A connection *serves* a profile when that profile owns it (`_server_scope_keys`, else\n  the scope in the key) or adopted it (`_server_tool_scopes`, an identical shared\n  connection). `api.mcp_runtime.ledger_key_serves_view()` is the one predicate for\n  status, inventory and the reload summary. The Agent's launch-profile view (scope\n  `None`) is process-wide, so `filter_runtime_status_to_view()` downgrades\n  `get_mcp_status()` rows of servers the root profile does not serve to `configured`\n  rather than showing a routed profile's connection, tool count or connect error.\n- The scope is trusted only when the Agent's routing decision matches the profile WebUI\n  resolved. When it cannot be confirmed (for example a same-profile turn's mirror on an\n  Agent without the pin), status and inventory withhold runtime data\n  (`runtime_scope: \"unavailable\"`, shown as a notice in the MCP panel) and\n  `/reload-mcp` refuses instead of resetting another owner's connection.\n- `/reload-mcp` calls `shutdown_mcp_servers(scope=..., names=...)` with the profile's\n  own scope and the names of its live connections; `scope=None` without `names` is the\n  process-wide wildcard and is used only with Agents that predate profile-scoped MCP\n  (`runtime_scope: \"legacy_process\"`). A scoped shutdown only clears connect backoff for\n  the live keys it tears down, so WebUI also drops the profile's own cooldown/error\n  entries (`clear_profile_connect_cooldowns()`) before rediscovery: a server that failed\n  to spawn is retried by the reload, as the wildcard did, without touching another\n  owner's backoff.\n\nStatus and inventory stay passive: they never start or probe an MCP server. Ledger key\nhelpers resolve to Hermes Agent's `tools.mcp_tool_scope` when present so the key shape\nhas one owner; the local fallbacks only cover Agents that predate that module.\n\n#### MCP configuration writes\n\nMCP create/update, toggle and delete are separate from Agent runtime ownership.\nEach edit resolves the existing `_get_config_path()` once under `_cfg_lock`,\nreads a private raw YAML mapping, and commits to that same path with the existing\natomic writer. `HERMES_CONFIG_PATH` keeps its documented precedence; this does\nnot redirect operator-configured files or change profile resolution.\n\nRaw `${ENV_VAR}` references, unrelated configuration and masked credentials keep\ntheir original stored values. YAML-aliased containers and the selected server\nentry are detached before mutation so an edit cannot change a sibling or template. Runtime-expanded cache dictionaries are never the\nwrite source. An unreadable, malformed or non-mapping existing document aborts\nwithout overwriting it; a missing/empty document can be initialized. Failed disk\nwrites do not publish uncommitted mutations into the runtime cache. Reloading\nthat cache and sending HTTP responses happen after the lock is released.\n\nThis transaction serializes cooperating writes/reloads in one WebUI process.\nIt does not lock out other processes or provide Agent status/schema/dispatch\nisolation; those remain the runtime boundary described above.\n\n### 4.11 Model Catalog Rebuild Budget and Probe Scheduling\n\nThe model picker's catalog (`GET /api/models`) is produced by one cold rebuild that\nlive-probes providers. A probe is a network call to a provider the WebUI may not be able\nto reach — a LAN LM Studio/Ollama host is the usual case, because the probe runs in the\nserver process rather than the browser — so the rebuild runs on a daemon worker under a\nwall-clock budget:\n\n| Knob | Default | Meaning |\n| --- | --- | --- |\n| `HERMES_WEBUI_MODELS_REBUILD_BUDGET` | `4` (seconds) | Window a foreground caller waits for a cold rebuild. `0` restores the legacy synchronous unbounded rebuild. |\n| `CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS` | `5.0` | Per-endpoint cap for a custom provider `/v1/models` probe. |\n| `CUSTOM_MODELS_ATTEMPT_ONLY_TIMEOUT_SECONDS` | `0.01` | Attempt-only timeout for a probe reached after the window is already spent while the caller is still waiting. Not a per-probe minimum and not a slice — see below. |\n\nWithin the window the rebuild behaves normally; past it the caller is served a fallback\n(last-known disk cache, else a network-free static catalog) while the worker keeps going so\nthe refresh lands out-of-band for the next caller.\n\nCustom endpoints are probed **serially**: the active `model.base_url` first, then each\nnamed `custom_providers` entry in config order, then the LM Studio provider-group\nfallback. `_CustomProbeSchedule` in `api/config.py` hands each of those probes a fair\nslice of the *remaining* window instead of letting one probe take the whole per-endpoint\ncap, so an unreachable endpoint cannot leave the reachable providers behind it with no\nin-band probe at all (#7481).\n\n- Each slice is `min(cap, left / (remaining + 0.1))`, with **no lower bound**. The fractional\n  slot reserves headroom without halving a lone target's window. Unused time is lent to\n  later probes; the matching active/LM Studio target does not reserve a duplicate slot.\n  Wall-clock overhead can still cross the foreground deadline. A floor would make\n  the chain's cumulative spend grow with the endpoint count, and `custom_providers` has no\n  count limit, so a long enough chain of dead endpoints could again outspend the window and\n  push the reachable providers behind it out of the in-band rebuild.\n- The window belongs to the **caller, not to the chain**. One absolute deadline is captured\n  before the worker and the foreground wait start and is handed to `_CustomProbeSchedule`;\n  the foreground waits only `max(0, deadline - now)`. A schedule that minted its own deadline\n  at construction — deep inside the worker, after provider detection, the live id lookups and\n  the profile rebind — charged that discovery work to the caller's wait *and* granted it to\n  the chain again, so the chain could still be probing after the caller had already been\n  served the over-budget fallback, and the reachable provider landed out-of-band at best.\n- The two states that keep the unthrottled per-endpoint cap are stated explicitly rather\n  than inferred from the clock, because a spent window says nothing about whether anyone is\n  still waiting:\n  - the legacy unbounded path (budget `0`), which has no window to share;\n  - the out-of-band continuation — the worker still probing after the foreground caller\n    gave up and served its fallback. It is recognised through an explicit signal (the\n    caller sets the event when it stops waiting) and is the only in-process state allowed\n    to spend past the window. The narrow race where a probe finds the window spent while\n    the caller *is* still waiting is not that state: it gets the attempt-only timeout\n    above, never the cap.\n- A slice can truncate healthy endpoints even in short chains. Such timeouts are not\n  reported as unreachable and the partial catalog is never cached in memory or on disk.\n  The same worker retries truncated targets once at the full endpoint cap after the\n  foreground returns, retaining successful probes and publishing the complete catalog\n  through the existing generation, source-identity, ownership and durable fences.\n- An endpoint is probed **once per rebuild, except for that full-cap retry**. The three live-probe consumers\n  (active `model.base_url`, named `custom_providers`, LM Studio provider-group fallback)\n  share a per-rebuild memo keyed by the complete request URL (path slashes and query\n  preserved) plus the Authorization header sent, so the\n  common config where the active endpoint and `providers.lmstudio.base_url` are the same\n  LAN host no longer pays that host's connect timeout twice, and two named entries on one\n  endpoint probe it once. A different URL — or the same URL with a different key, which can\n  change the outcome — is still its own probe, and the memo is consulted only after the\n  per-endpoint SSRF/authentication checks, so a call that would have been refused still is.\n- Probe order, per-endpoint SSRF and authentication rules, and the per-endpoint cap are\n  preserved.\n\nPublication is ordered by **generation, not by wall clock**. Each cold rebuild takes the\nnext sequence number (`_allocate_models_rebuild_seq`), and a result is dropped when it is\nolder than the newest *allocated* generation (`_models_rebuild_seq`) — in which case it also\nleaves the build flag alone rather than clearing it, because that flag now belongs to the\nnewer rebuild. This matters for the out-of-band publisher, which outlives the\nforeground caller: without the guard it could resurrect a superseded\ncatalog over a newer one. A timestamp comparison cannot express the ordering, because an\nolder build can publish *after* a newer build has already started.\n\nThe fence is the latest **allocated** generation rather than the latest *published* one\nbecause `invalidate_models_cache()` clears `_cache_build_in_progress` without cancelling an\nin-flight worker: a newer rebuild can be allocated while an older one is still running. If\nthe older worker finishes first, a published-sequence comparison would let it publish the\ninvalidated catalog and release the flag that now belongs to the newer rebuild. Comparing\nagainst the allocated generation rejects it, so invalidation is a real freshness boundary,\nand a newer rebuild that then fails leaves the cache empty instead of resurrecting the\ninvalidated catalog.\n\nBoth `invalidate_models_cache()` and the provider-scoped invalidator used by\n`/api/models/refresh` share one epoch/owner reset. They advance the allocated generation,\nclear memory and provenance, evict the relevant credential pools before admission reopens,\nretire the in-flight owner, and delete the durable cache.\nEvery build already running becomes superseded *even when no successor rebuild is ever\nallocated*; otherwise a delayed worker could repopulate the cleared catalog under a\nfresh-looking fingerprint. Disk snapshots read before taking the catalog lock (including\nthe session-visit warmer) are published only if their captured epoch still matches: a\npreloaded fresh candidate whose epoch moved is dropped rather than published under the\ncurrent fingerprint. The *degraded* stale fallback is deliberately not fenced — it is\nreturned to a caller that already stopped waiting and is never published to memory, so\ndiscarding it would only swap a stale-but-real answer for a static catalog.\n\nA build's **identity is captured when it starts and re-validated when it publishes**, in\nmemory and at the durable commit: the source fingerprint (`_models_cache_source_fingerprint`\n— config, auth store and provider catalog; its `config_yaml` axis is the profile-specific\nconfig path, so it fences a foreign profile too) plus the profile name. A build that outlives\na config edit describes sources that no longer exist, so its result is dropped instead of\nbeing published under the *current* fingerprint. The check fails closed — an identity that\ncannot be read is not a match — and disk publication records the fingerprint the build READ,\nnever a recomputed one.\n\nOwnership of the single-flight slot is explicit too: the completion path clears\n`_cache_build_in_progress` only when the completing build is still the newest allocated\ngeneration, including on the error paths and after the disk I/O. A superseded publisher\ntherefore cannot admit a third rebuild beside the newer one it lost to.\n\nThe **durable commit is fenced separately from the in-memory one**, because the file write\nruns with the catalog lock released (a disk write must not hold it). Each build writes its\nown temp file — unique per build and thread, in the destination directory so the commit stays\na same-filesystem atomic rename — and the rename is serialized by\n`_models_cache_disk_commit_lock` after re-checking the accepted generation, the last\ncommitted generation (`_models_disk_committed_seq`) and the build's source identity.\nThe catalog lock remains held from that check **through** rename; invalidation takes the\nsame fixed order (disk-commit lock, then catalog lock) through deletion, so an admitted\nwriter cannot restore a file after invalidation returns. A superseded or re-sourced\ncommit discards its temp file and leaves the durable catalog to the build that won.\n\n**The two locks have one order everywhere: disk-commit then catalog.** The writer and the\ninvalidator both take them that way, so a caller that takes them in the opposite order is a\ndeadlock, not a latency bug — and both acquisitions are unbounded in production. A foreground\npublisher used to: `get_available_models` owns the catalog lock across its whole cold path,\nand its synchronous, within-budget and budget-boundary winners called\n`_save_models_cache_to_disk` from inside that ownership, i.e. catalog → commit. A publisher\nblocked on the commit mutex an invalidator held, with that invalidator then blocked on the\ncatalog lock the publisher owned, waited on each other forever, so a `/api/models` load and a\nconfig-save/`/api/models/refresh` invalidation could each hang (#7481 review). The foreground\npaths therefore do not commit inline: the catalog lock still spans the whole cold path,\nincluding the foreground wait and memory publication (owner, source identity and the\nabsolute deadline are read there too). Only the durable commit moves outside: it is\n**queued** and run by\n`_DeferredCatalogPublication` on its way out — after the catalog lock has been released — by\n`_commit_models_cache_to_disk_after_lock`. The caller still gets the cache and the durable\nfile populated before `get_available_models` returns, and the single-flight release rides\nwith the queued commit, so ownership still spans the durable write. The out-of-band worker\ncommits directly, which is correct because it holds no catalog lock by then.\n\n### 4.12 Insights Period Endpoint (`/api/insights`)\n\n`GET /api/insights` reports usage analytics for one time window. The window is\nselected by query parameters, and the response echoes the window it actually\nserved through `mode` (`\"trailing\"` or `\"custom\"`). Totals and the daily series\nare always computed over the same resolved interval. Data comes from the WebUI\nsession index plus non-WebUI rows in the active profile's `state.db`, when that\ndatabase exists. The optional `sync_to_insights` setting mirrors WebUI usage\ninto `state.db`; it does not control whether this endpoint includes CLI rows.\n\nWindow selection:\n\n- `days=N` — trailing window of the last `N` days. This is what the panel's\n  7/30/90/365 presets send, and the fallback when no absolute bound is usable.\n- `start=…` / `end=…` — an absolute window. Each bound is either an exact Unix\n  epoch timestamp in seconds or a `YYYY-MM-DD` calendar date. A supplied\n  `start` with no `end` runs to the server clock. A supplied `end` with no\n  `start` back-steps the start by 30 days using the bound's own arithmetic: a\n  `YYYY-MM-DD` end subtracts 30 calendar days (local midnight to local\n  midnight, DST-safe), while a numeric end subtracts exactly `30 * 86400`\n  seconds — so across a daylight-saving change a numeric end's default start can\n  land on a different local date than the calendar one would.\n- The **Custom range…** selection sends the two `<input type=date>` values as\n  raw `YYYY-MM-DD` strings, not epoch seconds, so the server reads them in its\n  own timezone and a browser in another timezone cannot shift the selected\n  calendar day. The panel defaults the inputs to the last 30 calendar days only\n  when no range has been picked yet, so preset → Custom never erases a chosen\n  range.\n\nBounds and fallbacks:\n\n- A `YYYY-MM-DD` bound selects a whole local calendar day (start = that day's\n  local midnight; an end date's exclusive stop is the next local midnight,\n  except a date equal to today, which clamps to the server clock because that\n  day is not over). An epoch bound keeps its exact `[start, end)` precision.\n  Bounds are swapped if supplied reversed.\n- The window never extends into the future: an `end` beyond the server clock\n  is clamped to `now`.\n- Windows are clamped to five calendar years so the daily series cannot grow\n  unbounded.\n- The request fails closed to the trailing `days` window (`mode: \"trailing\"`)\n  when an absolute bound cannot be served faithfully: a supplied bound that is\n  unparseable (e.g. `2026-02-31`) or out of the supported epoch range, a\n  pre-epoch date (Chromium's date input accepts `0001-…`, and no session\n  predates 1970), or a strictly-future start. A supplied-but-invalid bound is\n  never treated as an omitted one, so a rejected request cannot fabricate an\n  interval the caller did not ask for. An explicit zero-length window pinned to\n  the clock itself (`start == now`) stays an empty custom interval rather than\n  silently serving the trailing window.\n\n### 4.12 Project Bindings (`/api/projects/bind`)\n\nA session project can carry **bindings**: a set of workspaces (one of them the\ndefault), a model, and — API-only since the dialog row is hidden and no session\napplies it yet — a stored reasoning effort. The chip's quick-create (`+`) button, and the top-level New Chat button\nwhile that project filter is active, open a new session already configured for\nthe project's context. Right-click\na project chip and pick **Project settings…** to edit them: one row in the chip\nmenu (no inline summary, no per-axis unbind rows), a dialog built on the shared\n`.app-dialog*` classes (skins, Escape, focus trap) that stays below the shared\napp dialog's z-index so a prompt it opens stacks on top, and a menu clamped to\nthe viewport.\n\n`POST /api/projects/bind` updates a single project by `project_id`. Every body\nfield is optional and only the supplied ones are touched:\n\n- `workspaces: [str]` — replaces the full bound list. Each entry passes the\n  same trusted-path check as `/api/session/new` (`validate_workspace_to_add`\n  then `resolve_trusted_workspace`) and is auto-registered in the saved\n  workspace list, so a path outside the default root can be bound in one step.\n  Entries are de-duplicated, keeping first-seen order. `null` clears the list\n  (and the default with it).\n- `default_workspace: str` — the workspace a new session starts in. It must be\n  a member of `workspaces`; a value outside the list is auto-added so the\n  invariant \"`default_workspace` ∈ `workspaces`\" always holds. Removing the\n  workspace that was the default also drops the default.\n- `workspace: str` — the legacy single-workspace field from before\n  multi-workspace bindings. It is still accepted and kept in sync as\n  `workspaces: [w]`, so pre-existing rows that only set `workspace` keep\n  working; `_project_workspaces` reads the list first and falls back to it.\n- `auto_assign: bool` — accepted and **stored but dormant**. The backfill\n  sweep it used to drive, the dialog toggle that set it and the\n  workspace-keyed filing of new sessions were split out into a follow-up PR, so\n  this build reads the stored value nowhere. It is shape-checked (a JSON\n  boolean) in the request pre-flight, like `reasoning_effort`, and `null`/`false`\n  still clears it.\n- `model` / `model_provider` / `reasoning_effort` — single-value bindings per\n  project; `null` clears that axis (the project is then unbound on it). The\n  dialog renders the model row only: it never submits `reasoning_effort`, so\n  saving a binding PRESERVES an existing effort value instead of clearing it —\n  and, since the UX re-gate of 2026-10-08, no session applies that stored value\n  either (see below).\n\n`reasoning_effort` is **stored but dormant**. `/api/projects/bind` still accepts\nit (and `null` still clears it), and the dialog still renders no row for it\n(re-gate 2026-10-07) — but since the UX re-gate of 2026-10-08 nothing forwards\nit into a new session, so","isInternal":false,"tokens":11025,"sizeBytes":50084},{"name":"DESIGN.md","path":"DESIGN.md","rawUrl":"https://raw.githubusercontent.com/nesquena/hermes-webui/HEAD/DESIGN.md","title":"Hermes Calm Console","category":"project-spec","format":"markdown","content":"---\nversion: alpha\nname: Hermes Calm Console\ndescription: \"A restrained agent control surface: conversational content first, tool traces as quiet metadata, minimal chrome.\"\ncolors:\n  primary: \"#EAE0D5\"\n  secondary: \"#C6AC8F\"\n  tertiary: \"#C6AC8F\"\n  neutral: \"#0A0908\"\n  surface: \"#22333B\"\n  surfaceSubtle: \"#11100E\"\n  borderSubtle: \"#3B4A50\"\n  ink: \"#0A0908\"\n  success: \"#86C08B\"\n  warning: \"#E0B15D\"\n  error: \"#F87171\"\ntypography:\n  body-md:\n    fontFamily: \"Georgia, Times New Roman, serif\"\n    fontSize: 15px\n    fontWeight: 400\n    lineHeight: 1.68\n  body-sm:\n    fontFamily: \"-apple-system, BlinkMacSystemFont, Segoe UI, Inter, system-ui, sans-serif\"\n    fontSize: 12px\n    fontWeight: 400\n    lineHeight: 1.45\n  user-message:\n    fontFamily: \"-apple-system, BlinkMacSystemFont, Segoe UI, Inter, system-ui, sans-serif\"\n    fontSize: 14px\n    fontWeight: 400\n    lineHeight: 1.55\n  mono-xs:\n    fontFamily: \"SF Mono, ui-monospace, monospace\"\n    fontSize: 11px\n    fontWeight: 500\n    lineHeight: 1.55\nrounded:\n  sm: 4px\n  md: 8px\n  lg: 12px\n  pill: 999px\nspacing:\n  xs: 4px\n  sm: 8px\n  md: 12px\n  lg: 16px\ncomponents:\n  app-shell:\n    backgroundColor: \"{colors.neutral}\"\n    textColor: \"{colors.primary}\"\n    rounded: \"{rounded.sm}\"\n    padding: 16px\n  panel:\n    backgroundColor: \"{colors.surface}\"\n    textColor: \"{colors.primary}\"\n    rounded: \"{rounded.lg}\"\n    padding: 16px\n  border-line:\n    backgroundColor: \"{colors.borderSubtle}\"\n    textColor: \"{colors.primary}\"\n    rounded: \"{rounded.sm}\"\n    padding: 4px\n  state-success:\n    backgroundColor: \"{colors.success}\"\n    textColor: \"{colors.ink}\"\n    rounded: \"{rounded.sm}\"\n    padding: 4px\n  state-warning:\n    backgroundColor: \"{colors.warning}\"\n    textColor: \"{colors.ink}\"\n    rounded: \"{rounded.sm}\"\n    padding: 4px\n  state-error:\n    backgroundColor: \"{colors.error}\"\n    textColor: \"{colors.ink}\"\n    rounded: \"{rounded.sm}\"\n    padding: 4px\n  tool-call-group:\n    backgroundColor: \"{colors.neutral}\"\n    textColor: \"{colors.secondary}\"\n    rounded: \"{rounded.md}\"\n    padding: 4px\n  tool-card:\n    backgroundColor: \"{colors.surfaceSubtle}\"\n    textColor: \"{colors.secondary}\"\n    rounded: \"{rounded.md}\"\n    padding: 8px\n  user-message:\n    backgroundColor: \"{colors.tertiary}\"\n    textColor: \"{colors.ink}\"\n    rounded: \"{rounded.lg}\"\n    padding: 12px\n---\n\n## Overview\n\nHermes WebUI should feel like a calm developer console, not a demo page assembled from colorful cards. The primary artifact is the conversation. Tool calls, thinking traces, context compaction records, token usage, and runtime status are useful, but they are transcript metadata and should sit below the visual priority of user and assistant prose.\n\nThe desired direction is Linear/Vercel precision with a little Claude-style conversational warmth: quiet surfaces, clear spacing, restrained accent use, and progressive disclosure for debugging detail.\n\n## Colors\n\n- **Primary (#EAE0D5):** main text on dark surfaces. The warm parchment should feel readable and grounded, not like bright white terminal text.\n- **Secondary/Tertiary (#C6AC8F):** metadata and restrained accent. Use sparingly for active state, focus, user bubbles, and quiet emphasis.\n- **Neutral (#0A0908):** app background and ink. This gives the WebUI depth without returning to the previous navy/gold theme.\n- **Surface (#22333B):** panels, sidebar, and stronger interactive surfaces. It should carry the structure while the conversation remains primary.\n- **Light surfaces (#EAE0D5 / #F4EEE7):** light mode uses the palette's parchment as the field and a slightly lifted derived surface for panels.\n- **Semantic colors:** success/warning/error/info are state colors only, not decorative palette choices.\n\n## Typography\n\nUse Claude-like split typography: assistant prose gets an editorial serif stack (Georgia as the available substitute for Anthropic Serif), while user bubbles and functional UI stay in a crisp sans stack. This keeps the bot voice calmer and more readable without making controls feel bookish. Use monospace only for code, file paths, commands, tool names, and compact metadata. Avoid making whole cards feel like terminal output unless they actually are logs.\n\nScale should stay tight: 11px metadata, 12px labels, 14px body, 16–18px headings. Do not proliferate 10px/10.5px/12.5px one-offs unless there is a real layout constraint.\n\n## Layout\n\nConversation rhythm:\n\n1. User message — right aligned, compact bubble.\n2. Assistant content — left aligned, prose-first, no heavy bubble.\n3. Tool/thinking/context traces — quiet disclosure rows inside the assistant turn.\n4. Raw logs/details — hidden until explicitly expanded.\n\nMetadata should not break the reading flow. A turn that used ten tools should read as one assistant turn with one compact `Used 10 tools` disclosure, not ten content cards.\n\n## Elevation & Depth\n\nUse almost no shadows in the transcript. Shadows are reserved for popovers, dropdowns, modal dialogs, and floating controls. Cards inside chat should use either a subtle border or a subtle tint, not both aggressively.\n\n## Shapes\n\n- Rows/list items: `4–8px` radius.\n- Cards/panels: `8–12px` radius.\n- Pills: only true chips/badges use `999px`.\n- Avoid stacks of nested rounded rectangles. If a card contains another card, one of them is probably unnecessary.\n\n## Components\n\n### Tool/thinking activity group\n\nCollapsed by default in settled history and during live runs unless the user has explicitly opened that Activity row before. Persist open/closed disclosure state per chat and per turn, so switching away from a chat and coming back preserves the mode the user left it in. Summary line uses one disclosure for internals and stays intentionally terse, e.g. `Activity: 4 tools`. It should not repeat the always-present thinking area, list individual tool names, or add a second trailing count badge. Expanding reveals thinking and individual tool cards together. Thinking and tools should not create separate transcript rows unless there is an error or approval state that needs attention.\n\n### Tool card\n\nA tool card is a debug event row, not a chat message. Show icon, name, short target/preview, and status. Arguments and result snippets stay behind expansion. Result snippets should be truncated; full logs belong behind “show more”.\n\n### Thinking/context cards\n\nSame visual family as tool-call metadata. They should be quieter than assistant prose and should not use bright tinted full cards unless the user expands them.\n\nAutomatic compression follows a quiet live-only divider treatment rather than a\ntool-card row. Use `Compressing context` for the active barrier and\n`Context auto-compressed` after continuation/completion; render both as centered\nnon-interactive text with horizontal rules. Do not give it a caret, click\ntarget, distinct accent color, special leading dot, or separate card identity.\nOnce the final answer is settled, omit the live-only compression row unless it is\nneeded to explain a visible recovery or error state.\n\n### Composer\n\nThe composer is the command surface. Keep it legible and focused: modest radius, subtle border, transparent inactive chips, no theatrical hover scaling.\n\n## Do's and Don'ts\n\nDo:\n\n- Collapse noisy agent internals by default.\n- Use one accent color at a time.\n- Prefer neutral borders and restrained surfaces.\n- Make debug traces accessible and inspectable without making them visually dominant.\n- Add stable class/data hooks for future visual regression tests.\n\nDon't:\n\n- Render every tool call as a first-class chat card.\n- Mix gold, cyan, purple, orange, red, and green as decorative colors in the same viewport.\n- Add new hardcoded radius/color values when a token exists.\n- Use shadows, gradients, and hover transforms for routine controls.\n- Hide important error or approval states; those are allowed to be prominent because they require action.\n","frontmatter":{"version":"alpha","name":"Hermes Calm Console","description":"A restrained agent control surface: conversational content first, tool traces as quiet metadata, minimal chrome.","colors":{"primary":"#EAE0D5","secondary":"#C6AC8F","tertiary":"#C6AC8F","neutral":"#0A0908","surface":"#22333B","surfaceSubtle":"#11100E","borderSubtle":"#3B4A50","ink":"#0A0908","success":"#86C08B","warning":"#E0B15D","error":"#F87171"},"typography":{"body-md":{"fontFamily":"Georgia, Times New Roman, serif","fontSize":"15px","fontWeight":400,"lineHeight":1.68},"body-sm":{"fontFamily":"-apple-system, BlinkMacSystemFont, Segoe UI, Inter, system-ui, sans-serif","fontSize":"12px","fontWeight":400,"lineHeight":1.45},"user-message":{"fontFamily":"-apple-system, BlinkMacSystemFont, Segoe UI, Inter, system-ui, sans-serif","fontSize":"14px","fontWeight":400,"lineHeight":1.55},"mono-xs":{"fontFamily":"SF Mono, ui-monospace, monospace","fontSize":"11px","fontWeight":500,"lineHeight":1.55}},"rounded":{"sm":"4px","md":"8px","lg":"12px","pill":"999px"},"spacing":{"xs":"4px","sm":"8px","md":"12px","lg":"16px"},"components":{"app-shell":{"backgroundColor":"{colors.neutral}","textColor":"{colors.primary}","rounded":"{rounded.sm}","padding":"16px"},"panel":{"backgroundColor":"{colors.surface}","textColor":"{colors.primary}","rounded":"{rounded.lg}","padding":"16px"},"border-line":{"backgroundColor":"{colors.borderSubtle}","textColor":"{colors.primary}","rounded":"{rounded.sm}","padding":"4px"},"state-success":{"backgroundColor":"{colors.success}","textColor":"{colors.ink}","rounded":"{rounded.sm}","padding":"4px"},"state-warning":{"backgroundColor":"{colors.warning}","textColor":"{colors.ink}","rounded":"{rounded.sm}","padding":"4px"},"state-error":{"backgroundColor":"{colors.error}","textColor":"{colors.ink}","rounded":"{rounded.sm}","padding":"4px"},"tool-call-group":{"backgroundColor":"{colors.neutral}","textColor":"{colors.secondary}","rounded":"{rounded.md}","padding":"4px"},"tool-card":{"backgroundColor":"{colors.surfaceSubtle}","textColor":"{colors.secondary}","rounded":"{rounded.md}","padding":"8px"},"user-message":{"backgroundColor":"{colors.tertiary}","textColor":"{colors.ink}","rounded":"{rounded.lg}","padding":"12px"}}},"isInternal":false,"tokens":1834,"sizeBytes":7885}],"systemPromptSnippet":"<yakaai_skills repo=\"nesquena/hermes-webui\">\n<!-- File: ARCHITECTURE.md (Tokens: ~11025 | Category: project-spec) -->\n# Hermes Web UI: Developer and Architecture Guide\n\n> This document is the canonical reference for anyone (human or agent) working on the\n> Hermes Web UI. It covers the exact current state of the code, every design decision and\n> quirk discovered during development, and a phased architecture improvement roadmap that\n> runs in parallel with the feature roadmap in ROADMAP.md.\n>\n> Keep this document updated as architecture changes are made.\n\n> Current shipped build: `v0.51.792` (July 1, 2026).\n> Automated coverage: ~11,500 tests via `pytest tests/ --collect-only -q`. CI runs on\n> Python 3.11, 3.12, and 3.13 (3 parallel shards each) against every PR, plus a ruff\n> lint gate, a headless browser smoke test, and a Docker smoke test.\n>\n> Notable architecture state: the bootstrap and first-run onboarding flow own setup discovery; the default WebUI state directory is `~/.hermes/webui`; `ctl.sh` provides a daemon wrapper for homelab installs; chat streaming is still WebUI-owned SSE with stream-ownership guards, cancellation, async manual compression, and turn-journal audit plumbing; provider/model discovery is profile-aware with live-model cache invalidation and custom-provider scoping. (Version/test-count numbers above are a periodic snapshot — the authoritative source is the latest git tag and `pytest --collect-only`.)\n\n---\n\n## 1. Overview and Purpose\n\nThe Hermes Web UI is a lightweight web application that gives you a browser-based\ninterface to the Hermes agent that is functionally equivalent to the CLI. It is modeled on\nthe Claude-style interface: a sidebar for session management, a central chat area,\nand a demand-driven right panel used for workspace browsing and preview surfaces.\nThe right panel is closed by default on desktop and opens only when it is actively\nbeing used for browsing or previewing content.\n\nTo prevent a visible first-paint mismatch on refresh, `static/index.html` preloads the\nsaved workspace panel state into `document.documentElement.dataset.workspacePanel`\nbefore the main stylesheet loads. Desktop CSS honors that preload marker immediately,\nand `static/boot.js` keeps the dataset synchronized with the runtime panel state machine.\n\nThe design philosophy is deliberately minimal. There is no build step, no bundler, no\nfrontend framework. The Python server is split into a routing shell (server.py) and\nbusiness logic modules (api/). The frontend is seven vanilla JS modules loaded from static/.\nThis makes the code easy to modify from a terminal or by an agent.\n\nHermes-level chrome is intentionally consolidated: the sidebar has no dedicated brand header.\nInstead, the footer exposes a single \"Hermes WebUI\" launch button that opens one tabbed\ncontrol-center modal for global preferences, conversation import/export, and clear-conversation\nactions. The topbar remains focused on conversation context and the workspace/files toggle.\n\n---\n\n## 2. File Inventory\n\n    <repo>/\n    server.py              Thin routing shell + HTTP Handler + auth middleware.\n                           Delegates all route handling to api/routes.py.\n    bootstrap.py           One-shot launcher: optional agent install, deps, health wait, browser open.\n    start.sh               Thin wrapper around bootstrap.py for shell-based startup.\n    ctl.sh                 Daemon lifecycle wrapper (start/stop/restart/status/logs) for homelab installs.\n    pyproject.toml         Standard build metadata plus the Ruff lint gate; source-checkout launch surface still centers on bootstrap.py / start.sh / ctl.sh.\n    Dockerfile             python:3.12-slim container image\n    docker-compose.yml     Compose config with named volume and optional auth\n    .dockerignore          Excludes .git, tests/, .env* from Docker builds\n    api/\n      __init__.py          Package marker\n      agent_compat.py      Resolver for Hermes Agent names moved to sibling modules (compatibility-only)\n      auth.py              Optional password authentication, signed cookies, passkeys/WebAuthn\n      config.py            Discovery, globals, model detection, reloadable config\n      helpers.py           HTTP helpers: j(), bad(), require(), safe_resolve(), security headers\n      goals.py             Persistent-goal commands and profile-scoped native GoalManager bridge\n      models.py            Session model + CRUD, per-session profile tracking, CLI/state.db bridge\n      profiles.py          Profile state management, hermes_cli wrapper\n      onboarding.py        First-run onboarding status, real provider config writes, OAuth linking, readiness detection\n      routes.py            All GET + POST route handlers (if/elif dispatch, no decorators)\n      startup.py           Startup helpers: auto_install_agent_deps()\n      state_sync.py        state.db bridge — opt-in /insights usage/title sync; always mirrors the session workspace into sessions.cwd\n      streaming.py         SSE engine, run_agent, cancel, compression, HERMES_HOME save/restore\n      updates.py           Self-update check and release notes\n      upload.py            Multipart parser, file upload handler\n      workspace.py         File ops: list_dir, read_file_content, git detection, workspace helpers\n    static/\n      index.html           HTML template\n      style.css            All CSS incl. mobile responsive, themes + skins, KaTeX\n      ui.js                DOM helpers, renderMd, tool cards, context indicator, file tree\n      workspace.js         File preview, file ops, git badge, central api() fetch wrapper\n      sessions.js          Session CRUD, list rendering, collapsible groups, search, SSE sync\n      messages.js          send(), SSE event handlers, approval/clarify, transcript, recovery\n      panels.js            Cron, skills, memory, profiles, todo, settings (Control Center)\n      commands.js          Slash command registry, parser, autocomplete dropdown\n      boot.js              Event wiring, mobile nav, voice input, theme/skin boot, bfcache handler\n      onboarding.js        First-run wizard overlay, provider setup flow\n      i18n.js              Localization catalog (en, es, de, zh, zh-Hant, ru, …)\n      login.js             Login page + open-redirect guard\n      icons.js             Lucide icon path registry\n      sw.js                Service worker: offline shell cache, version-pinned assets\n    tests/\n      conftest.py          Isolated test server/state fixtures\n      ~1,150 test files    ~11,500 tests collected via pytest (run `pytest --collect-only -q` for exact)\n      test_regressions.py  Permanent regression gate\n    CONTRIBUTING.md        Contributor workflow and PR expectations.\n    ROADMAP.md             Feature and product roadmap document.\n    SPRINTS.md             Forward sprint plan with CLI + Claude parity targets.\n    ARCHITECTURE.md        THIS FILE.\n    TESTING.md             Manual browser test plan and automated coverage reference.\n    CHANGELOG.md           Release notes per version.\n    CONTRIBUTORS.md        Community credit roll (regenerated via the maintainer workspace script).\n    requirements.txt       Python dependencies.\n    .env.example           Sample environment variable overrides.\n\n> Per-file line counts intentionally omitted — they drift every release. Use\n> `git ls-files | xargs wc -l` (or your editor) for current sizes; the role of\n> each file above is the durable part.\n\nState directory (runtime data, separate from source):\n\n    ~/.hermes/webui/\n    sessions/          One JSON file per session: {session_id}.json\n    workspaces.json    Registered workspaces list\n    last_workspace.txt Last-used workspace path\n    settings.json      User settings (default model, workspace, send key, password hash)\n    projects.json      Session project groups (name, color, id)\n\nLog file:\n\n    ~/.hermes/webui/bootstrap-8787.log   start.sh/bootstrap background server log\n    ~/.hermes/webui.log                  ctl.sh daemon log\n\n---\n\n## 3. Runtime Environment\n\n### Context replay matching\n\n`api/streaming.py::_strip_replayed_context_items` filters replayed model-context\nblocks using longest-match statistics from a suffix automaton over reversed\nreplay keys. Its greedy walk still removes only blocks of at least three rows;\nshort repeats, row order, and provider-facing `api_content` identity are preserved.\nLong arc-summary identity is normalized once per row and indexed by role, provider\npayload and the existing 1,500-character prefix rule. Malformed unhashable roles\nretain an equality-scan fallback. For ordinary hashable roles/keys, matching uses\nO(existing + candidate) key operations and auxiliary space, in addition to payload\nnormalization. All indexes are call-local; no session cache, durable schema, writer\nownership, or SSE contract changes. Inputs are not mutated and retained rows are\nreturned unchanged. All three context-reconciliation branches share this helper.\n\n- Python interpreter: <agent-dir>/venv/bin/python\n- The venv has all Hermes agent dependencies (run_agent, tools/*, cron/*)\n- Server binds to 127.0.0.1:8787 (localhost only, not public internet)\n- Access from Mac: SSH tunnel: ssh -N -L 8787:127.0.0.1:8787 <user>@<your-server>\n- The server imports Hermes modules via sys.path.insert(0, parent_dir)\n\nSuffix/prefix replay matching uses a linear prefix-function scan and constructs\neach comparison key once, preserving the existing replay equality contract.\nRead-side stale-stream maintenance defers when the session writer lock is busy;\nit leaves stream ownership and pending input intact for a later repair pass.\n\nEnvironment variables controlling behavior:\n\n    HERMES_WEBUI_HOST              Bind address (default: 127.0.0.1)\n    HERMES_WEBUI_PORT              Port (default: 8787)\n    HERMES_WEBUI_DEFAULT_WORKSPACE Default workspace path for new sessions\n    HERMES_WEBUI_STATE_DIR         Where sessions/ folder lives\n    HERMES_CONFIG_PATH             Path to ~/.hermes/config.yaml\n    HERMES_WEBUI_DEFAULT_MODEL     Optional model override; unset means provider default\n    HERMES_WEBUI_PASSWORD          Optional: enable password auth (off by default)\n    HERMES_WEBUI_SKIP_ONBOARDING   Optional: bypass the first-run onboarding wizard\n    HERMES_PREFILL_MESSAGES_FILE   Optional JSON message list for browser-turn prefill context\n    HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT Optional command that prints JSON messages or plain-text user prefill context\n    HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT Optional script timeout in seconds (default 5, max 30)\n    HERMES_WEBUI_PREFILL_CONTEXT_MAX_CHARS Optional parsed prefill budget in characters (default 12000, 0 disables)\n    HERMES_HOME                    Base directory for Hermes state (~/.hermes by default)\n\nTest isolation environment variables (set by conftest.py):\n\n    HERMES_WEBUI_TEST_PORT=...                         Optional pinned test port\n    HERMES_WEBUI_TEST_STATE_DIR=/tmp/hermes-webui-tests/* Optional pinned test state (default: OS temp dir; must be outside ~/.hermes)\n    HERMES_WEBUI_DEFAULT_WORKSPACE=.../test-workspace  Isolated test workspace\n\nTests NEVER talk to the production server (port 8787).\nThe test state dir is wiped before each test session and deleted after.\nSee: <repo>/tests/conftest.py\n\nPer-request environment variables (set by chat handler, restored after):\n\n    TERMINAL_CWD         Set to session.workspace before running agent.\n                         The terminal tool reads this to default cwd.\n    HERMES_EXEC_ASK      Set to \"1\" to enable approval gate for dangerous commands.\n    HERMES_SESSION_KEY   Set to session_id. The approval tool keys pending entries\n                         by this value, enabling per-session approval state.\n    HERMES_HOME          Set to the active profile's directory before running agent.\n                         Saved and restored around each agent run.\n\nWARNING: These env vars are process-global. Two concurrent chat requests will clobber\neach other. This is safe only for single-user, single-concurrent-request use.\nSee Architecture Phase B for the fix.\n\n---\n\n## 4. Server Architecture: Current State\n\n### 4.1 HTTP Server Layer\n\nPython stdlib ThreadingHTTPServer (from http.server). Each HTTP request runs in its own\nthread. The Handler class subclasses BaseHTTPRequestHandler with two methods:\n\n    do_GET    Routes: /, /health, /api/session, /api/sessions, /api/list,\n                      /api/chat/stream, /api/file, /api/approval/pending,\n                      /api/session/worktree/status\n    do_POST   Routes: /api/upload, /api/session/new, /api/session/update,\n                      /api/session/delete, /api/chat/start, /api/chat,\n                      /api/approval/respond, /api/session/worktree/remove\n\nRouting is a flat if/elif chain inside each method. No routing framework.\n\nHelper functions used by all handlers:\n\n    j(handler, payload, status=200)     Sends JSON response with correct headers\n    t(handler, payload, status=200, ct) Sends plain text or HTML response\n    read_body(handler)                  Reads and JSON-parses the POST body\n\nCRITICAL ORDERING RULE in do_POST:\nThe /api/upload check MUST appear BEFORE calling read_body(). read_body() calls\nhandler.rfile.read() which consumes the HTTP body stream. The upload handler also\nneeds rfile (to read the multipart payload). If read_body() runs first on a multipart\nrequest, the upload handler receives an empty body and the upload silently fails.\n\n### 4.2 Session Model\n\nSession is a plain Python class (not a dataclass, not SQLAlchemy):\n\n    Fields:\n      session_id    hex string, 12 chars (uuid4().hex[:12])\n      title         string, auto-set from first user message\n      workspace     absolute path string, resolved at creation\n      model         model ID string (e.g. \"anthropic/claude-sonnet-4.6\")\n      messages      list of OpenAI-format message dicts\n      created_at    float Unix timestamp\n      updated_at    float Unix timestamp, updated on every save()\n      pinned        bool, default False (Sprint 12)\n      archived      bool, default False (Sprint 14)\n      project_id    string or null, FK to projects.json (Sprint 15)\n      tool_calls    list of tool call dicts (Sprint 10)\n\n    Key methods:\n      path (property)  Returns SESSION_DIR/{session_id}.json\n      save()           Writes __dict__ as pretty JSON to path, updates updated_at\n      load(cls, sid)   Class method: reads JSON from disk, returns Session or None\n      compact()        Returns metadata-only dict (no messages) for the session list\n\n    In-memory cache:\n      SESSIONS = {}    dict: session_id -> Session object\n      LOCK = threading.Lock()   defined but NOT currently used around SESSIONS access\n\n    get_session(sid): checks SESSIONS cache, loads from disk on miss, raises KeyError\n    new_session(workspace, model): creates Session, caches in SESSIONS, saves, returns\n    all_sessions(): scans SESSION_DIR/*.json + SESSIONS, deduplicates, sorts by updated_at,\n                    returns list of compact() dicts\n\n    all_sessions() does a full directory scan on every call.\n    With 10 sessions: negligible. With 1000+: will be slow.\n    See Architecture Phase C for the index file fix.\n\ntitle_from(): takes messages list, finds first user message, returns first 64 chars.\nCalled after run_conversation() completes to set the session title retroactively.\n\n#### Session transcript reconciliation with `state.db`\n\n`reconciled_state_db_messages_for_session()` uses\n`merge_session_messages_append_only()` to combine a WebUI sidecar or context\nprojection with active Agent `state.db` rows. Its explicit\n`incoming_provenance=\"state_db\"` fence permits a state-only row whose timestamp\npredates the sidecar tail to use a safe chronological slot. Paginated\n`msg_limit` consumers rely on this merged order directly rather than applying a\nlater timestamp sort.\n\nThe same merge helper also stitches ordered child sidecars onto archived\ncompression parents. Those calls leave incoming provenance unverified, so their\nstable message sequence remains append-only even when parent rows carry later,\nrestamped timestamps. Timestamp alone is never authority to move a continuation\ninside its parent transcript.\n\nIf an older row could only be placed before the first surviving sidecar/context\nrow, the insertion helper declines it to avoid resurrecting compacted history.\nReconciliation then appends that row instead of dropping it; rows without a\nusable timestamp and rows at or after the sidecar tail also append normally.\nThe fallback therefore preserves an accepted state-only row when exact ordering\nis ambiguous, while safely placeable recovery rows remain chronological.\n\n#### Imported `state.db` sidebar projection\n\n`api.models.get_cli_sessions()` projects conversations from the active Hermes\nprofile's `state.db` into sidebar-shaped rows. The default projection keeps\ninteractive sources (CLI, TUI, ACP, messaging, and similar user-facing sessions)\nin a bounded 20-row candidate window. Background sources use independent recovery\npasses so a high-volume worker source cannot consume that interactive window:\n\n- Cron: up to `CRON_PROJECT_CHIP_LIMIT` rows.\n- Webhook: up to `WEBHOOK_PROJECT_CHIP_LIMIT` rows.\n- Kanban: up to `KANBAN_PROJECT_CHIP_LIMIT` rows.\n- One-shot (`hermes -z`): kept out of the interactive window and its\n  unassigned refill, with no default pass of its own. They keep normal project\n  chips, so the assigned recovery pass still reaches one filed under a project.\n  An explicit `source_filter=\"oneshot\"` lists the newest ones, and a session\n  the user opens by id resolves through `get_cli_session_by_id()`.\n\nSource-specific views still use their dedicated bounds, and the later sidebar\nvisibility stage decides whether recovered background rows are shown. In\n`all_profiles=True` mode the per-profile source bounds are disabled before rows\nare merged; cross-profile scoping, visibility, deduplication, and final route\nlimits remain downstream responsibilities.\n\n#### Compression lineage and session-list invalidation\n\n`api.agent_sessions._is_continuation_session()` is the shared classifier for\nsidebar projection, lineage metadata/reporting, and `state.db` transcript\nstitching. It uses the direct parent link, no conflicting non-empty source,\n`compression` or `cli_close` parent end reason, and the existing two-second\n`started_at` overlap allowance. A `source=\"tool\"` child is always a separate\nconversation, even when its parent is also a tool session or has no source.\nA direct `_branched_from`, `_delegate_from`, or `_reset_from` marker in the\nchild's `model_config` also makes a boundary; inherited ancestor markers do\nnot. Malformed or unverifiable marker evidence fails closed as a boundary.\nThe overlap allowance is the existing master policy, not a new window set by\nthis change.\n\nThe gateway watcher's cheap database fingerprint includes `model_config`, so\na marker-only update causes a fresh projection. Its published-payload hash\ncovers every emitted session field, not just ID, activity time and message\ncount; a changed projected title or lineage field can therefore emit\n`sessions_changed` even without message-row churn.\n\n### 4.3 SSE Streaming Engine\n\nThis is the most architecturally interesting part. Two endpoints cooperate:\n\n    POST /api/chat/start     Receives the user message. Creates a queue.Queue, stores it\n                             in STREAMS[stream_id], spawns a daemon thread running\n                             _run_agent_streaming(), returns {stream_id} immediately.\n\n    GET  /api/chat/stream    Long-lived SSE connection. Reads from STREAMS[stream_id]\n                             and forwards events to the browser until 'done' or 'error'.\n\nQueue registry:\n\n    STREAMS = {}               dict: stream_id -> queue.Queue\n    STREAMS_LOCK = threading.Lock()\n\nSSE event types and their data shapes:\n\n    token       {\"text\": \"...\"}                         LLM token delta\n    tool        {\"name\": \"...\", \"preview\": \"...\"}       Tool invocation started\n    approval    {\"command\": \"...\", \"description\": \"...\", \"pattern_keys\": [...]}\n    done        {\"session\": {compact_fields + messages}} Agent finished successfully\n    error       {\"message\": \"...\", \"trace\": \"...\"}       Agent threw exception\n\nThe SSE handler loop:\n    - Blocks on queue.get(timeout=30)\n    - On timeout (no events in 30s): sends a heartbeat comment (\": heartbeat\n\n\")\n      to keep the connection alive through proxies and firewalls\n    - On 'done' or 'error' event: breaks the loop and returns\n    - Catches BrokenPipeError and ConnectionResetError silently (browser disconnected)\n\nStream cleanup: _run_agent_streaming() pops its stream_id from STREAMS in a finally\nblock. If the browser disconnects mid-stream, the daemon thread runs to completion and\nthen cleans up. The queue fills and the put_nowait() calls fail silently (queue.Full\nis caught).\n\nFallback sync endpoint: POST /api/chat still exists and holds the connection open until\nthe agent finishes. The frontend never uses it but it can be useful for debugging.\n\n### 4.4 Agent Invocation (_run_agent_streaming)\n\n    def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id):\n\n1. Fetches session from SESSIONS (not from disk -- session was just updated by /api/chat/start)\n2. Sets TERMINAL_CWD, HERMES_EXEC_ASK, HERMES_SESSION_KEY env vars\n3. Creates AIAgent with:\n   - model=model, platform='cli', quiet_mode=True\n   - enabled_toolsets=CLI_TOOLSETS (from config.yaml or hardcoded default)\n   - session_id=session_id\n   - stream_delta_callback=on_token (fires per token)\n   - tool_progress_callback=on_tool (fires per tool invocation)\n4. Calls agent.run_conversation(user_message=msg_text, conversation_history=s.messages,\n                                 task_id=session_id)\n   NOTE: keyword is task_id NOT session_id (common mistake, documented in skill)\n5. On return: updates s.messages, calls title_from(), saves session\n6. Puts ('done', {session: ...}) into queue\n7. Finally block: restores env vars, pops stream_id from STREAMS\n\non_token callback:\n    if text is None: return  # end-of-stream sentinel from AIAgent\n    put('token', {'text': text})\n\non_tool callback:\n    put('tool', {'name': name, 'preview': preview})\n    # Also immediately surface any pending approval:\n    if has_pending(session_id):\n        with _lock: p = dict(_pending.get(session_id, {}))\n        if p: put('approval', p)\n\nThe approval surface-on-tool logic means approvals appear immediately after the tool\nfires (within the same SSE stream), without waiting for the next poll cycle.\n\n### 4.5 Approval System Integration\n\nThe approval system uses the existing Hermes gateway module at tools/approval.py.\nAll state lives in module-level variables in that file:\n\n    _pending = {}        dict: session_key -> pending_entry_dict\n    _lock = Lock()       protects _pending\n    _permanent_approved  set of permanently approved pattern keys\n\nBecause server.py imports tools.approval at module load time and everything runs in the\nsame process, this state IS shared between HTTP threads and agent daemon threads.\n\nImportant: this only works because Python imports are cached (sys.modules). The same\nmodule object is used everywhere. If the approval module were ever imported in a subprocess\nor via importlib.reload(), this would break.\n\nGET /api/approval/pending:\n    - Peeks at _pending[sid] without removing it\n    - Returns {pending: entry} or {pending: null}\n    - Called by the browser every 1500ms while S.busy is true (polling fallback)\n\nPOST /api/approval/respond:\n    - Pops _pending[sid] (removes it)\n    - For choice \"once\" or \"session\": calls approve_session(sid, pattern_key) for each key\n    - For choice \"always\": calls approve_session + approve_permanent + save_permanent_allowlist\n    - For choice \"deny\": just pops, does nothing (agent gets denied result)\n    - Returns {ok: true, choice: choice}\n\n### 4.6 File Upload Parser\n\nparse_multipart(rfile, content_type, content_length):\n    - Reads all content_length bytes from rfile into memory (up to MAX_UPLOAD_BYTES, default 20MB, env-overridable via HERMES_WEBUI_MAX_UPLOAD_MB)\n    - Extracts boundary from Content-Type header\n    - Splits raw bytes on b'--' + boundary\n    - For each part: parses MIME headers via email.parser.HeaderParser\n    - Returns (fields, files) where fields is {name: value} and files is {name: (filename, bytes)}\n\nhandle_upload(handler):\n    - Calls parse_multipart()\n    - Validates: file field present, filename present, session exists\n    - Sanitizes filename: replaces non-word chars with _, truncates to 200 chars\n    - Writes bytes to session.workspace / safe_name\n    - Returns {filename, path, size}\n\nWhy not cgi.FieldStorage:\n    - Deprecated in Python 3.11+\n    - Broken for binary files (silently corrupts or throws)\n    - The manual parser handles all file types correctly\n\n### 4.7 File System Operations\n\nsafe_resolve(root, requested):\n    - Resolves requested path relative to root\n    - Calls .relative_to(root) to assert the result is inside root\n    - Raises ValueError on path traversal (../../etc/passwd)\n\nlist_dir(workspace, rel='.'):\n    - Calls safe_resolve, then iterdir()\n    - Sorts: directories first, then files, case-insensitive alpha within each group\n    - Returns up to 200 entries with {name, path, type, size}\n\nread_file_content(workspace, rel):\n    - Calls safe_resolve\n    - Enforces MAX_FILE_BYTES = 200KB size limit\n    - Reads as UTF-8 with errors='replace' (binary files show replacement chars)\n    - Returns {path, content, size, lines}\n\n### 4.8 Persistent Goal Profile Boundary\n\n`api/goals.py` exposes the WebUI `/goal` command payloads and post-turn evaluation hook.\nHermes Agent's native `GoalManager` is the authoritative owner of goal evaluation,\ncontinuation decisions, wait barriers, failure counters, contracts, subgoals, and\n`state.db` persistence.\n\nFor a profile-scoped WebUI session, the bridge delegates only when the Agent exposes\nboth the context-local `set_hermes_home_override()` API and call-time default\n`SessionDB` path resolution. The bridge probes the resolved default path under the\nselected context before constructing the native manager, then binds that profile's\nHermes home before every native call. The override is reset in a `finally` block after\nevery operation, so concurrent sessions using the same session ID under different\nprofiles cannot cross-read or cross-write goal state. Goal snapshot rollback uses the\nsame scoped native persistence path.\n\nOlder Hermes Agent versions that lack either capability continue through\n`_LegacyProfileGoalManager`, which pins persistence to the selected profile's explicit\n`state.db` path. This includes intermediate versions whose context API is present but\nwhose default `SessionDB()` path remains frozen at module import. Keep this fallback\ncompatibility-only: new goal semantics belong in Hermes Agent's native manager rather\nthan a second WebUI implementation.\n\n\n### 4.9 Hermes Agent Moved-Name Compatibility\n\nHermes Agent owns its module layout. Its September 2026 decomposition moved names the\nWebUI uses (for example `tools.approval.set_current_session_key` to\n`tools.approval_context`) into `<stem>_<topic>` sibling modules. The old paths resolve\nonly through temporary PLUGIN-COMPAT `__getattr__` pointers that emit\n`HermesPluginCompatWarning` and are removed on schedule. The Agent's\n`compat_manifest.json` is the authoritative map of what moved where.\n\nWebUI code reaches a moved name only through\n`api.agent_compat.agent_attr(owner, name, home, default=...)`, which resolves in this\norder:\n\n1. the owner module's own namespace: pre-split Agents, and tests that stub the original\n   module in `sys.modules` or patch the name onto it;\n2. the `home` module: split Agents, with or without the old-path pointer (no warning);\n3. plain attribute access on the owner: non-module test doubles.\n\nIt raises like the import it replaces (or returns `default`), so each call site keeps its\nexisting fallback. Current users: approval session identity and MCP discovery\n(`streaming.py`), `/reload-mcp` (`commands.py`), MCP runtime status (`routes.py`), Claude\nCode credential linking (`oauth.py`), LM Studio reasoning options (`config.py`), and\nkanban connections and dispatch (`kanban_bridge.py`).\n\nImport names that are still native to their module directly. Never\n`from <old module> import <moved name>`, and never feature-detect a moved name with\n`hasattr`/`getattr` on the old module: once the pointers are removed those silently turn\n\"moved\" into \"missing\" behind the call sites' broad `except` blocks. When a later Agent\nsplit moves another name, route it through `agent_attr` and add pre-split and\npointer-removed cases to `tests/test_agent_compat.py`. The resolver is\ncompatibility-only: delete it, and import directly from the new homes, once the WebUI\nstops supporting Agents that predate the split.\n\n### 4.10 MCP Runtime Profile Boundary\n\nHermes Agent owns the in-process MCP ledger. It keys a connection by\n`(profile_home_key, name)` and registers its tools in that profile's registry overlay\nonly when the calling task serves a *routed* profile: the context-local Hermes-home\noverride differs from the process home. Otherwise the connection uses the bare server\nname and the global registry slot, which belong to the process profile.\n\n- `api.profiles._set_hermes_home()` is the single writer of the process-profile home:\n  startup (`init_profile_state()`) and `switch_profile(process_wide=True)` both go\n  through it, so `get_process_profile_home()` and the Agent pin\n  (`hermes_constants.pin_process_hermes_home()`, when the Agent provides it) are updated\n  in the same step as `HERMES_HOME`. Streaming turns still mirror their profile into\n  `os.environ['HERMES_HOME']` for legacy readers; without the pin, that mirror makes a\n  turn's own profile look like the process profile, so same-named servers of different\n  profiles share one bare-name connection.\n- `/api/mcp/servers`, `/api/mcp/tools`, `/api/notes/sources` and `/reload-mcp` run\n  their Agent calls inside `api.mcp_runtime.mcp_runtime_scope()`, which binds the\n  request profile's home and secret scope (root profile included) without touching\n  `os.environ`. Status, tool count and inventory are read from one scope; the inventory\n  lists only tools in that profile's own registry slot for servers it configures.\n- A connection *serves* a profile when that profile owns it (`_server_scope_keys`, else\n  the scope in the key) or adopted it (`_server_tool_scopes`, an identical shared\n  connection). `api.mcp_runtime.ledger_key_serves_view()` is the one predicate for\n  status, inventory and the reload summary. The Agent's launch-profile view (scope\n  `None`) is process-wide, so `filter_runtime_status_to_view()` downgrades\n  `get_mcp_status()` rows of servers the root profile does not serve to `configured`\n  rather than showing a routed profile's connection, tool count or connect error.\n- The scope is trusted only when the Agent's routing decision matches the profile WebUI\n  resolved. When it cannot be confirmed (for example a same-profile turn's mirror on an\n  Agent without the pin), status and inventory withhold runtime data\n  (`runtime_scope: \"unavailable\"`, shown as a notice in the MCP panel) and\n  `/reload-mcp` refuses instead of resetting another owner's connection.\n- `/reload-mcp` calls `shutdown_mcp_servers(scope=..., names=...)` with the profile's\n  own scope and the names of its live connections; `scope=None` without `names` is the\n  process-wide wildcard and is used only with Agents that predate profile-scoped MCP\n  (`runtime_scope: \"legacy_process\"`). A scoped shutdown only clears connect backoff for\n  the live keys it tears down, so WebUI also drops the profile's own cooldown/error\n  entries (`clear_profile_connect_cooldowns()`) before rediscovery: a server that failed\n  to spawn is retried by the reload, as the wildcard did, without touching another\n  owner's backoff.\n\nStatus and inventory stay passive: they never start or probe an MCP server. Ledger key\nhelpers resolve to Hermes Agent's `tools.mcp_tool_scope` when present so the key shape\nhas one owner; the local fallbacks only cover Agents that predate that module.\n\n#### MCP configuration writes\n\nMCP create/update, toggle and delete are separate from Agent runtime ownership.\nEach edit resolves the existing `_get_config_path()` once under `_cfg_lock`,\nreads a private raw YAML mapping, and commits to that same path with the existing\natomic writer. `HERMES_CONFIG_PATH` keeps its documented precedence; this does\nnot redirect operator-configured files or change profile resolution.\n\nRaw `${ENV_VAR}` references, unrelated configuration and masked credentials keep\ntheir original stored values. YAML-aliased containers and the selected server\nentry are detached before mutation so an edit cannot change a sibling or template. Runtime-expanded cache dictionaries are never the\nwrite source. An unreadable, malformed or non-mapping existing document aborts\nwithout overwriting it; a missing/empty document can be initialized. Failed disk\nwrites do not publish uncommitted mutations into the runtime cache. Reloading\nthat cache and sending HTTP responses happen after the lock is released.\n\nThis transaction serializes cooperating writes/reloads in one WebUI process.\nIt does not lock out other processes or provide Agent status/schema/dispatch\nisolation; those remain the runtime boundary described above.\n\n### 4.11 Model Catalog Rebuild Budget and Probe Scheduling\n\nThe model picker's catalog (`GET /api/models`) is produced by one cold rebuild that\nlive-probes providers. A probe is a network call to a provider the WebUI may not be able\nto reach — a LAN LM Studio/Ollama host is the usual case, because the probe runs in the\nserver process rather than the browser — so the rebuild runs on a daemon worker under a\nwall-clock budget:\n\n| Knob | Default | Meaning |\n| --- | --- | --- |\n| `HERMES_WEBUI_MODELS_REBUILD_BUDGET` | `4` (seconds) | Window a foreground caller waits for a cold rebuild. `0` restores the legacy synchronous unbounded rebuild. |\n| `CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS` | `5.0` | Per-endpoint cap for a custom provider `/v1/models` probe. |\n| `CUSTOM_MODELS_ATTEMPT_ONLY_TIMEOUT_SECONDS` | `0.01` | Attempt-only timeout for a probe reached after the window is already spent while the caller is still waiting. Not a per-probe minimum and not a slice — see below. |\n\nWithin the window the rebuild behaves normally; past it the caller is served a fallback\n(last-known disk cache, else a network-free static catalog) while the worker keeps going so\nthe refresh lands out-of-band for the next caller.\n\nCustom endpoints are probed **serially**: the active `model.base_url` first, then each\nnamed `custom_providers` entry in config order, then the LM Studio provider-group\nfallback. `_CustomProbeSchedule` in `api/config.py` hands each of those probes a fair\nslice of the *remaining* window instead of letting one probe take the whole per-endpoint\ncap, so an unreachable endpoint cannot leave the reachable providers behind it with no\nin-band probe at all (#7481).\n\n- Each slice is `min(cap, left / (remaining + 0.1))`, with **no lower bound**. The fractional\n  slot reserves headroom without halving a lone target's window. Unused time is lent to\n  later probes; the matching active/LM Studio target does not reserve a duplicate slot.\n  Wall-clock overhead can still cross the foreground deadline. A floor would make\n  the chain's cumulative spend grow with the endpoint count, and `custom_providers` has no\n  count limit, so a long enough chain of dead endpoints could again outspend the window and\n  push the reachable providers behind it out of the in-band rebuild.\n- The window belongs to the **caller, not to the chain**. One absolute deadline is captured\n  before the worker and the foreground wait start and is handed to `_CustomProbeSchedule`;\n  the foreground waits only `max(0, deadline - now)`. A schedule that minted its own deadline\n  at construction — deep inside the worker, after provider detection, the live id lookups and\n  the profile rebind — charged that discovery work to the caller's wait *and* granted it to\n  the chain again, so the chain could still be probing after the caller had already been\n  served the over-budget fallback, and the reachable provider landed out-of-band at best.\n- The two states that keep the unthrottled per-endpoint cap are stated explicitly rather\n  than inferred from the clock, because a spent window says nothing about whether anyone is\n  still waiting:\n  - the legacy unbounded path (budget `0`), which has no window to share;\n  - the out-of-band continuation — the worker still probing after the foreground caller\n    gave up and served its fallback. It is recognised through an explicit signal (the\n    caller sets the event when it stops waiting) and is the only in-process state allowed\n    to spend past the window. The narrow race where a probe finds the window spent while\n    the caller *is* still waiting is not that state: it gets the attempt-only timeout\n    above, never the cap.\n- A slice can truncate healthy endpoints even in short chains. Such timeouts are not\n  reported as unreachable and the partial catalog is never cached in memory or on disk.\n  The same worker retries truncated targets once at the full endpoint cap after the\n  foreground returns, retaining successful probes and publishing the complete catalog\n  through the existing generation, source-identity, ownership and durable fences.\n- An endpoint is probed **once per rebuild, except for that full-cap retry**. The three live-probe consumers\n  (active `model.base_url`, named `custom_providers`, LM Studio provider-group fallback)\n  share a per-rebuild memo keyed by the complete request URL (path slashes and query\n  preserved) plus the Authorization header sent, so the\n  common config where the active endpoint and `providers.lmstudio.base_url` are the same\n  LAN host no longer pays that host's connect timeout twice, and two named entries on one\n  endpoint probe it once. A different URL — or the same URL with a different key, which can\n  change the outcome — is still its own probe, and the memo is consulted only after the\n  per-endpoint SSRF/authentication checks, so a call that would have been refused still is.\n- Probe order, per-endpoint SSRF and authentication rules, and the per-endpoint cap are\n  preserved.\n\nPublication is ordered by **generation, not by wall clock**. Each cold rebuild takes the\nnext sequence number (`_allocate_models_rebuild_seq`), and a result is dropped when it is\nolder than the newest *allocated* generation (`_models_rebuild_seq`) — in which case it also\nleaves the build flag alone rather than clearing it, because that flag now belongs to the\nnewer rebuild. This matters for the out-of-band publisher, which outlives the\nforeground caller: without the guard it could resurrect a superseded\ncatalog over a newer one. A timestamp comparison cannot express the ordering, because an\nolder build can publish *after* a newer build has already started.\n\nThe fence is the latest **allocated** generation rather than the latest *published* one\nbecause `invalidate_models_cache()` clears `_cache_build_in_progress` without cancelling an\nin-flight worker: a newer rebuild can be allocated while an older one is still running. If\nthe older worker finishes first, a published-sequence comparison would let it publish the\ninvalidated catalog and release the flag that now belongs to the newer rebuild. Comparing\nagainst the allocated generation rejects it, so invalidation is a real freshness boundary,\nand a newer rebuild that then fails leaves the cache empty instead of resurrecting the\ninvalidated catalog.\n\nBoth `invalidate_models_cache()` and the provider-scoped invalidator used by\n`/api/models/refresh` share one epoch/owner reset. They advance the allocated generation,\nclear memory and provenance, evict the relevant credential pools before admission reopens,\nretire the in-flight owner, and delete the durable cache.\nEvery build already running becomes superseded *even when no successor rebuild is ever\nallocated*; otherwise a delayed worker could repopulate the cleared catalog under a\nfresh-looking fingerprint. Disk snapshots read before taking the catalog lock (including\nthe session-visit warmer) are published only if their captured epoch still matches: a\npreloaded fresh candidate whose epoch moved is dropped rather than published under the\ncurrent fingerprint. The *degraded* stale fallback is deliberately not fenced — it is\nreturned to a caller that already stopped waiting and is never published to memory, so\ndiscarding it would only swap a stale-but-real answer for a static catalog.\n\nA build's **identity is captured when it starts and re-validated when it publishes**, in\nmemory and at the durable commit: the source fingerprint (`_models_cache_source_fingerprint`\n— config, auth store and provider catalog; its `config_yaml` axis is the profile-specific\nconfig path, so it fences a foreign profile too) plus the profile name. A build that outlives\na config edit describes sources that no longer exist, so its result is dropped instead of\nbeing published under the *current* fingerprint. The check fails closed — an identity that\ncannot be read is not a match — and disk publication records the fingerprint the build READ,\nnever a recomputed one.\n\nOwnership of the single-flight slot is explicit too: the completion path clears\n`_cache_build_in_progress` only when the completing build is still the newest allocated\ngeneration, including on the error paths and after the disk I/O. A superseded publisher\ntherefore cannot admit a third rebuild beside the newer one it lost to.\n\nThe **durable commit is fenced separately from the in-memory one**, because the file write\nruns with the catalog lock released (a disk write must not hold it). Each build writes its\nown temp file — unique per build and thread, in the destination directory so the commit stays\na same-filesystem atomic rename — and the rename is serialized by\n`_models_cache_disk_commit_lock` after re-checking the accepted generation, the last\ncommitted generation (`_models_disk_committed_seq`) and the build's source identity.\nThe catalog lock remains held from that check **through** rename; invalidation takes the\nsame fixed order (disk-commit lock, then catalog lock) through deletion, so an admitted\nwriter cannot restore a file after invalidation returns. A superseded or re-sourced\ncommit discards its temp file and leaves the durable catalog to the build that won.\n\n**The two locks have one order everywhere: disk-commit then catalog.** The writer and the\ninvalidator both take them that way, so a caller that takes them in the opposite order is a\ndeadlock, not a latency bug — and both acquisitions are unbounded in production. A foreground\npublisher used to: `get_available_models` owns the catalog lock across its whole cold path,\nand its synchronous, within-budget and budget-boundary winners called\n`_save_models_cache_to_disk` from inside that ownership, i.e. catalog → commit. A publisher\nblocked on the commit mutex an invalidator held, with that invalidator then blocked on the\ncatalog lock the publisher owned, waited on each other forever, so a `/api/models` load and a\nconfig-save/`/api/models/refresh` invalidation could each hang (#7481 review). The foreground\npaths therefore do not commit inline: the catalog lock still spans the whole cold path,\nincluding the foreground wait and memory publication (owner, source identity and the\nabsolute deadline are read there too). Only the durable commit moves outside: it is\n**queued** and run by\n`_DeferredCatalogPublication` on its way out — after the catalog lock has been released — by\n`_commit_models_cache_to_disk_after_lock`. The caller still gets the cache and the durable\nfile populated before `get_available_models` returns, and the single-flight release rides\nwith the queued commit, so ownership still spans the durable write. The out-of-band worker\ncommits directly, which is correct because it holds no catalog lock by then.\n\n### 4.12 Insights Period Endpoint (`/api/insights`)\n\n`GET /api/insights` reports usage analytics for one time window. The window is\nselected by query parameters, and the response echoes the window it actually\nserved through `mode` (`\"trailing\"` or `\"custom\"`). Totals and the daily series\nare always computed over the same resolved interval. Data comes from the WebUI\nsession index plus non-WebUI rows in the active profile's `state.db`, when that\ndatabase exists. The optional `sync_to_insights` setting mirrors WebUI usage\ninto `state.db`; it does not control whether this endpoint includes CLI rows.\n\nWindow selection:\n\n- `days=N` — trailing window of the last `N` days. This is what the panel's\n  7/30/90/365 presets send, and the fallback when no absolute bound is usable.\n- `start=…` / `end=…` — an absolute window. Each bound is either an exact Unix\n  epoch timestamp in seconds or a `YYYY-MM-DD` calendar date. A supplied\n  `start` with no `end` runs to the server clock. A supplied `end` with no\n  `start` back-steps the start by 30 days using the bound's own arithmetic: a\n  `YYYY-MM-DD` end subtracts 30 calendar days (local midnight to local\n  midnight, DST-safe), while a numeric end subtracts exactly `30 * 86400`\n  seconds — so across a daylight-saving change a numeric end's default start can\n  land on a different local date than the calendar one would.\n- The **Custom range…** selection sends the two `<input type=date>` values as\n  raw `YYYY-MM-DD` strings, not epoch seconds, so the server reads them in its\n  own timezone and a browser in another timezone cannot shift the selected\n  calendar day. The panel defaults the inputs to the last 30 calendar days only\n  when no range has been picked yet, so preset → Custom never erases a chosen\n  range.\n\nBounds and fallbacks:\n\n- A `YYYY-MM-DD` bound selects a whole local calendar day (start = that day's\n  local midnight; an end date's exclusive stop is the next local midnight,\n  except a date equal to today, which clamps to the server clock because that\n  day is not over). An epoch bound keeps its exact `[start, end)` precision.\n  Bounds are swapped if supplied reversed.\n- The window never extends into the future: an `end` beyond the server clock\n  is clamped to `now`.\n- Windows are clamped to five calendar years so the daily series cannot grow\n  unbounded.\n- The request fails closed to the trailing `days` window (`mode: \"trailing\"`)\n  when an absolute bound cannot be served faithfully: a supplied bound that is\n  unparseable (e.g. `2026-02-31`) or out of the supported epoch range, a\n  pre-epoch date (Chromium's date input accepts `0001-…`, and no session\n  predates 1970), or a strictly-future start. A supplied-but-invalid bound is\n  never treated as an omitted one, so a rejected request cannot fabricate an\n  interval the caller did not ask for. An explicit zero-length window pinned to\n  the clock itself (`start == now`) stays an empty custom interval rather than\n  silently serving the trailing window.\n\n### 4.12 Project Bindings (`/api/projects/bind`)\n\nA session project can carry **bindings**: a set of workspaces (one of them the\ndefault), a model, and — API-only since the dialog row is hidden and no session\napplies it yet — a stored reasoning effort. The chip's quick-create (`+`) button, and the top-level New Chat button\nwhile that project filter is active, open a new session already configured for\nthe project's context. Right-click\na project chip and pick **Project settings…** to edit them: one row in the chip\nmenu (no inline summary, no per-axis unbind rows), a dialog built on the shared\n`.app-dialog*` classes (skins, Escape, focus trap) that stays below the shared\napp dialog's z-index so a prompt it opens stacks on top, and a menu clamped to\nthe viewport.\n\n`POST /api/projects/bind` updates a single project by `project_id`. Every body\nfield is optional and only the supplied ones are touched:\n\n- `workspaces: [str]` — replaces the full bound list. Each entry passes the\n  same trusted-path check as `/api/session/new` (`validate_workspace_to_add`\n  then `resolve_trusted_workspace`) and is auto-registered in the saved\n  workspace list, so a path outside the default root can be bound in one step.\n  Entries are de-duplicated, keeping first-seen order. `null` clears the list\n  (and the default with it).\n- `default_workspace: str` — the workspace a new session starts in. It must be\n  a member of `workspaces`; a value outside the list is auto-added so the\n  invariant \"`default_workspace` ∈ `workspaces`\" always holds. Removing the\n  workspace that was the default also drops the default.\n- `workspace: str` — the legacy single-workspace field from before\n  multi-workspace bindings. It is still accepted and kept in sync as\n  `workspaces: [w]`, so pre-existing rows that only set `workspace` keep\n  working; `_project_workspaces` reads the list first and falls back to it.\n- `auto_assign: bool` — accepted and **stored but dormant**. The backfill\n  sweep it used to drive, the dialog toggle that set it and the\n  workspace-keyed filing of new sessions were split out into a follow-up PR, so\n  this build reads the stored value nowhere. It is shape-checked (a JSON\n  boolean) in the request pre-flight, like `reasoning_effort`, and `null`/`false`\n  still clears it.\n- `model` / `model_provider` / `reasoning_effort` — single-value bindings per\n  project; `null` clears that axis (the project is then unbound on it). The\n  dialog renders the model row only: it never submits `reasoning_effort`, so\n  saving a binding PRESERVES an existing effort value instead of clearing it —\n  and, since the UX re-gate of 2026-10-08, no session applies that stored value\n  either (see below).\n\n`reasoning_effort` is **stored but dormant**. `/api/projects/bind` still accepts\nit (and `null` still clears it), and the dialog still renders no row for it\n(re-gate 2026-10-07) — but since the UX re-gate of 2026-10-08 nothing forwards\nit into a new session, so\n\n<!-- File: DESIGN.md (Tokens: ~1834 | Category: project-spec) -->\n---\nversion: alpha\nname: Hermes Calm Console\ndescription: \"A restrained agent control surface: conversational content first, tool traces as quiet metadata, minimal chrome.\"\ncolors:\n  primary: \"#EAE0D5\"\n  secondary: \"#C6AC8F\"\n  tertiary: \"#C6AC8F\"\n  neutral: \"#0A0908\"\n  surface: \"#22333B\"\n  surfaceSubtle: \"#11100E\"\n  borderSubtle: \"#3B4A50\"\n  ink: \"#0A0908\"\n  success: \"#86C08B\"\n  warning: \"#E0B15D\"\n  error: \"#F87171\"\ntypography:\n  body-md:\n    fontFamily: \"Georgia, Times New Roman, serif\"\n    fontSize: 15px\n    fontWeight: 400\n    lineHeight: 1.68\n  body-sm:\n    fontFamily: \"-apple-system, BlinkMacSystemFont, Segoe UI, Inter, system-ui, sans-serif\"\n    fontSize: 12px\n    fontWeight: 400\n    lineHeight: 1.45\n  user-message:\n    fontFamily: \"-apple-system, BlinkMacSystemFont, Segoe UI, Inter, system-ui, sans-serif\"\n    fontSize: 14px\n    fontWeight: 400\n    lineHeight: 1.55\n  mono-xs:\n    fontFamily: \"SF Mono, ui-monospace, monospace\"\n    fontSize: 11px\n    fontWeight: 500\n    lineHeight: 1.55\nrounded:\n  sm: 4px\n  md: 8px\n  lg: 12px\n  pill: 999px\nspacing:\n  xs: 4px\n  sm: 8px\n  md: 12px\n  lg: 16px\ncomponents:\n  app-shell:\n    backgroundColor: \"{colors.neutral}\"\n    textColor: \"{colors.primary}\"\n    rounded: \"{rounded.sm}\"\n    padding: 16px\n  panel:\n    backgroundColor: \"{colors.surface}\"\n    textColor: \"{colors.primary}\"\n    rounded: \"{rounded.lg}\"\n    padding: 16px\n  border-line:\n    backgroundColor: \"{colors.borderSubtle}\"\n    textColor: \"{colors.primary}\"\n    rounded: \"{rounded.sm}\"\n    padding: 4px\n  state-success:\n    backgroundColor: \"{colors.success}\"\n    textColor: \"{colors.ink}\"\n    rounded: \"{rounded.sm}\"\n    padding: 4px\n  state-warning:\n    backgroundColor: \"{colors.warning}\"\n    textColor: \"{colors.ink}\"\n    rounded: \"{rounded.sm}\"\n    padding: 4px\n  state-error:\n    backgroundColor: \"{colors.error}\"\n    textColor: \"{colors.ink}\"\n    rounded: \"{rounded.sm}\"\n    padding: 4px\n  tool-call-group:\n    backgroundColor: \"{colors.neutral}\"\n    textColor: \"{colors.secondary}\"\n    rounded: \"{rounded.md}\"\n    padding: 4px\n  tool-card:\n    backgroundColor: \"{colors.surfaceSubtle}\"\n    textColor: \"{colors.secondary}\"\n    rounded: \"{rounded.md}\"\n    padding: 8px\n  user-message:\n    backgroundColor: \"{colors.tertiary}\"\n    textColor: \"{colors.ink}\"\n    rounded: \"{rounded.lg}\"\n    padding: 12px\n---\n\n## Overview\n\nHermes WebUI should feel like a calm developer console, not a demo page assembled from colorful cards. The primary artifact is the conversation. Tool calls, thinking traces, context compaction records, token usage, and runtime status are useful, but they are transcript metadata and should sit below the visual priority of user and assistant prose.\n\nThe desired direction is Linear/Vercel precision with a little Claude-style conversational warmth: quiet surfaces, clear spacing, restrained accent use, and progressive disclosure for debugging detail.\n\n## Colors\n\n- **Primary (#EAE0D5):** main text on dark surfaces. The warm parchment should feel readable and grounded, not like bright white terminal text.\n- **Secondary/Tertiary (#C6AC8F):** metadata and restrained accent. Use sparingly for active state, focus, user bubbles, and quiet emphasis.\n- **Neutral (#0A0908):** app background and ink. This gives the WebUI depth without returning to the previous navy/gold theme.\n- **Surface (#22333B):** panels, sidebar, and stronger interactive surfaces. It should carry the structure while the conversation remains primary.\n- **Light surfaces (#EAE0D5 / #F4EEE7):** light mode uses the palette's parchment as the field and a slightly lifted derived surface for panels.\n- **Semantic colors:** success/warning/error/info are state colors only, not decorative palette choices.\n\n## Typography\n\nUse Claude-like split typography: assistant prose gets an editorial serif stack (Georgia as the available substitute for Anthropic Serif), while user bubbles and functional UI stay in a crisp sans stack. This keeps the bot voice calmer and more readable without making controls feel bookish. Use monospace only for code, file paths, commands, tool names, and compact metadata. Avoid making whole cards feel like terminal output unless they actually are logs.\n\nScale should stay tight: 11px metadata, 12px labels, 14px body, 16–18px headings. Do not proliferate 10px/10.5px/12.5px one-offs unless there is a real layout constraint.\n\n## Layout\n\nConversation rhythm:\n\n1. User message — right aligned, compact bubble.\n2. Assistant content — left aligned, prose-first, no heavy bubble.\n3. Tool/thinking/context traces — quiet disclosure rows inside the assistant turn.\n4. Raw logs/details — hidden until explicitly expanded.\n\nMetadata should not break the reading flow. A turn that used ten tools should read as one assistant turn with one compact `Used 10 tools` disclosure, not ten content cards.\n\n## Elevation & Depth\n\nUse almost no shadows in the transcript. Shadows are reserved for popovers, dropdowns, modal dialogs, and floating controls. Cards inside chat should use either a subtle border or a subtle tint, not both aggressively.\n\n## Shapes\n\n- Rows/list items: `4–8px` radius.\n- Cards/panels: `8–12px` radius.\n- Pills: only true chips/badges use `999px`.\n- Avoid stacks of nested rounded rectangles. If a card contains another card, one of them is probably unnecessary.\n\n## Components\n\n### Tool/thinking activity group\n\nCollapsed by default in settled history and during live runs unless the user has explicitly opened that Activity row before. Persist open/closed disclosure state per chat and per turn, so switching away from a chat and coming back preserves the mode the user left it in. Summary line uses one disclosure for internals and stays intentionally terse, e.g. `Activity: 4 tools`. It should not repeat the always-present thinking area, list individual tool names, or add a second trailing count badge. Expanding reveals thinking and individual tool cards together. Thinking and tools should not create separate transcript rows unless there is an error or approval state that needs attention.\n\n### Tool card\n\nA tool card is a debug event row, not a chat message. Show icon, name, short target/preview, and status. Arguments and result snippets stay behind expansion. Result snippets should be truncated; full logs belong behind “show more”.\n\n### Thinking/context cards\n\nSame visual family as tool-call metadata. They should be quieter than assistant prose and should not use bright tinted full cards unless the user expands them.\n\nAutomatic compression follows a quiet live-only divider treatment rather than a\ntool-card row. Use `Compressing context` for the active barrier and\n`Context auto-compressed` after continuation/completion; render both as centered\nnon-interactive text with horizontal rules. Do not give it a caret, click\ntarget, distinct accent color, special leading dot, or separate card identity.\nOnce the final answer is settled, omit the live-only compression row unless it is\nneeded to explain a visible recovery or error state.\n\n### Composer\n\nThe composer is the command surface. Keep it legible and focused: modest radius, subtle border, transparent inactive chips, no theatrical hover scaling.\n\n## Do's and Don'ts\n\nDo:\n\n- Collapse noisy agent internals by default.\n- Use one accent color at a time.\n- Prefer neutral borders and restrained surfaces.\n- Make debug traces accessible and inspectable without making them visually dominant.\n- Add stable class/data hooks for future visual regression tests.\n\nDon't:\n\n- Render every tool call as a first-class chat card.\n- Mix gold, cyan, purple, orange, red, and green as decorative colors in the same viewport.\n- Add new hardcoded radius/color values when a token exists.\n- Use shadows, gradients, and hover transforms for routine controls.\n- Hide important error or approval states; those are allowed to be prominent because they require action.\n</yakaai_skills>"}