Compare commits

...

14 Commits

Author SHA1 Message Date
nesquena-hermes
4947a6b0c3 v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: approval pending check broken by stale has_pending import (#228)

api/routes.py imported has_pending/pop_pending from tools.approval, but the
agent module renamed has_pending to has_blocking_approval (checks gateway
queue, not _pending dict) and removed pop_pending. The import fell through
to fallback lambdas that always returned False, making GET /api/approval/pending
always return {pending:null} even after a successful inject_test.

Fix: check _pending directly under _lock — same dict submit_pending writes to.
Stale imports removed.

Before: 554 pass, 1 fail | After: 555 pass, 0 fail

* fix: move login JS into external file, remove inline handlers (#226)

Login page used inline onsubmit/onkeydown handlers and an inline <script>
block — all blocked by strict script-src CSP, causing silent login failure.

Fix: extract doLogin() and Enter key listener into static/login.js (served
from /static/, already a public path). Form uses id='login-form' and
data-* attributes for i18n strings instead of injected JS literals.
Also guards res.json() parse with try/catch so non-JSON error bodies
(e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'.

Fixes #222.

* fix: improve update error messages when pull fails (#227)

_apply_update_inner() ran git pull --ff-only and returned only raw stderr
on failure, making all failure modes indistinguishable.

Fix: explicit git fetch before pull; if fetch fails, returns human-readable
network error. Diverged history and missing upstream tracking branch each
get distinct messages with exact recovery commands. Generic fallback
truncates to 300 chars and shows sentinel when git produces no output.

Also adds tests/test_update_checker.py with 13 tests covering all 4 new
diagnostic code paths (0 tests existed before).

Fixes #223.

* fix: stabilize 30s terminal approval prompt visibility (#225)

Adds minimum 30-second visibility guard for the approval card using
_approvalVisibleSince, _approvalHideTimer, and a signature fingerprint
to deduplicate repeated poll ticks.

Fix: respondApproval() and all stream-end paths (done/cancel/apperror/
error/start-error) now call hideApprovalCard(true) so the card hides
immediately when the user responds or the session ends. The 30s guard
only applies to mid-session poll ticks where the approval is still live
but briefly absent.

Adds 11 structural tests covering the new timer variables, force
parameter, force-on-respond, force-on-stream-end, and poll-loop
no-force behavior.

* feat: replace emoji icons with self-hosted Lucide SVG icons (#221)

Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled
in static/icons.js (no CDN dependency). Adds li(name) function returning
inline SVG geometry from a hardcoded whitelist — unknown keys return '' so
dynamic server-supplied names never inject arbitrary SVG.

Changes:
  - static/icons.js: new file with 21 icon paths + li() renderer
  - static/index.html: all nav/action buttons now use li() icons
  - static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons
  - static/messages.js: cancelStream button uses SVG square stop icon
  - .gitignore: adds node_modules/ entry

Verified: all 35 onclick= functions exist in JS, all 21 li() calls
reference defined icons, applyBotName() selectors intact, version
label present, no removed IDs referenced by JS.

* docs: v0.44.0 release notes, bump version, update test counts

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 10:02:28 -07:00
nesquena-hermes
0df9d4830f docs: v0.43.1 — CSRF reverse proxy fix (#220)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 01:27:09 -07:00
Nathan Esquenazi
e0a95193d8 fix: CSRF check supports reverse proxy headers (#218) (#219)
Tests pass: 20/21 QA suite (1 known skip), all browser API sanity checks green, CSRF fix verified end-to-end.
2026-04-10 01:24:18 -07:00
nesquena-hermes
e3c85624d9 docs: v0.43.0 release — auto-install agent deps, session ID validator, test suite isolation fix (571 tests) (#217)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 01:10:02 -07:00
nesquena-hermes
ed9023a431 fix: wire auto_install_agent_deps into server.py startup (#216)
* fix: wire auto_install_agent_deps into server.py startup; add api/startup.py to ARCHITECTURE.md

* fix(tests): kill stale process on test port before server start in conftest

Stale servers left by QA harness runs (ports 8792/8793 etc.) or prior
test sessions could interfere with conftest starting its own server on
TEST_PORT (8788). If the port was already occupied, _wait_for_server
hit the wrong server and tests got unexpected 404s/500s, failing
non-deterministically — the 'conftest isolation issue' seen this session.

Fix: run fuser -k on TEST_PORT before launching the new server process,
with a 0.5s sleep for port release. The full suite now runs 571/571
reliably regardless of what other servers were previously active.

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 00:56:07 -07:00
nesquena-hermes
e59fedd351 feat: auto-install missing agent deps on startup (#215)
* feat: auto-install missing agent deps on startup

* fix: patch HERMES_HOME in test_skips_when_agent_dir_missing to prevent real agent fallback

The test patched HERMES_WEBUI_AGENT_DIR to a nonexistent path but left
HERMES_HOME unpatched. In the full test suite HERMES_HOME resolves to the
real hermes agent dir, causing the fallback in _agent_dir() to find and
use it — making auto_install_agent_deps() call pip instead of returning
False. Fix: also patch HERMES_HOME to a nonexistent dir in env_overrides.

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 00:42:02 -07:00
nesquena-hermes
9a5435176d fix: broaden session ID validator to support new hermes-agent format (#212)
* fix: broaden session ID validator to support new hermes-agent format

* test: add more path traversal evil IDs to session validator test

Add null byte, backslash, forward slash, and dot-extension variants
to the rejected session ID test to cover additional attack vectors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:00:02 -07:00
nesquena-hermes
31281a6025 docs: v0.42.2 release — CSP unsafe-inline fix (564 tests) (#210)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 19:08:30 -07:00
nesquena-hermes
cc8cbc4d3f fix(security): add unsafe-inline and CDN allowlist to CSP script-src (#209)
The CSP script-src 'self' policy blocked all inline onclick= event handlers
in index.html (55+ handlers including toggleSettings(), switchPanel(),
filterSessions() etc.), making the settings panel, sidebar navigation, and
most interactive UI elements non-functional.

Also restores https://cdn.jsdelivr.net to both script-src and style-src
(required for Mermaid.js dynamic load in ui.js and Prism.js static load
in index.html). This was present in the original PR #197 merge but was
dropped in the v0.42.1 commit.

script-src additions:
- 'unsafe-inline': required for onclick=/oninput=/onchange= attributes
- https://cdn.jsdelivr.net: Mermaid (dynamic) and Prism (static with SRI)

style-src: retains 'unsafe-inline' + cdn.jsdelivr.net (Prism CSS)

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 19:07:51 -07:00
nesquena-hermes
0e5e465ea0 fix: i18n button text stripping and German translation corrections (v0.42.1)
Some checks failed
Release & Docker / release (push) Has been cancelled
Three sidebar buttons (+ New job/skill/profile) and three suggestion
buttons had data-i18n on the outer element, causing applyLocaleToDOM
to strip the + prefix and emoji characters when switching locales.
Fixed by wrapping only the label text in a <span data-i18n=...>.

Also corrects German translations:
- cancelling: imperative -> progressive (Wird abgebrochen...)
- editing: first-person verb -> noun (Bearbeitung)
- empty_subtitle: add missing 'explore files' clause
- settings_desc_check_updates: add git fetch detail
- settings_desc_cli_sessions: add 'continue the conversation' clause

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 19:04:48 -07:00
nesquena-hermes
a92e21553d docs: v0.42.0 release — German i18n, custom provider routing, phantom Custom group fix (564 tests) (#207)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 18:44:04 -07:00
David Schuchert
06f46439c0 feat: add German translation and make UI elements translatable (#190)
Co-authored-by: David Work <davidwork@MBP-von-David.fritz.box>
2026-04-09 18:35:23 -07:00
nesquena-hermes
e68c1b92a4 fix: do not build phantom Custom group when active provider is set (#206)
* fix: do not build phantom "Custom" group when active provider is set

When model.provider is a real provider (e.g. openai-codex) and model.base_url
is configured, hermes_cli reports 'custom' as an authenticated provider. The
WebUI model picker was building a separate "Custom" group for it and parking
the configured default_model there instead of under the active provider's
group — diverging from the TUI which correctly shows the model under its
configured provider.

Two fixes in api/config.py get_available_models():

1. Discard 'custom' from detected_providers when active_provider is set and
   isn't 'custom' itself. The base_url belongs to the active provider.

2. Replace the substring-based default-model injection check with an exact
   match against _PROVIDER_DISPLAY. The old check `active_provider.lower() in
   g.get('provider', '').lower()` silently failed for hyphenated IDs like
   'openai-codex' vs display name 'OpenAI Codex' (hyphen vs. space),
   falling through to groups[0] and landing the model in the alphabetical
   first group instead.

Adds two regression tests in tests/test_model_resolver.py covering both
conditions.

* fix: do not build phantom Custom group when active provider is set

Two bugs in get_available_models():

1. Phantom Custom group: hermes_cli reports 'custom' as authenticated
whenever model.base_url is set. With provider=openai-codex + base_url,
detected_providers contained both 'openai-codex' and 'custom', producing
a duplicate group. Fixed by discarding 'custom' from detected_providers
when the active provider is any real named provider.

2. Hyphen/space mismatch in default_model injection: the substring check
'openai-codex' in 'openai codex' is False (hyphen vs space), causing the
default model to fall through to groups[0] (alphabetically first provider)
instead of the active provider group. Fixed by using _PROVIDER_DISPLAY
for exact display-name comparison.

Also fixes test helper _available_models_with_full_cfg to clear model env
vars during the call, preventing real hermes profile env from leaking into
the test assertions.

---------

Co-authored-by: mbac <marco.baciarello@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 18:33:24 -07:00
sean
fb19c7ea1f fix: route slash-based custom provider models correctly (#189)
Co-authored-by: smurmann <smurmann@users.noreply.github.com>
2026-04-09 18:23:40 -07:00
24 changed files with 1416 additions and 167 deletions

3
.gitignore vendored
View File

@@ -25,3 +25,6 @@ full-UI.png
# OS files
.DS_Store
Thumbs.db
# Local reference clones — never committed
docs/

View File

@@ -40,6 +40,7 @@ This makes the code easy to modify from a terminal or by an agent.
models.py Session model + CRUD, per-session profile tracking (~137 lines)
profiles.py Profile state management, hermes_cli wrapper (~246 lines)
routes.py All GET + POST route handlers (~1180 lines)
startup.py Startup helpers: auto_install_agent_deps() (~50 lines)
streaming.py SSE engine, run_agent, cancel, HERMES_HOME save/restore (~236 lines)
upload.py Multipart parser, file upload handler (~78 lines)
workspace.py File ops: list_dir, read_file_content, workspace helpers (~77 lines)

View File

@@ -6,6 +6,54 @@
---
## [v0.44.0] — 2026-04-10
### Features
- **Lucide SVG icons** (PR #221): Replaces all emoji icons in the sidebar, workspace, and tool cards with self-hosted Lucide SVG paths via `static/icons.js`. No CDN dependency — icons are bundled directly. The `li(name)` renderer uses a hardcoded whitelist, so server-supplied tool names never inject arbitrary SVG. All 35 `onclick=` functions verified to exist in JS; all 21 icon references verified in `icons.js`.
### Bug Fixes
- **Approval card hides immediately on respond/stream-end** (PR #225): `respondApproval()` and all stream-end SSE handlers (done, cancel, apperror, error, start-error) now call `hideApprovalCard(true)`. Previously the 30s minimum-visibility guard deferred the hide, leaving the card visible with disabled buttons for up to 30s after the user clicked Approve/Deny or the session completed. The poll-loop tick correctly keeps no-force so the guard still protects against transient polling gaps. Adds 11 structural tests for the timer logic.
- **Login page CSP fix** (PR #226): Moves `doLogin()` and Enter key listener from inline `<script>`/`onsubmit`/`onkeydown` attributes into `static/login.js`. Inline handlers are blocked by strict `script-src` CSP, causing silent login failure. i18n error strings now passed via `data-*` attributes instead of injected JS literals. Also guards `res.json()` parse with try/catch so non-JSON server errors fall back to the password-error message. Fixes #222.
- **Update error messages** (PR #227): `_apply_update_inner()` now fetches before pulling and surfaces three distinct failure modes with actionable recovery commands: network unreachable, diverged history (`git reset --hard`), and missing upstream tracking branch (`git branch --set-upstream-to`). Generic fallback truncates to 300 chars with a sentinel for empty output. Adds 13 tests covering all new diagnostic code paths. Fixes #223.
- **Approval pending check** (PR #228): `GET /api/approval/pending` always returned `{pending: null}` after the agent module renamed `has_pending` to `has_blocking_approval`. The route now checks `_pending` directly under `_lock`, matching how `submit_pending` writes to it. Fixes `test_approval_submit_and_respond`.
### Tests
- 579 passing, 16 skipped (up from 555 on v0.43.1 — +24 new tests across PRs #225, #227, #228)
## [v0.43.1] — 2026-04-10
- **CSRF fix for reverse proxies** (PR #219): The CSRF check now accepts `X-Forwarded-Host` and `X-Real-Host` headers in addition to `Host`, so deployments behind Caddy, nginx, and Traefik no longer reject POST requests with "Cross-origin request rejected". Security is preserved — requests with no matching proxy header are still rejected. Fixes #218.
## [v0.43.0] — 2026-04-10
### Features
- **Auto-install agent dependencies on startup** (PRs #215 + #216): When `hermes-agent` is found on disk but its Python dependencies are missing (common in Docker deployments where the agent is volume-mounted post-build), `server.py` now calls `api/startup.auto_install_agent_deps()` to install from `requirements.txt` or `pyproject.toml`. Falls back gracefully — failures are logged and never fatal.
### Bug Fixes
- **Session ID validator broadened** (PR #212): `Session.load()` rejected any session ID containing non-hex characters, breaking sessions created by the new hermes-agent format (`YYYYMMDD_HHMMSS_xxxxxx`). Validator now accepts `[0-9a-z_]` while rejecting path traversal patterns (null bytes, slashes, backslashes, dot-extensions).
- **Test suite isolation** (PR #216): `conftest.py` now kills any stale process on the test port (8788) before starting the fixture server. Stale QA harness servers (8792/8793) could occupy 8788 and cause non-deterministic test failures across the full suite.
## [v0.42.2] — 2026-04-10
### Bug Fixes
- **CSP blocking inline event handlers** (PR #209): `script-src 'self'` blocked all 55+ inline `onclick=` handlers in `index.html`, making the settings panel, sidebar navigation, and most interactive controls non-functional. Added `'unsafe-inline'` to `script-src`. Also restores `https://cdn.jsdelivr.net` to `script-src` and `style-src` for Mermaid.js and Prism.js (dropped in v0.42.1).
## [v0.42.1] — 2026-04-11
### Bug Fixes
- **i18n button text stripping** (post-review): Three sidebar buttons (`+ New job`, `+ New skill`, `+ New profile`) and three suggestion buttons had `data-i18n` on the outer element, which caused `applyLocaleToDOM` to replace the entire `textContent` — stripping the `+` prefix and emoji characters on locale switch. Fixed by wrapping only the translatable label text in a `<span data-i18n="...">`.
- **German translation corrections** (post-review): Fixed `cancelling` (imperative → progressive `"Wird abgebrochen…"`), `editing` (first-person verb → noun `"Bearbeitung"`), and completed truncated descriptions for `empty_subtitle`, `settings_desc_check_updates`, and `settings_desc_cli_sessions`.
## [v0.42.0] — 2026-04-10
### Features
- **German translation** (PR #190 by @DavidSchuchert): Complete `de` locale covering all UI strings — settings, commands, sidebar, approval cards. Also extends the i18n system with `data-i18n-title` and `data-i18n-placeholder` attribute support so tooltip text and input placeholders are now translatable. German speech recognition uses `de-DE`.
### Bug Fixes
- **Custom slash-model routing** (PR #189 by @smurmann): Model IDs like `google/gemma-4-26b-a4b` from custom providers (LM Studio, Ollama) were silently misrouted to OpenRouter because of the slash-heuristic. Custom providers now win: entries in `config.yaml → custom_providers` are checked first, so their model IDs route to the correct local endpoint regardless of format.
- **Phantom Custom group in model picker** (PR #191 by @mbac): When `model.provider` was a named provider (e.g. `openai-codex`) and `model.base_url` was set, `hermes_cli` reported `'custom'` as authenticated, producing a duplicate "Custom" group in the dropdown. The real provider's group was missing the configured default model. Fixed by discarding the phantom `custom` entry when a real named provider is active.
- **Hyphen/space model group injection** (PR #191): The "ensure default_model appears" post-pass used `active_provider.lower() in group_name.lower()`, which fails for `openai-codex` vs display name `OpenAI Codex` (hyphen vs space). Now uses `_PROVIDER_DISPLAY` for exact display-name matching.
## [v0.41.0] — 2026-04-10
### Features

View File

@@ -3,7 +3,7 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI.
>
> Last updated: v0.39.0 (April 8, 2026)
> Last updated: v0.44.0 (April 10, 2026) — 595 tests, 579 passing
> Tests: 499 total (499 passing, 0 failures)
> Source: <repo>/
@@ -40,6 +40,7 @@
| Sprint 21 | Mobile responsive + Docker | Hamburger sidebar, bottom nav, files slide-over, Docker support (#21, #7) | 415 |
| Sprint 22 | Multi-profile support | Profile picker, management panel, seamless switching, per-session tracking (#28) | 415 |
| Sprint 23 | Agentic transparency | Token/cost display, subagent cards, skill picker in cron, skill linked files, workspace tree persistence, timestamp fixes | 424 |
| v0.44.0 patch | Fix batch: approval card, login CSP, update diagnostics, Lucide icons | PRs #221 #225 #226 #227 #228 | 579 |
| v0.32 | Auto-compaction handling | Compression detection, /compact command, real context window indicator | 424 |
| v0.33 | /insights sync | Opt-in state.db sync so `hermes /insights` includes WebUI sessions | 424 |
| v0.34 | Sprint 26 — Pluggable themes | Dark, Light, Slate, Solarized, Monokai, Nord; settings unsaved-changes guard; /theme command | 433 |

View File

@@ -8,7 +8,7 @@
> Prerequisites: SSH tunnel is active on port 8786. Open http://localhost:8786 in browser.
> Server health check: curl http://127.0.0.1:8786/health should return {"status":"ok"}.
>
> Automated tests: 547 total (547 passing, 0 known isolation failures)
> Automated tests: 595 total (579 passing, 16 skipped, 0 known failures)
> Run: `pytest tests/ -v --timeout=60`
---

View File

@@ -389,14 +389,21 @@ def resolve_model_provider(model_id: str) -> tuple:
"""Resolve model name, provider, and base_url for AIAgent.
Model IDs from the dropdown can be in several formats:
- 'claude-sonnet-4.6' (bare name, uses config default provider)
- 'anthropic/claude-sonnet-4.6' (OpenRouter format, provider/model)
- '@minimax:MiniMax-M2.7' (explicit provider hint from dropdown)
- 'claude-sonnet-4.6' (bare name, uses config default provider)
- 'anthropic/claude-sonnet-4.6' (OpenRouter-style provider/model)
- '@minimax:MiniMax-M2.7' (explicit provider hint from dropdown)
The @provider:model format is used for models from non-default provider
groups in the dropdown, so we can route them through the correct provider
via resolve_runtime_provider(requested=provider) instead of the default.
Custom OpenAI-compatible endpoints are special: their model IDs often look
like provider/model (for example ``google/gemma-4-26b-a4b``), which would be
mistaken for an OpenRouter model if we only looked at the slash. To avoid
that, first check whether the selected model matches an entry in
config.yaml -> custom_providers and route it through that named custom
provider.
Returns (model, provider, base_url) where provider and base_url may be None.
"""
config_provider = None
@@ -410,6 +417,20 @@ def resolve_model_provider(model_id: str) -> tuple:
if not model_id:
return model_id, config_provider, config_base_url
# Custom providers declared in config.yaml should win over slash-based
# OpenRouter heuristics. Their model IDs commonly contain '/' too.
custom_providers = cfg.get('custom_providers', [])
if isinstance(custom_providers, list):
for entry in custom_providers:
if not isinstance(entry, dict):
continue
entry_model = (entry.get('model') or '').strip()
entry_name = (entry.get('name') or '').strip()
entry_base_url = (entry.get('base_url') or '').strip()
if entry_model and entry_name and model_id == entry_model:
provider_hint = 'custom:' + entry_name.lower().replace(' ', '-')
return model_id, provider_hint, entry_base_url or None
# @provider:model format — explicit provider hint from the dropdown.
# Route through that provider directly (resolve_runtime_provider will
# resolve credentials in streaming.py).
@@ -659,6 +680,14 @@ def get_available_models() -> dict:
_seen_custom_ids.add(_cp_model)
detected_providers.add('custom')
# If the user configured a real model.provider, the base_url belongs to
# THAT provider, not to a separate "Custom" group. hermes_cli reports
# 'custom' as authenticated whenever base_url is set, which would otherwise
# build a phantom "Custom" bucket next to the real provider's group. Drop
# it unless the user explicitly chose 'custom' as their active provider.
if active_provider and active_provider != 'custom':
detected_providers.discard('custom')
# 5. Build model groups
if detected_providers:
for pid in sorted(detected_providers):
@@ -720,11 +749,21 @@ def get_available_models() -> dict:
_norm = lambda mid: mid.split('/', 1)[-1] if '/' in mid else mid
all_ids_norm = {_norm(m['id']) for g in groups for m in g.get('models', [])}
if _norm(default_model) not in all_ids_norm:
# Determine which group to inject into
# Determine which group to inject into. Compare against the
# provider's display name from _PROVIDER_DISPLAY rather than
# doing a substring match on active_provider — substring
# matching breaks on hyphenated provider IDs like 'openai-codex'
# vs display name 'OpenAI Codex' (hyphen vs. space), which
# silently falls through to groups[0] and lands the model in
# the wrong group.
label = default_model.split('/')[-1] if '/' in default_model else default_model
target_display = (
_PROVIDER_DISPLAY.get(active_provider, active_provider or '').lower()
if active_provider else ''
)
injected = False
for g in groups:
if active_provider and active_provider.lower() in g.get('provider', '').lower():
if target_display and g.get('provider', '').lower() == target_display:
g['models'].insert(0, {'id': default_model, 'label': label})
injected = True
break

View File

@@ -41,7 +41,9 @@ def _security_headers(handler):
handler.send_header('Referrer-Policy', 'same-origin')
handler.send_header(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"img-src 'self' data:; font-src 'self' data:; connect-src 'self'; "
"base-uri 'self'; form-action 'self'"
)

View File

@@ -74,7 +74,7 @@ class Session:
@classmethod
def load(cls, sid):
# Validate session ID format to prevent path traversal
if not sid or not all(c in '0123456789abcdef' for c in sid):
if not sid or not all(c in '0123456789abcdefghijklmnopqrstuvwxyz_' for c in sid):
return None
p = SESSION_DIR / f'{sid}.json'
if not p.exists():

View File

@@ -32,12 +32,21 @@ def _check_csrf(handler) -> bool:
if not origin and not referer:
return True # non-browser clients (curl, agent) have no Origin
target = origin or referer
# Allow same-origin: Origin must match Host
if host and target:
# Extract host:port from origin/referer
m = _re.match(r'^https?://([^/]+)', target)
if m and m.group(1) == host:
return True
# Extract host:port from origin/referer
m = _re.match(r'^https?://([^/]+)', target)
if not m:
return False
origin_host = m.group(1)
# Allow same-origin: check Host, X-Forwarded-Host (reverse proxy), and
# X-Real-Host against the origin. Reverse proxies (Caddy, nginx) set
# X-Forwarded-Host to the client's original Host header.
allowed_hosts = {h.strip() for h in [
host,
handler.headers.get('X-Forwarded-Host', ''),
handler.headers.get('X-Real-Host', ''),
] if h.strip()}
if origin_host in allowed_hosts:
return True
return False
from api.models import (
Session, get_session, new_session, all_sessions, title_from,
@@ -55,14 +64,13 @@ from api.streaming import _sse, _run_agent_streaming, cancel_stream
# Approval system (optional -- graceful fallback if agent not available)
try:
from tools.approval import (
has_pending, pop_pending, submit_pending,
submit_pending,
approve_session, approve_permanent, save_permanent_allowlist,
is_approved, _pending, _lock, _permanent_approved,
resolve_gateway_approval,
)
except ImportError:
has_pending = lambda *a, **k: False
pop_pending = lambda *a, **k: None
submit_pending = lambda *a, **k: None
approve_session = lambda *a, **k: None
approve_permanent = lambda *a, **k: None
@@ -121,29 +129,14 @@ button:hover{background:rgba(124,185,255,.25)}
<div class="logo">{{BOT_NAME_INITIAL}}</div>
<h1>{{BOT_NAME}}</h1>
<p class="sub">{{LOGIN_SUBTITLE}}</p>
<form onsubmit="doLogin(event);return false">
<input type="password" id="pw" placeholder="{{LOGIN_PLACEHOLDER}}" autofocus
onkeydown="if(event.key==='Enter'){doLogin(event);event.preventDefault();}">
<form id="login-form" data-invalid-pw="{{LOGIN_INVALID_PW}}" data-conn-failed="{{LOGIN_CONN_FAILED}}">
<input type="password" id="pw" placeholder="{{LOGIN_PLACEHOLDER}}" autofocus>
<button type="submit">{{LOGIN_BTN}}</button>
</form>
<div class="err" id="err"></div>
</div>
<script>
async function doLogin(e){
e.preventDefault();
const pw=document.getElementById('pw').value;
const err=document.getElementById('err');
err.style.display='none';
try{
const res=await fetch('/api/auth/login',{method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({password:pw}),credentials:'include'});
const data=await res.json();
if(res.ok&&data.ok){window.location.href='/';}
else{err.textContent=data.error||'{{LOGIN_INVALID_PW}}';err.style.display='block';}
}catch(ex){err.textContent='{{LOGIN_CONN_FAILED}}';err.style.display='block';}
}
</script></body></html>'''
<script src="/static/login.js"></script>
</body></html>'''
# ── GET routes ────────────────────────────────────────────────────────────────
@@ -168,8 +161,8 @@ def handle_get(handler, parsed) -> bool:
.replace('{{LOGIN_SUBTITLE}}', _html.escape(_login_strings['subtitle']))
.replace('{{LOGIN_PLACEHOLDER}}', _html.escape(_login_strings['placeholder']))
.replace('{{LOGIN_BTN}}', _html.escape(_login_strings['btn']))
.replace('{{LOGIN_INVALID_PW}}', _login_strings['invalid_pw'].replace('\\','\\\\').replace("'","\\'"))
.replace('{{LOGIN_CONN_FAILED}}', _login_strings['conn_failed'].replace('\\','\\\\').replace("'","\\'"))
.replace('{{LOGIN_INVALID_PW}}', _html.escape(_login_strings['invalid_pw']))
.replace('{{LOGIN_CONN_FAILED}}', _html.escape(_login_strings['conn_failed']))
)
return t(handler, _page, content_type='text/html; charset=utf-8')
@@ -960,10 +953,10 @@ def _handle_file_read(handler, parsed):
def _handle_approval_pending(handler, parsed):
sid = parse_qs(parsed.query).get('session_id', [''])[0]
if has_pending(sid):
with _lock:
p = dict(_pending.get(sid, {}))
return j(handler, {'pending': p})
with _lock:
p = _pending.get(sid)
if p:
return j(handler, {'pending': dict(p)})
return j(handler, {'pending': None})

46
api/startup.py Normal file
View File

@@ -0,0 +1,46 @@
"""Hermes Web UI -- startup helpers."""
from __future__ import annotations
import os, subprocess, sys
from pathlib import Path
def _agent_dir() -> Path | None:
hermes_home = Path(os.environ.get('HERMES_HOME', str(Path.home() / '.hermes')))
for raw in [os.environ.get('HERMES_WEBUI_AGENT_DIR', '').strip(), str(hermes_home / 'hermes-agent')]:
if not raw:
continue
p = Path(raw).expanduser()
if p.is_dir():
return p.resolve()
return None
def auto_install_agent_deps() -> bool:
agent_dir = _agent_dir()
if agent_dir is None:
print('[!!] Auto-install skipped: agent directory not found.', flush=True)
return False
req_file = agent_dir / 'requirements.txt'
pyproject = agent_dir / 'pyproject.toml'
if req_file.exists():
install_args = [sys.executable, '-m', 'pip', 'install', '--quiet', '-r', str(req_file)]
print(f' Installing from {req_file} ...', flush=True)
elif pyproject.exists():
install_args = [sys.executable, '-m', 'pip', 'install', '--quiet', str(agent_dir)]
print(f' Installing from {agent_dir} (pyproject.toml) ...', flush=True)
else:
print('[!!] Auto-install skipped: no requirements.txt or pyproject.toml in agent dir.', flush=True)
return False
try:
result = subprocess.run(install_args, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
print(f'[!!] pip install failed (exit {result.returncode}):', flush=True)
for line in (result.stderr or '').splitlines()[-10:]:
print(f' {line}', flush=True)
return False
print('[ok] pip install completed.', flush=True)
return True
except subprocess.TimeoutExpired:
print('[!!] Auto-install timed out after 120s.', flush=True)
return False
except Exception as e:
print(f'[!!] Auto-install error: {e}', flush=True)
return False

View File

@@ -148,6 +148,17 @@ def _apply_update_inner(target):
branch = _detect_default_branch(path)
compare_ref = f'origin/{branch}'
# Fetch before attempting pull, so the remote ref is current.
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
if not fetch_ok:
return {
'ok': False,
'message': (
'Could not reach the remote repository. '
'Check your internet connection and try again.'
),
}
# Check for dirty working tree
status_out, _ = _run_git(['status', '--porcelain'], path)
stashed = False
@@ -162,7 +173,31 @@ def _apply_update_inner(target):
if not pull_ok:
if stashed:
_run_git(['stash', 'pop'], path)
return {'ok': False, 'message': f'Pull failed: {pull_out[:200]}'}
# Diagnose the most common failure modes and surface actionable messages.
pull_lower = pull_out.lower()
if 'not possible to fast-forward' in pull_lower or 'diverged' in pull_lower:
return {
'ok': False,
'message': (
f'The local {target} repo has commits that are not on the remote '
'branch, so a fast-forward update is not possible. '
'Run: git -C ' + str(path) + ' fetch origin && '
'git -C ' + str(path) + ' reset --hard ' + compare_ref
),
'diverged': True,
}
if 'does not track' in pull_lower or 'no tracking information' in pull_lower:
return {
'ok': False,
'message': (
f'The local {target} branch has no upstream tracking branch configured. '
'Run: git -C ' + str(path) + ' branch --set-upstream-to=' + compare_ref
),
}
# Generic fallback — include the raw git output for debugging.
detail = pull_out.strip()[:300] if pull_out.strip() else '(no output from git)'
return {'ok': False, 'message': f'Pull failed: {detail}'}
# Pop stash if we stashed
if stashed:

View File

@@ -12,6 +12,7 @@ from api.auth import check_auth
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
from api.helpers import j
from api.routes import handle_get, handle_post
from api.startup import auto_install_agent_deps
class Handler(BaseHTTPRequestHandler):
@@ -75,7 +76,16 @@ def main() -> None:
print(f'[!!] Warning: Hermes agent found but missing modules: {missing}', flush=True)
for mod, err in errors.items():
print(f' {mod}: {err}', flush=True)
print(' Agent features may not work correctly.', flush=True)
print(' Attempting to install missing dependencies from agent requirements.txt...', flush=True)
auto_install_agent_deps()
ok, missing, errors = verify_hermes_imports()
if not ok:
print(f'[!!] Still missing after install attempt: {missing}', flush=True)
for mod, err in errors.items():
print(f' {mod}: {err}', flush=True)
print(' Agent features may not work correctly.', flush=True)
else:
print('[ok] Agent dependencies installed successfully.', flush=True)
STATE_DIR.mkdir(parents=True, exist_ok=True)
SESSION_DIR.mkdir(parents=True, exist_ok=True)

View File

@@ -136,6 +136,232 @@ const LOCALES = {
login_btn: 'Sign in',
login_invalid_pw: 'Invalid password',
login_conn_failed: 'Connection failed',
// Sidebar & Tabs
tab_chat: 'Chat',
tab_tasks: 'Tasks',
tab_skills: 'Skills',
tab_memory: 'Memory',
tab_workspaces: 'Spaces',
tab_profiles: 'Profiles',
tab_todos: 'Todos',
new_conversation: 'New conversation',
filter_conversations: 'Filter conversations...',
scheduled_jobs: 'Scheduled jobs',
new_job: 'New job',
loading: 'Loading...',
search_skills: 'Search skills...',
new_skill: 'New skill',
personal_memory: 'Personal memory',
current_task_list: 'Current task list',
workspace_desc: 'Add and switch workspaces for your sessions.',
new_profile: 'New profile',
transcript: 'Transcript',
download_transcript: 'Download as Markdown',
import: 'Import',
// Settings detail
settings_label_sound: 'Notification sound',
settings_desc_sound: 'Play a sound when the assistant finishes a response.',
settings_label_notifications: 'Browser notifications',
settings_desc_notifications: 'Show a system notification when a response completes while the tab is in the background.',
settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.',
settings_desc_cli_sessions: 'Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.',
settings_desc_sync_insights: 'Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.',
settings_desc_check_updates: 'Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.',
settings_desc_bot_name: 'Display name for the assistant throughout the UI. Defaults to Hermes.',
settings_desc_password: 'Enter a new password to set or change it. Leave blank to keep current setting.',
password_placeholder: 'Enter new password…',
disable_auth: 'Disable Auth',
sign_out: 'Sign Out',
cancel: 'Cancel',
create_job: 'Create job',
save_skill: 'Save skill',
editing: 'Editing',
// Empty state
empty_title: 'What can I help with?',
empty_subtitle: 'Ask anything, run commands, explore files, or manage your scheduled tasks.',
suggest_files: 'What files are in this workspace?',
suggest_schedule: "What's on my schedule today?",
suggest_plan: 'Help me plan a small project.',
},
de: {
_lang: 'de',
_label: 'Deutsch',
_speech: 'de-DE',
// boot.js
cancelling: 'Wird abgebrochen\u2026',
cancel_failed: 'Abbrechen fehlgeschlagen: ',
mic_denied: 'Mikrofonzugriff verweigert. Überprüfen Sie die Browserberechtigungen.',
mic_no_speech: 'Keine Sprache erkannt. Versuchen Sie es erneut.',
mic_network: 'Spracherkennung nicht verfügbar.',
mic_error: 'Spracheingabefehler: ',
session_imported: 'Sitzung importiert',
import_failed: 'Import fehlgeschlagen: ',
import_invalid_json: 'Ungültiges JSON',
image_pasted: 'Bild eingefügt: ',
// messages.js
edit_message: 'Nachricht bearbeiten',
regenerate: 'Antwort regenerieren',
copy: 'Kopieren',
copied: 'Kopiert!',
you: 'Du',
thinking: 'Nachdenken',
expand_all: 'Alle ausklappen',
collapse_all: 'Alle einklappen',
edit_failed: 'Bearbeiten fehlgeschlagen: ',
regen_failed: 'Regeneration fehlgeschlagen: ',
reconnect_active: 'Eine Antwort wird noch generiert. Neu laden, wenn bereit?',
reconnect_finished: 'Eine Antwort war in Arbeit, als Sie zuletzt gegangen sind. Nachrichten könnten aktualisiert worden sein.',
// approval card
approval_heading: 'Genehmigung erforderlich',
approval_desc_prefix: 'Gefährlicher Befehl erkannt',
approval_btn_once: 'Einmal zulassen',
approval_btn_once_title: 'Diesen einen Befehl zulassen (Enter)',
approval_btn_session: 'Sitzung zulassen',
approval_btn_session_title: 'Für diese Konversationssitzung zulassen',
approval_btn_always: 'Immer zulassen',
approval_btn_always_title: 'Dieses Befehlsmuster immer zulassen',
approval_btn_deny: 'Ablehnen',
approval_btn_deny_title: 'Ablehnen \u2014 diesen Befehl nicht ausführen',
approval_responding: 'Antwortet\u2026',
untitled: 'Unbenannt',
n_messages: (n) => `${n} Nachrichten`,
model_unavailable: ' (nicht verfügbar)',
model_unavailable_title: 'Dieses Modell ist nicht mehr in Ihrer aktuellen Provider-Liste',
// commands.js
cmd_help: 'Verfügbare Befehle auflisten',
cmd_clear: 'Konversationsverlauf löschen',
cmd_compact: 'Kontext komprimieren',
cmd_model: 'Modell wechseln (z.B. /model gpt-4o)',
cmd_workspace: 'Workspace nach Namen wechseln',
cmd_new: 'Neue Chat-Sitzung starten',
cmd_usage: 'Token-Verbrauchsanzeige umschalten',
cmd_theme: 'Theme wechseln (dark/light/slate/solarized/monokai/nord/oled)',
cmd_personality: 'Agenten-Persönlichkeit wechseln',
available_commands: 'Verfügbare Befehle:',
type_slash: 'Tippe / für Befehle',
conversation_cleared: 'Konversation gelöscht',
model_usage: 'Nutzung: /model <name>',
no_model_match: 'Kein Modell gefunden für "',
switched_to: 'Gewechselt zu ',
workspace_usage: 'Nutzung: /workspace <name>',
no_workspace_match: 'Kein Workspace gefunden für "',
switched_workspace: 'Gewechselt zu Workspace: ',
workspace_switch_failed: 'Workspace-Wechsel fehlgeschlagen: ',
new_session: 'Neue Sitzung erstellt',
compressing: 'Kontext-Komprimierung wird angefordert...',
token_usage_on: 'Token-Verbrauch an',
token_usage_off: 'Token-Verbrauch aus',
theme_usage: 'Nutzung: /theme ',
theme_set: 'Theme: ',
no_active_session: 'Keine aktive Sitzung',
no_personalities: 'Keine Persönlichkeiten gefunden (füge sie in ~/.hermes/personalities/ hinzu)',
available_personalities: 'Verfügbare Persönlichkeiten:',
personality_switch_hint: '\n\nNutze `/personality <name>` zum Wechseln, oder `/personality none` zum Löschen.',
personalities_load_failed: 'Fehler beim Laden der Persönlichkeiten',
personality_cleared: 'Persönlichkeit gelöscht',
personality_set: 'Persönlichkeit: ',
failed_colon: 'Fehlgeschlagen: ',
// ui.js
no_workspace: 'Kein Workspace',
// workspace.js
unsaved_confirm: 'Sie haben ungespeicherte Änderungen in der Vorschau. Verwerfen und fortfahren?',
save: 'Speichern',
edit: 'Bearbeiten',
save_title: 'Änderungen speichern',
edit_title: 'Diese Datei bearbeiten',
saved: 'Gespeichert',
save_failed: 'Speichern fehlgeschlagen: ',
image_load_failed: 'Bild konnte nicht geladen werden',
file_open_failed: 'Datei konnte nicht geöffnet werden',
downloading: (name) => `Lade ${name} herunter\u2026`,
double_click_rename: 'Doppelklick zum Umbenennen',
renamed_to: 'Umbenannt in ',
rename_failed: 'Umbenennen fehlgeschlagen: ',
delete_title: 'Löschen',
delete_confirm: (name) => `${name} löschen?`,
deleted: 'Gelöscht ',
delete_failed: 'Löschen fehlgeschlagen: ',
new_file_prompt: 'Neuer Dateiname (z.B. notes.md):',
created: 'Erstellt ',
create_failed: 'Erstellen fehlgeschlagen: ',
new_folder_prompt: 'Neuer Ordnername:',
folder_created: 'Ordner erstellt ',
folder_create_failed: 'Ordner erstellen fehlgeschlagen: ',
remove_title: 'Entfernen',
empty_dir: '(leer)',
upload_failed: 'Upload fehlgeschlagen: ',
all_uploads_failed: (n) => `Alle ${n} Upload(s) fehlgeschlagen`,
// settings panel
settings_title: 'Einstellungen',
settings_save_btn: 'Einstellungen speichern',
settings_label_model: 'Standard-Modell',
settings_label_send_key: 'Sende-Taste',
settings_label_theme: 'Theme',
settings_label_language: 'Sprache',
settings_label_token_usage: 'Token-Verbrauch anzeigen',
settings_label_cli_sessions: 'CLI-Sitzungen anzeigen',
settings_label_sync_insights: 'Mit Insights synchronisieren',
settings_label_check_updates: 'Nach Updates suchen',
settings_label_bot_name: 'Assistenten-Name',
settings_label_password: 'Zugangspasswort',
settings_saved: 'Einstellungen gespeichert',
settings_save_failed: 'Speichern fehlgeschlagen: ',
settings_load_failed: 'Laden der Einstellungen fehlgeschlagen: ',
settings_saved_pw: 'Einstellungen gespeichert (Passwort gesetzt \u2014 Login jetzt erforderlich)',
// login page
login_title: 'Anmelden',
login_subtitle: 'Geben Sie Ihr Passwort ein, um fortzufahren',
login_placeholder: 'Passwort',
login_btn: 'Anmelden',
login_invalid_pw: 'Ungültiges Passwort',
login_conn_failed: 'Verbindung fehlgeschlagen',
// Sidebar & Tabs
tab_chat: 'Chat',
tab_tasks: 'Aufgaben',
tab_skills: 'Skills',
tab_memory: 'Gedächtnis',
tab_workspaces: 'Spaces',
tab_profiles: 'Profile',
tab_todos: 'Todos',
new_conversation: 'Neuer Chat',
filter_conversations: 'Chats filtern...',
scheduled_jobs: 'Geplante Aufgaben',
new_job: 'Neuer Job',
loading: 'Lädt...',
search_skills: 'Skills suchen...',
new_skill: 'Neuer Skill',
personal_memory: 'Persönliches Gedächtnis',
current_task_list: 'Aktuelle Aufgabenliste',
workspace_desc: 'Workspaces hinzufügen und wechseln.',
new_profile: 'Neues Profil',
transcript: 'Protokoll',
download_transcript: 'Als Markdown herunterladen',
import: 'Importieren',
// Settings detail
settings_label_sound: 'Benachrichtigungston',
settings_desc_sound: 'Spielt einen Ton ab, wenn der Assistent eine Antwort beendet.',
settings_label_notifications: 'Browser-Benachrichtigungen',
settings_desc_notifications: 'Zeigt eine Systembenachrichtigung an, wenn eine Antwort fertiggestellt wird, während der Tab im Hintergrund ist.',
settings_desc_token_usage: 'Zeigt die Anzahl der Input/Output-Token unter jeder Antwort des Assistenten an. Auch umschaltbar mit /usage.',
settings_desc_cli_sessions: 'Fügt Sitzungen aus der Hermes CLI (state.db) in die Sitzungsliste ein. Klicken Sie auf eine CLI-Sitzung, um sie zu importieren und das Gespräch fortzusetzen.',
settings_desc_sync_insights: 'Spiegelt den WebUI-Token-Verbrauch in die state.db, sodass hermes /insights Browser-Sitzungsdaten enthält. Standardmäßig aus.',
settings_desc_check_updates: 'Zeigt ein Banner an, wenn neuere Versionen der WebUI oder des Agenten verfügbar sind. Führt regelmäßig einen Git-Fetch im Hintergrund aus.',
settings_desc_bot_name: 'Anzeigename für den Assistenten in der UI. Standardmäßig Hermes.',
settings_desc_password: 'Geben Sie ein neues Passwort ein, um es zu setzen oder zu ändern. Leer lassen, um die aktuelle Einstellung beizubehalten.',
password_placeholder: 'Neues Passwort eingeben…',
disable_auth: 'Authentifizierung deaktivieren',
sign_out: 'Abmelden',
cancel: 'Abbrechen',
create_job: 'Job erstellen',
save_skill: 'Skill speichern',
editing: 'Bearbeitung',
// Empty state
empty_title: 'Wie kann ich helfen?',
empty_subtitle: 'Frage mich alles, führe Befehle aus, erkunde Dateien oder verwalte deine Aufgaben.',
suggest_files: 'Welche Dateien sind in diesem Workspace?',
suggest_schedule: 'Was steht heute auf meinem Plan?',
suggest_plan: 'Hilf mir, ein kleines Projekt zu planen.',
},
zh: {
@@ -321,6 +547,16 @@ function applyLocaleToDOM() {
const val = t(key);
if (val && val !== key) el.textContent = val;
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
const val = t(key);
if (val && val !== key) el.title = val;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
const val = t(key);
if (val && val !== key) el.placeholder = val;
});
}
// Apply saved locale immediately so there's no flash of English on reload.

69
static/icons.js Normal file
View File

@@ -0,0 +1,69 @@
// ── Lucide icon library (self-hosted SVG paths, no CDN dependency) ──────────
// All icons are 24×24 viewBox, stroke-based, currentColor.
// Usage: li('folder') → returns a ready-to-embed SVG string
// The returned SVG uses display:inline-block + vertical-align so it sits
// neatly beside text in both HTML templates and innerHTML assignments.
const LI_PATHS = {
// Navigation tabs
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
'layers': '<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>',
'lightbulb': '<path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/>',
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
'list-todo': '<rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
// Editing / actions
'pencil': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
'braces': '<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/>',
'trash-2': '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'refresh-cw': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
'check': '<polyline points="20 6 9 17 4 12"/>',
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
'square': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>',
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
'arrow-up': '<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>',
'loader': '<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>',
// Tool icons
'terminal': '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
'file-pen': '<path d="M12 22h6a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v10"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10.4 19.4 14 16l-4-1 .4 4.4z"/><path d="m14 16 1.5-1.5a2.12 2.12 0 0 1 3 3L17 19"/>',
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
'play': '<polygon points="5 3 19 12 5 21 5 3"/>',
'wrench': '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
'brain': '<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/>',
'book-open': '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'bot': '<rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/>',
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
'shuffle': '<polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/>',
// File-type icons
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
'file-code': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><polyline points="10 13 8 15 10 17"/><polyline points="14 13 16 15 14 17"/>',
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
// Suggestion buttons
'clipboard-list': '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="12" y2="16"/>',
'map': '<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/>',
};
/**
* Returns a Lucide SVG string for the given icon name.
* @param {string} name key in LI_PATHS (e.g. 'folder', 'trash-2')
* @param {number} size width/height in px (default 16)
* @returns {string} SVG element string ready for innerHTML
*/
function li(name, size = 16) {
const p = LI_PATHS[name];
if (!p) { console.warn('li(): unknown icon', name); return ''; }
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" `
+ `stroke="currentColor" stroke-width="2" stroke-linecap="round" `
+ `stroke-linejoin="round" aria-hidden="true" `
+ `style="display:inline-block;vertical-align:-0.15em;flex-shrink:0">${p}</svg>`;
}

View File

@@ -14,32 +14,32 @@
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.41.0</div></div></div>
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.44.0</div></div></div>
<div class="sidebar-nav">
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">&#128172;</button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</button>
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills">&#129513;</button>
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory">&#129504;</button>
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces">&#128193;</button>
<button class="nav-tab" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles')" title="Agent profiles"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list">&#9989;</button>
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills" data-i18n-title="tab_skills"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/></svg></button>
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_profiles"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
</div>
<!-- Chat panel -->
<div class="panel-view active" id="panelChat">
<div class="sidebar-section">
<button class="new-chat-btn" id="btnNewChat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New conversation <span style="font-size:10px;opacity:.5;margin-left:4px">&#8984;K</span>
<span data-i18n="new_conversation">New conversation</span> <span style="font-size:10px;opacity:.5;margin-left:4px">&#8984;K</span>
</button>
</div>
<div class="session-search"><input id="sessionSearch" placeholder="Filter conversations..." oninput="filterSessions()"></div>
<div class="session-search"><input id="sessionSearch" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()"></div>
<div class="session-list" id="sessionList"></div>
</div>
<!-- Tasks (cron) panel -->
<div class="panel-view" id="panelTasks">
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
<div style="font-size:11px;color:var(--muted)">Scheduled jobs</div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ New job</button>
<div style="font-size:11px;color:var(--muted)" data-i18n="scheduled_jobs">Scheduled jobs</div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ <span data-i18n="new_job">New job</span></button>
</div>
<!-- Create job form (hidden by default) -->
<div id="cronCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
@@ -57,18 +57,18 @@
<div id="cronFormSkillTags" class="skill-picker-tags"></div>
</div>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitCronCreate()">Create job</button>
<button class="cron-btn" style="flex:1" onclick="toggleCronForm()">Cancel</button>
<button class="cron-btn run" style="flex:1" onclick="submitCronCreate()" data-i18n="create_job">Create job</button>
<button class="cron-btn" style="flex:1" onclick="toggleCronForm()" data-i18n="cancel">Cancel</button>
</div>
<div id="cronFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px">Loading...</div></div>
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Skills panel -->
<div class="panel-view" id="panelSkills">
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
<div class="skills-search" style="flex:1;padding:0"><input id="skillsSearch" placeholder="Search skills..." oninput="filterSkills()"></div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px;flex-shrink:0;margin-left:6px" onclick="toggleSkillForm()">+ New skill</button>
<div class="skills-search" style="flex:1;padding:0"><input id="skillsSearch" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()"></div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px;flex-shrink:0;margin-left:6px" onclick="toggleSkillForm()">+ <span data-i18n="new_skill">New skill</span></button>
</div>
<!-- Skill create/edit form (hidden by default) -->
<div id="skillCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
@@ -76,46 +76,46 @@
<input id="skillFormCategory" placeholder="Category (optional, e.g. devops)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<textarea id="skillFormContent" rows="6" placeholder="SKILL.md content (YAML frontmatter + markdown body)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;margin-bottom:6px;box-sizing:border-box"></textarea>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitSkillSave()">Save skill</button>
<button class="cron-btn" style="flex:1" onclick="toggleSkillForm()">Cancel</button>
<button class="cron-btn run" style="flex:1" onclick="submitSkillSave()" data-i18n="save_skill">Save skill</button>
<button class="cron-btn" style="flex:1" onclick="toggleSkillForm()" data-i18n="cancel">Cancel</button>
</div>
<div id="skillFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
<div class="skills-list" id="skillsList"><div style="padding:12px;color:var(--muted);font-size:12px">Loading...</div></div>
<div class="skills-list" id="skillsList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Memory panel -->
<div class="panel-view" id="panelMemory">
<div style="padding:8px 12px 4px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<span style="font-size:11px;color:var(--muted)">Personal memory</span>
<button class="cron-btn run" id="memEditBtn" style="padding:3px 8px;font-size:10px" onclick="toggleMemoryEdit()">&#9998; Edit</button>
<span style="font-size:11px;color:var(--muted)" data-i18n="personal_memory">Personal memory</span>
<button class="cron-btn run" id="memEditBtn" style="padding:3px 8px;font-size:10px" onclick="toggleMemoryEdit()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> <span data-i18n="edit">Edit</span></button>
</div>
<div class="memory-panel" id="memoryPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
<div class="memory-panel" id="memoryPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
<!-- Memory edit form (hidden by default) -->
<div id="memoryEditForm" style="display:none;padding:8px 12px;border-top:1px solid var(--border);flex-shrink:0">
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">Editing: <span id="memEditSection">memory</span></div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px"><span data-i18n="editing">Editing</span>: <span id="memEditSection">memory</span></div>
<textarea id="memEditContent" rows="10" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:11px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;box-sizing:border-box;margin-bottom:6px;line-height:1.5"></textarea>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitMemorySave()">Save</button>
<button class="cron-btn" style="flex:1" onclick="closeMemoryEdit()">Cancel</button>
<button class="cron-btn run" style="flex:1" onclick="submitMemorySave()" data-i18n="save">Save</button>
<button class="cron-btn" style="flex:1" onclick="closeMemoryEdit()" data-i18n="cancel">Cancel</button>
</div>
<div id="memEditError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
</div>
<!-- Todo panel -->
<div class="panel-view" id="panelTodos">
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted);flex-shrink:0">Current task list</div>
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted);flex-shrink:0" data-i18n="current_task_list">Current task list</div>
<div id="todoPanel" style="flex:1;overflow-y:auto;padding:8px 12px"></div>
</div>
<!-- Workspaces panel -->
<div class="panel-view" id="panelWorkspaces">
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)">Add and switch workspaces for your sessions.</div>
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)" data-i18n="workspace_desc">Add and switch workspaces for your sessions.</div>
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Profiles panel -->
<div class="panel-view" id="panelProfiles">
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
<div style="font-size:11px;color:var(--muted)">Agent profiles</div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleProfileForm()">+ New profile</button>
<div style="font-size:11px;color:var(--muted)" data-i18n="tab_profiles">Agent profiles</div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleProfileForm()">+ <span data-i18n="new_profile">New profile</span></button>
</div>
<!-- Profile create form (hidden by default) -->
<div id="profileCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
@@ -153,19 +153,19 @@
</select>
<div style="position:relative">
<div id="sidebarWsDisplay" style="display:flex;align-items:center;gap:7px;padding:0 0 8px;cursor:pointer;border-radius:8px;transition:background .15s" onclick="toggleWsDropdown()" title="Switch workspace">
<span style="font-size:14px;opacity:.7">&#128193;</span>
<span style="opacity:.7;line-height:1"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
<div style="min-width:0;flex:1">
<div style="font-size:11px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" id="sidebarWsName">Workspace</div>
<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:1px" id="sidebarWsPath"></div>
</div>
<span style="font-size:10px;color:var(--muted);flex-shrink:0">&#9662;</span>
<span style="color:var(--muted);flex-shrink:0;line-height:1"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></span>
</div>
<div class="ws-dropdown" id="wsDropdown"></div>
</div>
<div class="sidebar-actions">
<button class="sm-btn" id="btnDownload" title="Download as Markdown">&#8595; Transcript</button>
<button class="sm-btn" id="btnExportJSON" title="Export full session as JSON">&#10092;/&#10093; JSON</button>
<button class="sm-btn" id="btnImportJSON" title="Import session from JSON">&#8593; Import</button>
<button class="sm-btn" id="btnDownload" title="Download as Markdown" data-i18n-title="download_transcript"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <span data-i18n="transcript">Transcript</span></button>
<button class="sm-btn" id="btnExportJSON" title="Export full session as JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg> JSON</button>
<button class="sm-btn" id="btnImportJSON" title="Import session from JSON"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> <span data-i18n="import">Import</span></button>
<input type="file" id="importFileInput" accept=".json" style="display:none">
</div>
</div>
@@ -176,28 +176,44 @@
<button class="mobile-hamburger" id="btnHamburger" onclick="toggleMobileSidebar()" title="Menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div>
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta" data-i18n="new_conversation">Start a new conversation</div></div>
<div class="topbar-chips">
<div id="profileChipWrap" style="position:relative">
<div class="chip profile-chip" id="profileChip" onclick="toggleProfileDropdown()" title="Switch profile" style="cursor:pointer"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span id="profileChipLabel">default</span> &#9662;</div>
<div class="chip profile-chip" id="profileChip" onclick="toggleProfileDropdown()" title="Switch profile" style="cursor:pointer"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span id="profileChipLabel">default</span> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></div>
<div class="profile-dropdown" id="profileDropdown"></div>
</div>
<div class="chip model" id="modelChip">GPT-5.4 Mini</div>
<button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none">&#128465; Clear</button>
<button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings">&#9881;</button>
<button class="chip mobile-files-btn" id="btnMobileFiles" onclick="toggleMobileFiles()" title="Files">&#128193;</button>
<button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg> <span data-i18n="copy">Clear</span></button>
<button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings" data-i18n-title="settings_title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
<button class="chip mobile-files-btn" id="btnMobileFiles" onclick="toggleMobileFiles()" title="Files"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
</div>
</div>
<div class="messages" id="messages">
<div class="empty-state" id="emptyState">
<div class="empty-logo">🦉</div>
<h2>What can I help with?</h2>
<p>Ask anything, run commands, explore files, or manage your scheduled tasks.</p>
<div class="empty-logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="80" height="80" aria-label="Hermes caduceus">
<defs>
<linearGradient id="hermes-gold" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#F5C542;stop-opacity:1"/>
<stop offset="100%" style="stop-color:#D4961C;stop-opacity:1"/>
</linearGradient>
</defs>
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#hermes-gold)"/>
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9"/>
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill="#D4961C" opacity="0.8"/>
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9"/>
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill="#D4961C" opacity="0.8"/>
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42" fill="none" stroke="#F5C542" stroke-width="2.5" stroke-linecap="round"/>
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42" fill="none" stroke="#D4961C" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="32" cy="10" r="4" fill="#F5C542"/>
<circle cx="32" cy="10" r="2" fill="#FFF8E1" opacity="0.7"/>
</svg></div>
<h2 data-i18n="empty_title">What can I help with?</h2>
<p data-i18n="empty_subtitle">Ask anything, run commands, explore files, or manage your scheduled tasks.</p>
<div class="suggestion-grid">
<button class="suggestion" data-msg="What files are in this workspace?">📁 What files are in this workspace?</button>
<button class="suggestion" data-msg="What's on my schedule today?">📋 What's on my schedule today?</button>
<button class="suggestion" data-msg="Help me plan a small project.">🗺 Help me plan a small project.</button>
<button class="suggestion" data-msg="What files are in this workspace?"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> <span data-i18n="suggest_files">What files are in this workspace?</span></button>
<button class="suggestion" data-msg="What's on my schedule today?"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="12" y2="16"/></svg> <span data-i18n="suggest_schedule">What's on my schedule today?</span></button>
<button class="suggestion" data-msg="Help me plan a small project."><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg> <span data-i18n="suggest_plan">Help me plan a small project.</span></button>
</div>
</div>
<div class="messages-inner" id="msgInner"></div>
@@ -211,10 +227,10 @@
</div>
</div>
<div class="reconnect-banner" id="reconnectBanner">
<span id="reconnectMsg">&#9888; A response may have been in progress when you last left. Reload messages?</span>
<span id="reconnectMsg"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> A response may have been in progress when you last left. Reload messages?</span>
<div style="display:flex;gap:8px;flex-shrink:0">
<button class="reconnect-btn" onclick="dismissReconnect()">Dismiss</button>
<button class="reconnect-btn" onclick="refreshSession()">&#8635; Reload</button>
<button class="reconnect-btn" onclick="refreshSession()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> Reload</button>
</div>
</div>
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
@@ -227,20 +243,20 @@
<div class="approval-cmd" id="approvalCmd"></div>
<div class="approval-btns">
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
<span class="approval-btn-icon">&#10003;</span>
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_once">Allow once</span>
<kbd class="approval-kbd"></kbd>
</button>
<button class="approval-btn session" id="approvalBtnSession" onclick="respondApproval('session')" title="Allow for this session">
<span class="approval-btn-icon">&#128274;</span>
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_session">Allow session</span>
</button>
<button class="approval-btn always" id="approvalBtnAlways" onclick="respondApproval('always')" title="Always allow this command pattern">
<span class="approval-btn-icon">&#9734;</span>
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_always">Always allow</span>
</button>
<button class="approval-btn deny" id="approvalBtnDeny" onclick="respondApproval('deny')" title="Deny — do not run this command">
<span class="approval-btn-icon">&#10005;</span>
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_deny">Deny</span>
</button>
</div>
@@ -249,10 +265,10 @@
<!-- Activity bar: shows tool progress / status above composer (not inside input) -->
<div id="activityBar" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;">
<div id="activityBarInner" style="display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:8px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.07);font-size:12px;color:var(--muted);animation:fadeIn .15s ease;">
<span id="activityIcon" style="font-size:13px;opacity:.6">&#9881;</span>
<span id="activityIcon" style="opacity:.6;line-height:1"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/></svg></span>
<span id="activityText" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
<button id="btnCancel" onclick="cancelStream()" style="display:none;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.35);color:#e94560;font-size:11px;font-weight:600;padding:3px 10px;border-radius:6px;cursor:pointer;flex-shrink:0;transition:background .15s" title="Cancel this task">&#9632; Cancel</button>
<button id="btnDismissStatus" onclick="setStatus('')" style="display:none;background:none;border:none;color:var(--muted);font-size:14px;line-height:1;cursor:pointer;padding:0 2px;opacity:.5;flex-shrink:0" title="Dismiss">&#x2715;</button>
<button id="btnCancel" onclick="cancelStream()" style="display:none;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.35);color:#e94560;font-size:11px;font-weight:600;padding:3px 10px;border-radius:6px;cursor:pointer;flex-shrink:0;transition:background .15s;align-items:center;gap:4px" title="Cancel this task"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg> Cancel</button>
<button id="btnDismissStatus" onclick="setStatus('')" style="display:none;background:none;border:none;color:var(--muted);line-height:1;cursor:pointer;padding:0 2px;opacity:.5;flex-shrink:0" title="Dismiss"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
<span id="activityDots" style="display:flex;gap:3px;align-items:center">
<span style="width:4px;height:4px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out infinite"></span>
<span style="width:4px;height:4px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out .22s infinite"></span>
@@ -305,11 +321,11 @@
<span>Workspace</span>
<span class="git-badge" id="gitBadge" style="display:none"></span>
<div class="panel-actions">
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none">&#8593;</button>
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()">&#43;</button>
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()">&#128193;</button>
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)">&#8635;</button>
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview">&#10005;</button>
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
</div>
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
@@ -318,8 +334,8 @@
<div class="preview-path" id="previewPath">
<span id="previewPathText"></span>
<span class="preview-badge" id="previewBadge"></span>
<button id="btnDownloadFile" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px" onclick="downloadFile(_previewCurrentPath)" title="Download file to your computer">&#8681; Download</button>
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none" onclick="toggleEditMode()">&#9998; Edit</button>
<button id="btnDownloadFile" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px;display:inline-flex;align-items:center;gap:4px" onclick="downloadFile(_previewCurrentPath)" title="Download file to your computer"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Download</button>
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none;align-items:center;gap:4px" onclick="toggleEditMode()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg> Edit</button>
</div>
<pre class="preview-code" id="previewCode"></pre>
<div class="preview-img-wrap" id="previewImgWrap" style="display:none"><img class="preview-img" id="previewImg" src="" alt=""></div>
@@ -332,7 +348,7 @@
<div class="settings-panel">
<div class="settings-header">
<h3 style="margin:0;font-size:16px" data-i18n="settings_title">Settings</h3>
<button class="panel-icon-btn" onclick="_closeSettingsPanel()" title="Close">&#10005;</button>
<button class="panel-icon-btn" onclick="_closeSettingsPanel()" title="Close"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="settings-body">
<div class="settings-field">
@@ -365,58 +381,58 @@
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsSoundEnabled" style="width:15px;height:15px;accent-color:var(--accent)">
Notification sound
<span data-i18n="settings_label_sound">Notification sound</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Play a sound when the assistant finishes a response.</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sound">Play a sound when the assistant finishes a response.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsNotificationsEnabled" style="width:15px;height:15px;accent-color:var(--accent)">
Browser notifications
<span data-i18n="settings_label_notifications">Browser notifications</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Show a system notification when a response completes while the tab is in the background.</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_notifications">Show a system notification when a response completes while the tab is in the background.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowTokenUsage" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_token_usage">Show token usage after responses</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_token_usage">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowCliSessions" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_cli_sessions">Show CLI sessions in sidebar</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_cli_sessions">Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsSyncInsights" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_sync_insights">Sync usage to /insights</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Mirrors WebUI token usage to state.db so <code>hermes /insights</code> includes browser session data. Off by default.</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sync_insights">Mirrors WebUI token usage to state.db so <code>hermes /insights</code> includes browser session data. Off by default.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsCheckUpdates" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_check_updates">Check for updates</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_check_updates">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
</div>
<div class="settings-field">
<label for="settingsBotName" data-i18n="settings_label_bot_name">Assistant Name</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Display name for the assistant throughout the UI. Defaults to Hermes.</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_bot_name">Display name for the assistant throughout the UI. Defaults to Hermes.</div>
<input type="text" id="settingsBotName" placeholder="Hermes" maxlength="64" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>
<input type="password" id="settingsPassword" placeholder="Enter new password…" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_password">Enter a new password to set or change it. Leave blank to keep current setting.</div>
<input type="password" id="settingsPassword" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none">Disable Auth</button>
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none">Sign Out</button>
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none" data-i18n="disable_auth">Disable Auth</button>
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
</div>
</div>
</div>
@@ -424,27 +440,28 @@
<nav class="mobile-bottom-nav" id="mobileBottomNav">
<button class="mobile-nav-btn active" data-panel="chat" onclick="mobileSwitchPanel('chat')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>Chat</span>
<span data-i18n="tab_chat">Chat</span>
</button>
<button class="mobile-nav-btn" data-panel="tasks" onclick="mobileSwitchPanel('tasks')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<span>Tasks</span>
<span data-i18n="tab_tasks">Tasks</span>
</button>
<button class="mobile-nav-btn" data-panel="skills" onclick="mobileSwitchPanel('skills')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<span>Skills</span>
<span data-i18n="tab_skills">Skills</span>
</button>
<button class="mobile-nav-btn" data-panel="memory" onclick="mobileSwitchPanel('memory')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/></svg>
<span>Memory</span>
<span data-i18n="tab_memory">Memory</span>
</button>
<button class="mobile-nav-btn" data-panel="workspaces" onclick="mobileSwitchPanel('workspaces')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h8l2 2h10v14H2z"/></svg>
<span>Spaces</span>
<span data-i18n="tab_workspaces">Spaces</span>
</button>
</nav>
<div class="toast" id="toast"></div>
<script src="/static/i18n.js"></script>
<script src="/static/icons.js"></script>
<script src="/static/ui.js"></script>
<script src="/static/workspace.js"></script>
<script src="/static/sessions.js"></script>
@@ -453,4 +470,4 @@
<script src="/static/panels.js"></script>
<script src="/static/boot.js"></script>
</body>
</html>
</html>

55
static/login.js Normal file
View File

@@ -0,0 +1,55 @@
/* Login page — external script, no inline handlers.
* Loaded by the /login route. Reads data attributes from the form for
* i18n strings so the server does not need to inject JS literals.
*/
document.addEventListener('DOMContentLoaded', function () {
var form = document.getElementById('login-form');
var input = document.getElementById('pw');
if (!form || !input) return;
var invalidPw = form.getAttribute('data-invalid-pw') || 'Invalid password';
var connFailed = form.getAttribute('data-conn-failed') || 'Connection failed';
function showErr(msg) {
var err = document.getElementById('err');
if (err) { err.textContent = msg; err.style.display = 'block'; }
}
function hideErr() {
var err = document.getElementById('err');
if (err) { err.style.display = 'none'; }
}
async function doLogin(e) {
e.preventDefault();
var pw = input.value;
hideErr();
try {
var res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
credentials: 'include',
});
var data = {};
try { data = await res.json(); } catch (_) {}
if (res.ok && data.ok) {
window.location.href = '/';
} else {
showErr(data.error || invalidPw);
}
} catch (ex) {
showErr(connFailed);
}
}
form.addEventListener('submit', doLogin);
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
doLogin(e);
}
});
});

View File

@@ -24,7 +24,7 @@ async function send(){
setStatus(S.pendingFiles&&S.pendingFiles.length?'Uploading…':'Sending…');
let uploaded=[];
try{uploaded=await uploadPendingFiles();}
catch(e){if(!text){setStatus(` ${e.message}`);return;}}
catch(e){if(!text){setStatus(`Upload error: ${e.message}`);return;}}
let msgText=text;
if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploaded.join(', ')}`;
@@ -69,12 +69,12 @@ async function send(){
markInflight(activeSid, streamId);
// Show Cancel button
const cancelBtn=$('btnCancel');
if(cancelBtn) cancelBtn.style.display='';
if(cancelBtn) cancelBtn.style.display='inline-flex';
}catch(e){
delete INFLIGHT[activeSid];
stopApprovalPolling();
// Only hide approval card if it belongs to the session that just finished
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();removeThinking();
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);removeThinking();
S.messages.push({role:'assistant',content:`**Error:** ${e.message}`});
renderMessages();setBusy(false);setStatus('Error: '+e.message);
return;
@@ -182,7 +182,7 @@ async function send(){
delete INFLIGHT[activeSid];
clearInflight();
stopApprovalPolling();
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
@@ -227,19 +227,18 @@ async function send(){
// This is distinct from the SSE network 'error' event below.
source.close();
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
clearLiveToolCards();if(!assistantText)removeThinking();
try{
const d=JSON.parse(e.data);
const isRateLimit=d.type==='rate_limit';
const icon=isRateLimit?'⏱️':'⚠️';
const label=isRateLimit?'Rate limit reached':'Error';
const hint=d.hint?`\n\n*${d.hint}*`:'';
S.messages.push({role:'assistant',content:`**${icon} ${label}:** ${d.message}${hint}`});
S.messages.push({role:'assistant',content:`**${label}:** ${d.message}${hint}`});
}catch(_){
S.messages.push({role:'assistant',content:'**⚠️ Error:** An error occurred. Check server logs.'});
S.messages.push({role:'assistant',content:'**Error:** An error occurred. Check server logs.'});
}
renderMessages();
}else if(typeof trackBackgroundError==='function'){
@@ -256,7 +255,7 @@ async function send(){
try{
const d=JSON.parse(e.data);
// Show as a small inline notice, not a full error
setStatus(`⚠️ ${d.message||'Warning'}`);
setStatus(`${d.message||'Warning'}`);
// If it's a fallback notice, show it briefly then clear
if(d.type==='fallback') setTimeout(()=>setStatus(''),4000);
}catch(_){}
@@ -287,7 +286,7 @@ async function send(){
source.addEventListener('cancel',e=>{
source.close();
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;const _cbc=$('btnCancel');if(_cbc)_cbc.style.display='none';
}
@@ -302,7 +301,7 @@ async function send(){
function _handleStreamError(){
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
clearLiveToolCards();if(!assistantText)removeThinking();
@@ -342,11 +341,45 @@ function autoResize(){const el=$('msg');el.style.height='auto';el.style.height=M
// ── Approval polling ──
let _approvalPollTimer = null;
let _approvalHideTimer = null;
let _approvalVisibleSince = 0;
let _approvalSignature = '';
const APPROVAL_MIN_VISIBLE_MS = 30000;
// showApprovalCard moved above respondApproval
function hideApprovalCard() {
$("approvalCard").classList.remove("visible");
function _clearApprovalHideTimer() {
if (_approvalHideTimer) {
clearTimeout(_approvalHideTimer);
_approvalHideTimer = null;
}
}
function _resetApprovalCardState() {
_clearApprovalHideTimer();
_approvalVisibleSince = 0;
_approvalSignature = '';
}
function hideApprovalCard(force=false) {
const card = $("approvalCard");
if (!card) return;
if (!force && _approvalVisibleSince) {
const remaining = APPROVAL_MIN_VISIBLE_MS - (Date.now() - _approvalVisibleSince);
if (remaining > 0) {
const scheduledSignature = _approvalSignature;
_clearApprovalHideTimer();
_approvalHideTimer = setTimeout(() => {
_approvalHideTimer = null;
if (_approvalSignature !== scheduledSignature) return;
hideApprovalCard(true);
}, remaining);
return;
}
}
_approvalSessionId = null;
_resetApprovalCardState();
card.classList.remove("visible");
$("approvalCmd").textContent = "";
$("approvalDesc").textContent = "";
}
@@ -357,15 +390,24 @@ let _approvalSessionId = null;
function showApprovalCard(pending) {
const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []);
const desc = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : "");
const cmd = pending.command || "";
const sig = JSON.stringify({desc, cmd, sid: pending._session_id || (S.session && S.session.session_id) || null});
const card = $("approvalCard");
const sameApproval = card.classList.contains("visible") && _approvalSignature === sig;
$("approvalDesc").textContent = desc;
$("approvalCmd").textContent = pending.command || "";
$("approvalCmd").textContent = cmd;
_approvalSessionId = pending._session_id || (S.session && S.session.session_id) || null;
_approvalSignature = sig;
if (!sameApproval) {
_approvalVisibleSince = Date.now();
_clearApprovalHideTimer();
}
// Re-enable buttons in case a previous approval disabled them
["approvalBtnOnce","approvalBtnSession","approvalBtnAlways","approvalBtnDeny"].forEach(id => {
const b = $(id); if (b) { b.disabled = false; b.classList.remove("loading"); }
});
const card = $("approvalCard");
card.classList.add("visible");
if (!sameApproval) card.scrollIntoView({block:"nearest", behavior:"smooth"});
// Apply current locale to data-i18n elements inside the card
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
// Focus Allow once button so Enter works immediately
@@ -382,7 +424,7 @@ async function respondApproval(choice) {
if (b) { b.disabled = true; if (b.id === "approvalBtn" + choice.charAt(0).toUpperCase() + choice.slice(1)) b.classList.add("loading"); }
});
_approvalSessionId = null;
hideApprovalCard();
hideApprovalCard(true);
try {
await api("/api/approval/respond", {
method: "POST",
@@ -395,7 +437,7 @@ function startApprovalPolling(sid) {
stopApprovalPolling();
_approvalPollTimer = setInterval(async () => {
if (!S.busy || !S.session || S.session.session_id !== sid) {
stopApprovalPolling(); hideApprovalCard(); return;
stopApprovalPolling(); hideApprovalCard(true); return;
}
try {
const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid));
@@ -441,4 +483,3 @@ function sendBrowserNotification(title,body){
}
// ── Panel navigation (Chat / Tasks / Skills / Memory) ──

View File

@@ -664,12 +664,26 @@ function renderMessages(){
}
function toolIcon(name){
const icons={terminal:'⬛',read_file:'📄',write_file:'✏️',search_files:'🔍',
web_search:'🌐',web_extract:'🌐',execute_code:'⚙️',patch:'🔧',
memory:'🧠',skill_manage:'📚',todo:'✅',cronjob:'⏱️',delegate_task:'🤖',
send_message:'💬',browser_navigate:'🌐',vision_analyze:'👁️',
subagent_progress:'🔀'};
return icons[name]||'🔧';
const icons={
terminal: li('terminal'),
read_file: li('file-text'),
write_file: li('file-pen'),
search_files: li('search'),
web_search: li('globe'),
web_extract: li('globe'),
execute_code: li('play'),
patch: li('wrench'),
memory: li('brain'),
skill_manage: li('book-open'),
todo: li('list-todo'),
cronjob: li('clock'),
delegate_task: li('bot'),
send_message: li('message-square'),
browser_navigate:li('globe'),
vision_analyze: li('eye'),
subagent_progress:li('shuffle'),
};
return icons[name]||li('wrench');
}
function buildToolCard(tc){
@@ -925,17 +939,17 @@ function appendThinking(){
function removeThinking(){const el=$('thinkingRow');if(el)el.remove();}
function fileIcon(name, type){
if(type==='dir') return '📁';
if(type==='dir') return li('folder',14);
const e=fileExt(name);
if(IMAGE_EXTS.has(e)) return '📷';
if(MD_EXTS.has(e)) return '📝';
if(typeof DOWNLOAD_EXTS!=='undefined'&&DOWNLOAD_EXTS.has(e)) return '⬇️';
if(e==='.py') return '🐍';
if(e==='.js'||e==='.ts'||e==='.jsx'||e==='.tsx') return '⚡';
if(e==='.json'||e==='.yaml'||e==='.yml'||e==='.toml') return '⚙';
if(e==='.sh'||e==='.bash') return '💻';
if(e==='.pdf') return '⬇️';
return '📄';
if(IMAGE_EXTS.has(e)) return li('image',14);
if(MD_EXTS.has(e)) return li('file-text',14);
if(typeof DOWNLOAD_EXTS!=='undefined'&&DOWNLOAD_EXTS.has(e)) return li('download',14);
if(e==='.py') return li('file-code',14);
if(e==='.js'||e==='.ts'||e==='.jsx'||e==='.tsx') return li('zap',14);
if(e==='.json'||e==='.yaml'||e==='.yml'||e==='.toml') return li('settings',14);
if(e==='.sh'||e==='.bash') return li('terminal',14);
if(e==='.pdf') return li('download',14);
return li('file-text',14);
}
function renderBreadcrumb(){
@@ -1005,7 +1019,7 @@ function _renderTreeItems(container, entries, depth){
// Icon
const iconEl=document.createElement('span');
iconEl.className='file-icon';iconEl.textContent=fileIcon(item.name,item.type);
iconEl.className='file-icon';iconEl.innerHTML=fileIcon(item.name,item.type);
el.appendChild(iconEl);
// Name

View File

@@ -210,6 +210,18 @@ def test_server():
Start an isolated test server on TEST_PORT with a clean state directory.
Paths are discovered dynamically -- no hardcoded absolute path assumptions.
"""
# Kill any leftover process on the test port before starting.
# Stale servers from QA harness runs or prior test sessions cause
# conftest to think the server is already up, producing false failures.
try:
import subprocess as _sp
_sp.run(['fuser', '-k', f'{TEST_PORT}/tcp'],
capture_output=True, timeout=5)
except Exception:
pass
import time as _time
_time.sleep(0.5) # brief pause to let the port release
# Clean slate
if TEST_STATE_DIR.exists():
shutil.rmtree(TEST_STATE_DIR)

View File

@@ -6,8 +6,8 @@ tuples for different provider configurations.
import api.config as config
def _resolve_with_config(model_id, provider=None, base_url=None, default=None):
"""Helper: temporarily set config.cfg model section, call resolve, restore."""
def _resolve_with_config(model_id, provider=None, base_url=None, default=None, custom_providers=None):
"""Helper: temporarily set config.cfg model/custom provider sections, call resolve, restore."""
old_cfg = dict(config.cfg)
model_cfg = {}
if provider:
@@ -17,6 +17,8 @@ def _resolve_with_config(model_id, provider=None, base_url=None, default=None):
if default:
model_cfg['default'] = default
config.cfg['model'] = model_cfg if model_cfg else {}
if custom_providers is not None:
config.cfg['custom_providers'] = custom_providers
try:
return config.resolve_model_provider(model_id)
finally:
@@ -139,6 +141,23 @@ def test_slash_prefix_non_default_still_routes_openrouter():
assert provider == 'openrouter'
def test_custom_provider_model_with_slash_routes_to_named_custom_provider():
"""Slash-containing custom endpoint model IDs must not be mistaken for OpenRouter models."""
model, provider, base_url = _resolve_with_config(
'google/gemma-4-26b-a4b',
provider='openrouter',
base_url='https://openrouter.ai/api/v1',
custom_providers=[{
'name': 'Local LM Studio',
'base_url': 'http://lmstudio.local:1234/v1',
'model': 'google/gemma-4-26b-a4b',
}],
)
assert model == 'google/gemma-4-26b-a4b'
assert provider == 'custom:local-lm-studio'
assert base_url == 'http://lmstudio.local:1234/v1'
# ── get_available_models() @provider: hint behaviour ──────────────────────
def _available_models_with_provider(provider):
@@ -202,3 +221,108 @@ def test_default_provider_models_not_prefixed():
assert bare_id in returned_ids, (
f"_PROVIDER_MODELS entry '{bare_id}' is missing from the Anthropic group"
)
# ── get_available_models(): phantom "Custom" group regression ─────────────
#
# When the user has model.provider set to a real provider (e.g. openai-codex)
# AND a model.base_url set, hermes_cli reports the 'custom' pseudo-provider as
# authenticated. The WebUI picker must NOT build a separate "Custom" group in
# that case — the base_url belongs to the active provider.
def _available_models_with_full_cfg(provider, default, base_url):
"""Helper: set model.provider, model.default, model.base_url at once.
Clears model-override env vars (HERMES_MODEL, OPENAI_MODEL, LLM_MODEL)
during the call so the real hermes profile environment doesn't leak into
the test and override the fixture's default model.
"""
import os
import api.config as _cfg
old_cfg = dict(_cfg.cfg)
_cfg.cfg['model'] = {
'provider': provider,
'default': default,
'base_url': base_url,
}
# Clear model-override env vars to prevent the real profile from leaking in
_model_env_keys = ('HERMES_MODEL', 'OPENAI_MODEL', 'LLM_MODEL')
_saved_env = {k: os.environ.pop(k, None) for k in _model_env_keys}
try:
return _cfg.get_available_models()
finally:
_cfg.cfg.clear()
_cfg.cfg.update(old_cfg)
for k, v in _saved_env.items():
if v is not None:
os.environ[k] = v
def test_no_phantom_custom_group_when_active_provider_is_set(monkeypatch):
"""Issue: with provider=openai-codex + base_url set, gpt-5.4 was landing
under a phantom "Custom" group instead of the "OpenAI Codex" group."""
import sys, types
# Force hermes_cli to report both the real provider and the phantom
# 'custom' as authenticated, simulating what list_available_providers()
# returns when base_url is configured.
fake_mod = types.ModuleType('hermes_cli.models')
fake_mod.list_available_providers = lambda: [
{'id': 'openai-codex', 'authenticated': True},
{'id': 'custom', 'authenticated': True},
]
fake_auth = types.ModuleType('hermes_cli.auth')
fake_auth.get_auth_status = lambda pid: {'key_source': 'env'}
monkeypatch.setitem(sys.modules, 'hermes_cli.models', fake_mod)
monkeypatch.setitem(sys.modules, 'hermes_cli.auth', fake_auth)
result = _available_models_with_full_cfg(
provider='openai-codex',
default='gpt-5.4',
base_url='https://chatgpt.com/backend-api/codex',
)
group_names = [g['provider'] for g in result['groups']]
assert 'Custom' not in group_names, (
f"Phantom 'Custom' group present; full groups: {group_names}"
)
def test_default_model_lands_under_active_provider_group(monkeypatch):
"""The configured default_model must appear under the active provider's
display group, even when the model isn't in _PROVIDER_MODELS[provider]
AND the active provider isn't the alphabetical first detected provider.
Regression guard for a hyphen-vs-space bug in the "ensure default_model
appears" post-pass: the substring check `active_provider.lower() in
g.get('provider', '').lower()` was failing for 'openai-codex' vs
display name 'OpenAI Codex' (hyphen vs. space), silently falling back
to groups[0] — which, when another provider sorted earlier
alphabetically (e.g. 'anthropic'), placed gpt-5.4 in the WRONG group.
"""
import sys, types
fake_mod = types.ModuleType('hermes_cli.models')
fake_mod.list_available_providers = lambda: [
{'id': 'anthropic', 'authenticated': True}, # sorts before openai-codex
{'id': 'openai-codex', 'authenticated': True},
{'id': 'custom', 'authenticated': True},
]
fake_auth = types.ModuleType('hermes_cli.auth')
fake_auth.get_auth_status = lambda pid: {'key_source': 'env'}
monkeypatch.setitem(sys.modules, 'hermes_cli.models', fake_mod)
monkeypatch.setitem(sys.modules, 'hermes_cli.auth', fake_auth)
result = _available_models_with_full_cfg(
provider='openai-codex',
default='gpt-5.4',
base_url='https://chatgpt.com/backend-api/codex',
)
groups = {g['provider']: [m['id'] for m in g['models']] for g in result['groups']}
assert 'OpenAI Codex' in groups, f"OpenAI Codex group missing: {list(groups)}"
assert 'gpt-5.4' in groups['OpenAI Codex'], (
f"gpt-5.4 not in OpenAI Codex group; contents: {groups['OpenAI Codex']}"
)
# And crucially, it must NOT have landed in the alphabetically-first
# group (Anthropic) via the fallback path.
assert 'gpt-5.4' not in groups.get('Anthropic', []), (
f"gpt-5.4 leaked into Anthropic group via fallback: {groups.get('Anthropic')}"
)

View File

@@ -154,8 +154,15 @@ class TestSessionIDValidation:
result = Session.load(valid_hex)
assert result is None # No file, but no error
def test_new_format_session_id_passes_validation(self):
"""New hermes-agent session IDs (YYYYMMDD_HHMMSS_xxxxxx) must pass validation."""
from api.models import Session
# Should pass the validator (returns None only because the file doesn't exist)
result = Session.load("20260406_164014_74b2d1")
assert result is None # file doesn't exist, but validator passed
def test_non_hex_session_id_rejected(self):
"""A session ID with non-hex chars must be rejected."""
"""A session ID with dangerous chars must be rejected."""
from api.models import Session
evil_ids = [
"../../../etc/passwd",
@@ -163,11 +170,15 @@ class TestSessionIDValidation:
"session; rm -rf /",
"hello world",
"ZZZZZZZZZZZZZZZZ",
"session\x00evil",
"..\\..\\windows\\system32",
"session/../../etc/passwd",
"valid_looking.json",
]
for sid in evil_ids:
result = Session.load(sid)
assert result is None, \
f"Session.load should reject non-hex ID '{sid}', got {result}"
f"Session.load should reject dangerous ID '{sid}', got {result}"
def test_empty_session_id_rejected(self):
"""An empty session ID must be rejected."""

View File

@@ -280,3 +280,107 @@ class TestApprovalRespondHTTP:
assert status == 200
assert "choice" in result
assert result["choice"] == "always"
class TestApprovalCardTimerLogic:
"""Tests for the 30s minimum visibility guard introduced in PR #225."""
def _get_js(self):
return pathlib.Path(__file__).parent.parent / 'static' / 'messages.js'
def test_approval_min_visible_ms_constant_present(self):
"""APPROVAL_MIN_VISIBLE_MS constant exists and is 30000."""
src = self._get_js().read_text()
assert 'APPROVAL_MIN_VISIBLE_MS' in src
import re
m = re.search(r'APPROVAL_MIN_VISIBLE_MS\s*=\s*(\d+)', src)
assert m is not None, 'APPROVAL_MIN_VISIBLE_MS not assigned'
assert int(m.group(1)) == 30000, f'Expected 30000, got {m.group(1)}'
def test_hide_approval_card_has_force_parameter(self):
"""hideApprovalCard() accepts a force parameter."""
src = self._get_js().read_text()
assert 'hideApprovalCard(force=false)' in src or \
'hideApprovalCard(force = false)' in src, \
'hideApprovalCard must have force=false default parameter'
def test_hide_approval_card_checks_force_flag(self):
"""hideApprovalCard body has a conditional on force."""
src = self._get_js().read_text()
# The guard: if (!force && _approvalVisibleSince)
assert '!force' in src, 'hideApprovalCard must check !force before deferred hide'
def test_approval_hide_timer_variable_present(self):
"""Module-level _approvalHideTimer variable is declared."""
src = self._get_js().read_text()
assert '_approvalHideTimer' in src
def test_approval_visible_since_variable_present(self):
"""Module-level _approvalVisibleSince variable is declared."""
src = self._get_js().read_text()
assert '_approvalVisibleSince' in src
def test_approval_signature_variable_present(self):
"""Module-level _approvalSignature variable is declared."""
src = self._get_js().read_text()
assert '_approvalSignature' in src
def test_respond_approval_calls_hide_with_force(self):
"""respondApproval must call hideApprovalCard(true) — not no-arg."""
src = self._get_js().read_text()
# Extract respondApproval function body
import re
m = re.search(r'async function respondApproval.*?(?=\nasync function|\nfunction |\Z)',
src, re.DOTALL)
assert m, 'respondApproval function not found'
body = m.group(0)
# Must call hideApprovalCard(true), not the bare hideApprovalCard()
assert 'hideApprovalCard(true)' in body, \
'respondApproval must call hideApprovalCard(true) so card hides immediately after user clicks'
# Must NOT have bare hideApprovalCard() without force
bare_calls = re.findall(r'hideApprovalCard\((?!true)', body)
assert not bare_calls, \
f'respondApproval has bare hideApprovalCard() calls (no force=true): {bare_calls}'
def test_stream_done_calls_hide_with_force(self):
"""Done SSE event handler must call hideApprovalCard(true)."""
src = self._get_js().read_text()
# Find the done event handler section (stopApprovalPolling followed by hideApprovalCard)
import re
# Look for pattern: stopApprovalPolling();\n + hideApprovalCard
matches = re.findall(
r'stopApprovalPolling\(\);\s*\n\s*if\(!_approvalSessionId[^)]*\)\s*hideApprovalCard\((\w*)\)',
src
)
# All stopApprovalPolling paths that call hideApprovalCard should use force=true
for match in matches:
assert match == 'true', \
f'After stopApprovalPolling(), hideApprovalCard called without force=true (got: {match!r})'
def test_poll_loop_still_uses_no_force(self):
"""Poll loop hideApprovalCard() (when pending gone) keeps no-force — correct behavior."""
src = self._get_js().read_text()
# Line 446: else { hideApprovalCard(); } — this is the poll-loop path
# The 30s guard should protect this call (don't force from poll ticks)
assert 'else { hideApprovalCard(); }' in src or \
'else {hideApprovalCard();}' in src or \
'else { hideApprovalCard() }' in src, \
'Poll loop should still call hideApprovalCard() without force=true'
def test_show_approval_card_signature_dedup(self):
"""showApprovalCard uses a signature to avoid resetting timer on repeat polls."""
src = self._get_js().read_text()
# The sig computation must use JSON.stringify on card content
import re
m = re.search(r'function showApprovalCard.*?(?=\nfunction |\nasync function |\Z)',
src, re.DOTALL)
assert m, 'showApprovalCard function not found'
body = m.group(0)
assert 'JSON.stringify' in body, 'showApprovalCard must compute a signature via JSON.stringify'
assert '_approvalSignature' in body, 'showApprovalCard must check/set _approvalSignature'
def test_clear_approval_hide_timer_helper_present(self):
"""_clearApprovalHideTimer helper exists to cancel deferred hides."""
src = self._get_js().read_text()
assert '_clearApprovalHideTimer' in src, \
'_clearApprovalHideTimer helper must exist to cancel deferred setTimeout'

71
tests/test_sprint32.py Normal file
View File

@@ -0,0 +1,71 @@
from pathlib import Path
from unittest.mock import MagicMock, patch
import subprocess
from api.startup import auto_install_agent_deps
class TestAutoInstallAgentDeps:
def test_installs_from_requirements_txt(self, tmp_path):
agent_dir = tmp_path / 'hermes-agent'
agent_dir.mkdir()
req = agent_dir / 'requirements.txt'
req.write_text('pyyaml\n')
with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False):
with patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=0, stderr='')
assert auto_install_agent_deps() is True
args = mock_run.call_args[0][0]
assert '-r' in args and str(req) in args
def test_falls_back_to_pyproject(self, tmp_path):
agent_dir = tmp_path / 'hermes-agent'
agent_dir.mkdir()
(agent_dir / 'pyproject.toml').write_text('[project]\nname="hermes-agent"\n')
with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False):
with patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=0, stderr='')
assert auto_install_agent_deps() is True
args = mock_run.call_args[0][0]
assert str(agent_dir) in args and '-r' not in args
def test_skips_when_agent_dir_missing(self, tmp_path, capsys):
missing = tmp_path / 'nonexistent-agent'
# Patch both HERMES_WEBUI_AGENT_DIR and HERMES_HOME so the fallback
# path (HERMES_HOME/hermes-agent) also resolves to a nonexistent dir,
# preventing the real agent dir from being found in the test environment.
env_overrides = {
'HERMES_WEBUI_AGENT_DIR': str(missing),
'HERMES_HOME': str(tmp_path / 'no-hermes-home'),
}
with patch.dict('os.environ', env_overrides, clear=False):
with patch('subprocess.run') as mock_run:
assert auto_install_agent_deps() is False
assert not mock_run.called
assert 'skipped' in capsys.readouterr().out.lower()
def test_skips_when_no_install_file(self, tmp_path, capsys):
agent_dir = tmp_path / 'hermes-agent'
agent_dir.mkdir()
with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False):
with patch('subprocess.run') as mock_run:
assert auto_install_agent_deps() is False
assert not mock_run.called
assert 'skipped' in capsys.readouterr().out.lower()
def test_tolerates_pip_failure(self, tmp_path, capsys):
agent_dir = tmp_path / 'hermes-agent'
agent_dir.mkdir()
(agent_dir / 'requirements.txt').write_text('somepkg\n')
with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False):
with patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=1, stderr='ERROR: could not find package')
assert auto_install_agent_deps() is False
assert 'failed' in capsys.readouterr().out.lower() or 'pip' in capsys.readouterr().out.lower()
def test_tolerates_timeout(self, tmp_path, capsys):
agent_dir = tmp_path / 'hermes-agent'
agent_dir.mkdir()
(agent_dir / 'requirements.txt').write_text('somepkg\n')
with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False):
with patch('subprocess.run', side_effect=subprocess.TimeoutExpired('pip', 120)):
assert auto_install_agent_deps() is False
assert 'timed out' in capsys.readouterr().out.lower()

View File

@@ -0,0 +1,317 @@
"""
Tests for api/updates.py -- specifically the diagnostic code paths added
in fix/223-update-pull-failed-diagnostics (PR #227).
Tests cover the four new branches in _apply_update_inner():
1. fetch fails → network error message
2. pull fails + diverged history → recovery command with git reset --hard
3. pull fails + no upstream tracking → recovery command with set-upstream-to
4. pull fails + generic fallback → raw git output truncated at 300 chars
"""
from pathlib import Path
from unittest.mock import patch, call
import subprocess
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_run_git_side_effect(*sequence):
"""Return a side_effect function that yields successive (stdout, ok) tuples."""
it = iter(sequence)
def _side_effect(args, cwd, timeout=10):
return next(it)
return _side_effect
# ---------------------------------------------------------------------------
# Path used for patching
# ---------------------------------------------------------------------------
_MODULE = 'api.updates'
# ---------------------------------------------------------------------------
# Tests for _apply_update_inner() diagnostic paths
# ---------------------------------------------------------------------------
class TestApplyUpdateDiagnostics:
"""New code paths introduced in PR #227."""
def _apply(self, target, run_git_side_effect):
"""Call _apply_update_inner with _apply_lock bypassed and _run_git mocked."""
from api import updates
with patch(f'{_MODULE}._run_git', side_effect=run_git_side_effect), \
patch.object(updates, '_apply_lock') as mock_lock:
mock_lock.acquire.return_value = True
mock_lock.release.return_value = None
return updates._apply_update_inner(target)
# ------------------------------------------------------------------
# Path 1: fetch step fails → network error message
# ------------------------------------------------------------------
def test_fetch_failure_returns_network_error_message(self, tmp_path):
"""When git fetch fails, return a human-readable connection error."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
# Call sequence: upstream query, fetch
mock_run_git.side_effect = [
('origin/master', True), # rev-parse @{upstream}
('', False), # fetch fails
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
msg = result['message'].lower()
assert 'could not reach' in msg or 'internet connection' in msg or 'remote repository' in msg
def test_fetch_failure_does_not_attempt_pull(self, tmp_path):
"""When fetch fails, pull is never called."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True), # upstream query
('', False), # fetch fails
]
updates._apply_update_inner('webui')
# Only 2 calls: upstream query + fetch. No pull call.
assert mock_run_git.call_count == 2
# ------------------------------------------------------------------
# Path 2: pull fails + diverged history
# ------------------------------------------------------------------
def test_diverged_history_returns_reset_hard_command(self, tmp_path):
"""Diverged history produces a message with 'reset --hard'."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True), # upstream query
('', True), # fetch succeeds
('', True), # status --porcelain (clean)
('Not possible to fast-forward, aborting.', False), # pull fails
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert result.get('diverged') is True
msg = result['message']
assert 'reset --hard' in msg
def test_diverged_history_message_contains_compare_ref(self, tmp_path):
"""Diverged history message includes the upstream ref."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/feat/my-feature', True), # upstream query
('', True), # fetch
('', True), # status (clean)
('Your branch and origin have diverged.', False), # pull
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert 'origin/feat/my-feature' in result['message']
def test_diverged_matching_is_case_insensitive(self, tmp_path):
"""'DIVERGED' in uppercase is still detected."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', True),
('', True),
('DIVERGED from upstream', False),
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert result.get('diverged') is True
# ------------------------------------------------------------------
# Path 3: pull fails + no upstream tracking configured
# ------------------------------------------------------------------
def test_no_tracking_returns_set_upstream_command(self, tmp_path):
"""Missing upstream tracking branch produces set-upstream-to message."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True), # upstream query
('', True), # fetch
('', True), # status (clean)
('There is no tracking information for the current branch.', False), # pull
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert 'set-upstream-to' in result['message']
assert result.get('diverged') is None
def test_no_tracking_alternate_phrasing(self, tmp_path):
"""'does not track' alternate git message is also detected."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', True),
('', True),
('fatal: The current branch local does not track a remote branch.', False),
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert 'set-upstream-to' in result['message']
def test_no_tracking_message_contains_compare_ref(self, tmp_path):
"""set-upstream-to message includes the upstream ref to configure."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/main', True),
('', True),
('', True),
('no tracking information', False),
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert 'origin/main' in result['message']
# ------------------------------------------------------------------
# Path 4: pull fails + generic fallback (truncated raw output)
# ------------------------------------------------------------------
def test_generic_failure_includes_truncated_git_output(self, tmp_path):
"""Generic pull failure includes up to 300 chars of git output."""
(tmp_path / '.git').mkdir()
long_error = 'X' * 500 # 500-char error from git
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', True),
('', True),
(long_error, False),
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
msg = result['message']
# The raw output in the message must be truncated at 300 chars
assert 'X' * 300 in msg
assert 'X' * 301 not in msg
def test_generic_failure_empty_output_shows_sentinel(self, tmp_path):
"""When git produces no output, message contains a fallback sentinel."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', True),
('', True),
('', False), # pull fails with empty output
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert 'no output' in result['message'].lower() or result['message']
def test_generic_failure_does_not_set_diverged(self, tmp_path):
"""A generic pull failure must not set diverged=True."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', True),
('', True),
('Some unrecognized git error', False),
]
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert not result.get('diverged')
# ------------------------------------------------------------------
# Regression: existing success path still works after fetch addition
# ------------------------------------------------------------------
def test_successful_update_still_returns_ok(self, tmp_path):
"""Fetch + status + pull success path returns ok=True (regression guard)."""
(tmp_path / '.git').mkdir()
from api import updates
# Patch the cache's 'checked_at' key directly to avoid the lock
# invalidation block raising. We use a fresh dict swap.
fake_cache = {'webui': None, 'agent': None, 'checked_at': 1}
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git, \
patch(f'{_MODULE}._update_cache', fake_cache), \
patch(f'{_MODULE}._cache_lock'):
mock_run_git.side_effect = [
('origin/master', True), # upstream query
('', True), # fetch succeeds
('', True), # status (clean working tree)
('Already up to date.', True), # pull succeeds
]
result = updates._apply_update_inner('webui')
assert result['ok'] is True
# ------------------------------------------------------------------
# Agent target works the same as webui target
# ------------------------------------------------------------------
def test_fetch_failure_for_agent_target(self, tmp_path):
"""Fetch failure path also works when target='agent'."""
(tmp_path / '.git').mkdir()
from api import updates
with patch(f'{_MODULE}._AGENT_DIR', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', False), # fetch fails
]
result = updates._apply_update_inner('agent')
assert result['ok'] is False
assert 'could not reach' in result['message'].lower() or \
'internet' in result['message'].lower() or \
'remote' in result['message'].lower()