Skip to content

feat(deploy): version /opt/hermes/* under deploy/eligia-vps/#3

Merged
Wizarck merged 1 commit into
mainfrom
feat/deploy-eligia-vps-under-repo
May 13, 2026
Merged

feat(deploy): version /opt/hermes/* under deploy/eligia-vps/#3
Wizarck merged 1 commit into
mainfrom
feat/deploy-eligia-vps-under-repo

Conversation

@Wizarck

@Wizarck Wizarck commented May 13, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the disaster-recovery gap flagged after the T6 deploy: three files were living only on the eligia VPS at /opt/hermes/* and would have been lost on a fresh-VPS rebuild. This PR brings them under git in deploy/eligia-vps/ so the fork is now the full source of truth for the Hermes deployment.

What lands here

File Was at (VPS) Now at (repo)
Dockerfile overlay /opt/hermes/wamba_build/Dockerfile.eligia-overlay deploy/eligia-vps/Dockerfile.eligia-overlay
Compose definition /opt/hermes/docker-compose.yml deploy/eligia-vps/docker-compose.yml
Hermes config /opt/hermes/data/config.yaml deploy/eligia-vps/config.yaml
Runbook (none) deploy/eligia-vps/README.md

Build-context simplification

The Dockerfile.eligia-overlay was already COPY-ing from paths that exist as the fork's actual source (gateway/, agent/, plugins/). Before this PR, those files lived as a hand-managed snapshot at /opt/hermes/wamba_build/; with this PR the build context IS the fork checkout directly — no more snapshot drift between fork main and the VPS.

Security

No secrets land in this directory. All sensitive values (ANTHROPIC_API_KEY_HERMES, LANGFUSE_*, TELEGRAM_BOT_TOKEN, WA_ACCESS_TOKEN, ...) are still SOPS-encrypted in Wizarck/eligia-core/secrets/secrets.env and injected via sops exec-env in the hermes.service systemd unit. The compose file uses ${VAR} placeholders exclusively. config.yaml has api_key: '' empty strings.

Runbook highlights (in deploy/eligia-vps/README.md)

  • Build + deploy commands (docker build … && systemctl restart hermes).
  • Verification snippet for Langfuse activation.
  • Enumerated deltas between repo state and live filesystem (encrypted env, mounted data dirs, container runtime state).
  • Step-by-step disaster-recovery procedure for rebuilding from a blank VPS (~30 min if data backups intact).
  • Out-of-scope items called out (hermes.service systemd unit, Cloudflared tunnel config, image registry).

Follow-ups (NOT in this PR)

  • Migrate prod from /opt/hermes/wamba_build/ snapshot → /opt/hermes/source/ fork checkout. Today the live VPS still uses the snapshot.
  • Version /etc/systemd/system/hermes.service (probably as deploy/eligia-vps/hermes.service).
  • Push the built image to a registry so disaster recovery doesn't require rebuilding from source.

Test plan

Generated with Claude Code.

Summary by CodeRabbit

  • New Features

    • Added production deployment infrastructure for the ELIGIA VPS environment, including container orchestration configuration with health monitoring.
    • Integrated observability and tracing capabilities into the deployment stack.
    • Complete runtime configuration for Hermes agent with LLM, provider, and toolset settings.
  • Documentation

    • Added comprehensive deployment guide covering build, startup, health verification, and disaster-recovery procedures for the ELIGIA VPS stack.

Review Change Stack

Before this commit, three files lived only on the eligia VPS at
/opt/hermes/* and would have been lost in disaster recovery:

  - /opt/hermes/wamba_build/Dockerfile.eligia-overlay
  - /opt/hermes/docker-compose.yml
  - /opt/hermes/data/config.yaml

After this commit they are versioned in the fork under deploy/eligia-vps/.
The Dockerfile.eligia-overlay was already built FROM the fork source files
(gateway/, agent/, plugins/, etc.) via a snapshot at /opt/hermes/wamba_build/
— moving it into the fork means the build context IS the fork checkout
directly, eliminating the snapshot.

Also adds a README.md that:

  - Documents the wiring (systemd → sops → docker compose → container).
  - Lists the build + deploy commands.
  - Enumerates the expected deltas between repo state and live filesystem
    (decrypted env, mounted data dirs, container runtime state, etc.).
  - Provides a step-by-step disaster-recovery procedure for rebuilding
    from a blank VPS.
  - Calls out follow-ups (versioning /etc/systemd/system/hermes.service,
    migrating prod from /opt/hermes/wamba_build/ to /opt/hermes/source/).

The docker-compose.yml comments are also updated to point at the new
build path; no behaviour change. All secrets are still SOPS-encrypted in
Wizarck/eligia-core secrets.env and consumed via env var expansion at
container start time — nothing sensitive lands in this directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR introduces a complete production deployment stack for the Hermes agent on ELIGIA's VPS. It includes a Dockerfile overlay that extends the upstream Hermes image with ELIGIA-specific modules and Langfuse observability, Docker Compose configuration for service orchestration, comprehensive agent runtime settings, and detailed deployment documentation with operation and recovery runbooks.

Changes

ELIGIA Hermes VPS Deployment

Layer / File(s) Summary
Deployment Architecture & Operations Documentation
deploy/eligia-vps/README.md
Complete deployment guide describing VPS filesystem layout, systemd integration, Docker Compose wiring, build/restart/health-check workflows, Langfuse verification commands, repo vs. VPS filesystem differences, and disaster recovery runbook for fresh VPS provisioning.
Container Image Build & Langfuse Plugin Overlay
deploy/eligia-vps/Dockerfile.eligia-overlay
Pins upstream nousresearch/hermes-agent base, layers ELIGIA gateway/CLI modules into /opt/hermes with hermes:hermes ownership, overlays patched Langfuse plugin, installs langfuse SDK, and removes pycache to force recompilation of patched Python modules.
Docker Compose Service & Volume Orchestration
deploy/eligia-vps/docker-compose.yml
Defines hermes service running eligia/hermes-agent:wamba in host network mode with environment variable wiring for API keys/credentials, host directory and named volume mounts, restart=unless-stopped policy, gateway run command, and TCP healthcheck on port 8642.
Hermes Agent Runtime Configuration
deploy/eligia-vps/config.yaml
Sets Claude Haiku as default LLM, defines agent execution constraints, personality definitions, I/O modes (terminal/browser/voice), auxiliary subsystems (vision/compression/MCP), platform toolsets (CLI/Telegram/Discord/WhatsApp), MCP server endpoints, and enables observability/langfuse plugin for cost tracking and dashboard integration.

Sequence Diagrams

sequenceDiagram
  participant Systemd as systemd<br/>(hermes.service)
  participant SOPS as SOPS Secrets
  participant Compose as Docker Compose
  participant Container as Hermes Container<br/>(gateway run)
  participant Langfuse as Langfuse<br/>(observability)
  Systemd->>SOPS: Decrypt secrets
  Systemd->>Compose: docker compose up
  Compose->>Container: Start with env vars<br/>+ mounted config/data
  Container->>Container: Load config.yaml from<br/>/opt/data/config.yaml
  Container->>Langfuse: Send traces & costs<br/>(via plugin)
  Note over Container: Healthcheck probes<br/>127.0.0.1:8642 (gateway)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A fortress built for Hermes, now complete,
With config, compose, and Dockerfile neat,
From upstream base, we layer with care,
Langfuse watches, observability fair,
On ELIGIA's VPS, now we're set to soar!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main change: versioning previously untracked VPS files (/opt/hermes/*) under deploy/eligia-vps/ in the repository.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/deploy-eligia-vps-under-repo

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
deploy/eligia-vps/Dockerfile.eligia-overlay (1)

47-47: ⚡ Quick win

Pin langfuse to a bounded version to avoid breaking API changes.

Using langfuse>=3.0 allows installation of v4.x, which has breaking changes from v3 (released March 2026). The v4 SDK restructures the observation-centric data model, removes update_current_trace, changes OpenTelemetry span export behavior, remaps API namespaces, and requires Pydantic v2. If the application was built against v3 APIs, pulling v4 will cause runtime failures. Pin to langfuse>=3,<4 to maintain reproducible builds and API compatibility.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/eligia-vps/Dockerfile.eligia-overlay` at line 47, The Dockerfile RUN
line installs langfuse using an open-ended spec ("langfuse>=3.0") which may pull
v4 and break v3-based code; update the package spec in the RUN pip install
invocation to pin a bounded range (for example "langfuse>=3,<4") so builds
remain reproducible and compatible with existing v3 APIs, then rebuild the image
and verify imports that rely on v3 behavior (e.g., any code calling
update_current_trace or relying on v3 OpenTelemetry export behavior).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@deploy/eligia-vps/config.yaml`:
- Line 253: The configuration key tirith_fail_open is currently set to true
which makes Tirith bypass protections on failure; change tirith_fail_open to
false so Tirith operates fail-closed in production. Locate the tirith_fail_open
setting in the config (search for tirith_fail_open) and update its value to
false, then validate the config syntax and restart/redeploy the service so the
new fail-closed behavior takes effect.
- Line 169: The config currently sets redact_pii: false which can leak
user-identifying data; change the setting to enable PII redaction by setting
redact_pii to true (i.e., update the privacy.redact_pii configuration key in
deploy/eligia-vps/config.yaml) so production logs/observability redact
identifiers and sensitive content; ensure any related logging/observability
components read this flag so redaction is active in production.

In `@deploy/eligia-vps/README.md`:
- Around line 18-40: Add language identifiers to the two fenced code blocks that
currently lack them: change the opening fences for the block starting "systemd
unit: hermes.service" and the block containing "langfuse_client: Langfuse /
application: hermes-bot / consumer: HERMES" to use ```text (or another
appropriate language tag) so markdownlint MD040 passes; ensure the closing
fences remain ``` and update the other similar block mentioned in the comment as
well.

---

Nitpick comments:
In `@deploy/eligia-vps/Dockerfile.eligia-overlay`:
- Line 47: The Dockerfile RUN line installs langfuse using an open-ended spec
("langfuse>=3.0") which may pull v4 and break v3-based code; update the package
spec in the RUN pip install invocation to pin a bounded range (for example
"langfuse>=3,<4") so builds remain reproducible and compatible with existing v3
APIs, then rebuild the image and verify imports that rely on v3 behavior (e.g.,
any code calling update_current_trace or relying on v3 OpenTelemetry export
behavior).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 43e41186-a7b5-43dc-90ca-62ed3229198d

📥 Commits

Reviewing files that changed from the base of the PR and between a097a9e and ddc88f9.

📒 Files selected for processing (4)
  • deploy/eligia-vps/Dockerfile.eligia-overlay
  • deploy/eligia-vps/README.md
  • deploy/eligia-vps/config.yaml
  • deploy/eligia-vps/docker-compose.yml

tool_progress: all
background_process_notifications: all
privacy:
redact_pii: false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enable PII redaction for production traffic.

privacy.redact_pii: false risks leaking user identifiers/content into logs and observability systems.

Proposed change
 privacy:
-  redact_pii: false
+  redact_pii: true
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
redact_pii: false
privacy:
redact_pii: true
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/eligia-vps/config.yaml` at line 169, The config currently sets
redact_pii: false which can leak user-identifying data; change the setting to
enable PII redaction by setting redact_pii to true (i.e., update the
privacy.redact_pii configuration key in deploy/eligia-vps/config.yaml) so
production logs/observability redact identifiers and sensitive content; ensure
any related logging/observability components read this flag so redaction is
active in production.

tirith_enabled: true
tirith_path: tirith
tirith_timeout: 5
tirith_fail_open: true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Set Tirith to fail-closed in production.

With tirith_fail_open: true, failures in Tirith bypass protections instead of blocking.

Proposed change
 security:
   redact_secrets: true
   tirith_enabled: true
   tirith_path: tirith
   tirith_timeout: 5
-  tirith_fail_open: true
+  tirith_fail_open: false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
tirith_fail_open: true
tirith_fail_open: false
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/eligia-vps/config.yaml` at line 253, The configuration key
tirith_fail_open is currently set to true which makes Tirith bypass protections
on failure; change tirith_fail_open to false so Tirith operates fail-closed in
production. Locate the tirith_fail_open setting in the config (search for
tirith_fail_open) and update its value to false, then validate the config syntax
and restart/redeploy the service so the new fail-closed behavior takes effect.

Comment on lines +18 to +40
```
systemd unit: hermes.service
sops exec-env /opt/eligia/eligia-core/secrets/secrets.env
│ ↑ decrypts the SOPS-encrypted secrets file from the
│ eligia-core repo and injects all vars into the env
docker compose up -d --force-recreate
│ ↑ reads /opt/hermes/docker-compose.yml (sibling of this README)
│ which references the env vars (`${ANTHROPIC_API_KEY_HERMES}`,
│ `${LANGFUSE_PUBLIC_KEY}`, ...) injected above
Container `hermes` running image `eligia/hermes-agent:wamba`
│ ↑ built once from this Dockerfile.eligia-overlay; rebuild
│ whenever this directory changes
Hermes loads /opt/data/config.yaml (mounted from /opt/hermes/data/config.yaml)
└──► plugins.enabled: [observability/langfuse]
└──► writes traces to Langfuse Cloud with
metadata.application = "hermes-bot"
metadata.consumer = "HERMES"
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add language identifiers to fenced code blocks.

Two fences are missing language tags, which fails markdownlint (MD040).

Proposed change
-```
+```text
 systemd unit: hermes.service
 ...
-```
+```

 ...

-```
+```text
 langfuse_client: Langfuse
 application: hermes-bot
 consumer: HERMES
-```
+```

Also applies to: 98-102

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 18-18: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/eligia-vps/README.md` around lines 18 - 40, Add language identifiers
to the two fenced code blocks that currently lack them: change the opening
fences for the block starting "systemd unit: hermes.service" and the block
containing "langfuse_client: Langfuse / application: hermes-bot / consumer:
HERMES" to use ```text (or another appropriate language tag) so markdownlint
MD040 passes; ensure the closing fences remain ``` and update the other similar
block mentioned in the comment as well.

@Wizarck
Wizarck merged commit 79414a9 into main May 13, 2026
4 of 6 checks passed
@Wizarck
Wizarck deleted the feat/deploy-eligia-vps-under-repo branch May 13, 2026 17:59
Wizarck pushed a commit that referenced this pull request May 15, 2026
…rch#25071)

* tui: make URLs clickable + hover-highlight in any terminal

Problem
-------
URLs printed by `hermes --tui` were not clickable in basic macOS Terminal.app.
Cmd+click did nothing, the cursor didn't change shape — like nothing was
detected — even though arrow buttons and other Box onClick handlers worked
fine.

Root cause
----------
Two layers of dead plumbing:

1. `<Link>` only emitted the underlying `<ink-link>` (which carries the
   hyperlink metadata into the screen buffer) when `supportsHyperlinks()`
   said yes. On Apple_Terminal that's false, so the per-cell hyperlink
   field stayed empty, so `Ink.getHyperlinkAt()` had nothing to return on
   click. The visible underline was just decorative.

2. `Ink.openHyperlink()` calls `this.onHyperlinkClick?.(url)`, but
   `onHyperlinkClick` was never assigned anywhere in the codebase. The
   click pipeline (`App.tsx → onOpenHyperlink → Ink.openHyperlink`) ran
   but bailed silently on the optional chain.

Bonus discovery: even when wired up, there was no hover affordance —
terminal apps can't change the system mouse cursor, so users had no
visual signal that a cell was clickable. Arrow buttons in the chrome
worked because they had explicit `<Box onClick>` styling; inline link
URLs didn't.

Fix
---
- `Link.tsx`: always emit `<ink-link>` regardless of terminal capability.
  The renderer's `wrapWithOsc8Link` already gates the actual OSC 8 escape
  on `supportsHyperlinks()` further down — so terminals that don't
  understand OSC 8 still don't see the escape, but the screen-buffer
  metadata (which the click dispatcher reads) is now populated everywhere.

- `ink.tsx + root.ts`: add `onHyperlinkClick?: (url: string) => void` to
  `Options` / `RenderOptions`, wire it to the existing `Ink.onHyperlinkClick`
  field in the constructor.

- `src/lib/openExternalUrl.ts`: small platform-aware opener using
  `child_process.spawn` with arg-array (no shell) — http(s) only, rejects
  `file:`, `javascript:`, `data:`, etc., so a hostile model can't trigger
  arbitrary local handlers via `<Link url="file:///...">`. Detached + stdio
  ignore so closing the TUI doesn't kill the browser and Chrome stderr
  doesn't leak into the alt screen.

- `entry.tsx`: pass `onHyperlinkClick: openExternalUrl` to `ink.render`.

- `hyperlinkHover.ts` + Ink hover wiring: track the URL under the pointer
  in `Ink.hoveredHyperlink`, update it from `dispatchHover`, and inverse-
  highlight every cell of the matching link in the render-pass overlay
  (same pattern as `applySearchHighlight`). This is the cursor-hover
  affordance for clickable links — terminals don't expose cursor shape,
  so we light up the link itself.

- `types/hermes-ink.d.ts`: add `onHyperlinkClick` to the `RenderOptions`
  shim so consumers (`entry.tsx`) type-check against the new option.

Tests
-----
- `src/lib/openExternalUrl.test.ts` (15 cases): http(s) accepted; file/js/
  data/mailto/ftp/ssh rejected; macOS open(1), Windows cmd.exe start with
  empty title slot, Linux xdg-open dispatch; shell-metacharacter URLs
  pass through unmolested as a single argv element; synchronous spawn
  failure returns false.

Verified empirically in Apple Terminal 455.1 (macOS 15.7.3): clicking a
URL opens in default browser, hovering inverts the link cells, and
moving away clears the highlight. Full TUI suite: 713 passing, 0
type errors.

Reverts
-------
The earlier attempt that version-gated Apple_Terminal in
`supports-hyperlinks.ts` was based on a wrong assumption — Terminal.app
silently strips OSC 8 sequences but does not render them as clickable
hyperlinks. Reverted to the original allowlist.

* tui: address Copilot review — explorer.exe on win32 + comment fixes

- openExternalUrl: switch win32 from `cmd.exe /c start` to `explorer.exe`.
  cmd.exe's `start` builtin reparses the URL through cmd's tokenizer, so
  `&`, `|`, `^`, `<`, `>` either split the command or get reinterpreted —
  breaking both the protocol-allowlist safety story AND plain http(s) URLs
  with `&` in query strings. `explorer.exe <url>` invokes the registered
  protocol handler directly with no shell.

- openExternalUrl.test.ts: rename the win32 test to reflect the new
  contract and add two regression tests — one with `&|^<>` metachars,
  one with the common analytics-URL `&` query-param pattern — both pinned
  to single-argv-element delivery via explorer.exe.

- Link.tsx: fix misleading comment. OSC 8 escapes are emitted
  unconditionally by the renderer (`wrapWithOsc8Link` in
  render-node-to-output.ts, `oscLink` in log-update.ts). Non-supporting
  terminals silently strip the sequence, which is why hover/click
  affordance has to come from the in-process overlay rather than the
  terminal's own link rendering.

Verified: 715/715 tests pass, type-check + build clean.

* tui: address Copilot review #2 — async spawn errors + hover scope + docs

1. openExternalUrl: attach a no-op `'error'` listener on the spawned
   child BEFORE unref(). spawn() returns a ChildProcess synchronously
   even when the binary is missing (ENOENT on xdg-open / explorer.exe),
   unreachable, or otherwise unusable; the failure surfaces later as
   an 'error' event. An unhandled 'error' on an EventEmitter crashes
   Node, which would tear down the whole TUI. The listener is a
   deliberate no-op — we already returned `true` synchronously and the
   user just doesn't see the browser pop.

2. openExternalUrl.test.ts: add a regression test using a real
   EventEmitter to simulate the async-error path. Pins both the
   listener-attached contract and the "doesn't throw on emit" behavior.
   Was 17/17, now 18/18.

3. ink.tsx dispatchHover: bypass `getHyperlinkAt()` and read
   `cellAt(...).hyperlink` directly. `getHyperlinkAt` falls back to
   `findPlainTextUrlAt` for cells without an OSC 8 hyperlink, but the
   render-pass overlay (`applyHyperlinkHoverHighlight`) only matches on
   `cell.hyperlink === hoveredUrl` — so plain-text URLs would burn
   re-renders without ever producing the highlight. Hover is now a
   strictly 1:1 fit for what the overlay can paint. Plain-text URLs
   still get the click action via the existing dispatch path.

4. root.ts + ink.tsx doc comments: replace the misleading "typically
   `open` / `xdg-open` / `start` shell" wording with the actual safe
   recipe — argv-array spawn into `open` / `xdg-open` / `explorer.exe`,
   with an explicit warning that `cmd.exe /c start` reparses the URL
   through cmd's tokenizer and is unsafe + breaks `&`-query URLs.

Verified: 716/716 tests pass, type-check + build clean.

* tui: address Copilot review #3 — hover damage, alt-screen cleanup, opener allowlist

1. ink.tsx onRender: stop folding steady-state hover into hlActive.
   hlActive forces a full-screen damage diff so previous-frame inverted
   cells get re-emitted when the highlight set changes. The transition
   IS the trigger — enter / leave / change-to-other-link. While the
   pointer just sits on a link the painted cells don't change and the
   per-cell diff handles the no-op. Folding the steady state in would
   burn a full-screen diff on every frame. Added a
   lastRenderedHoveredHyperlink tracker and gate the hlActive bump on
   `hovered !== lastRendered`.

2. ink.tsx setAltScreenActive: clear hoveredHyperlink (and the tracker)
   when toggling alt-screen state. Hover dispatch is alt-screen-gated,
   so once we leave there's no path to clear it. Without this, remounting
   <AlternateScreen> would paint a phantom hover from the previous
   session until the next mouse-move arrived.

3. openExternalUrl.ts openCommand: allowlist linux + the BSD family for
   xdg-open and return null for everything else (aix, sunos, cygwin,
   haiku, etc.). Previously the default-fallback always returned
   xdg-open, which made the caller's `if (!command) return false` dead
   and yielded a misleading `true` on platforms that probably don't
   have xdg-open. New tests cover the null path AND the
   openExternalUrl-returns-false-without-spawning behavior.

Verified: 718/718 tests pass, type-check + build clean.

* tui: address Copilot review #4 — doc comment accuracy

1. openExternalUrl return-value doc: now lists all three false paths
   (URL rejected / no opener for platform / synchronous spawn throw)
   plus a note that async 'error' events still return true because the
   spawn was attempted.

2. ink.tsx onHyperlinkClick field doc: clarifies the callback receives
   either an OSC 8 hyperlink OR a plain-text URL detected by
   findPlainTextUrlAt — App.tsx routes both into the same callback.

3. hyperlinkHover applyHyperlinkHoverHighlight doc: drops the misleading
   'caller forces full-frame damage' promise. Caller decides; for hover
   the current caller only forces full damage on transitions.

No behavior change. 718/718 tests pass.

* tui: address Copilot review #5 — lint fixes

1. ink.tsx: reorder `./hyperlinkHover.js` import before `./screen.js` to
   satisfy perfectionist/sort-imports.

2. Link.tsx: drop unused `fallback` parameter destructuring + the
   trailing `void (null as ...)` dead-statement (would trip
   no-unused-expressions). Kept `fallback?: ReactNode` on the Props
   interface as a documented compat shim so existing call sites still
   compile, with a comment explaining why it's no longer wired up.

3. openExternalUrl.test.ts: replace `typeof import('node:child_process').spawn`
   inline annotations (forbidden by @typescript-eslint/consistent-type-imports)
   with a `SpawnLike` type alias backed by a real `import type { spawn as SpawnFn }`.

No behavior change. 718/718 tests pass, type-check clean, lint clean on
all modified files.
Wizarck pushed a commit that referenced this pull request Jun 14, 2026
…bes + test-leak fix (NousResearch#40909)

* fix(gateway,windows): reliability — supervisor task, JOB breakaway, status --deep

Three coordinated fixes for the Windows gateway reliability story:

1. CREATE_BREAKAWAY_FROM_JOB on every detached spawn

   The 'hermes update' triggered from the Electron Desktop GUI ran inside
   Electron's job object. Without breakaway, the post-update gateway
   watcher spawned by update — already DETACHED_PROCESS — was still
   reaped when Electron's job tore down, so the gateway never came back
   after a GUI-initiated update. Adds CREATE_BREAKAWAY_FROM_JOB (0x01000000)
   to:
     - hermes_cli/_subprocess_compat.py::windows_detach_flags() — used by
       every helper that calls windows_detach_popen_kwargs(), including
       launch_detached_profile_gateway_restart()
     - The watcher subprocess's own respawn snippet in
       hermes_cli/gateway.py (inlined flags so the watcher's child
       respawn also breaks away)

   _spawn_detached() in gateway_windows.py already had the flag; this
   change brings the rest of the codebase to parity.

2. Per-minute supervisor Scheduled Task — Windows equivalent of
   systemd Restart=always

   Introduces hermes_cli/gateway_supervisor.py and registers it as a
   second Scheduled Task ('Hermes_Gateway_Supervisor', SC MINUTE /MO 1,
   LIMITED rights) alongside the existing ONLOGON task. Every minute,
   the supervisor uses the same gateway.status.get_running_pid() probe
   as 'hermes gateway status' and, if no gateway is alive, calls
   gateway_windows._spawn_detached() (which now includes BREAKAWAY) to
   bring one back.

   Covers every crash mode, not just 'machine rebooted': taskkill,
   OOM, GUI update SIGTERM, parent job teardown. Cheap — one pythonw
   startup per minute when down, one PID-existence check per minute
   when up.

   Wired into both the schtasks-success and Startup-folder-fallback
   install paths via _install_supervisor_best_effort(), and removed in
   uninstall(). Best-effort: a failing supervisor install logs a
   warning but doesn't roll back the primary install.

3. 'hermes gateway status --deep' shows per-probe PASS/FAIL

   Replaces the existing terse '--deep' output (which only printed
   paths) with an actual diagnostic table:
     [1] PID file present
     [2] Lock file held by a live process
     [3] get_running_pid() result
     [4] _pid_exists(pid) — OS-level liveness
     [5] gateway_state.json (state + age)
     [6] Last lifecycle event from gateway-exit-diag.log

   When the high-level summary disagrees with reality, the user can
   see exactly which signal is lying.

Test-leak fix
-------------

tests/hermes_cli/test_gateway_wsl.py::TestGatewayCommandWSLMessages
monkey-patched is_linux/is_wsl/supports_systemd_services to simulate
WSL but did NOT stub is_windows(). On a Windows host, the dispatcher
in _gateway_command_inner takes the is_windows() branch BEFORE the
WSL guidance branch, so the test invoked gateway_windows.install()
for real. install() writes to %APPDATA%\...\Startup\Hermes_Gateway.cmd
— the REAL user Startup folder, never sandboxed by tmp_path — pointing
at the test's pytest-of-<user>/pytest-<N>/.../gateway-service/ wrapper.
When pytest tore down the tmp_path, every subsequent Windows login
flashed a cmd.exe window that failed to find the missing target.

Stubs is_windows=False on all four affected tests:
  test_install_wsl_no_systemd
  test_start_wsl_no_systemd
  test_status_wsl_running_manual
  test_status_wsl_not_running

Defense-in-depth: _build_startup_launcher() now prefixes the launcher
with 'if not exist <target> exit /b 0', so any future stale Startup
entry silently no-ops instead of flashing a console window.

Status enhancements
-------------------

- status() now reports supervisor task presence alongside the existing
  schtasks/Startup info, and nudges the user to reinstall if the
  supervisor isn't registered.
- Deep mode dumps both the supervisor task name + script path.

* fix(gateway,windows): drop the per-minute supervisor task — keep breakaway + deep probes

Earlier in this branch we added a per-minute schtasks-based supervisor to
respawn the gateway after crashes / GUI-update SIGTERMs. The implementation
flashed a brief console window on every firing, which stole window focus.
We tried several variants:

  - cmd.exe wrapper invoking pythonw  -> flashes (cmd.exe is console-subsystem)
  - schtasks /TR pointing at pythonw  -> flashes (uv venv launcher pythonw is
    actually subsystem=Console, not GUI; it respawns the real pythonw)
  - schtasks /TR pointing at base uv  -> still flashes (Task Scheduler-side
    conhost preallocation; documented Windows quirk)
  - XML registration with <Hidden>true>  -> still flashes (<Hidden> only hides
    the task in the Task Scheduler UI, not the spawned window)

Researched what leading projects do:

  - Ollama: GUI-subsystem tray exe + Startup-folder shortcut. No supervisor.
  - Tailscale: real Windows Service via SCM. Session 0, no console possible.
  - Syncthing: --no-console flag inside the binary + Startup folder.
  - openclaw: VBS Run(..., 0, False) wrapper. Suppresses the *window* but
    Super User Q971162 confirms focus-steal still occurs in some cases.

None of these use a per-minute polling scheduled task. The 'auto-restart on
crash' responsibility belongs INSIDE the daemon (Tailscale's in-process
recovery / Ollama's monitor+worker pair) OR is delegated to the Windows
Service Control Manager — not Task Scheduler.

So this commit drops the supervisor entirely. The CREATE_BREAKAWAY_FROM_JOB
fix in _subprocess_compat.py (from commit c1e5fa4) survives — that is the
*real* fix for problem #2 (GUI-update kills gateway): the post-update
watcher in launch_detached_profile_gateway_restart() now breaks out of
Electron's job object, so the gateway respawn watcher survives the GUI
quit and successfully respawns the gateway.

Surviving from c1e5fa4:
  * CREATE_BREAKAWAY_FROM_JOB in hermes_cli/_subprocess_compat.py (fixes #2)
  * Inlined breakaway flag in the watcher respawn snippet in gateway.py
  * hermes gateway status --deep PASS/FAIL probes (fixes #1 — visibility)
  * 'if not exist <target> exit /b 0' guard in _build_startup_launcher
    (fixes #3 — silent no-op for stale Startup entries)
  * tests/hermes_cli/test_gateway_wsl.py is_windows=False stubs (root cause
    of #3 — pytest WSL tests no longer leak Startup entries on Win hosts)

Removed in this commit:
  * hermes_cli/gateway_supervisor.py (entire file)
  * Supervisor section in hermes_cli/gateway_windows.py (~180 lines):
      get_supervisor_task_name, get_supervisor_script_path,
      _build_supervisor_cmd_script, _write_supervisor_script,
      _install_supervisor_task, is_supervisor_task_registered,
      _install_supervisor_best_effort
  * _install_supervisor_best_effort() calls in install() (3 spots)
  * supervisor cleanup block in uninstall()
  * supervisor display lines in status() / status(deep=True)

Future direction (out of scope for this PR): the right place for Windows
'Restart=always' semantics is a real Windows Service installed via
pywin32's win32serviceutil.ServiceFramework — session-0 isolation, SCM
auto-restart, no console window possible. That's a meaningful next-PR
project, not a band-aid.

Tests: 51 pass / 2 pre-existing failures in
tests/hermes_cli/test_gateway_{windows,wsl}.py (the 2 failures are
TestSupportsSystemdServicesWSL cases that fail on origin/main too —
unrelated to this PR).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant