Merge branch 'main' into bb/salvage-43809-wsl-bridge

This commit is contained in:
Jeffrey Quesnelle 2026-07-12 22:46:49 -04:00 committed by GitHub
commit 6d3009454d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 2883 additions and 227 deletions

View file

@ -491,18 +491,18 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes
### Electron Desktop Chat App (`apps/desktop/`)
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared``JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared``JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.ts` + `backendSupportsServe()` in `electron/main.ts`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. For scoped Desktop architecture, state, resolver, transport, and testing rules, read `apps/desktop/AGENTS.md`.
**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
- **Backend already provides everything.** `tui_gateway/server.py` `commands.catalog` (empty-query list) and `complete.slash` (typed-query completions) both include built-in commands, user `quick_commands`, AND skill-derived commands (`scan_skill_commands()` / `get_skill_commands()`). The desktop app does not need a new RPC to see skills.
- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMANDS` (the ~19 built-ins shown in the palette) plus block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover.
- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMAND_SPECS` (the built-ins and their Desktop surfaces) plus `NO_DESKTOP_SURFACE` block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover.
- `isDesktopSlashCommand(name)` — gates **execution**. Returns true for built-ins AND for any non-built-in (skill / quick command), so typed extension commands run.
- `isDesktopSlashSuggestion(name)` — gates **discovery/completion**. Used by BOTH completion paths in `app/chat/composer/hooks/use-slash-completions.ts` (empty-query catalog filter + typed-query `complete.slash` filter) and by `filterDesktopCommandsCatalog`.
- `isDesktopSlashExtensionCommand(name)` — true when the command is NOT a known Hermes built-in (i.e. a skill or user quick command). Both suggestion and catalog-filter paths allow extensions through so skill commands surface in the palette. (Added when fixing "skill commands missing from the desktop slash palette" — the curated allow-list was silently dropping every skill/quick command from completions even though they executed fine when typed.)
- **Dispatch** lives in `app/session/hooks/use-prompt-actions.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt.
- **Dispatch** lives in `app/session/hooks/use-prompt-actions/slash.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt.
**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: `apps/desktop/src/lib/desktop-slash-commands.test.ts` (run via the repo-root `vitest`, since `apps/desktop` resolves deps from the root workspace install).
**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: from `apps/desktop`, run `npx vitest run src/lib/desktop-slash-commands.test.ts` (workspace dependencies are installed at the repo root).
---

View file

@ -412,13 +412,25 @@ def init_agent(
agent.skip_context_files = skip_context_files
agent.load_soul_identity = load_soul_identity
agent.pass_session_id = pass_session_id
agent._credential_pool = credential_pool
agent.log_prefix_chars = log_prefix_chars
agent.log_prefix = f"{log_prefix} " if log_prefix else ""
# Store effective base URL for feature detection (prompt caching, reasoning, etc.)
agent.base_url = base_url or ""
provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None
agent.provider = provider_name or ""
if credential_pool is not None:
try:
from agent.credential_pool import credential_pool_matches_provider
if not credential_pool_matches_provider(
credential_pool,
agent.provider,
base_url=agent.base_url,
):
credential_pool = None
except Exception:
credential_pool = None
agent._credential_pool = credential_pool
agent.acp_command = acp_command or command
agent.acp_args = list(acp_args or args or [])
if api_mode in {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse", "codex_app_server"}:

View file

@ -445,6 +445,44 @@ def get_pool_strategy(provider: str) -> str:
return STRATEGY_FILL_FIRST
def credential_pool_matches_provider(
pool_or_provider: Any,
provider: Optional[str],
*,
base_url: Optional[str] = None,
) -> bool:
"""Return whether a pool belongs to the requested runtime provider.
Named custom endpoints intentionally use two identities: the live agent is
``custom`` while its pool is keyed ``custom:<name>``. Accept that pair only
when the runtime base URL resolves to the exact same custom pool key.
Empty string identities fail closed. Legacy pool adapters without a
``provider`` attribute remain compatible; production pools are scoped.
"""
raw_pool_provider = getattr(pool_or_provider, "provider", None)
if raw_pool_provider is None:
if isinstance(pool_or_provider, str):
raw_pool_provider = pool_or_provider
else:
# Backward compatibility for lightweight/unscoped pool adapters.
# Production CredentialPool instances always carry ``provider``;
# old plugins and tests may expose only select()/has_credentials().
return True
pool_provider = str(raw_pool_provider or "").strip().lower()
provider_norm = str(provider or "").strip().lower()
if not pool_provider or not provider_norm:
return False
if pool_provider == provider_norm:
return True
if provider_norm != "custom" or not pool_provider.startswith(CUSTOM_POOL_PREFIX):
return False
try:
matched_pool = get_custom_provider_pool_key(base_url or "")
except Exception:
return False
return str(matched_pool or "").strip().lower() == pool_provider
DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1

View file

@ -289,7 +289,7 @@ def build_verify_on_stop_nudge(
+ "), read any failure, repair the code, and summarize what passed."
)
else:
temp_dir = tempfile.gettempdir()
temp_dir = os.path.realpath(tempfile.gettempdir())
command_instruction = (
"No canonical test/lint/build command was detected. Create a focused "
f"temporary verification script under `{temp_dir}` using an OS-safe "

200
apps/desktop/AGENTS.md Normal file
View file

@ -0,0 +1,200 @@
# Desktop Engineering Guide
How to build Hermes Desktop well. This is a judgment guide, not an inventory —
it teaches the invariants and the reasoning behind them so a change fits the app
even as files move. Read it with the repository `AGENTS.md` (root rules still
apply) and [`DESIGN.md`](./DESIGN.md) for the visual and interaction contract.
When a rule here and the code disagree, trust the code and fix whichever is
wrong — but never break an invariant to make a change easier.
## What this app is
Desktop is its own native chat surface. It is not the browser dashboard and it
does not embed the TUI. Three parties, each authoritative for one thing:
- **Electron** owns the machine: process lifecycle, native filesystem/git/
windows, install/update, and a narrow, typed capability bridge.
- **The renderer** owns the experience: navigation, presentation, and ephemeral
interaction state.
- **The agent backend** owns the work: sessions, tools, model calls, streaming.
Keep the seams clean. The renderer never reaches for Node or Electron directly;
native power arrives through a deliberate capability, not a general escape hatch.
Agent behavior lives behind the gateway, never reimplemented in React. When a
change blurs a seam, that is the smell — fix the seam, don't widen it.
## Decide state by authority
The first question for any piece of state is *who is allowed to be right about
it*, not where it is convenient to store it. Put state with its authority:
- The **backend** is authoritative for anything another Hermes surface can also
change. Treat the renderer's copy as a cache of that truth.
- **Electron** is authoritative for machine and runtime facts.
- The **renderer** owns only what is purely about this window's presentation.
From that, everything else follows: shared renderer state lives in small stores
owned by the feature that owns the concern; request-shaped server data that wants
invalidation lives in the query layer; short-lived interaction detail stays in
the component; hot coordination that must not paint stays in a ref. Reach for the
narrowest home that still lets the state be correct. A new global store is a
claim that many distant surfaces need it — earn that claim.
Persisted state must declare its scope in its own key: is this global, or does it
belong to a connection, a profile, a stored session, a project, or a window?
Getting the scope wrong is how one profile's setting bleeds into another.
## Identity is not incidental
Sessions have more than one identity, and conflating them is a recurring source
of "session not found" and vanishing history. Reason about which identity a
surface needs: durable navigation and anything the user pins or persists key off
the stable/durable identity; live streaming keys off the runtime identity; state
that must outlive compression keys off the lineage root. Keep the mapping between
them explicit and translate at the boundary rather than passing the wrong id
inward.
## Server truth is cached, not owned
The renderer paints from a cache of backend truth, so it must reconcile, not
assume:
- **Merge, don't clobber.** A refresh is new information layered over what you
already know, not a replacement that can drop live or pinned rows.
- **Be optimistic, then honest.** Direct manipulation should paint immediately
from a snapshot; a failed write rolls back visibly and an authoritative
refresh gets the last word.
- **Guard against the past.** Async results can arrive out of order; a stale
response must never overwrite newer intent. Generation counters and request
tokens exist for this.
- **Isolate the foreground.** Only the surface the user is looking at may publish
into the shared view; background work updates its own cache quietly.
- **Coalesce noise, flush signal.** Batch high-frequency cosmetic updates, but
let terminal transitions (a turn finishing, needing input, failing) reach the
user immediately.
- **Preserve reference identity on no-ops.** Handing React a fresh array that
contains the same data re-renders expensive trees for nothing.
## Switching context is a re-home, not a reboot
Changing profile, connection, or mode is a workspace switch, not a cold start.
The shell and whatever the user was doing stay put; only the gateway-bound view
is cleared and repopulated, and the previous context must not leak into the next
one. Reserve the full-screen boot/connecting experience for a genuinely unusable
backend.
There are three distinct switch shapes, and conflating them is the classic bug:
- A **connection/mode apply** (local ↔ remote ↔ cloud) is the soft re-home:
shell mounted, gateway-bound stores explicitly wiped, then reconnect. Query
invalidation alone cannot evict live session stores — wipe them.
- A **runtime home change** (switching the underlying `HERMES_HOME` profile) is
a hard re-home: the window legitimately reloads and state resets by remount.
- A **live profile swap** in the same window activates another profile's socket
while background profiles keep streaming; lists merge rather than wipe, and
only an explicit user selection starts a fresh foreground draft.
Treating a soft switch as hard flickers the app; treating a hard one as soft
strands stale rows. After any swap, the active socket, active profile, and
connection atoms must agree, or REST and filesystem calls route to the wrong
backend.
## Cross everything as an observable ladder
Desktop lives at the seams: versions, profiles, local vs remote vs cloud,
partially installed runtimes, stale caches, older backends. The durable technique
for all of it is the same — an ordered ladder of candidates:
1. Precedence is written down, in one place, as data or a pure function.
2. A candidate is trusted only after it is validated at the right boundary.
Existence is not proof; probe what you're about to rely on.
3. A failed *read* falls to the next rung; a failed *authoritative write*
surfaces or rolls back rather than silently retargeting.
4. A missing capability and a transient failure are different: the first may
enable a compatibility path or a disabled state; the second should retry.
5. Retries are bounded and end in a real recovery affordance — never an infinite
spinner or a hot loop.
6. One resolver owns each policy so every caller gets the same answer. Scatter is
how two call sites drift apart.
This is the shape of backend discovery, command/version fallbacks, connection and
auth resolution, workspace-cwd selection, capability detection, and preview
normalization alike. Learn the shape, not a snapshot of the current rungs.
Two auth-flavored corollaries worth naming because they are easy to get wrong:
- **One-time credentials are never reused.** An OAuth gateway connection mints a
fresh WebSocket ticket on every dial; a mint failure means reauthentication,
not "fall back to the cached URL." Only long-lived token/local auth may reuse
a cached URL as a lower rung.
- **A connection test must exercise the leg you'll actually use.** An HTTP
status probe passing while the WebSocket/auth leg fails is a false positive
that ships as "it said connected but nothing works."
## Compatibility without carrying the past forever
Desktop and its runtime update on separate clocks, so a change can meet an older
backend. Keep those users working: preserve the current feature, keep the
fallback narrow and tied to an identified older runtime, and cover it with a
test. A fallback that quietly degrades the feature it's meant to protect is worse
than the crash it replaced.
## Keep the waist narrow, grow at the edges
The root contribution rubric governs here too. New capability should arrive at
the smallest surface that solves it: extend what exists, add a feature locally,
lean on an existing seam — before you invent a framework. The shell's internal
registries are composition seams, not a public plugin ABI; do not build a
universal extension system, a manifest, or a plugin adapter for a single
consumer. Design a shared contract only once more than one real consumer proves
its shape. "Plugin" means several unrelated things across Hermes — do not assume
one surface's extension model runs in another.
## Respect the person using it
Design and engineering meet at intent. The user's attention and context are
sacred:
- Never navigate, move focus, or open a surface because something *happened* in
the background. Offer; don't hijack.
- The states around loading are distinct experiences — empty, loading,
reconnecting, degraded/stale, and exhausted-recovery each deserve their own
honest copy and their own way out.
- Keyboard ownership follows focus. The focused surface wins its keys; one
cancel gesture does exactly one thing.
- Expensive, stateful surfaces (terminals, live tools) stay alive when hidden.
Visibility is not lifecycle.
## Make it feel instant
Performance is a feature the user feels, especially in drag, resize, scroll,
typing, streaming, and terminals. The principles are timeless even as the code
changes: keep hot-path state local or narrowly derived; don't subscribe heavy
trees to per-frame updates; coalesce pointer work; avoid reading layout right
after writing style; and don't mount expensive content mid-gesture. Prove speed
against realistic content — a fast empty demo proves nothing about a long
transcript. If motion is masking latency, remove the motion, don't tune it.
## Testing as a habit of proof
Test the behavior that would actually break a user, not a snapshot of today's
data. Favor invariants over frozen values. Exercise the real path for anything
at a seam — resolver precedence and its failure rungs, identity and scope
boundaries, optimistic rollback and stale-response ordering, and both sides of a
local/remote adapter with its profile routing intact. Match how the suite is
actually run rather than inventing a command; when in doubt, read the scripts.
## The taste test before you hand off
- Does every piece of state live with its authority, at the narrowest scope?
- Would a background event ever steal the foreground or the user's focus?
- Does each resolver have one home, a validated ladder, and a bounded, recoverable
end?
- Do local, remote, and profile routing still agree?
- Does async failure leave a usable UI and a way forward?
- Do hot interactions stay cheap under realistic load?
- Does the change pass the [`DESIGN.md`](./DESIGN.md) checklist and update all
locales?
If any answer is "not sure," that's the part to go verify.

View file

@ -6,12 +6,29 @@ concern, tokens over literals, flat over boxed.** If you reach for a raw color,
a one-off shadow, a bespoke button, or a hardcoded `px-*` on a control — stop,
there's already a primitive for it.
This file owns the visual and interaction contract. Read
[`AGENTS.md`](./AGENTS.md) for architecture, state, resolver, transport, and
testing rules.
This doc contains two kinds of content, maintained differently:
- **Principles** (flatness, intent, feedback, motion, cancellation) are durable.
They hold as components come and go.
- **Named contracts** (tokens, `Button` variants, primitive names) are the
design system's current API. They are maintained *with* the code: if you
change a primitive, token, or variant, update its entry here **in the same
change** — a stale name in this file is a bug, exactly like a stale type.
When a rule and the code disagree, fix whichever is wrong rather than forking a
one-off at the call site.
## Principles
1. **Flat, not boxed.** No card-in-card, no divider borders inside a panel.
Group with whitespace and a single hairline, never nested rounded boxes.
2. **Borderless + shadow for elevation.** Overlays float on `shadow-nous` + a
`--stroke-nous` hairline, not hard borders.
2. **Borderless elevation for floating panels.** Overlays float on
`shadow-nous` + a `--stroke-nous` hairline, not thick framed boxes. In-panel
structure may use token hairlines sparingly.
3. **One primitive per concern.** One `Button`, one set of control variants,
one `SearchField`, one `Loader`, one `ErrorState`. Migrate onto them; don't
fork.
@ -20,11 +37,40 @@ there's already a primitive for it.
5. **Style lives in the primitive.** Variants and sizes own padding, radius,
color, chrome. Call sites pass a `variant`/`size`, not `className` overrides
that re-specify those.
6. **Intent before automation.** Surface useful actions and previews, but do not
open panes, move focus, or navigate because a tool happened to produce
something.
7. **Immediate feedback.** Direct manipulation updates the view first. Network
or disk persistence reconciles afterward and rolls back visibly on failure.
## Information architecture
- **Chat is the home surface.** The transcript and composer stay primary; tools,
previews, files, review, and terminal complement the conversation.
- **Pages are durable destinations.** Chat, Skills, Messaging, and Artifacts
remain in shell chrome. Do not hide a distinct product noun inside an
unrelated page.
- **Route overlays are short tasks.** Settings, Command Center, Cron, Profiles,
Agents, and Starmap render as `OverlayView` cards and return to the previous
route on close. Model/session pickers and dialogs layer above the current
surface; they are not navigation stacks.
- **Panes are working context.** Preview, files, review, and terminal remain
attached to the current task. Their state survives temporary hiding and chat
switches where the underlying tool is meant to persist.
- **One action, one home.** A command may have keyboard, palette, and visible
affordances, but they invoke the same action and state. Do not fork behavior
per entry point.
- **Projects own workspace cwd.** Use Sidebar → Projects for local folders and
worktrees; do not reintroduce a per-session/right-sidebar folder-picker flow.
Navigation must preserve context. A background session finishing, a tool result
arriving, or a project refresh may update badges and cached data; it must not
replace the foreground transcript or steal focus.
## Surfaces & elevation
Every overlay / dialog / toast (boot-failure, install, notifications,
model-picker, onboarding, prompt-overlays, updates, base `Dialog`) uses:
Floating panels (base `Dialog`, route overlays, boot/install/update surfaces,
model-picker, onboarding, prompt overlays, notifications) use:
```
shadow-nous /* downward-weighted, layered contact→ambient falloff */
@ -35,6 +81,11 @@ Both are CSS vars in `src/styles.css` — tune in one place, everything inherits
Don't add per-overlay `shadow-[…]` or `border-(--ui-stroke-secondary)`
one-offs; if elevation needs to change, change the token.
Menus and popovers use their own shared `shadow-md` +
`--ui-stroke-secondary` primitive treatment. Drag affordances may use tokenized
dashed targets and local blur. These are semantic surface classes, not licenses
for call-site shadow or border inventions.
## Stroke & color tokens
| Token | Use |
@ -62,8 +113,9 @@ fill/shadow), `ghost`, `link`, `text` (boxless quiet inline — "Cancel",
"Open logs").
**Sizes:** `default`, `xs`, `sm`, `lg`, `inline` (flush, zero box — for buttons
that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), and the icon
family `icon` / `icon-xs` / `icon-sm` / `icon-lg` / `icon-titlebar`.
that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro`
(status-stack/table-footers), and the icon family `icon` / `icon-xs` /
`icon-sm` / `icon-lg` / `icon-titlebar`.
Notes:
- Text buttons are square (no radius) and sized by padding + line-height (no
@ -107,11 +159,36 @@ Notes:
through for a11y.
- **Logs:** `LogView` — no bg, hairline border, tight padding, small mono.
Every place we surface raw logs uses it.
- **Empty:** `EmptyState` / `EmptyPanel` — don't hand-roll centered empties.
- **Empty:** `EmptyState` for plain page bodies; `PanelEmpty` for overlay
master/detail empties with an icon and action. Don't hand-roll a third
centered empty.
## Chat, tools & boot surfaces
- The transcript and composer are built on `@assistant-ui/react`. Extend the
existing components under `src/components/assistant-ui` and
`src/app/chat/composer`; do not fork a second markdown, message, tool-call, or
approval renderer for one feature.
- A tool result may expose an inline action that opens a preview. It must not
open the rail automatically.
- Install, onboarding, connecting, boot failure, and reauthentication are
distinct states with shared visual primitives. Preserve their recovery
semantics when unifying appearance.
- Respect `AppShell` overlay ownership. Persistent terminal/content layers,
route overlays, dialogs, and boot surfaces must not compete through ad-hoc
z-index literals.
## Iconography & brand
- **`Codicon`** is the icon set. No mixing icon libraries inline.
- **Tabler** is the default component/chrome set. Import its curated aliases and
`iconSize` scale from `src/lib/icons.ts`; do not import icon packages directly
in feature code.
- **`Codicon`** is the compact editor/tool/status vocabulary. Use
`src/components/ui/codicon.tsx`, including `codiconIcon()` where a
Tabler-shaped component is required.
- Pick the vocabulary by semantic context and reuse the existing icon for an
action. Do not introduce a third icon set or mix styles within one control
group.
- **`BrandMark`** (`src/components/brand-mark.tsx`) is the brand glyph — the
`nous-girl` mark on a white tile, softly rounded, identical in light/dark.
It replaced scattered Sparkles glyphs in updates / onboarding / about. Use it
@ -124,6 +201,43 @@ Notes:
- Choreographed exits (e.g. onboarding's "matrix" fade-down) stagger per-element
then settle the surface — the outer container's fade is *delayed* so it
doesn't swallow the inner animation. Don't let a global fade race the detail.
- Motion follows state; it never delays state. Selection, drag targets, cancel,
and pressed feedback paint in the current frame.
- Do not animate layout geometry with `transition-all` on a hot interaction.
Name the properties, avoid backdrop-filter repaints during movement, and
remove animation before masking a performance problem.
## Direct manipulation & performance
The app should feel instant under real load — long transcripts, several panes,
live streams. Design toward that:
- Direct manipulation paints first; persistence reconciles after and rolls back
visibly on failure.
- Keep interaction feedback cheap: hot-path state stays local or narrowly
derived, not wired into heavy trees; pointer work coalesces per frame.
- One drop region has one visual owner, and drop targets speak one affordance
language across files, sessions, tabs, and panes. Overlapping targets resolve
to the active one instead of stacking overlays.
- Forgiving geometry beats pixel-perfect triggers; edge actions live near their
edge, not clustered in the center.
- Expensive stateful surfaces stay mounted when hidden. Visibility is not
lifecycle.
Prove speed with realistic content. A fast empty-state demo says nothing about a
long transcript or a busy terminal.
## Keyboard & cancellation
- Keyboard ownership follows focus. The focused surface wins its keys; shell
shortcuts must not steal a terminal's or editor's bindings.
- Register global shortcuts through the shared layer, not ad-hoc listeners.
- One cancel gesture does one thing: cancel the active interaction, or close the
topmost dismissable surface — never both, never the control underneath.
- Cancellation is synchronous in the UI even if cleanup is async: overlays,
cursors, and pending gesture state clear at once.
- Flows that deliberately cannot be dismissed (install/onboarding, destructive
confirmation) must make that explicit.
## i18n
@ -135,12 +249,15 @@ Notes:
## State (TypeScript)
Mirrors the repo TS style (see root `AGENTS.md`):
The detailed state contract lives in the scoped
[`AGENTS.md`](./AGENTS.md). Visual code follows these essentials:
- Shared/cross-component state → small **nanostores**, not prop-drilling.
Each feature owns its atoms; shared atoms live in `src/store`.
- Rendering components subscribe with `useStore`; non-render actions read with
`$atom.get()`.
- Subscribe to derived coarse facts instead of high-frequency source atoms when
the component does not render the full value.
- Colocated action modules over god hooks. A hook owns one narrow job.
- Keep persistence beside the atom that owns it. Route roots stay thin.
- Prefer `interface` for public props; extend React primitives
@ -163,5 +280,13 @@ Mirrors the repo TS style (see root `AGENTS.md`):
- [ ] No `className` overriding a primitive's padding / size / radius / chrome?
- [ ] Overlay uses `shadow-nous` + `border-(--stroke-nous)`, no hard border?
- [ ] Flat — no card-in-card, no gratuitous row dividers?
- [ ] No automatic navigation, focus steal, or pane opening from background
events?
- [ ] Direct manipulation paints immediately and rolls back cleanly on failure?
- [ ] Hot interactions avoid broad subscriptions, layout thrash, and
`transition-all`?
- [ ] Keyboard ownership and single-action `Esc` behavior are correct?
- [ ] All four locales updated for any new/changed string?
- [ ] `cursor-pointer`, focus ring, and `Esc`-to-close behave?
- [ ] Touched a primitive, token, or variant? Its named-contract entry in this
file is updated in the same change.

View file

@ -87,7 +87,63 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig
### How it works
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.ts` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.ts`.
The packaged app ships the Electron shell and a native React chat surface. On
first launch it can install the Hermes Agent runtime into `HERMES_HOME`
(`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows), using the same layout as a
CLI install.
The app has three boundaries:
- **Electron** resolves and validates a runnable backend, owns native
filesystem/git/window capabilities, and exposes a narrow preload bridge.
- **React** owns the Desktop routes, panes, interaction state, and
`@assistant-ui/react` transcript.
- **Hermes Agent** runs as a headless `hermes serve` process and exposes the
`tui_gateway` JSON-RPC/WebSocket API. The renderer connects through
[`apps/shared`](../shared/), which is also used by the browser dashboard.
Backend resolution is an ordered ladder:
1. `HERMES_DESKTOP_HERMES_ROOT`
2. the current source checkout during development
3. a completed managed install
4. `HERMES_DESKTOP_HERMES`, or `hermes` on `PATH`
5. a system Python that can import the Hermes runtime
6. the first-launch bootstrap installer
Candidates are probed before use; an existing shim or interpreter is not enough.
A runtime that predates `serve` falls back to headless
`dashboard --no-open`. This is compatibility for the backend command only and
does not launch or embed the dashboard UI.
The Electron orchestration entry point is `electron/main.ts`; pure resolution,
probe, hardening, and platform policies live in focused modules beside it. The
renderer is under `src/`, with shared atoms in `src/store` and transport/native
adapters in `src/lib`.
Before changing the app, read:
- [`AGENTS.md`](./AGENTS.md): architecture, state ownership, resolver/fallback,
transport, performance, and testing rules.
- [`DESIGN.md`](./DESIGN.md): visual system, information architecture, motion,
direct manipulation, and keyboard behavior.
### Connections, projects, and switching
Desktop supports a managed local backend, explicit remote gateways, and Hermes
Cloud connections. Remote and cloud modes use the same remote-capability path;
authentication and discovery differ, not the renderer feature model.
Projects are the workspace abstraction. A project may own multiple folders,
repositories, worktrees, and sessions; a bare new chat remains detached unless
the user enters a project or configures a default project directory. Use the
Projects UI rather than adding a second per-session folder-picker workflow.
Changing profiles or connection modes is a soft workspace switch, not another
cold boot. The shell and current management overlay remain mounted while
gateway-bound nanostores are wiped, query-backed data is invalidated, and the
new connection repopulates skeletons. This prevents rows or transcripts from
the previous gateway bleeding into the next one.
### Verification
@ -97,9 +153,13 @@ Run before opening a PR (lint may surface pre-existing warnings but must exit cl
npm run fix
npm run typecheck
npm run lint
npm run test:desktop:all
npm run test:ui
npm run test:desktop:platforms
```
Run `npm run test:desktop:all` for install, boot, update, packaging, or other
release-path changes.
### Troubleshooting
Boot logs land in `HERMES_HOME/logs/desktop.log` (includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure.

View file

@ -19,6 +19,7 @@ import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
import { PanelEmpty } from '../overlays/panel'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
import { FallbackModelsField } from './fallback-models-field'
import { fieldCopyForSchemaKey } from './field-copy'
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
import { MemoryConnect } from './memory/connect'
@ -100,6 +101,13 @@ function ConfigField({
<ListRow action={action} description={descriptionNode} title={label} wide={wide} />
)
// `fallback_providers` is a list of {provider, model} objects; the generic
// `list` branch below would stringify them to "[object Object]". Render the
// dedicated structured editor instead.
if (schemaKey === 'fallback_providers') {
return row(<FallbackModelsField onChange={onChange} value={value} />, true)
}
if (schema.type === 'boolean') {
return row(
<div className="flex items-center justify-end">

View file

@ -0,0 +1,113 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
// Radix Select calls scrollIntoView / pointer-capture APIs jsdom lacks.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
const getGlobalModelOptions = vi.fn()
vi.mock('@/hermes', () => ({
getGlobalModelOptions: () => getGlobalModelOptions()
}))
beforeEach(() => {
getGlobalModelOptions.mockResolvedValue({
providers: [
{ name: 'GitHub Copilot', slug: 'copilot', models: ['gpt-5-mini', 'gpt-5.4-mini'] },
{ name: 'OpenAI Codex', slug: 'openai-codex', models: ['gpt-5.4-mini'] },
{ name: 'Nous', slug: 'nous', models: ['hermes-4'] }
]
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderField(value: unknown, onChange = vi.fn()) {
const { FallbackModelsField } = await import('./fallback-models-field')
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={client}>
<FallbackModelsField onChange={onChange} value={value} />
</QueryClientProvider>
)
return onChange
}
async function renderFieldWithRerender(value: unknown, onChange = vi.fn()) {
const { FallbackModelsField } = await import('./fallback-models-field')
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const view = render(
<QueryClientProvider client={client}>
<FallbackModelsField onChange={onChange} value={value} />
</QueryClientProvider>
)
return (next: unknown) =>
view.rerender(
<QueryClientProvider client={client}>
<FallbackModelsField onChange={onChange} value={next} />
</QueryClientProvider>
)
}
const CHAIN = [
{ provider: 'copilot', model: 'gpt-5-mini' },
{ provider: 'openai-codex', model: 'gpt-5.4-mini' }
]
describe('FallbackModelsField', () => {
it('renders each {provider, model} entry as its own row (never "[object Object]")', async () => {
await renderField(CHAIN)
// One Remove control per entry proves the object list became rows — the old
// generic `list` input stringified the array to "[object Object]".
expect(screen.getAllByLabelText('Remove')).toHaveLength(2)
expect(screen.getByText('Add fallback')).toBeTruthy()
expect(screen.queryByText(/\[object Object\]/)).toBeNull()
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
})
it('removing a row emits the remaining entries', async () => {
const onChange = await renderField(CHAIN)
fireEvent.click(screen.getAllByLabelText('Remove')[0])
expect(onChange.mock.calls.at(-1)?.[0]).toEqual([{ provider: 'openai-codex', model: 'gpt-5.4-mini' }])
})
it('adding a blank row does not persist a partial entry', async () => {
const onChange = await renderField(CHAIN)
fireEvent.click(screen.getByText('Add fallback'))
// The new empty row stays in the UI but only complete pairs are emitted.
expect(onChange.mock.calls.at(-1)?.[0]).toEqual(CHAIN)
expect(screen.getAllByLabelText('Remove')).toHaveLength(3)
})
it('shows an empty-state hint when there are no fallbacks', async () => {
await renderField([])
expect(screen.getByText(/No fallback models/)).toBeTruthy()
expect(screen.queryAllByLabelText('Remove')).toHaveLength(0)
})
it('resyncs rows when persisted config changes', async () => {
const rerender = await renderFieldWithRerender(CHAIN)
expect(screen.getAllByLabelText('Remove')).toHaveLength(2)
rerender([{ provider: 'nous', model: 'hermes-4' }])
await waitFor(() => expect(screen.getAllByLabelText('Remove')).toHaveLength(1))
})
})

View file

@ -0,0 +1,143 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { Plus, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { CONTROL_TEXT } from './constants'
interface FallbackEntry {
provider: string
model: string
}
// Normalize the raw config value (`fallback_providers`: a list of
// `{provider, model}` dicts) into editor rows. Defensive against legacy string
// entries ("provider/model") so the editor never crashes on odd data.
function normalizeEntries(value: unknown): FallbackEntry[] {
if (!Array.isArray(value)) {
return []
}
return value.map(item => {
if (item && typeof item === 'object') {
const record = item as Record<string, unknown>
return { provider: String(record.provider ?? ''), model: String(record.model ?? '') }
}
if (typeof item === 'string') {
const slash = item.indexOf('/')
return slash > 0 ? { provider: item.slice(0, slash), model: item.slice(slash + 1) } : { provider: '', model: item }
}
return { provider: '', model: '' }
})
}
/**
* Structured editor for the top-level `fallback_providers` config list a
* chain of `{provider, model}` pairs tried in order when the default model
* fails. Replaces the generic comma-string `list` input, which stringified the
* objects to "[object Object], [object Object]".
*
* Mirrors the Auxiliary Models picker in `model-settings.tsx`: provider + model
* selects sourced from `getGlobalModelOptions()`. Half-filled rows are kept in
* local state and only complete pairs are emitted upward, so the config
* autosave never persists a partial `{provider, model: ''}`.
*/
export function FallbackModelsField({
value,
onChange
}: {
value: unknown
onChange: (next: FallbackEntry[]) => void
}) {
const { t } = useI18n()
const m = t.settings.model
const modelOptions = useQuery({
queryKey: ['model-options', 'global'],
queryFn: () => getGlobalModelOptions()
})
const providers = (modelOptions.data?.providers ?? []).filter(provider => provider.slug)
const [rows, setRows] = useState<FallbackEntry[]>(() => normalizeEntries(value))
// Settings can reload after a profile/config change while this component
// stays mounted. Avoid displaying or saving the previous profile's chain.
useEffect(() => {
setRows(normalizeEntries(value))
}, [value])
const commit = (next: FallbackEntry[]) => {
setRows(next)
onChange(next.filter(entry => entry.provider && entry.model))
}
const updateRow = (index: number, patch: Partial<FallbackEntry>) =>
commit(rows.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)))
return (
<div className="grid w-full gap-1.5">
{rows.length === 0 && <p className="text-xs text-muted-foreground">{m.fallbackEmpty}</p>}
{rows.map((entry, index) => {
const providerRow = providers.find(provider => provider.slug === entry.provider)
const catalog = providerRow?.models ?? []
// Keep an out-of-catalog model selectable so an existing custom
// provider/model renders instead of showing a blank box.
const modelItems = entry.model && !catalog.includes(entry.model) ? [entry.model, ...catalog] : catalog
return (
<div className="flex flex-wrap items-center gap-2" key={index}>
<span className="w-4 shrink-0 text-center font-mono text-[0.7rem] text-muted-foreground">{index + 1}</span>
<Select onValueChange={provider => updateRow(index, { provider, model: '' })} value={entry.provider}>
<SelectTrigger className={cn('min-w-36', CONTROL_TEXT)}>
<SelectValue placeholder={m.provider} />
</SelectTrigger>
<SelectContent>
{providers.map(provider => (
<SelectItem key={provider.slug} value={provider.slug}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select onValueChange={model => updateRow(index, { model })} value={entry.model}>
<SelectTrigger className={cn('min-w-52 flex-1', CONTROL_TEXT)}>
<SelectValue placeholder={m.model} />
</SelectTrigger>
<SelectContent>
{modelItems.map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
aria-label={t.common.remove}
onClick={() => commit(rows.filter((_, i) => i !== index))}
size="icon-xs"
variant="ghost"
>
<X className="size-3.5" />
</Button>
</div>
)
})}
<div>
<Button onClick={() => commit([...rows, { provider: '', model: '' }])} size="sm" variant="textStrong">
<Plus className="size-3.5" />
{m.fallbackAdd}
</Button>
</div>
</div>
)
}

View file

@ -298,23 +298,62 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
return moa.presets[selectedMoaPreset] || moa.presets[moa.default_preset] || Object.values(moa.presets)[0] || null
}, [moa, selectedMoaPreset])
// Mirror of `moa` so inline edits compute the next state purely (outside the
// setState updater) and hand it straight to the debounced autosave.
const moaRef = useRef<MoaConfigResponse | null>(null)
useEffect(() => {
moaRef.current = moa
}, [moa])
const moaSaveTimer = useRef<number | null>(null)
useEffect(
() => () => {
if (moaSaveTimer.current) {
window.clearTimeout(moaSaveTimer.current)
}
},
[]
)
// Quiet debounced persist for inline MoA edits — mirrors the config page's
// autosave so slot/aggregator tweaks save themselves, matching the
// preset-level ops (set default / add / delete) that already persist on
// click. No `applying` spinner, so selecting stays responsive.
const scheduleMoaSave = useCallback((next: MoaConfigResponse) => {
if (moaSaveTimer.current) {
window.clearTimeout(moaSaveTimer.current)
}
moaSaveTimer.current = window.setTimeout(() => {
void saveMoaModels(next)
.then(setMoa)
.catch(err => setError(err instanceof Error ? err.message : String(err)))
}, 600)
}, [])
const updateMoaPreset = useCallback(
(updater: (preset: NonNullable<typeof currentMoaPreset>) => NonNullable<typeof currentMoaPreset>) => {
setMoa(prev => {
if (!prev || !selectedMoaPreset || !prev.presets[selectedMoaPreset]) {
return prev
}
const prev = moaRef.current
return {
...prev,
presets: {
...prev.presets,
[selectedMoaPreset]: updater(prev.presets[selectedMoaPreset])
}
if (!prev || !selectedMoaPreset || !prev.presets[selectedMoaPreset]) {
return
}
const next: MoaConfigResponse = {
...prev,
presets: {
...prev.presets,
[selectedMoaPreset]: updater(prev.presets[selectedMoaPreset])
}
})
}
moaRef.current = next
setMoa(next)
scheduleMoaSave(next)
},
[selectedMoaPreset]
[scheduleMoaSave, selectedMoaPreset]
)
const updateMoaSlot = useCallback((slot: MoaModelSlot, patch: Partial<MoaModelSlot>): MoaModelSlot => {
@ -841,12 +880,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
</section>
{moa && currentMoaPreset && (
<section>
<div className="mb-2.5 flex items-center justify-between">
<SectionHeading icon={Cpu} title="Mixture of Agents" />
<Button disabled={applying} onClick={() => void saveMoa(moa)} size="sm" variant="textStrong">
{applying ? m.applying : t.common.save}
</Button>
</div>
<SectionHeading icon={Cpu} title="Mixture of Agents" />
<p className="mb-2 text-xs text-muted-foreground">
Configure named presets that appear as models under the Mixture of Agents provider. The aggregator is the
acting model.

View file

@ -0,0 +1,112 @@
import { cleanup, render, screen } from '@testing-library/react'
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import { ClarifyTool, readClarifyResult } from './clarify-tool'
afterEach(() => {
cleanup()
})
function renderClarify(ui: ReactNode) {
return render(
<I18nProvider configClient={null} initialLocale="en">
{ui}
</I18nProvider>
)
}
function settledClarifyProps(
args: ToolCallMessagePartProps['args'],
result: ToolCallMessagePartProps['result'],
toolCallId: string
): ToolCallMessagePartProps {
return {
addResult: vi.fn(),
args,
argsText: JSON.stringify(args),
isError: false,
result,
resume: vi.fn(),
status: { type: 'complete' },
toolCallId,
toolName: 'clarify',
type: 'tool-call'
}
}
describe('readClarifyResult', () => {
it('reads question + user_response from the tool JSON payload', () => {
expect(
readClarifyResult({
question: 'Which target?',
choices_offered: ['staging', 'prod'],
user_response: 'staging'
})
).toEqual({
question: 'Which target?',
answer: 'staging',
error: undefined
})
})
it('parses a JSON string result the same way as an object', () => {
expect(
readClarifyResult(
JSON.stringify({
question: 'Ship it?',
user_response: 'yes'
})
)
).toEqual({
question: 'Ship it?',
answer: 'yes',
error: undefined
})
})
it('keeps an empty user_response so Skip can render as skipped', () => {
expect(readClarifyResult({ question: 'Ok?', user_response: '' })).toEqual({
question: 'Ok?',
answer: '',
error: undefined
})
})
})
describe('ClarifyTool settled view', () => {
it('keeps the question and answer visible after the tool completes', () => {
renderClarify(
<ClarifyTool
{...settledClarifyProps(
{ question: 'Which deployment target?', choices: ['staging', 'prod'] },
{
question: 'Which deployment target?',
choices_offered: ['staging', 'prod'],
user_response: 'staging'
},
'clarify-1'
)}
/>
)
expect(screen.getByText('Which deployment target?')).toBeTruthy()
expect(screen.getByText('staging')).toBeTruthy()
expect(document.querySelector('[data-clarify-settled]')).toBeTruthy()
expect(document.querySelector('[data-clarify-answer]')?.textContent).toBe('staging')
})
it('labels an empty response as Skipped', () => {
renderClarify(
<ClarifyTool
{...settledClarifyProps({ question: 'Anything else?' }, { question: 'Anything else?', user_response: '' }, 'clarify-2')}
/>
)
expect(screen.getByText('Anything else?')).toBeTruthy()
expect(screen.getByText('Skipped')).toBeTruthy()
})
})

View file

@ -19,55 +19,74 @@ import { Kbd } from '@/components/ui/kbd'
import { Textarea } from '@/components/ui/textarea'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Loader2, MessageQuestion } from '@/lib/icons'
import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { selectMessageRunning } from './tool/fallback-model'
import { parseMaybeObject } from './tool/fallback-model/format'
interface ClarifyArgs {
question?: string
choices?: string[] | null
}
function readClarifyArgs(args: unknown): ClarifyArgs {
if (!args || typeof args !== 'object') {
return {}
}
interface ClarifyResult {
question?: string
answer?: string
error?: string
}
const row = args as Record<string, unknown>
function stringField(row: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = row[key]
if (typeof value === 'string') {
return value
}
}
}
function readClarifyArgs(args: unknown): ClarifyArgs {
const row = parseMaybeObject(args)
const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null
return {
question: typeof row.question === 'string' ? row.question : undefined,
question: stringField(row, 'question'),
choices: choices && choices.length > 0 ? choices : null
}
}
// Each option (and "Other") is keyed A, B, C… so it can be picked by pressing
// that letter — the badge doubles as the shortcut hint.
/** Parse clarify tool JSON (`question` + `user_response`). */
export function readClarifyResult(result: unknown): ClarifyResult {
const row = parseMaybeObject(result)
if (Object.keys(row).length === 0) {
return typeof result === 'string' && result.trim() ? { answer: result.trim() } : {}
}
return {
question: stringField(row, 'question'),
answer: stringField(row, 'user_response', 'answer'),
error: stringField(row, 'error')
}
}
const letterFor = (index: number): string => String.fromCharCode(65 + index)
// Choice and "Other" rows share a layout; only color differs. Mirrors a tool
// row's compact rhythm so the panel reads as part of the transcript.
const OPTION_ROW_CLASS =
'flex w-full items-start gap-2 rounded-[0.25rem] px-1.5 py-1 text-left disabled:cursor-not-allowed disabled:opacity-50'
// Content-sizing freeform field (CSS `field-sizing` — same primitive as the
// commit bar and search field): starts at one line, grows with what's typed,
// and never reflows the panel when focused. Bare so the "Other" row matches the
// choice rows above it.
const FREEFORM_INPUT_CLASS =
'field-sizing-content max-h-40 min-h-0 w-full resize-none bg-transparent p-0 leading-(--conversation-line-height) text-(--ui-text-primary) outline-none placeholder:text-(--ui-text-tertiary) disabled:opacity-50'
// field-sizing on top of Textarea's shared chrome; kill min-h-16 for one-liners.
const CLARIFY_TEXTAREA_CLASS = 'field-sizing-content max-h-40 min-h-0 resize-none'
// Quiet inline panel that matches the surrounding tool rows: a single hairline
// border in the shared stroke token, a soft surface fill, and a faint primary
// accent that signals "this one needs you" without the loud animated ring.
const CLARIFY_SHELL_CLASS =
'my-1.5 rounded-md border border-primary/20 bg-(--ui-chat-surface-background) text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)'
const CLARIFY_ICON_CLASS = 'mt-px size-4 shrink-0 text-(--ui-text-tertiary)'
function ClarifyShell({ children, className, ...props }: ComponentProps<'div'>) {
return (
<div className={cn(CLARIFY_SHELL_CLASS, className)} data-slot="clarify-inline" {...props}>
@ -76,10 +95,20 @@ function ClarifyShell({ children, className, ...props }: ComponentProps<'div'>)
)
}
// Selection lives on the letter badge alone — a solid primary fill — not the
// whole row, which stays a quiet hover target. `preview` is the focused-but-empty
// "Other" state: the badge outlines in primary to show it's armed, then fills
// once a value is actually typed.
function ClarifyLine({
children,
className,
icon: Icon,
...props
}: ComponentProps<'div'> & { icon: typeof MessageQuestion }) {
return (
<div className={cn('flex items-start gap-2', className)} {...props}>
<div className="min-w-0 flex-1">{children}</div>
<Icon aria-hidden className={CLARIFY_ICON_CLASS} />
</div>
)
}
function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean; selected: boolean }) {
return (
<Kbd
@ -96,21 +125,62 @@ function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean
}
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
// Answered → settled Q&A (ToolFallback collapsed the answer away).
if (props.result !== undefined) {
return <ClarifyToolSettled {...props} />
}
return <ClarifyToolLive {...props} />
}
function ClarifyToolLive(props: ToolCallMessagePartProps) {
const messageRunning = useAuiState(selectMessageRunning)
// Only the live, still-blocked turn shows the interactive panel. Once the
// message stops running — answered, the turn ended, or the user hit Stop —
// fall back to the standard tool block so the Q/A settles like every other
// row instead of stranding a dead prompt the gateway no longer waits on.
const isPending = messageRunning && props.result === undefined
if (!isPending) {
// Stopped mid-prompt with no result — don't leave a dead interactive panel.
if (!messageRunning) {
return <ToolFallback {...props} />
}
return <ClarifyToolPending {...props} />
}
function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {
const { t } = useI18n()
const copy = t.assistant.clarify
const fromArgs = useMemo(() => readClarifyArgs(args), [args])
const fromResult = useMemo(() => readClarifyResult(result), [result])
const question = fromResult.question || fromArgs.question || ''
const answer = fromResult.answer
const error = fromResult.error
const skipped = !error && answer !== undefined && !answer.trim()
const answerText = error || (skipped ? copy.skipped : (answer ?? '').trim())
return (
<ClarifyShell className="grid gap-1.5 px-2.5 py-2" data-clarify-settled="">
{question ? (
<ClarifyLine icon={MessageQuestion}>
<span className="whitespace-pre-wrap font-medium leading-(--conversation-line-height)">{question}</span>
</ClarifyLine>
) : null}
{answerText ? (
<ClarifyLine icon={CircleLetterA}>
<p
className={cn(
'whitespace-pre-wrap leading-(--conversation-line-height)',
error ? 'text-destructive' : 'text-(--ui-text-secondary)',
skipped && 'italic text-(--ui-text-tertiary)'
)}
data-clarify-answer=""
>
{answerText}
</p>
</ClarifyLine>
) : null}
</ClarifyShell>
)
}
function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const { t } = useI18n()
const copy = t.assistant.clarify
@ -175,8 +245,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
})
triggerHaptic('submit')
clearClarifyRequest(matchingRequest.requestId, matchingRequest.sessionId)
// The matching tool.complete will land shortly after, swapping this
// panel for the ToolFallback view above.
// tool.complete lands next → ClarifyToolSettled.
} catch (error) {
notifyError(error, copy.sendFailed)
setSubmitting(false)
@ -327,17 +396,13 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
<span className="flex-1 wrap-anywhere">{choice}</span>
</button>
))}
{/* "Other" is an inline content-sizing field, not a separate view. */}
<label className={cn(OPTION_ROW_CLASS, 'focus-within:bg-(--chrome-action-hover)')}>
<label className={cn(OPTION_ROW_CLASS, 'items-center')}>
<KeyBadge char={letterFor(choices.length)} preview={otherFocused} selected={Boolean(trimmedDraft)} />
<textarea
className={FREEFORM_INPUT_CLASS}
<Textarea
className={CLARIFY_TEXTAREA_CLASS}
disabled={submitting}
onBlur={() => setOtherFocused(false)}
onChange={event => onDraftChange(event.target.value)}
// Focusing "Other" is a switch to typing your own answer, so it
// deselects any picked choice — a chosen option and an active
// Other field can never both look selected.
onFocus={() => {
setSelectedChoice(null)
setOtherFocused(true)
@ -346,19 +411,21 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
placeholder={copy.other}
ref={textareaRef}
rows={1}
size="sm"
value={draft}
/>
</label>
</div>
) : (
<Textarea
className={FREEFORM_INPUT_CLASS}
className={CLARIFY_TEXTAREA_CLASS}
disabled={submitting}
onChange={event => onDraftChange(event.target.value)}
onKeyDown={handleTextareaKey}
placeholder={copy.placeholder}
ref={textareaRef}
rows={1}
size="sm"
value={draft}
/>
)}

View file

@ -0,0 +1,67 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $secretRequest, $sudoRequest, clearAllPrompts, setSecretRequest, setSudoRequest } from '@/store/prompts'
import { $activeSessionId } from '@/store/session'
import { PromptOverlays } from './prompt-overlays'
vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() }))
vi.mock('@/store/notifications', () => ({ notifyError: vi.fn() }))
function renderPrompts() {
render(
<I18nProvider configClient={null}>
<PromptOverlays />
</I18nProvider>
)
}
afterEach(() => {
cleanup()
clearAllPrompts()
$activeSessionId.set(null)
$gateway.set(null)
vi.clearAllMocks()
})
describe('PromptOverlays', () => {
it('dismisses a stale sudo dialog when the gateway no longer has the password request', async () => {
const request = vi.fn().mockRejectedValue(new Error('no pending password request'))
$activeSessionId.set('s1')
$gateway.set({ request } as never)
setSudoRequest({ requestId: 'sudo-1', sessionId: 's1' })
renderPrompts()
expect(screen.getByText('Administrator password')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() => expect($sudoRequest.get()).toBeNull())
expect(request).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' })
expect(notifyError).not.toHaveBeenCalled()
})
it('dismisses a stale secret dialog when the gateway no longer has the value request', async () => {
const request = vi.fn().mockRejectedValue(new Error('no pending value request'))
$activeSessionId.set('s1')
$gateway.set({ request } as never)
setSecretRequest({ envVar: 'TEST_SECRET', prompt: 'Paste a secret', requestId: 'secret-1', sessionId: 's1' })
renderPrompts()
expect(screen.getByText('TEST_SECRET')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() => expect($secretRequest.get()).toBeNull())
expect(request).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' })
expect(notifyError).not.toHaveBeenCalled()
})
})

View file

@ -15,6 +15,7 @@ import {
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { isMissingPendingPromptRequest } from '@/lib/gateway-rpc'
import { triggerHaptic } from '@/lib/haptics'
import { KeyRound, Loader2, Lock } from '@/lib/icons'
import { $gateway } from '@/store/gateway'
@ -69,6 +70,12 @@ function SudoDialog() {
triggerHaptic('submit')
clearSudoRequest(request.sessionId, request.requestId)
} catch (error) {
if (isMissingPendingPromptRequest(error, 'password')) {
clearSudoRequest(request.sessionId, request.requestId)
return
}
notifyError(error, copy.sudoSendFailed)
setSubmitting(false)
}
@ -165,6 +172,12 @@ function SecretDialog() {
triggerHaptic('submit')
clearSecretRequest(request.sessionId, request.requestId)
} catch (error) {
if (isMissingPendingPromptRequest(error, 'value')) {
clearSecretRequest(request.sessionId, request.requestId)
return
}
notifyError(error, copy.secretSendFailed)
setSubmitting(false)
}

View file

@ -707,6 +707,9 @@ export const en: Translations = {
change: 'Change',
autoUseMain: 'auto · use main model',
providerDefault: '(provider default)',
fallbackAdd: 'Add fallback',
fallbackEmpty: 'No fallback models — the default model is used unless it fails.',
notInCatalog: "isn't in this provider's model list — calls may fall back to a backup.",
tasks: {
vision: { label: 'Vision', hint: 'Image analysis' },
web_extract: { label: 'Web extract', hint: 'Page summarization' },
@ -2324,6 +2327,7 @@ export const en: Translations = {
other: 'Other (type your answer)',
placeholder: 'Type your answer…',
skip: 'Skip',
skipped: 'Skipped',
continueLabel: 'Continue'
},
tool: {

View file

@ -2266,6 +2266,7 @@ export const ja = defineLocale({
other: 'その他(回答を入力)',
placeholder: '回答を入力…',
skip: 'スキップ',
skipped: 'スキップ済み',
continueLabel: '続行'
},
tool: {

View file

@ -611,6 +611,9 @@ export interface Translations {
change: string
autoUseMain: string
providerDefault: string
fallbackAdd: string
fallbackEmpty: string
notInCatalog: string
tasks: Record<string, AuxTaskCopy>
}
providers: {
@ -1947,6 +1950,7 @@ export interface Translations {
other: string
placeholder: string
skip: string
skipped: string
continueLabel: string
}
tool: {

View file

@ -2197,6 +2197,7 @@ export const zhHant = defineLocale({
other: '其他(輸入您的答案)',
placeholder: '輸入您的答案…',
skip: '略過',
skipped: '已略過',
continueLabel: '繼續'
},
tool: {

View file

@ -895,6 +895,9 @@ export const zh: Translations = {
change: '更改',
autoUseMain: '自动 · 使用主模型',
providerDefault: '(提供方默认)',
fallbackAdd: '添加备用模型',
fallbackEmpty: '未配置备用模型 — 默认模型失败时才会使用备用模型。',
notInCatalog: '不在该提供方的模型列表中 — 调用可能回退到备用模型。',
tasks: {
vision: { label: '视觉', hint: '图片分析' },
web_extract: { label: '网页提取', hint: '页面总结' },
@ -2483,6 +2486,7 @@ export const zh: Translations = {
other: '其他 (输入你的答案)',
placeholder: '输入你的答案…',
skip: '跳过',
skipped: '已跳过',
continueLabel: '继续'
},
tool: {

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { isMissingRpcMethod } from './gateway-rpc'
import { isMissingPendingPromptRequest, isMissingRpcMethod } from './gateway-rpc'
describe('isMissingRpcMethod', () => {
it('detects JSON-RPC method-not-found errors', () => {
@ -14,3 +14,15 @@ describe('isMissingRpcMethod', () => {
expect(isMissingRpcMethod(new Error('no such project'))).toBe(false)
})
})
describe('isMissingPendingPromptRequest', () => {
it('detects stale prompt response errors from the gateway', () => {
expect(isMissingPendingPromptRequest(new Error('no pending password request'), 'password')).toBe(true)
expect(isMissingPendingPromptRequest(new Error('RPC failed: no pending value request'), 'value')).toBe(true)
})
it('ignores unrelated gateway failures', () => {
expect(isMissingPendingPromptRequest(new Error('gateway not connected'), 'password')).toBe(false)
expect(isMissingPendingPromptRequest(new Error('no pending value request'), 'password')).toBe(false)
})
})

View file

@ -4,3 +4,10 @@ export function isMissingRpcMethod(error: unknown): boolean {
return /method not found|-32601|unknown method|no such method/i.test(message)
}
/** True when a prompt response raced a backend-side timeout / completion. */
export function isMissingPendingPromptRequest(error: unknown, key: string): boolean {
const message = error instanceof Error ? error.message : String(error)
return message.toLowerCase().includes(`no pending ${key.toLowerCase()} request`)
}

View file

@ -59,6 +59,7 @@ import {
IconLogin as LogIn,
IconMail as Mail,
IconMaximize as Maximize,
IconCircleLetterA as CircleLetterA,
IconMessageCircle as MessageCircle,
IconMessageQuestion as MessageQuestion,
IconMessage2 as MessageSquareText,
@ -141,6 +142,7 @@ export {
ChevronRight,
ChevronRightIcon,
CircleIcon,
CircleLetterA,
Clipboard,
Clock,
Cloud,

View file

@ -882,9 +882,13 @@ class APIServerAdapter(BasePlatformAdapter):
self._run_streams: Dict[str, "asyncio.Queue[Optional[Dict]]"] = {}
# Creation timestamps for orphaned-run TTL sweep
self._run_streams_created: Dict[str, float] = {}
# Runs with a connected SSE consumer; their queue is actively draining.
self._run_stream_subscribers: set[str] = set()
# Active run agent/task references for stop support
self._active_run_agents: Dict[str, Any] = {}
self._active_run_tasks: Dict[str, "asyncio.Task"] = {}
# Stop is cooperative: the executor thread may outlive the HTTP request.
self._stopping_run_ids: set[str] = set()
# Pollable run status for dashboards and external control-plane UIs.
self._run_statuses: Dict[str, Dict[str, Any]] = {}
# Active approval session key for each run_id. The approval core
@ -899,7 +903,8 @@ class APIServerAdapter(BasePlatformAdapter):
# from a request flood (#7483).
self._max_concurrent_runs: int = self._resolve_max_concurrent_runs()
# Number of in-flight runs on the non-streaming chat/responses paths
# (the /v1/runs path tracks its own in-flight set via _run_streams).
# (the /v1/runs path tracks its own in-flight set via
# _active_run_tasks).
self._inflight_agent_runs: int = 0
def _readiness_work_counts(self) -> tuple[int, int, int]:
@ -4001,14 +4006,18 @@ class APIServerAdapter(BasePlatformAdapter):
The cap bounds total in-flight agent activity across every
agent-serving endpoint: the non-streaming chat/responses paths
(tracked by ``_inflight_agent_runs``) plus the ``/v1/runs`` streaming
path (tracked by ``_run_streams``). A configured value of 0 disables
the cap entirely.
(tracked by ``_inflight_agent_runs``) plus the ``/v1/runs`` path
(tracked by live entries in ``_active_run_tasks``). Stream queues are
transport state and may disappear while their underlying run remains
active, so they must not define run concurrency. A configured value of
0 disables the cap entirely.
"""
limit = self._max_concurrent_runs
if limit <= 0:
return None
inflight = self._inflight_agent_runs + len(self._run_streams)
inflight = self._inflight_agent_runs + sum(
not task.done() for task in self._active_run_tasks.values()
)
if inflight >= limit:
return web.json_response(
_openai_error(
@ -4295,12 +4304,19 @@ class APIServerAdapter(BasePlatformAdapter):
event_cb = self._make_run_event_callback(run_id, loop)
def _put_event_if_active(event: Optional[Dict]) -> None:
"""Enqueue only while this run still owns live transport state."""
if self._run_streams.get(run_id) is q:
q.put_nowait(event)
# Also wire stream_delta_callback so message.delta events flow through.
def _text_cb(delta: Optional[str]) -> None:
if delta is None:
return
if run_id not in self._run_streams:
return
try:
loop.call_soon_threadsafe(q.put_nowait, {
loop.call_soon_threadsafe(_put_event_if_active, {
"event": "message.delta",
"run_id": run_id,
"timestamp": time.time(),
@ -4323,6 +4339,18 @@ class APIServerAdapter(BasePlatformAdapter):
async def _run_and_close():
try:
self._set_run_status(run_id, "running")
if run_id in self._stopping_run_ids:
_put_event_if_active({
"event": "run.cancelled",
"run_id": run_id,
"timestamp": time.time(),
})
self._set_run_status(
run_id,
"cancelled",
last_event="run.cancelled",
)
return
agent = self._create_agent(
ephemeral_system_prompt=ephemeral_system_prompt,
session_id=session_id,
@ -4407,12 +4435,23 @@ class APIServerAdapter(BasePlatformAdapter):
return r, u
result, usage = await asyncio.get_running_loop().run_in_executor(None, _run_sync)
if run_id in self._stopping_run_ids:
_put_event_if_active({
"event": "run.cancelled",
"run_id": run_id,
"timestamp": time.time(),
})
self._set_run_status(
run_id,
"cancelled",
last_event="run.cancelled",
)
# Check for structured failure (non-retryable client errors like
# 401/400 return failed=True instead of raising, so the except
# block below never fires — issue #15561).
if isinstance(result, dict) and result.get("failed"):
elif isinstance(result, dict) and result.get("failed"):
error_msg = _redact_api_error_text(result.get("error") or "agent run failed")
q.put_nowait({
_put_event_if_active({
"event": "run.failed",
"run_id": run_id,
"timestamp": time.time(),
@ -4426,7 +4465,7 @@ class APIServerAdapter(BasePlatformAdapter):
)
else:
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
q.put_nowait({
_put_event_if_active({
"event": "run.completed",
"run_id": run_id,
"timestamp": time.time(),
@ -4447,7 +4486,7 @@ class APIServerAdapter(BasePlatformAdapter):
last_event="run.cancelled",
)
try:
q.put_nowait({
_put_event_if_active({
"event": "run.cancelled",
"run_id": run_id,
"timestamp": time.time(),
@ -4464,7 +4503,7 @@ class APIServerAdapter(BasePlatformAdapter):
last_event="run.failed",
)
try:
q.put_nowait({
_put_event_if_active({
"event": "run.failed",
"run_id": run_id,
"timestamp": time.time(),
@ -4486,12 +4525,13 @@ class APIServerAdapter(BasePlatformAdapter):
pass
# Sentinel: signal SSE stream to close
try:
q.put_nowait(None)
_put_event_if_active(None)
except Exception:
pass
self._active_run_agents.pop(run_id, None)
self._active_run_tasks.pop(run_id, None)
self._run_approval_sessions.pop(run_id, None)
self._stopping_run_ids.discard(run_id)
task = asyncio.create_task(_run_and_close())
self._active_run_tasks[run_id] = task
@ -4543,6 +4583,7 @@ class APIServerAdapter(BasePlatformAdapter):
return web.json_response(_openai_error(f"Run not found: {run_id}", code="run_not_found"), status=404)
q = self._run_streams[run_id]
self._run_stream_subscribers.add(run_id)
response = web.StreamResponse(
status=200,
@ -4570,6 +4611,7 @@ class APIServerAdapter(BasePlatformAdapter):
except Exception as exc:
logger.debug("[api_server] SSE stream error for run %s: %s", run_id, exc)
finally:
self._run_stream_subscribers.discard(run_id)
self._run_streams.pop(run_id, None)
self._run_streams_created.pop(run_id, None)
@ -4678,6 +4720,7 @@ class APIServerAdapter(BasePlatformAdapter):
return web.json_response(_openai_error(f"Run not found: {run_id}", code="run_not_found"), status=404)
self._set_run_status(run_id, "stopping", last_event="run.stopping")
self._stopping_run_ids.add(run_id)
if agent is not None:
try:
@ -4685,37 +4728,29 @@ class APIServerAdapter(BasePlatformAdapter):
except Exception:
pass
if task is not None and not task.done():
task.cancel()
# Bounded wait: run_conversation() executes in the default
# executor thread which task.cancel() cannot preempt — we rely on
# agent.interrupt() above to break the loop. Cap the wait so a
# slow/unresponsive interrupt can't hang this handler.
try:
await asyncio.wait_for(asyncio.shield(task), timeout=5.0)
except asyncio.TimeoutError:
logger.warning(
"[api_server] stop for run %s timed out after 5s; "
"agent may still be finishing the current step",
run_id,
)
except (asyncio.CancelledError, Exception):
pass
return web.json_response({"run_id": run_id, "status": "stopping"})
async def _sweep_orphaned_runs(self) -> None:
"""Periodically clean up run streams that were never consumed."""
"""Periodically expire transport buffers and terminal status records."""
while True:
await asyncio.sleep(60)
self._sweep_orphaned_runs_once(time.time())
def _sweep_orphaned_runs_once(self, now: Optional[float] = None) -> None:
"""Expire old SSE buffers without treating transport age as run age."""
if now is None:
now = time.time()
stale = [
run_id
for run_id, created_at in list(self._run_streams_created.items())
if now - created_at > self._RUN_STREAM_TTL
]
for run_id in stale:
logger.debug("[api_server] sweeping orphaned run %s", run_id)
stale = [
run_id
for run_id, created_at in list(self._run_streams_created.items())
if now - created_at > self._RUN_STREAM_TTL
and run_id not in self._run_stream_subscribers
]
for run_id in stale:
logger.debug("[api_server] sweeping expired run transport %s", run_id)
task = self._active_run_tasks.get(run_id)
task_done = task is None or task.done()
if task_done:
try:
from tools.approval import unregister_gateway_notify
@ -4724,20 +4759,24 @@ class APIServerAdapter(BasePlatformAdapter):
unregister_gateway_notify(approval_session_key)
except Exception:
pass
self._run_streams.pop(run_id, None)
self._run_streams_created.pop(run_id, None)
# The transport TTL always bounds buffering. Live control state is
# independent and survives until the executor-backed task returns.
self._run_streams.pop(run_id, None)
self._run_streams_created.pop(run_id, None)
if task_done:
self._active_run_agents.pop(run_id, None)
self._active_run_tasks.pop(run_id, None)
self._run_approval_sessions.pop(run_id, None)
self._stopping_run_ids.discard(run_id)
stale_statuses = [
run_id
for run_id, status in list(self._run_statuses.items())
if status.get("status") in {"completed", "failed", "cancelled"}
and now - float(status.get("updated_at", 0) or 0) > self._RUN_STATUS_TTL
]
for run_id in stale_statuses:
self._run_statuses.pop(run_id, None)
stale_statuses = [
run_id
for run_id, status in list(self._run_statuses.items())
if status.get("status") in {"completed", "failed", "cancelled"}
and now - float(status.get("updated_at", 0) or 0) > self._RUN_STATUS_TTL
]
for run_id in stale_statuses:
self._run_statuses.pop(run_id, None)
# ------------------------------------------------------------------
# BasePlatformAdapter interface

View file

@ -1298,6 +1298,27 @@ def get_auth_provider_display_name(provider_id: str) -> str:
return SERVICE_PROVIDER_NAMES.get(normalized, provider_id)
def is_runtime_provider_routable(provider_id: str) -> bool:
"""Return whether runtime resolution recognizes a provider identity.
This is a capability check, not a credential check. It follows the same
alias/plugin-aware normalization as ``resolve_provider`` while preserving
special runtime identities that intentionally live outside the registry.
"""
normalized = (provider_id or "").strip().lower()
if not normalized:
return False
if normalized in {"auto", "openrouter", "custom", "moa"}:
return True
if normalized.startswith("custom:"):
return True
try:
resolve_provider(normalized)
except AuthError:
return False
return True
def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]:
"""Return the persisted credential pool, or one provider slice.

View file

@ -1394,6 +1394,25 @@ import threading as _threading # noqa: E402
_picker_prewarm_done = _threading.Event()
def _credential_pool_is_usable(provider: str, *, raw_pool_present: bool = False) -> bool:
"""Return whether *provider* has a credential that can be selected now.
``auth.json`` historically allowed opaque token-style pool values that do
not deserialize into ``PooledCredential`` entries. Preserve visibility for
those legacy values, but when a real pool exists its availability state is
authoritative: an all-exhausted/dead pool is not authenticated.
"""
try:
from agent.credential_pool import load_pool
pool = load_pool(provider)
if pool.has_credentials():
return pool.has_available()
except Exception:
pass
return raw_pool_present
def _extra_headers_from_config(entry: Any) -> dict[str, str]:
if not isinstance(entry, dict):
return {}
@ -1683,6 +1702,12 @@ def list_authenticated_providers(
# section 2 (HERMES_OVERLAYS) with proper auth store checking.
if pconfig and pconfig.auth_type != "api_key":
continue
# models.dev catalogs include providers Hermes may not route yet.
# Gate on runtime capability rather than registry membership: special
# providers and plugin aliases can be routable without a registry row.
from hermes_cli.auth import is_runtime_provider_routable
if not is_runtime_provider_routable(hermes_id):
continue
if pconfig and pconfig.api_key_env_vars:
env_vars = list(pconfig.api_key_env_vars)
else:
@ -1696,8 +1721,13 @@ def list_authenticated_providers(
try:
from hermes_cli.auth import _load_auth_store
store = _load_auth_store()
if store and store.get("credential_pool", {}).get(hermes_id):
has_creds = True
raw_pool_present = bool(
store and store.get("credential_pool", {}).get(hermes_id)
)
if raw_pool_present:
has_creds = _credential_pool_is_usable(
hermes_id, raw_pool_present=True
)
except Exception:
pass
if not has_creds:
@ -1712,6 +1742,15 @@ def list_authenticated_providers(
model_ids = curated.get(hermes_id, [])
if hermes_id in _MODELS_DEV_PREFERRED:
model_ids = _merge_with_models_dev(hermes_id, model_ids)
# A providers.<built-in>.models block extends the provider's discovered
# catalog. Section 3 cannot emit it later because this built-in row owns
# the slug, so merge declarations here before applying max_models.
configured_models: list[str] = []
if isinstance(user_providers, dict):
configured = user_providers.get(hermes_id)
if isinstance(configured, dict):
configured_models = _declared_model_ids(configured.get("models"))
model_ids = list(dict.fromkeys([*configured_models, *model_ids]))
total = len(model_ids)
if hermes_id in _UNCAPPED_PICKER_PROVIDERS:
top = model_ids # Aggregator: show full catalog regardless of max_models
@ -1786,9 +1825,7 @@ def list_authenticated_providers(
# imports on demand but aren't in the raw auth.json yet.
if not has_creds:
try:
from agent.credential_pool import load_pool
pool = load_pool(hermes_slug)
if pool.has_credentials():
if _credential_pool_is_usable(hermes_slug):
has_creds = True
except Exception as exc:
logger.debug("Credential pool check failed for %s: %s", hermes_slug, exc)
@ -1927,9 +1964,7 @@ def list_authenticated_providers(
pass
if not _cp_has_creds:
try:
from agent.credential_pool import load_pool
_cp_pool = load_pool(_cp.slug)
if _cp_pool.has_credentials():
if _credential_pool_is_usable(_cp.slug):
_cp_has_creds = True
except Exception:
pass

View file

@ -11,7 +11,13 @@ from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
from hermes_cli import auth as auth_mod
from agent.credential_pool import CredentialPool, PooledCredential, get_custom_provider_pool_key, load_pool
from agent.credential_pool import (
CredentialPool,
PooledCredential,
credential_pool_matches_provider,
get_custom_provider_pool_key,
load_pool,
)
from agent.secret_scope import get_secret as _get_secret
from hermes_cli.auth import (
AuthError,
@ -1731,7 +1737,19 @@ def resolve_runtime_provider(
if not pool_api_key or not _agent_key_is_usable(nous_state, min_ttl):
logger.debug("Nous pool entry agent_key still unavailable, falling through to runtime resolution")
pool_api_key = ""
if entry is not None and pool_api_key:
if (
entry is not None
and pool_api_key
and credential_pool_matches_provider(
pool,
provider,
base_url=(
getattr(entry, "runtime_base_url", None)
or getattr(entry, "base_url", None)
or ""
),
)
):
return _resolve_runtime_from_pool_entry(
provider=provider,
entry=entry,

View file

@ -516,7 +516,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
GitHubAuth, create_source_router, ensure_hub_dirs,
quarantine_bundle, install_from_quarantine, HubLockFile,
)
from tools.skills_guard import scan_skill, should_allow_install, format_scan_report
from tools.skills_guard import scan_skill_cached, should_allow_install, format_scan_report
c = console or _console
ensure_hub_dirs()
@ -648,8 +648,24 @@ def do_install(identifier: str, category: str = "", force: bool = False,
or getattr(meta, "identifier", "")
or identifier
)
result = scan_skill(q_path, source=scan_source)
from tools.skills_hub import HUB_DIR, source_url_for_bundle
result, scan_provenance = scan_skill_cached(
q_path,
source=scan_source,
source_url=source_url_for_bundle(bundle),
cache_dir=HUB_DIR / "scan-cache",
)
c.print(format_scan_report(result))
freshness = "fresh" if scan_provenance["fresh"] else "cached"
c.print(
f"[dim]Scan provenance: {freshness}; scanner "
f"{scan_provenance['scanner_version']}; hash {scan_provenance['bundle_hash']}[/]"
)
rules = ", ".join(scan_provenance["rules"]) or "none"
c.print(
f"[dim]Source: {scan_provenance['source_url']}; scanned "
f"{scan_provenance['scanned_at']}; rules: {rules}[/]"
)
# Check install policy
allowed, reason = should_allow_install(result, force=force)

View file

@ -671,7 +671,7 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
"approvals.mode": {
"type": "select",
"description": "Dangerous command approval mode",
"options": ["ask", "yolo", "deny"],
"options": ["manual", "smart", "off"],
},
"context.engine": {
"type": "select",
@ -1023,19 +1023,42 @@ def _normalize_main_model_assignment(provider: str, model: str) -> tuple[str, st
``normalize_model_for_provider`` (e.g. ``anthropic/claude-opus-4.6``
on native anthropic ``claude-opus-4-6``).
"""
from hermes_cli.config import get_compatible_custom_providers
from hermes_cli.models import _KNOWN_PROVIDER_NAMES, normalize_provider
from hermes_cli.model_normalize import normalize_model_for_provider
from hermes_cli.providers import resolve_custom_provider, resolve_user_provider
prov_in = (provider or "").strip()
model_in = (model or "").strip()
canonical = normalize_provider(prov_in)
# User-declared providers are real routing targets, not analytics vendor
# labels. Resolve them before the unknown-vendor fallback. ``providers:``
# keeps its declared bare slug; ``custom_providers:`` canonicalizes both a
# bare display name and ``custom:<name>`` to the durable custom slug.
try:
cfg = load_config()
except Exception:
cfg = {}
user_providers = cfg.get("providers") if isinstance(cfg, dict) else None
user_provider = resolve_user_provider(
prov_in, user_providers if isinstance(user_providers, dict) else {}
)
custom_provider = resolve_custom_provider(
prov_in,
get_compatible_custom_providers(cfg) if isinstance(cfg, dict) else [],
)
if user_provider is not None:
return user_provider.id, model_in
if custom_provider is not None:
return custom_provider.id, model_in
if canonical not in _KNOWN_PROVIDER_NAMES and "/" in model_in:
# Vendor prefix posing as a provider (analytics fallback). Resolve
# against the user's current provider when it's an aggregator that
# serves vendor-prefixed slugs; otherwise default to openrouter.
try:
cur_cfg = load_config().get("model", {})
cur_cfg = cfg.get("model", {})
cur_provider = (
str(cur_cfg.get("provider", "") or "").strip().lower()
if isinstance(cur_cfg, dict) else ""

View file

@ -1072,6 +1072,7 @@
error ? h("div", { className: "text-xs text-destructive px-2" }, error) : null,
h(BoardColumns, {
board: filteredBoard,
boardMeta: boardList.find(function (item) { return item.slug === board; }) || null,
laneByProfile,
selectedIds,
failedIds,
@ -1887,6 +1888,7 @@
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [icon, setIcon] = useState("");
const [projectDirectory, setProjectDirectory] = useState("");
const [switchTo, setSwitchTo] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState(null);
@ -1911,6 +1913,7 @@
name: name.trim() || autoName || undefined,
description: description.trim() || undefined,
icon: icon.trim() || undefined,
default_workdir: projectDirectory.trim() || undefined,
switch: switchTo,
}).catch(function (e) {
setErr(String(e && e.message ? e.message : e));
@ -1966,6 +1969,27 @@
className: "h-8",
}),
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" },
tx(t, "projectDirectory", "Project directory"), " ",
h("span", { className: "text-muted-foreground" },
tx(t, "projectDirectoryHint", "(recommended)"))),
h(Input, {
value: projectDirectory,
onChange: function (e) { setProjectDirectory(e.target.value); },
placeholder: tx(t, "projectDirectoryPlaceholder",
"Absolute path to the project folder"),
title: tx(t, "projectDirectoryHelp",
"Git projects use preserved worktrees. Other folders use the directory directly. Leave blank only for temporary work."),
className: "h-8",
autoCapitalize: "none",
autoCorrect: "off",
spellCheck: false,
}),
h("div", { className: "text-xs text-muted-foreground" },
tx(t, "projectDirectoryExplanation",
"Sets the default location for task files so project output is preserved.")),
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" }, tx(t, "icon", "Icon"), " ",
h("span", { className: "text-muted-foreground" },
@ -2384,6 +2408,7 @@
return h(Column, {
key: col.name,
column: col,
boardMeta: props.boardMeta,
laneByProfile: props.laneByProfile,
selectedIds: props.selectedIds,
failedIds: props.failedIds,
@ -2504,6 +2529,8 @@
showCreate ? h(InlineCreate, {
columnName: props.column.name,
allTasks: props.allTasks,
defaultWorkspaceKind: (props.boardMeta && props.boardMeta.default_workspace_kind) || "scratch",
defaultWorkspacePath: (props.boardMeta && props.boardMeta.default_workdir) || "",
onSubmit: function (body) {
props.onCreate(body).then(function () { setShowCreate(false); });
},
@ -2745,12 +2772,13 @@
const [priority, setPriority] = useState(0);
const [parent, setParent] = useState("");
const [skills, setSkills] = useState("");
// Workspace controls. `scratch` (default) ignores path; `worktree` optionally
// takes a path (dispatcher derives one from the assignee profile otherwise);
// `dir` requires a path. Backend enforces the rule — we only hide/show the
// input here to save vertical space in the common `scratch` case.
const [workspaceKind, setWorkspaceKind] = useState("scratch");
const [workspacePath, setWorkspacePath] = useState("");
// A board with a configured workdir defaults to a persistent workspace:
// worktree for git repositories, dir for ordinary directories. Boards
// without one keep scratch for disposable research and ops tasks.
const defaultWorkspaceKind = props.defaultWorkspaceKind || "scratch";
const defaultWorkspacePath = props.defaultWorkspacePath || "";
const [workspaceKind, setWorkspaceKind] = useState(defaultWorkspaceKind);
const [workspacePath, setWorkspacePath] = useState(defaultWorkspacePath);
// Goal-mode: when on, the dispatched worker runs the Ralph-style /goal
// loop — a judge re-checks the card after each turn and the worker keeps
// going in the same session until done, or the turn budget runs out
@ -2793,15 +2821,15 @@
}
props.onSubmit(body);
setTitle(""); setAssignee(""); setPriority(0); setParent(""); setSkills("");
setWorkspaceKind("scratch"); setWorkspacePath("");
setWorkspaceKind(defaultWorkspaceKind); setWorkspacePath(defaultWorkspacePath);
setGoalMode(false); setGoalMaxTurns("");
};
const showPathInput = workspaceKind !== "scratch";
const pathPlaceholder = workspaceKind === "dir"
? tx(t, "workspacePathDir", "workspace path (required, e.g. ~/projects/my-app)")
? tx(t, "workspacePathDir", "workspace path (required without a board workdir)")
: tx(t, "workspacePathOptional",
"workspace path (optional, derived from assignee if blank)");
"repository path (optional when the board has a workdir)");
return h("div", { className: "hermes-kanban-inline-create" },
h("textarea", {
@ -2877,12 +2905,15 @@
h("div", { className: "flex gap-2" },
h(Select, Object.assign({
value: workspaceKind,
title: "scratch: isolated temp dir (default). worktree: git worktree on the assignee profile. dir: exact path (required below).",
className: "h-7 text-xs w-28",
title: "Choose whether task files are temporary or preserved after completion.",
className: "h-7 text-xs flex-1",
}, selectChangeHandler(setWorkspaceKind)),
h(SelectOption, { value: "scratch" }, "scratch"),
h(SelectOption, { value: "worktree" }, "worktree"),
h(SelectOption, { value: "dir" }, "dir"),
h(SelectOption, { value: "scratch" },
tx(t, "workspaceScratch", "Temporary — deleted on completion")),
h(SelectOption, { value: "worktree" },
tx(t, "workspaceWorktree", "Git worktree — preserved")),
h(SelectOption, { value: "dir" },
tx(t, "workspaceDir", "Directory — preserved")),
),
showPathInput ? h(Input, {
value: workspacePath,
@ -2891,6 +2922,11 @@
className: "h-7 text-xs flex-1",
}) : null,
),
workspaceKind === "scratch" ? h("div", {
className: "text-xs text-destructive",
role: "alert",
}, tx(t, "workspaceScratchWarning",
"This workspace and any files left in it are deleted when the task completes.")) : null,
h(Select, Object.assign({
value: parent,
className: "h-7 text-xs",

View file

@ -1982,6 +1982,7 @@ class CreateBoardBody(BaseModel):
description: Optional[str] = None
icon: Optional[str] = None
color: Optional[str] = None
default_workdir: Optional[str] = None
switch: bool = False
@ -2010,6 +2011,17 @@ def _board_counts(slug: str) -> dict[str, int]:
return {}
def _default_workspace_kind(board: dict[str, Any]) -> str:
"""Recommend a non-destructive task workspace from board metadata."""
workdir = str(board.get("default_workdir") or "").strip()
if not workdir:
return "scratch"
try:
return "worktree" if kanban_db._git_toplevel(Path(workdir)) else "dir"
except (OSError, ValueError):
return "dir"
@router.get("/boards")
def list_boards(include_archived: bool = Query(False)):
"""Return every board on disk with task counts and the active slug."""
@ -2019,12 +2031,27 @@ def list_boards(include_archived: bool = Query(False)):
b["is_current"] = (b["slug"] == current)
b["counts"] = _board_counts(b["slug"])
b["total"] = sum(b["counts"].values())
b["default_workspace_kind"] = _default_workspace_kind(b)
return {"boards": boards, "current": current}
@router.post("/boards")
def create_board_endpoint(payload: CreateBoardBody):
"""Create a new board. Idempotent — ``slug`` collision returns existing."""
default_workdir = None
if payload.default_workdir:
requested = Path(payload.default_workdir).expanduser()
if not requested.is_absolute():
raise HTTPException(
status_code=400,
detail="Project directory must be an absolute path.",
)
if not requested.is_dir():
raise HTTPException(
status_code=400,
detail="Project directory must be an existing directory.",
)
default_workdir = str(requested.resolve())
try:
meta = kanban_db.create_board(
payload.slug,
@ -2032,6 +2059,7 @@ def create_board_endpoint(payload: CreateBoardBody):
description=payload.description,
icon=payload.icon,
color=payload.color,
default_workdir=default_workdir,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@ -2040,6 +2068,7 @@ def create_board_endpoint(payload: CreateBoardBody):
kanban_db.set_current_board(meta["slug"])
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
meta["default_workspace_kind"] = _default_workspace_kind(meta)
return {"board": meta, "current": kanban_db.get_current_board()}

View file

@ -46,6 +46,8 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"VrtxOmega@pm.me": "VrtxOmega", # PR #43809 salvage (desktop: WSL folder-picker path bridge)
"135129512+ansel-f@users.noreply.github.com": "ansel-f", # PR #62388 salvage (approval: allow exact verifier temp cleanup without broadening rm safety boundary)
"robert@modern-minds.ai": "Hopfensaft", # PR #31933 salvage (dashboard: align approvals.mode dropdown with canonical engine values)
"palmer@dugoutfantasy.com": "professorpalmer", # PR #48591 salvage (sessions: CLI workspace filter + restore-cwd-on-resume)
"true@supersynergy.de": "Supersynergy", # PR #59241 salvage (desktop: workspace path status-bar action)
"esthon@gmail.com": "esthonjr", # PR #61950 salvage (desktop: legacy non-git workspace grouping + Windows path identity)

View file

@ -0,0 +1,64 @@
"""Credential pools must never cross provider or custom-endpoint boundaries."""
from types import SimpleNamespace
from unittest.mock import patch
from agent.credential_pool import credential_pool_matches_provider
from hermes_cli import runtime_provider as rp
def test_provider_match_requires_exact_non_custom_identity():
assert credential_pool_matches_provider("deepseek", "deepseek")
assert not credential_pool_matches_provider("openai-codex", "deepseek")
assert not credential_pool_matches_provider("", "deepseek")
def test_custom_pool_match_is_scoped_by_endpoint():
with patch(
"agent.credential_pool.get_custom_provider_pool_key",
return_value="custom:lab",
):
assert credential_pool_matches_provider(
"custom:lab", "custom", base_url="https://lab.example/v1"
)
assert not credential_pool_matches_provider(
"custom:other", "custom", base_url="https://lab.example/v1"
)
def test_runtime_ignores_pool_loaded_for_different_provider(monkeypatch):
entry = SimpleNamespace(
provider="openai-codex",
access_token="wrong-token",
runtime_api_key="wrong-token",
runtime_base_url="https://chatgpt.com/backend-api/codex",
base_url="https://chatgpt.com/backend-api/codex",
)
pool = SimpleNamespace(
provider="openai-codex",
has_credentials=lambda: True,
select=lambda: entry,
)
monkeypatch.setattr(rp, "load_pool", lambda _provider: pool)
monkeypatch.setattr(rp, "resolve_provider", lambda *_a, **_kw: "deepseek")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {"provider": "deepseek", "default": "deepseek-chat"},
)
monkeypatch.setattr(
rp,
"resolve_api_key_provider_credentials",
lambda _provider: {
"provider": "deepseek",
"api_key": "deepseek-key",
"base_url": "https://api.deepseek.com/v1",
"source": "env",
},
)
resolved = rp.resolve_runtime_provider(requested="deepseek")
assert resolved["provider"] == "deepseek"
assert resolved["api_key"] == "deepseek-key"
assert resolved["base_url"] == "https://api.deepseek.com/v1"

View file

@ -260,6 +260,27 @@ def test_no_suite_nudge_requests_temp_script(tmp_path, monkeypatch):
assert "creative UI/visual work" in nudge
def test_no_suite_nudge_uses_canonical_temp_dir(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
project = tmp_path / "project"
project.mkdir()
(project / "package.json").write_text("{}", encoding="utf-8")
real_temp = tmp_path / "real-temp"
real_temp.mkdir()
linked_temp = tmp_path / "linked-temp"
linked_temp.symlink_to(real_temp, target_is_directory=True)
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(linked_temp))
nudge = build_verify_on_stop_nudge(
session_id="s1",
changed_paths=[str(project / "src" / "app.ts")],
)
assert nudge is not None
assert str(real_temp) in nudge
assert str(linked_temp) not in nudge
def test_verify_guidance_can_be_disabled(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
_node_project(tmp_path)

View file

@ -568,14 +568,29 @@ class TestConcurrencyCap:
assert resp.headers.get("Retry-After")
def test_cap_counts_both_buckets(self):
# /v1/runs (tracked by _run_streams) + chat/responses (inflight)
# /v1/runs (tracked by live tasks) + chat/responses (inflight)
adapter = _make_adapter()
adapter._max_concurrent_runs = 4
adapter._inflight_agent_runs = 2
adapter._run_streams = {"r1": object(), "r2": object()}
resp = adapter._concurrency_limited_response()
assert resp is not None
assert resp.status == 429
async def _assert_live_tasks_are_counted_without_streams():
blocker = asyncio.Event()
async def _live_run():
await blocker.wait()
tasks = [asyncio.create_task(_live_run()) for _ in range(2)]
adapter._active_run_tasks = {f"r{i}": task for i, task in enumerate(tasks)}
adapter._run_streams = {}
try:
resp = adapter._concurrency_limited_response()
assert resp is not None
assert resp.status == 429
finally:
blocker.set()
await asyncio.gather(*tasks)
asyncio.run(_assert_live_tasks_are_counted_without_streams())
def test_zero_disables_cap(self):
adapter = _make_adapter()

View file

@ -10,6 +10,7 @@ Covers:
import asyncio
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
@ -442,12 +443,229 @@ class TestRunEvents:
assert resp.status == 401
# ---------------------------------------------------------------------------
# Run lifecycle TTL sweeping
# ---------------------------------------------------------------------------
class TestRunLifecycleSweep:
def test_sweep_keeps_transport_with_active_subscriber(self, adapter):
run_id = "run_subscribed"
queue = asyncio.Queue()
adapter._run_streams[run_id] = queue
adapter._run_streams_created[run_id] = 0
adapter._run_stream_subscribers.add(run_id)
adapter._sweep_orphaned_runs_once(time.time())
assert adapter._run_streams[run_id] is queue
assert run_id in adapter._run_streams_created
@pytest.mark.asyncio
async def test_expired_live_run_drops_transport_but_keeps_control_state(self, adapter):
"""Stream TTL bounds buffering without detaching a live run."""
app = _create_runs_app(adapter)
adapter._max_concurrent_runs = 1
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_create_agent") as mock_create:
mock_agent, agent_ready, _ = _make_slow_agent()
mock_create.return_value = mock_agent
start_resp = await cli.post("/v1/runs", json={"input": "hello"})
assert start_resp.status == 202
run_id = (await start_resp.json())["run_id"]
assert agent_ready.wait(timeout=3.0)
task = adapter._active_run_tasks[run_id]
assert isinstance(task, asyncio.Task)
assert not task.done()
pending = approval_mod._ApprovalEntry({
"command": "bash -c long-running",
"description": "approval after stream TTL",
"pattern_keys": ["shell-c"],
})
with approval_mod._lock:
approval_mod._gateway_queues[run_id] = [pending]
adapter._run_streams_created[run_id] -= adapter._RUN_STREAM_TTL + 1
# Exercise one real sweeper iteration without waiting 60 seconds.
with patch(
"gateway.platforms.api_server.asyncio.sleep",
side_effect=[None, asyncio.CancelledError()],
):
with pytest.raises(asyncio.CancelledError):
await adapter._sweep_orphaned_runs()
assert adapter._active_run_tasks[run_id] is task
assert adapter._active_run_agents[run_id] is mock_agent
assert run_id not in adapter._run_streams
assert run_id not in adapter._run_streams_created
assert adapter._run_approval_sessions[run_id] == run_id
limited = adapter._concurrency_limited_response()
assert limited is not None
assert limited.status == 429
approval_resp = await cli.post(
f"/v1/runs/{run_id}/approval",
json={"choice": "once"},
)
assert approval_resp.status == 200
assert pending.event.is_set()
assert pending.result == "once"
stop_resp = await cli.post(f"/v1/runs/{run_id}/stop")
assert stop_resp.status == 200
mock_agent.interrupt.assert_called_once_with("Stop requested via API")
@pytest.mark.asyncio
async def test_expired_transport_stops_buffering_new_deltas(self, adapter):
"""An unconsumed expired queue must not grow for the rest of a live run."""
app = _create_runs_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_create_agent") as mock_create:
mock_agent, agent_ready, _ = _make_slow_agent()
mock_create.return_value = mock_agent
start_resp = await cli.post("/v1/runs", json={"input": "hello"})
run_id = (await start_resp.json())["run_id"]
assert agent_ready.wait(timeout=3.0)
expired_queue = adapter._run_streams[run_id]
stream_delta = mock_create.call_args.kwargs["stream_delta_callback"]
adapter._run_streams_created[run_id] -= adapter._RUN_STREAM_TTL + 1
adapter._sweep_orphaned_runs_once(time.time())
before = expired_queue.qsize()
stream_delta("must-not-buffer")
mock_agent.interrupt("finish test")
for _ in range(40):
if run_id not in adapter._active_run_tasks:
break
await asyncio.sleep(0.05)
assert expired_queue.qsize() == before
@pytest.mark.asyncio
async def test_expired_orphan_run_state_is_reaped(self, adapter):
run_id = "run_expired_orphan"
adapter._run_streams[run_id] = asyncio.Queue()
adapter._run_streams_created[run_id] = 0
adapter._run_approval_sessions[run_id] = run_id
pending = approval_mod._ApprovalEntry({
"command": "bash -c orphaned",
"description": "orphaned approval",
"pattern_keys": ["shell-c"],
})
with approval_mod._lock:
approval_mod._gateway_queues[run_id] = [pending]
with patch(
"gateway.platforms.api_server.asyncio.sleep",
side_effect=[None, asyncio.CancelledError()],
):
with pytest.raises(asyncio.CancelledError):
await adapter._sweep_orphaned_runs()
assert run_id not in adapter._run_streams
assert run_id not in adapter._run_streams_created
assert run_id not in adapter._run_approval_sessions
assert pending.event.is_set()
with approval_mod._lock:
assert run_id not in approval_mod._gateway_queues
# ---------------------------------------------------------------------------
# POST /v1/runs/{run_id}/stop — interrupt a running agent
# ---------------------------------------------------------------------------
class TestStopRun:
@pytest.mark.asyncio
async def test_stop_before_agent_creation_prevents_run_start(self, adapter):
"""A stop accepted while queued must prevent agent construction."""
app = _create_runs_app(adapter)
original_create_task = asyncio.create_task
task_started = asyncio.Event()
allow_task = asyncio.Event()
def _delayed_create_task(coro):
async def _delayed():
task_started.set()
await allow_task.wait()
return await coro
return original_create_task(_delayed())
with patch("gateway.platforms.api_server.asyncio.create_task", side_effect=_delayed_create_task), \
patch.object(adapter, "_create_agent") as mock_create:
async with TestClient(TestServer(app)) as cli:
resp = await cli.post("/v1/runs", json={"input": "hello"})
run_id = (await resp.json())["run_id"]
await task_started.wait()
stop_resp = await cli.post(f"/v1/runs/{run_id}/stop")
assert stop_resp.status == 200
allow_task.set()
for _ in range(20):
if run_id not in adapter._active_run_tasks:
break
await asyncio.sleep(0.05)
mock_create.assert_not_called()
assert adapter._run_statuses[run_id]["status"] == "cancelled"
@pytest.mark.asyncio
async def test_stop_keeps_uncooperative_executor_tracked_until_exit(self, adapter):
"""Cancelling an asyncio wrapper must not hide its live executor thread."""
app = _create_runs_app(adapter)
run_can_finish = threading.Event()
run_finished = threading.Event()
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_create_agent") as mock_create:
mock_agent = MagicMock()
mock_agent.session_prompt_tokens = 0
mock_agent.session_completion_tokens = 0
mock_agent.session_total_tokens = 0
started = threading.Event()
def _run_conversation(*_args, **_kwargs):
started.set()
run_can_finish.wait(timeout=5)
run_finished.set()
return {"final_response": "late result"}
mock_agent.run_conversation.side_effect = _run_conversation
mock_create.return_value = mock_agent
resp = await cli.post("/v1/runs", json={"input": "hello"})
run_id = (await resp.json())["run_id"]
assert started.wait(timeout=3)
stop_resp = await cli.post(f"/v1/runs/{run_id}/stop")
assert stop_resp.status == 200
await asyncio.sleep(0.1)
assert not run_finished.is_set()
assert run_id in adapter._active_run_agents
assert run_id in adapter._active_run_tasks
assert adapter._run_statuses[run_id]["status"] == "stopping"
run_can_finish.set()
for _ in range(40):
if run_id not in adapter._active_run_tasks:
break
await asyncio.sleep(0.05)
assert run_id not in adapter._active_run_agents
assert run_id not in adapter._active_run_tasks
assert adapter._run_statuses[run_id]["status"] == "cancelled"
@pytest.mark.asyncio
async def test_stop_running_agent(self, adapter):
"""Stop should interrupt the agent and cancel the task."""

View file

@ -0,0 +1,97 @@
"""Regression test for #45759.
An all-exhausted credential pool holds entries but no *usable* credential.
``list_authenticated_providers`` must not treat such a provider as
authenticated -- otherwise an aggregator whose quota is spent gets matched
during no-provider ``/model`` resolution, wins the model name, and sticks as
the session provider (the "sticky provider fallback pollution" bug).
"""
import pytest
class _FakePool:
def __init__(self, available: bool):
self._available = available
def has_credentials(self) -> bool:
# The pool still holds entries...
return True
def has_available(self) -> bool:
# ...but none of them are usable when exhausted/dead.
return self._available
def _patch_opencode_pool(monkeypatch, *, available: bool):
"""Make the opencode-go aggregator look configured but with a pool whose
only credential is (un)available, depending on ``available``."""
import hermes_cli.auth as auth
import agent.credential_pool as cp
monkeypatch.setattr(
auth,
"_load_auth_store",
lambda: {
"version": 1,
"providers": {},
"active_provider": None,
"credential_pool": {"opencode-go": {"entries": [{"id": "x"}]}},
},
)
monkeypatch.setattr(
cp,
"load_pool",
lambda provider: _FakePool(available if provider == "opencode-go" else True),
)
@pytest.fixture(autouse=True)
def _strip_provider_env(monkeypatch):
"""Don't let real provider keys in the environment authenticate providers
through a different code path than the pool gate under test."""
import os
for key in list(os.environ):
if "OPENCODE" in key or key.endswith("_API_KEY"):
monkeypatch.delenv(key, raising=False)
def test_exhausted_pool_provider_is_not_authenticated(monkeypatch):
"""The fix: an exhausted pool is NOT authenticated. Fails on main, where
the gate accepted any stored pool entry regardless of usability."""
from hermes_cli.model_switch import get_authenticated_provider_slugs
_patch_opencode_pool(monkeypatch, available=False)
slugs = get_authenticated_provider_slugs(current_provider="alibaba")
assert "opencode-go" not in slugs
def test_pool_provider_with_available_credential_is_authenticated(monkeypatch):
"""Control: with a usable credential the provider IS authenticated, proving
the test drives the credential gate rather than excluding it for some other
reason."""
from hermes_cli.model_switch import get_authenticated_provider_slugs
_patch_opencode_pool(monkeypatch, available=True)
slugs = get_authenticated_provider_slugs(current_provider="alibaba")
assert "opencode-go" in slugs
def test_opaque_legacy_pool_value_stays_visible(monkeypatch):
"""Legacy token-style auth-store values have no parsed pool entries."""
from hermes_cli.model_switch import _credential_pool_is_usable
monkeypatch.setattr(
"agent.credential_pool.load_pool",
lambda _provider: type(
"EmptyPool",
(),
{
"has_credentials": lambda self: False,
"has_available": lambda self: False,
},
)(),
)
assert _credential_pool_is_usable("opencode-go", raw_pool_present=True)

View file

@ -0,0 +1,44 @@
"""Configured models extend built-in picker rows."""
from unittest.mock import patch
from hermes_cli.model_switch import list_authenticated_providers
def _provider_row(configured_models, *, max_models=None):
with (
patch(
"agent.models_dev.fetch_models_dev",
return_value={"deepseek": {"env": ["DEEPSEEK_API_KEY"], "name": "DeepSeek"}},
),
patch(
"agent.models_dev.PROVIDER_TO_MODELS_DEV",
{"deepseek": "deepseek"},
),
patch(
"hermes_cli.models.cached_provider_model_ids",
return_value=["live-a", "shared"],
),
patch("hermes_cli.providers.HERMES_OVERLAYS", {}),
patch.dict("os.environ", {"DEEPSEEK_API_KEY": "test-key"}),
):
rows = list_authenticated_providers(
current_provider="deepseek",
user_providers={"deepseek": {"models": configured_models}},
max_models=max_models,
)
return next(row for row in rows if row["slug"] == "deepseek")
def test_configured_models_precede_and_deduplicate_discovered_models():
row = _provider_row({"configured-x": {}, "shared": {}})
assert row["models"] == ["configured-x", "shared", "live-a"]
assert row["total_models"] == 3
def test_configured_models_are_merged_before_picker_limit():
row = _provider_row(["configured-x", "configured-y"], max_models=2)
assert row["models"] == ["configured-x", "configured-y"]
assert row["total_models"] == 4

View file

@ -0,0 +1,40 @@
"""Dashboard main-model writes preserve declared provider identities."""
from unittest.mock import patch
from hermes_cli.web_server import _normalize_main_model_assignment
def _normalize(config, provider, model="vendor/model-a"):
with patch("hermes_cli.web_server.load_config", return_value=config):
return _normalize_main_model_assignment(provider, model)
def test_providers_block_keeps_declared_bare_slug():
result = _normalize(
{"providers": {"commandcode": {"base_url": "http://localhost:55990/v1"}}},
"commandcode",
)
assert result == ("commandcode", "vendor/model-a")
def test_custom_provider_name_canonicalizes_to_durable_slug():
config = {
"custom_providers": [
{"name": "US Azure", "base_url": "http://localhost:18025/v1"}
]
}
assert _normalize(config, "US Azure") == ("custom:us-azure", "vendor/model-a")
assert _normalize(config, "custom:us-azure") == (
"custom:us-azure",
"vendor/model-a",
)
def test_unknown_vendor_still_uses_aggregator_fallback():
assert _normalize({}, "unconfigured-vendor") == (
"openrouter",
"vendor/model-a",
)

View file

@ -0,0 +1,44 @@
"""Picker rows must resolve to a runtime provider (#57503)."""
from unittest.mock import patch
from hermes_cli.auth import is_runtime_provider_routable
from hermes_cli.model_switch import list_authenticated_providers
def _rows_with_env(monkeypatch, env_name: str, provider: str) -> list[dict]:
monkeypatch.setenv(env_name, "test-key")
with (
patch(
"agent.models_dev.fetch_models_dev",
return_value={provider: {"env": [env_name], "name": provider.title()}},
),
patch(
"agent.models_dev.PROVIDER_TO_MODELS_DEV",
{provider: provider},
),
patch("hermes_cli.models.cached_provider_model_ids", return_value=["model-a"]),
patch("hermes_cli.providers.HERMES_OVERLAYS", {}),
):
return list_authenticated_providers(max_models=5)
def test_models_dev_only_provider_is_not_selectable(monkeypatch):
rows = _rows_with_env(monkeypatch, "MISTRAL_API_KEY", "mistral")
assert all(row["slug"] != "mistral" for row in rows)
assert not is_runtime_provider_routable("mistral")
def test_registered_provider_remains_selectable(monkeypatch):
rows = _rows_with_env(monkeypatch, "DEEPSEEK_API_KEY", "deepseek")
row = next(row for row in rows if row["slug"] == "deepseek")
assert row["models"] == ["model-a"]
assert row["total_models"] == 1
assert is_runtime_provider_routable("deepseek")
def test_special_runtime_provider_does_not_require_registry_membership():
assert is_runtime_provider_routable("openrouter")
assert is_runtime_provider_routable("custom:local-lab")

View file

@ -3539,6 +3539,26 @@ class TestBuildSchemaFromConfig:
assert "options" in entry
assert "local" in entry["options"]
def test_approvals_mode_options_match_config_values(self):
"""approvals.mode select options must match the values accepted by config.py.
Previously the dashboard showed ['ask', 'yolo', 'deny'] which are stale
names that don't correspond to any real config value. The correct values
are 'manual', 'smart', and 'off' (see hermes_cli/config.py).
'smart' was missing entirely, making it unreachable from the UI.
"""
from hermes_cli.web_server import CONFIG_SCHEMA
entry = CONFIG_SCHEMA["approvals.mode"]
assert entry["type"] == "select"
options = entry["options"]
assert "manual" in options, "'manual' missing from approvals.mode options"
assert "smart" in options, "'smart' missing from approvals.mode options"
assert "off" in options, "'off' missing from approvals.mode options"
# Stale names that were previously shown but don't match config values
assert "ask" not in options, "stale option 'ask' should not appear"
assert "yolo" not in options, "stale option 'yolo' should not appear"
assert "deny" not in options, "stale option 'deny' should not appear"
def test_empty_prefix_produces_correct_keys(self):
from hermes_cli.web_server import _build_schema_from_config
test_config = {"model": "test", "nested": {"key": "val"}}

View file

@ -9,6 +9,7 @@ from __future__ import annotations
import importlib.util
import os
import subprocess
import sys
import time
from pathlib import Path
@ -114,6 +115,102 @@ def test_create_task_appears_on_board(client):
assert "researcher" in data["assignees"]
def test_board_list_recommends_persistent_workspace_for_configured_workdir(
client, tmp_path
):
"""Board metadata should tell the UI which safe task default to use."""
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q", str(repo)], check=True)
kb.write_board_metadata("default", default_workdir=str(repo))
plain_dir = tmp_path / "notes"
plain_dir.mkdir()
kb.create_board("notes", default_workdir=str(plain_dir))
kb.create_board("disposable")
response = client.get("/api/plugins/kanban/boards")
assert response.status_code == 200
boards = {board["slug"]: board for board in response.json()["boards"]}
assert boards["default"]["default_workspace_kind"] == "worktree"
assert boards["notes"]["default_workspace_kind"] == "dir"
assert boards["disposable"]["default_workspace_kind"] == "scratch"
def test_create_board_persists_project_directory(client, tmp_path):
"""The dashboard board form should anchor future tasks to its project."""
project_dir = tmp_path / "project"
project_dir.mkdir()
response = client.post(
"/api/plugins/kanban/boards",
json={
"slug": "project-board",
"name": "Project Board",
"default_workdir": str(project_dir),
},
)
assert response.status_code == 200, response.text
board = response.json()["board"]
assert board["default_workdir"] == str(project_dir.resolve())
assert board["default_workspace_kind"] == "dir"
assert kb.read_board_metadata("project-board")["default_workdir"] == str(
project_dir.resolve()
)
@pytest.mark.parametrize("path", ["relative/project", "~/missing-project"])
def test_create_board_rejects_invalid_project_directory(client, path):
"""A board must not persist a path that cannot anchor worker output."""
response = client.post(
"/api/plugins/kanban/boards",
json={"slug": "invalid-project", "default_workdir": path},
)
assert response.status_code == 400
assert "project directory" in response.json()["detail"].lower()
def test_new_board_dialog_collects_project_directory():
"""Board creation should expose the setting that controls safe task defaults."""
bundle = (
Path(__file__).resolve().parents[2]
/ "plugins"
/ "kanban"
/ "dashboard"
/ "dist"
/ "index.js"
).read_text(encoding="utf-8")
assert 'const [projectDirectory, setProjectDirectory] = useState("");' in bundle
assert "Project directory" in bundle
assert "Absolute path to the project folder" in bundle
assert "default_workdir: projectDirectory.trim() || undefined" in bundle
def test_dashboard_workspace_picker_explains_persistence_contract():
"""Task creation must make scratch deletion visible without a hover."""
bundle = (
Path(__file__).resolve().parents[2]
/ "plugins"
/ "kanban"
/ "dashboard"
/ "dist"
/ "index.js"
).read_text(encoding="utf-8")
assert "Temporary — deleted on completion" in bundle
assert "Git worktree — preserved" in bundle
assert "Directory — preserved" in bundle
assert "defaultWorkspacePath: (props.boardMeta && props.boardMeta.default_workdir) || \"\"" in bundle
assert (
"This workspace and any files left in it are deleted when the task completes."
in bundle
)
def test_scheduled_tasks_have_their_own_column_not_todo(client):
"""Scheduled/time-delay tasks must not be silently bucketed into todo."""

View file

@ -2,6 +2,7 @@
import ast
import os
import tempfile
import threading
import time
from pathlib import Path
@ -116,6 +117,53 @@ class TestDetectDangerousRm:
assert key is not None
assert "delete" in desc.lower()
def test_nonrecursive_verification_artifact_cleanup_is_not_dangerous(self):
with mock_patch("tempfile.gettempdir", return_value="/tmp"):
for prefix in ("hermes-verify-", "hermes-ad-hoc-"):
assert detect_dangerous_command(f"rm -f /tmp/{prefix}example.py") == (
False,
None,
None,
)
def test_symlinked_temp_dir_only_exempts_canonical_target(self, tmp_path):
real_temp = tmp_path / "real-temp"
real_temp.mkdir()
linked_temp = tmp_path / "linked-temp"
linked_temp.symlink_to(real_temp, target_is_directory=True)
basename = "hermes-verify-example.py"
with mock_patch("tempfile.gettempdir", return_value=str(linked_temp)):
assert detect_dangerous_command(f"rm -f {linked_temp / basename}")[0] is True
assert detect_dangerous_command(f"rm -f {real_temp / basename}") == (
False,
None,
None,
)
def test_verification_cleanup_exemption_rejects_broader_deletions(self):
commands = (
"rm -rf /tmp/hermes-verify-example.py",
"rm -f /tmp/hermes-verify-example.py /tmp/other.py",
"rm -f /tmp/nested/hermes-verify-example.py",
"rm -f /tmp/nested/../hermes-verify-example.py",
"rm -f /tmp/./hermes-verify-example.py",
"rm -f /tmp//hermes-verify-example.py",
"rm -f /tmp/a/../../tmp/hermes-verify-example.py",
"rm -f /var/tmp/hermes-verify-example.py",
"rm -f /tmp/unrelated.py",
"rm -f /tmp/hermes-verify-*",
"rm -f /tmp/hermes-verify-$(touch>/tmp/pwned).py",
"rm -f /tmp/hermes-ad-hoc-`touch>/tmp/pwned`.py",
"rm -f /tmp/hermes-verify-example.py; touch /tmp/pwned",
)
with mock_patch("tempfile.gettempdir", return_value="/tmp"):
for command in commands:
is_dangerous, key, desc = detect_dangerous_command(command)
assert is_dangerous is True, command
assert key is not None, command
assert "delete" in desc.lower(), command
class TestWindowsShellDestructiveCommands:
def test_cmd_del_requires_approval(self):

View file

@ -467,6 +467,44 @@ class TestShellFileOpsHelpers:
# Should be safely escaped
assert result.count("'") >= 4 # wrapping + escaping
def test_escape_shell_arg_rewrites_windows_drive_paths_to_msys(self, monkeypatch, file_ops):
# bash eats backslashes and MSYS mangles ``C:\...``; the Git Bash
# ``/c/...`` form is the reliable one (reuses _windows_to_msys_path).
import tools.environments.local as local_mod
monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
assert file_ops._escape_shell_arg(r"C:\Users\alice\notes.txt") == "'/c/Users/alice/notes.txt'"
# Non-drive paths are untouched.
assert file_ops._escape_shell_arg("/tmp/foo") == "'/tmp/foo'"
def test_read_file_uses_bash_safe_windows_paths(self, mock_env, monkeypatch):
import tools.environments.local as local_mod
monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
commands = []
def side_effect(command, **kwargs):
commands.append(command)
if command.startswith("wc -c"):
return {"output": "5\n", "returncode": 0}
if command.startswith("head -c"):
return {"output": "hello", "returncode": 0}
if command.startswith("sed -n"):
return {"output": "hello\n", "returncode": 0}
if command.startswith("wc -l"):
return {"output": "1\n", "returncode": 0}
return {"output": "", "returncode": 0}
mock_env.execute.side_effect = side_effect
ops = ShellFileOperations(mock_env)
result = ops.read_file(r"C:\Users\alice\notes.txt")
assert result.error is None
assert commands[0] == "wc -c < '/c/Users/alice/notes.txt' 2>/dev/null"
assert commands[1] == "head -c 1000 '/c/Users/alice/notes.txt' 2>/dev/null"
assert commands[2] == "sed -n '1,500p' '/c/Users/alice/notes.txt'"
assert commands[3] == "wc -l < '/c/Users/alice/notes.txt'"
def test_is_likely_binary_by_extension(self, file_ops):
assert file_ops._is_likely_binary("photo.png") is True
assert file_ops._is_likely_binary("data.db") is True

View file

@ -0,0 +1,260 @@
"""Multi-file third-party skill bundles and scanner provenance (#60598)."""
import json
import subprocess
import threading
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from io import StringIO
from pathlib import Path
import pytest
from rich.console import Console
from tools.skills_guard import SCANNER_VERSION, scan_skill_cached
from tools.skills_hub import GitHubAuth, GitHubSource, HubLockFile, SkillBundle, UrlSource
SKILL_MD = """---
name: demo-bundle
description: A multi-file test skill.
---
# Demo
Read [the guide](references/guide.md#usage), use `templates/report.md?raw=1`, and run
`scripts/run.py`. See `examples/endpoint-inventory.md`. The repository also
contains assets/logo.png.
"""
class _QuietHandler(SimpleHTTPRequestHandler):
def log_message(self, *_args):
pass
@pytest.fixture
def served_repo(tmp_path):
repo = tmp_path / "upstream"
repo.mkdir()
(repo / "SKILL.md").write_text(SKILL_MD)
for rel, content in {
"references/guide.md": "safe guide\n",
"templates/report.md": "report\n",
"scripts/run.py": "print('ok')\n",
"assets/logo.png": b"\x89PNG\r\n\x1a\n\x00\xff",
"examples/endpoint-inventory.md": "example\n",
"examples/not-installed.md": "must not be copied\n",
"README.md": "must not be copied\n",
}.items():
path = repo / rel
path.parent.mkdir(parents=True, exist_ok=True)
if isinstance(content, bytes):
path.write_bytes(content)
else:
path.write_text(content)
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(["git", "add", "."], cwd=repo, check=True)
subprocess.run(
["git", "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "fixture"],
cwd=repo,
check=True,
)
server = ThreadingHTTPServer(
("127.0.0.1", 0), partial(_QuietHandler, directory=str(repo))
)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield repo, f"http://127.0.0.1:{server.server_port}/SKILL.md"
finally:
server.shutdown()
thread.join()
def test_url_source_fetches_only_referenced_allowed_support_directories(served_repo, monkeypatch):
_repo, url = served_repo
monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True)
monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None)
bundle = UrlSource().fetch(url)
assert bundle is not None
assert set(bundle.files) == {
"SKILL.md",
"references/guide.md",
"templates/report.md",
"scripts/run.py",
"assets/logo.png",
"examples/endpoint-inventory.md",
}
assert bundle.files["assets/logo.png"] == b"\x89PNG\r\n\x1a\n\x00\xff"
assert "examples/not-installed.md" not in bundle.files
assert bundle.metadata["source_url"] == url
def test_url_source_rejects_traversal_reference(monkeypatch):
source = UrlSource()
skill = "---\nname: bad\ndescription: bad\n---\n[bad](references/../../secret.txt)\n"
monkeypatch.setattr(source, "_fetch_text", lambda _url: skill)
assert source.fetch("https://example.com/bad/SKILL.md") is None
def test_github_source_rejects_symlink_in_referenced_directory(monkeypatch):
source = GitHubSource(GitHubAuth())
monkeypatch.setattr(source, "_fetch_file_content", lambda _repo, path: SKILL_MD if path.endswith("SKILL.md") else "x")
source._tree_cache["owner/repo"] = (
"main",
[
{"path": "skill/SKILL.md", "type": "blob", "mode": "100644"},
{"path": "skill/references/guide.md", "type": "blob", "mode": "120000"},
],
)
assert source.fetch("owner/repo/skill") is None
def test_github_source_fetches_only_exact_references_and_records_tree_revision(monkeypatch):
source = GitHubSource(GitHubAuth())
skill = "---\nname: demo\ndescription: demo\n---\n[guide](references/guide.md)\n"
fetched = []
monkeypatch.setattr(
source,
"_fetch_file_content",
lambda _repo, path: skill if path.endswith("SKILL.md") else None,
)
def _fetch_bytes(_repo, path):
fetched.append(path)
return b"guide"
monkeypatch.setattr(source, "_fetch_file_bytes", _fetch_bytes, raising=False)
source._tree_cache["owner/repo"] = (
"develop",
[
{"path": "skill/SKILL.md", "type": "blob", "mode": "100644"},
{"path": "skill/references/guide.md", "type": "blob", "mode": "100644"},
{"path": "skill/references/unreferenced.md", "type": "blob", "mode": "100644"},
],
)
source._tree_revisions = {"owner/repo": "deadbeef"}
bundle = source.fetch("owner/repo/skill")
assert bundle is not None
assert fetched == ["skill/references/guide.md"]
assert bundle.files["references/guide.md"] == b"guide"
assert bundle.metadata["source_url"] == "https://github.com/owner/repo/tree/deadbeef/skill"
assert bundle.metadata["source_revision"] == "deadbeef"
def test_scan_cache_records_full_provenance_and_hash_change_forces_rescan(tmp_path):
skill = tmp_path / "skill"
skill.mkdir()
(skill / "SKILL.md").write_text("---\nname: skill\ndescription: test\n---\n# safe\n")
cache = tmp_path / "scan-cache"
first, first_provenance = scan_skill_cached(
skill, source="owner/repo/skill", source_url="https://github.com/owner/repo", cache_dir=cache
)
second, second_provenance = scan_skill_cached(
skill, source="owner/repo/skill", source_url="https://github.com/owner/repo", cache_dir=cache
)
(skill / "SKILL.md").write_text("---\nname: skill\ndescription: changed\n---\n# safe\n")
third, third_provenance = scan_skill_cached(
skill, source="owner/repo/skill", source_url="https://github.com/owner/repo", cache_dir=cache
)
assert first.verdict == second.verdict == third.verdict == "safe"
assert first_provenance["fresh"] is True
assert second_provenance["fresh"] is False
assert third_provenance["fresh"] is True
assert first_provenance["bundle_hash"].startswith("sha256:")
assert len(first_provenance["bundle_hash"].split(":", 1)[1]) == 64
assert third_provenance["bundle_hash"] != first_provenance["bundle_hash"]
assert first_provenance["scanner_version"] == SCANNER_VERSION
assert first_provenance["source_url"] == "https://github.com/owner/repo"
assert isinstance(first_provenance["findings"], list)
assert isinstance(first_provenance["rules"], list)
assert first_provenance["scanned_at"]
def test_scan_cache_never_reuses_provenance_across_sources(tmp_path):
skill = tmp_path / "skill"
skill.mkdir()
(skill / "SKILL.md").write_text("---\nname: skill\ndescription: test\n---\n")
cache = tmp_path / "scan-cache"
_first, first = scan_skill_cached(
skill, source="community", source_url="https://one.example/SKILL.md", cache_dir=cache
)
_second, second = scan_skill_cached(
skill, source="community", source_url="https://two.example/SKILL.md", cache_dir=cache
)
assert first["fresh"] is True
assert second["fresh"] is True
assert second["source_url"] == "https://two.example/SKILL.md"
def test_lock_file_persists_scan_provenance(tmp_path):
lock = HubLockFile(tmp_path / "lock.json")
provenance = {
"source_url": "https://example.com/SKILL.md",
"bundle_hash": "sha256:" + "a" * 64,
"scanner_version": SCANNER_VERSION,
"findings": [],
"rules": [],
"scanned_at": "2026-07-09T00:00:00+00:00",
"fresh": True,
}
lock.record_install(
name="demo", source="url", identifier="https://example.com/SKILL.md",
trust_level="community", scan_verdict="safe", skill_hash="sha256:legacy",
install_path="demo", files=["SKILL.md"], scan_provenance=provenance,
)
assert lock.get_installed("demo")["scan_provenance"] == provenance
def test_real_temp_repo_and_home_install_e2e(served_repo, monkeypatch, tmp_path):
from hermes_cli.skills_hub import do_install
import tools.skills_hub as hub
_repo, url = served_repo
home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True)
monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None)
monkeypatch.setattr(hub, "create_source_router", lambda auth=None: [UrlSource()])
sink = StringIO()
do_install(url, console=Console(file=sink, force_terminal=False), skip_confirm=True)
installed = home / "skills" / "demo-bundle"
assert (installed / "references" / "guide.md").read_text() == "safe guide\n"
assert (installed / "templates" / "report.md").is_file()
assert (installed / "scripts" / "run.py").is_file()
assert (installed / "examples" / "endpoint-inventory.md").is_file()
assert not (installed / "examples" / "not-installed.md").exists()
assert (installed / "assets" / "logo.png").read_bytes() == b"\x89PNG\r\n\x1a\n\x00\xff"
entry = json.loads((home / "skills" / ".hub" / "lock.json").read_text())["installed"]["demo-bundle"]
assert entry["scan_provenance"]["source_url"] == url
assert entry["scan_provenance"]["fresh"] is True
assert "Scan provenance: fresh" in sink.getvalue()
def test_bundled_optional_source_still_includes_support_files(tmp_path, monkeypatch):
from tools.skills_hub import OptionalSkillSource
root = tmp_path / "optional-skills"
skill = root / "category" / "official-demo"
(skill / "references").mkdir(parents=True)
(skill / "SKILL.md").write_text("---\nname: official-demo\ndescription: demo\n---\n")
(skill / "references" / "all.md").write_text("all")
source = OptionalSkillSource()
source._optional_dir = root
bundle = source.fetch("official/category/official-demo")
assert bundle is not None
assert set(bundle.files) == {"SKILL.md", "references/all.md"}

View file

@ -264,6 +264,48 @@ def test_block_and_respond(capture):
assert result[0] == "my_answer"
@pytest.mark.parametrize("event", ["secret.request", "sudo.request"])
def test_sensitive_prompt_timeout_emits_expiry(capture, event):
server, buf = capture
assert server._block(event, "s1", {}, timeout=0) == ""
messages = [json.loads(line) for line in buf.getvalue().splitlines()]
request, expiry = [message["params"] for message in messages]
assert request["type"] == event
assert expiry["type"] == event.removesuffix(".request") + ".expire"
assert expiry["session_id"] == "s1"
assert expiry["payload"]["request_id"] == request["payload"]["request_id"]
@pytest.mark.parametrize(
("method", "value_key"),
[("secret.respond", "value"), ("sudo.respond", "password")],
)
def test_late_sensitive_prompt_response_is_idempotent(server, method, value_key):
response = server.handle_request(
{
"id": "late-response",
"method": method,
"params": {"request_id": "expired-request", value_key: ""},
}
)
assert response["result"] == {"status": "expired"}
def test_late_clarify_response_remains_protocol_error(server):
response = server.handle_request(
{
"id": "late-clarify",
"method": "clarify.respond",
"params": {"request_id": "expired-request", "answer": ""},
}
)
assert response["error"]["code"] == 4009
def test_clear_pending(server):
ev = threading.Event()
# _pending values are (sid, Event) tuples

View file

@ -17,6 +17,7 @@ import os
import re
import shlex
import sys
import tempfile
import threading
import time
import unicodedata
@ -1383,12 +1384,36 @@ def _command_detection_variants(command: str):
yield variant
def _is_verification_artifact_cleanup(command: str) -> bool:
"""Return whether *command* only removes one Hermes ad-hoc temp script."""
try:
argv = shlex.split(command, posix=True)
except ValueError:
return False
if len(argv) != 3 or argv[0] != "rm" or argv[1] != "-f":
return False
operand = argv[2]
temp_dir = os.path.realpath(tempfile.gettempdir())
basename = os.path.basename(operand)
if operand != os.path.join(temp_dir, basename):
return False
target = os.path.realpath(operand)
if os.path.dirname(target) != temp_dir:
return False
return re.fullmatch(r"hermes-(?:verify|ad-hoc)-[A-Za-z0-9_.-]+", basename) is not None
def detect_dangerous_command(command: str) -> tuple:
"""Check if a command matches any dangerous patterns.
Returns:
(is_dangerous, pattern_key, description) or (False, None, None)
"""
if _is_verification_artifact_cleanup(command):
return (False, None, None)
for command_variant in _command_detection_variants(command):
command_lower = command_variant.lower()
for pattern_re, description in DANGEROUS_PATTERNS_COMPILED:

View file

@ -958,7 +958,18 @@ class ShellFileOperations(FileOperations):
return path
def _escape_shell_arg(self, arg: str) -> str:
"""Escape a string for safe use in shell commands."""
"""Escape a string for safe use in shell commands.
On Windows a native drive path (``C:\\Users\\x``) is first rewritten to
the Git Bash / MSYS ``/c/Users/x`` form: bash eats the backslashes and
MSYS otherwise mangles ``C:\\...`` (the "Directory \\drivers\\etc does not
exist" class of failures). Reuses the env-layer translator so shell file
ops and the terminal ``cd`` agree on the path form. No-op off Windows and
for non-drive-qualified paths.
"""
from tools.environments.local import _windows_to_msys_path
arg = _windows_to_msys_path(arg)
# Use single quotes and escape any single quotes in the string
return "'" + arg.replace("'", "'\"'\"'") + "'"

View file

@ -25,12 +25,16 @@ Usage:
import re
import fnmatch
import hashlib
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Tuple
SCANNER_VERSION = "skills-guard-v1"
# ---------------------------------------------------------------------------
@ -87,6 +91,7 @@ class ScanResult:
findings: List[Finding] = field(default_factory=list)
scanned_at: str = ""
summary: str = ""
scan_provenance: dict = field(default_factory=dict)
# ---------------------------------------------------------------------------
@ -683,6 +688,81 @@ def scan_skill(skill_path: Path, source: str = "community") -> ScanResult:
)
def _content_digest(skill_path: Path) -> str:
"""Canonical SHA-256 over relative paths and exact file bytes."""
h = hashlib.sha256()
if skill_path.is_dir():
for file_path in sorted(skill_path.rglob("*")):
if file_path.is_file():
rel = file_path.relative_to(skill_path).as_posix()
h.update(rel.encode("utf-8") + b"\x00")
h.update(file_path.read_bytes())
else:
h.update(skill_path.read_bytes())
return h.hexdigest()
def full_content_hash(skill_path: Path) -> str:
"""Full canonical digest used to bind scanner attestations."""
return f"sha256:{_content_digest(skill_path)}"
def _finding_dict(finding: Finding) -> dict:
return {key: getattr(finding, key) for key in (
"pattern_id", "severity", "category", "file", "line", "match", "description"
)}
def scan_skill_cached(
skill_path: Path,
source: str = "community",
*,
source_url: str = "",
cache_dir: Path | None = None,
) -> Tuple[ScanResult, dict]:
"""Return a scan plus attestation, caching only exact current content."""
bundle_hash = full_content_hash(skill_path)
cache_root = cache_dir or skill_path.parent / ".scan-cache"
source_identity = hashlib.sha256(f"{source}\0{source_url}".encode("utf-8")).hexdigest()[:16]
cache_file = cache_root / f"{bundle_hash.split(':', 1)[1]}-{source_identity}.json"
try:
cached = json.loads(cache_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
cached = None
if (isinstance(cached, dict)
and cached.get("bundle_hash") == bundle_hash
and cached.get("scanner_version") == SCANNER_VERSION
and cached.get("source") == source
and cached.get("source_url") == source_url):
result = ScanResult(
skill_name=skill_path.name, source=source,
trust_level=cached["trust_level"], verdict=cached["verdict"],
findings=[Finding(**item) for item in cached.get("findings", [])],
scanned_at=cached["scanned_at"], summary=cached.get("summary", ""),
)
provenance = dict(cached)
provenance["fresh"] = False
result.scan_provenance = provenance
return result, provenance
result = scan_skill(skill_path, source=source)
findings = [_finding_dict(item) for item in result.findings]
provenance = {
"source": source, "source_url": source_url, "bundle_hash": bundle_hash,
"scanner_version": SCANNER_VERSION, "verdict": result.verdict,
"trust_level": result.trust_level, "findings": findings,
"rules": sorted({item["pattern_id"] for item in findings}),
"scanned_at": result.scanned_at, "summary": result.summary, "fresh": True,
}
try:
cache_root.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8")
except OSError:
pass
result.scan_provenance = provenance
return result, provenance
def should_allow_install(result: ScanResult, force: bool = False) -> Tuple[bool, str]:
"""
Determine whether a skill should be installed based on scan result and trust.
@ -774,20 +854,7 @@ def content_hash(skill_path: Path) -> str:
one on an in-memory bundle), so any change to the hash shape MUST
land in both places at once.
"""
h = hashlib.sha256()
if skill_path.is_dir():
for f in sorted(skill_path.rglob("*")):
if f.is_file():
try:
rel = f.relative_to(skill_path).as_posix()
h.update(rel.encode("utf-8"))
h.update(b"\x00")
h.update(f.read_bytes())
except OSError:
continue
elif skill_path.is_file():
h.update(skill_path.read_bytes())
return f"sha256:{h.hexdigest()[:16]}"
return f"sha256:{_content_digest(skill_path)[:16]}"
# ---------------------------------------------------------------------------

View file

@ -29,7 +29,7 @@ from hermes_constants import get_hermes_home
from hermes_cli._subprocess_compat import windows_hide_flags
from agent.skill_utils import is_excluded_skill_path
from typing import Any, Dict, List, Optional, Tuple, Union
from urllib.parse import urljoin, urlparse, urlunparse
from urllib.parse import unquote, urljoin, urlparse, urlsplit, urlunparse
import httpx
import yaml
@ -152,6 +152,46 @@ class SkillBundle:
metadata: Dict[str, Any] = field(default_factory=dict)
_ALLOWED_SUPPORT_DIRS = frozenset({"references", "templates", "scripts", "assets", "examples"})
_LOCAL_LINK_RE = re.compile(
r"(?:\]\(|`|(?:^|[\s\"']))((?:references|templates|scripts|assets|examples)/[^\s)`\"'<>]+)",
re.MULTILINE,
)
_SUSPICIOUS_LOCAL_REF_RE = re.compile(
r"(?:references|templates|scripts|assets|examples)/(?:[^\s)`\"'<>]*/)?\.\.(?:/|$)"
)
def _referenced_support_paths(skill_md: str) -> Optional[set[str]]:
"""Extract safe referenced paths; return None on a traversal attempt."""
normalized = skill_md.replace("\\", "/")
if _SUSPICIOUS_LOCAL_REF_RE.search(normalized):
return None
paths: set[str] = set()
for match in _LOCAL_LINK_RE.finditer(normalized):
raw = unquote(urlsplit(match.group(1).rstrip(".,;:")).path)
try:
safe = _validate_bundle_rel_path(raw)
except ValueError:
return None
if safe.split("/", 1)[0] in _ALLOWED_SUPPORT_DIRS:
paths.add(safe)
return paths
def source_url_for_bundle(bundle: SkillBundle) -> str:
"""Best available human-facing immutable-source provenance URL."""
explicit = bundle.metadata.get("source_url") or bundle.metadata.get("url")
if explicit:
return str(explicit)
if bundle.source == "github":
parts = bundle.identifier.split("/", 2)
if len(parts) >= 2:
suffix = f"/tree/main/{parts[2]}" if len(parts) == 3 else ""
return f"https://github.com/{parts[0]}/{parts[1]}{suffix}"
return bundle.identifier
def _normalize_bundle_path(path_value: str, *, field_name: str, allow_nested: bool) -> str:
"""Normalize and validate bundle-controlled paths before touching disk."""
if not isinstance(path_value, str):
@ -535,6 +575,7 @@ class GitHubSource(SkillSource):
# Per-instance cache: repo -> (default_branch, tree_entries)
# Survives within a single search/install flow, avoiding redundant API calls.
self._tree_cache: Dict[str, Tuple[str, List[dict]]] = {}
self._tree_revisions: Dict[str, str] = {}
# Per-repo cache of the optional skills.sh.json grouping sidecar,
# mapping skill_name -> human-readable grouping title. ``None`` means
# "fetched, no sidecar"; a missing key means "not fetched yet".
@ -601,9 +642,40 @@ class GitHubSource(SkillSource):
repo = f"{parts[0]}/{parts[1]}"
skill_path = parts[2]
files = self._download_directory(repo, skill_path)
if not files or "SKILL.md" not in files:
skill_md = self._fetch_file_content(repo, f"{skill_path.rstrip('/')}/SKILL.md")
if skill_md is None:
return None
referenced = _referenced_support_paths(skill_md)
if referenced is None:
return None
files: Dict[str, Union[str, bytes]] = {"SKILL.md": skill_md}
tree = self._get_repo_tree(repo)
if tree is not None:
branch, entries = tree
prefix = f"{skill_path.rstrip('/')}/"
entries_by_path = {item.get("path", ""): item for item in entries}
for rel_path in sorted(referenced):
item_path = f"{prefix}{rel_path}"
item = entries_by_path.get(item_path)
if item is None:
logger.warning("Referenced skill support file is missing: %s", item_path)
return None
if item.get("type") != "blob" or item.get("mode") == "120000":
logger.warning("Rejected non-regular file in skill bundle: %s", item_path)
return None
content = self._fetch_file_bytes(repo, item_path)
if content is None:
return None
files[rel_path] = content
revision = self._tree_revisions.get(repo) or branch
else:
for rel_path in referenced:
content = self._fetch_file_bytes(repo, f"{skill_path.rstrip('/')}/{rel_path}")
if content is None:
return None
files[rel_path] = content
revision = ""
skill_name = skill_path.rstrip("/").split("/")[-1]
trust = self.trust_level_for(identifier)
@ -614,6 +686,13 @@ class GitHubSource(SkillSource):
source="github",
identifier=identifier,
trust_level=trust,
metadata={
"source_url": (
f"https://github.com/{repo}/tree/{revision}/{skill_path}"
if revision else f"https://github.com/{repo}/{skill_path}"
),
"source_revision": revision,
},
)
def inspect(self, identifier: str) -> Optional[SkillMeta]:
@ -752,6 +831,9 @@ class GitHubSource(SkillSource):
return None
entries = tree_data.get("tree", [])
revision = tree_data.get("sha")
if isinstance(revision, str) and revision:
self._tree_revisions[repo] = revision
self._tree_cache[repo] = (default_branch, entries)
return (default_branch, entries)
@ -968,14 +1050,24 @@ class GitHubSource(SkillSource):
return None
def _fetch_file_content(self, repo: str, path: str) -> Optional[str]:
"""Fetch a single file's content from GitHub."""
"""Fetch a single text file from GitHub."""
content = self._fetch_file_bytes(repo, path)
if content is None:
return None
try:
return content.decode("utf-8")
except UnicodeDecodeError:
return None
def _fetch_file_bytes(self, repo: str, path: str) -> Optional[bytes]:
"""Fetch exact file bytes from GitHub without text decoding."""
url = f"https://api.github.com/repos/{repo}/contents/{path}"
resp = self._github_get(
url,
headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"},
)
if resp is not None and resp.status_code == 200:
return resp.text
return resp.content
return None
def _get_skillsh_groupings(self, repo: str) -> Optional[Dict[str, str]]:
@ -1318,12 +1410,12 @@ class WellKnownSkillSource(SkillSource):
# ---------------------------------------------------------------------------
class UrlSource(SkillSource):
"""Fetch a single-file SKILL.md skill directly from an HTTP(S) URL.
"""Fetch SKILL.md plus explicitly referenced, allowlisted support files.
The identifier IS the URL (e.g. ``https://example.com/path/SKILL.md``).
Only single-file skills are supported multi-file skills with
``references/`` or ``scripts/`` subfolders need a manifest we can't
discover from a bare URL.
Bare URLs cannot safely enumerate a repository, so only exact references
below references/templates/scripts/assets are fetched. Other repository
files are never copied.
The skill name is read from the ``name:`` field in the SKILL.md YAML
frontmatter (with a URL-slug fallback). Trust level is always
@ -1402,6 +1494,19 @@ class UrlSource(SkillSource):
fm = GitHubSource._parse_frontmatter_quick(text)
name = self._resolve_skill_name(fm, url)
referenced = _referenced_support_paths(text)
if referenced is None:
return None
files: Dict[str, Union[str, bytes]] = {"SKILL.md": text}
base_url = url.rsplit("/", 1)[0] + "/"
for rel_path in sorted(referenced):
support_url = urljoin(base_url, rel_path)
if urlparse(support_url).netloc != urlparse(url).netloc:
return None
content = self._fetch_bytes(support_url)
if content is None:
return None
files[rel_path] = content
# When auto-resolution fails, return a bundle with an empty name and
# ``awaiting_name=True`` in metadata. The install flow (``do_install``)
@ -1418,11 +1523,11 @@ class UrlSource(SkillSource):
return SkillBundle(
name=skill_name,
files={"SKILL.md": text},
files=files,
source="url",
identifier=url,
trust_level="community",
metadata={"url": url, "awaiting_name": not skill_name},
metadata={"url": url, "source_url": url, "awaiting_name": not skill_name},
)
@staticmethod
@ -1432,6 +1537,13 @@ class UrlSource(SkillSource):
return resp.text
return None
@staticmethod
def _fetch_bytes(url: str) -> Optional[bytes]:
resp = _guarded_http_get(url, timeout=20)
if resp is not None and resp.status_code == 200:
return resp.content
return None
# Skill names must look like identifiers: lowercase letters/digits with
# optional hyphens/underscores. Blocks dangerous (``../evil``) AND useless
# (``SKILL``, ``README``, empty) candidates before they hit the disk.
@ -3304,6 +3416,7 @@ class HubLockFile:
install_path: str,
files: List[str],
metadata: Optional[Dict[str, Any]] = None,
scan_provenance: Optional[Dict[str, Any]] = None,
) -> None:
# Validate both the skill name and the install path SHAPE before
# writing into lock.json. A poisoned lock entry is the precondition
@ -3321,6 +3434,7 @@ class HubLockFile:
"install_path": safe_install_path,
"files": files,
"metadata": metadata or {},
"scan_provenance": scan_provenance or {},
"installed_at": datetime.now(timezone.utc).isoformat(),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
@ -3461,6 +3575,7 @@ def install_from_quarantine(
category: str,
bundle: SkillBundle,
scan_result: ScanResult,
scan_provenance: Optional[Dict[str, Any]] = None,
) -> Path:
"""Move a scanned skill from quarantine into the skills directory."""
safe_skill_name = _validate_skill_name(skill_name)
@ -3529,6 +3644,7 @@ def install_from_quarantine(
install_path=str(install_dir.relative_to(_skills_dir())),
files=list(bundle.files.keys()),
metadata=bundle.metadata,
scan_provenance=scan_provenance or getattr(scan_result, "scan_provenance", None),
)
append_audit_log(

View file

@ -2038,15 +2038,26 @@ def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str:
_pending[rid] = (sid, ev)
payload["request_id"] = rid
_pending_prompt_payloads[rid] = (event, dict(payload))
answered = False
answer = ""
answer_present = False
try:
_emit(event, sid, payload)
ev.wait(timeout=timeout)
answered = ev.wait(timeout=timeout)
finally:
with _prompt_lock:
_pending.pop(rid, None)
_pending_prompt_payloads.pop(rid, None)
with _prompt_lock:
return _answers.pop(rid, "")
answer_present = rid in _answers
answer = _answers.pop(rid, "")
if not answered and not answer_present and event in {"secret.request", "sudo.request"}:
_emit(
f"{event.removesuffix('.request')}.expire",
sid,
{"request_id": rid},
)
return answer
def _clear_pending(sid: str | None = None) -> None:
@ -10166,11 +10177,13 @@ def _(rid, params: dict) -> dict:
# ── Methods: respond ─────────────────────────────────────────────────
def _respond(rid, params, key):
def _respond(rid, params, key, *, allow_expired=False):
r = params.get("request_id", "")
with _prompt_lock:
entry = _pending.get(r)
if not entry:
if allow_expired and r:
return _ok(rid, {"status": "expired"})
return _err(rid, 4009, f"no pending {key} request")
_, ev = entry
_answers[r] = params.get(key, "")
@ -10191,12 +10204,12 @@ def _(rid, params: dict) -> dict:
@method("sudo.respond")
def _(rid, params: dict) -> dict:
return _respond(rid, params, "password")
return _respond(rid, params, "password", allow_expired=True)
@method("secret.respond")
def _(rid, params: dict) -> dict:
return _respond(rid, params, "value")
return _respond(rid, params, "value", allow_expired=True)
@method("approval.respond")

View file

@ -287,7 +287,9 @@ Primary event types the client handles today:
| `clarify.request` | `{ question, choices?, request_id }` |
| `approval.request` | `{ command, description, allow_permanent? }` |
| `sudo.request` | `{ request_id }` |
| `sudo.expire` | `{ request_id }` clears a timed-out sudo prompt |
| `secret.request` | `{ prompt, env_var, request_id }` |
| `secret.expire` | `{ request_id }` clears a timed-out secret prompt |
| `background.complete` | `{ task_id, text }` |
| `billing.step_up.verification` | `{ verification_url, user_code }` |
| `review.summary` | `{ text }` |
@ -487,4 +489,4 @@ tui_gateway/
server.py RPC handlers and session logic
render.py optional rich/ANSI bridge
slash_worker.py persistent HermesCLI subprocess for slash commands
```
```

View file

@ -1307,6 +1307,24 @@ describe('createGatewayEventHandler', () => {
expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false)
})
it('clears only the matching sensitive prompt when the gateway expires it', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
patchOverlayState({
secret: { envVar: 'NEW_KEY', prompt: 'Enter new key', requestId: 'secret-new' },
sudo: { requestId: 'sudo-1' }
})
onEvent({ payload: { request_id: 'secret-old' }, type: 'secret.expire' } as any)
expect(getOverlayState().secret?.requestId).toBe('secret-new')
onEvent({ payload: { request_id: 'secret-new' }, type: 'secret.expire' } as any)
expect(getOverlayState().secret).toBeNull()
onEvent({ payload: { request_id: 'sudo-1' }, type: 'sudo.expire' } as any)
expect(getOverlayState().sudo).toBeNull()
})
// ── Credits notice (Strategy B) ──────────────────────────────────────
describe('credits notice', () => {
it('shows a notice immediately when idle (no turn in flight)', () => {

View file

@ -1,7 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js'
import {
applyVoiceRecordResponse,
dismissSensitivePrompt,
handleIdleHotkeyExit,
shouldAllowIdleHotkeyExit,
shouldFallThroughForScroll
@ -112,3 +114,33 @@ describe('applyVoiceRecordResponse', () => {
expect(setProcessing).toHaveBeenCalledWith(false)
})
})
describe('dismissSensitivePrompt', () => {
it('clears a sudo overlay before a stale cancel RPC resolves', async () => {
resetOverlayState()
patchOverlayState({ sudo: { requestId: 'sudo-1' } })
const rpc = vi.fn().mockResolvedValue(null)
const sys = vi.fn()
const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys)
expect(getOverlayState().sudo).toBeNull()
expect(sys).toHaveBeenCalledWith('sudo cancelled')
expect(rpc).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' })
await pending
})
it('clears a secret overlay before a stale cancel RPC resolves', async () => {
resetOverlayState()
patchOverlayState({ secret: { envVar: 'API_KEY', prompt: 'Enter API key', requestId: 'secret-1' } })
const rpc = vi.fn().mockResolvedValue(null)
const sys = vi.fn()
const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys)
expect(getOverlayState().secret).toBeNull()
expect(sys).toHaveBeenCalledWith('secret entry cancelled')
expect(rpc).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' })
await pending
})
})

View file

@ -807,6 +807,16 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
case 'sudo.expire':
patchOverlayState(prev => (prev.sudo?.requestId === ev.payload.request_id ? { ...prev, sudo: null } : prev))
return
case 'secret.expire':
patchOverlayState(prev => (prev.secret?.requestId === ev.payload.request_id ? { ...prev, secret: null } : prev))
return
case 'background.complete':
dropBgTask(ev.payload.task_id)
sys(`[bg ${ev.payload.task_id}] ${ev.payload.text}`)

View file

@ -16,7 +16,13 @@ import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionW
import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
import { getInputSelection } from './inputSelectionStore.js'
import type { InputHandlerActions, InputHandlerContext, InputHandlerResult } from './interfaces.js'
import type {
GatewayRpc,
InputHandlerActions,
InputHandlerContext,
InputHandlerResult,
OverlayState
} from './interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
import { patchTurnState } from './turnStore.js'
@ -97,6 +103,30 @@ export function applyVoiceRecordResponse(
}
}
export function dismissSensitivePrompt(
overlay: Pick<OverlayState, 'secret' | 'sudo'>,
rpc: GatewayRpc,
sys: (text: string) => void
) {
if (overlay.sudo) {
const requestId = overlay.sudo.requestId
patchOverlayState({ sudo: null })
sys('sudo cancelled')
return rpc<SudoRespondResponse>('sudo.respond', { password: '', request_id: requestId })
}
if (overlay.secret) {
const requestId = overlay.secret.requestId
patchOverlayState({ secret: null })
sys('secret entry cancelled')
return rpc<SecretRespondResponse>('secret.respond', { request_id: requestId, value: '' })
}
}
export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
const { actions, composer, gateway, terminal, voice, wheelStep } = ctx
const { actions: cActions, refs: cRefs, state: cState } = composer
@ -149,16 +179,8 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
.then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' })))
}
if (overlay.sudo) {
return gateway
.rpc<SudoRespondResponse>('sudo.respond', { password: '', request_id: overlay.sudo.requestId })
.then(r => r && (patchOverlayState({ sudo: null }), actions.sys('sudo cancelled')))
}
if (overlay.secret) {
return gateway
.rpc<SecretRespondResponse>('secret.respond', { request_id: overlay.secret.requestId, value: '' })
.then(r => r && (patchOverlayState({ secret: null }), actions.sys('secret entry cancelled')))
if (overlay.sudo || overlay.secret) {
return dismissSensitivePrompt(overlay, gateway.rpc, actions.sys)
}
if (overlay.modelPicker) {
@ -373,7 +395,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return
}
if (isCtrl(key, ch, 'c')) {
if (isCtrl(key, ch, 'c') || (key.escape && (overlay.secret || overlay.sudo))) {
cancelOverlayFromCtrlC()
} else if (key.escape && overlay.sessions) {
patchOverlayState({ sessions: false })

View file

@ -911,7 +911,13 @@ export function useMainApp(gw: GatewayClient) {
return
}
return respondWith('sudo.respond', { password: pw, request_id: overlay.sudo.requestId }, () => {
const requestId = overlay.sudo.requestId
if (!pw) {
patchOverlayState({ sudo: null })
}
return respondWith('sudo.respond', { password: pw, request_id: requestId }, () => {
patchOverlayState({ sudo: null })
patchUiState({ status: 'running…' })
})
@ -925,7 +931,13 @@ export function useMainApp(gw: GatewayClient) {
return
}
return respondWith('secret.respond', { request_id: overlay.secret.requestId, value }, () => {
const requestId = overlay.secret.requestId
if (!value) {
patchOverlayState({ secret: null })
}
return respondWith('secret.respond', { request_id: requestId, value }, () => {
patchOverlayState({ secret: null })
patchUiState({ status: 'running…' })
})

View file

@ -698,6 +698,7 @@ export type GatewayEvent =
}
| { payload: { request_id: string }; session_id?: string; type: 'sudo.request' }
| { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' }
| { payload: { request_id: string }; session_id?: string; type: 'secret.expire' | 'sudo.expire' }
| { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' }
| { payload?: { text?: string }; session_id?: string; type: 'review.summary' }
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' }

View file

@ -336,7 +336,11 @@ export default function ChannelsPage() {
{editing && (
<div
ref={editModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
className={cn(
"fixed inset-0 z-[100] flex min-h-dvh items-start justify-center overflow-y-auto bg-background/85 px-4",
"pb-[calc(1rem+env(safe-area-inset-bottom))] pt-[calc(1rem+env(safe-area-inset-top))]",
"sm:items-center sm:p-4",
)}
onClick={(e) => e.target === e.currentTarget && setEditing(null)}
role="dialog"
aria-modal="true"
@ -345,7 +349,7 @@ export default function ChannelsPage() {
<div
className={cn(
themedBody,
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col max-h-[90vh]",
"relative flex max-h-[calc(100dvh-2rem)] w-full max-w-lg flex-col border border-border bg-card shadow-2xl sm:max-h-[90dvh]",
)}
>
<Button
@ -377,7 +381,7 @@ export default function ChannelsPage() {
)}
</header>
<div className="p-5 grid gap-4 overflow-y-auto">
<div className="grid gap-4 overflow-y-auto overscroll-contain p-4 sm:p-5">
<p className="text-xs text-muted-foreground">
{editing.description}
</p>
@ -407,6 +411,7 @@ export default function ChannelsPage() {
<Input
id={`field-${field.key}`}
type={field.is_password ? "password" : "text"}
className="text-base leading-6 sm:text-xs sm:leading-4"
placeholder={
field.is_set
? field.redacted_value || "•••••• (set — leave blank to keep)"
@ -433,12 +438,17 @@ export default function ChannelsPage() {
</div>
))}
<div className="flex justify-end gap-2 pt-1">
<Button ghost size="sm" onClick={() => setEditing(null)}>
<div className="flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:justify-end">
<Button
ghost
size="sm"
className="w-full sm:w-auto"
onClick={() => setEditing(null)}
>
Cancel
</Button>
<Button
className="uppercase"
className="w-full uppercase sm:w-auto"
size="sm"
onClick={handleSave}
disabled={saving}

View file

@ -57,7 +57,7 @@ terminal.resize clipboard.paste image.attach
### Events streamed back
`message.delta`, `message.complete`, `tool.start`, `tool.progress`, `tool.complete`, `approval.request`, `clarify.request`, `sudo.request`, `secret.request`, `gateway.ready`, plus session lifecycle and error events.
`message.delta`, `message.complete`, `tool.start`, `tool.progress`, `tool.complete`, `approval.request`, `clarify.request`, `sudo.request`, `sudo.expire`, `secret.request`, `secret.expire`, `gateway.ready`, plus session lifecycle and error events. Expiry events carry the original `{ request_id }`; external hosts should clear only the matching pending prompt.
### Pi-style RPC mapping

View file

@ -95,7 +95,7 @@ hermes skills install official/research/arxiv
# Install from the hub in a chat session
/skills install official/creative/songwriting-and-ai-music
# Install a single-file SKILL.md directly from any HTTP(S) URL
# Install SKILL.md and its referenced support files from an HTTP(S) URL
hermes skills install https://sharethis.chat/SKILL.md
/skills install https://example.com/SKILL.md --name my-skill
```

View file

@ -1065,7 +1065,7 @@ hermes skills inspect official/security/1password
hermes skills inspect skills-sh/vercel-labs/json-render/json-render-react
hermes skills install official/migration/openclaw-migration
hermes skills install skills-sh/anthropics/skills/pdf --force
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md)
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (+ referenced support files)
hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none
hermes skills check
hermes skills update
@ -1083,7 +1083,7 @@ Notes:
- `--source skills-sh` searches the public `skills.sh` directory.
- `--source well-known` lets you point Hermes at a site exposing `/.well-known/skills/index.json`.
- `--source browse-sh` searches [browse.sh](https://browse.sh)'s catalog of 200+ site-specific browser-automation skills. Identifiers look like `browse-sh/airbnb.com/search-listings-ddgioa`.
- Passing an `http(s)://…/*.md` URL installs a single-file SKILL.md directly. When frontmatter has no `name:` and the URL slug isn't a valid identifier, an interactive terminal prompts for a name; non-interactive surfaces (`/skills install` inside the TUI, gateway platforms) require `--name <x>` instead.
- Passing an `http(s)://…/*.md` URL installs `SKILL.md` plus explicitly referenced files under `references/`, `templates/`, `scripts/`, `assets/`, and `examples/`. When frontmatter has no `name:` and the URL slug isn't a valid identifier, an interactive terminal prompts for a name; non-interactive surfaces (`/skills install` inside the TUI, gateway platforms) require `--name <x>` instead.
## `hermes bundles`

View file

@ -276,9 +276,18 @@ Statuses are retained briefly after terminal states (`completed`, `failed`, or `
Server-Sent Events stream of the run's tool-call progress, token deltas, and lifecycle events. Designed for dashboards and thick clients that want to attach/detach without losing state.
Unconsumed event buffers expire after five minutes so a detached client cannot
grow memory indefinitely. This expires transport state only: a run that is
still executing remains visible to status polling, approval, stop control, and
concurrency accounting until its executor work actually exits. A connected SSE
subscriber continues draining normally.
### POST /v1/runs/\{run_id\}/stop
Interrupt a running agent turn. The endpoint returns immediately with `{"status": "stopping"}` while Hermes asks the active agent to stop at the next safe interruption point.
The run stays tracked as `stopping` until the executor-backed work exits, then
settles as `cancelled`; requesting stop never hides a worker that is still
running.
### POST /v1/runs/\{run_id\}/approval

View file

@ -290,6 +290,7 @@ See [Skill Settings](/user-guide/configuration#skill-settings) and [Creating Ski
│ │ ├── references/ # Additional docs
│ │ ├── templates/ # Output formats
│ │ ├── scripts/ # Helper scripts callable from the skill
│ │ ├── examples/ # Referenced example outputs
│ │ └── assets/ # Supplementary files
│ └── vllm/
│ └── SKILL.md
@ -304,6 +305,13 @@ See [Skill Settings](/user-guide/configuration#skill-settings) and [Creating Ski
└── .bundled_manifest # Tracks seeded bundled skills
```
Third-party URL and GitHub installs include `SKILL.md` plus the exact local
files it references under `references/`, `templates/`, `scripts/`, `assets/`,
and `examples/`. Unreferenced repository files are not copied. Hermes scans the
complete quarantined bundle and records the source URL, exact content hash,
scanner version, findings, timestamp, and fresh-or-cached status in
`skills/.hub/lock.json`.
## External Skill Directories
If you maintain skills outside of Hermes — for example, a shared `~/.agents/skills/` directory used by multiple AI tools — you can tell Hermes to scan those directories too.
@ -517,7 +525,7 @@ hermes skills install openai/skills/k8s # Install with security scan
hermes skills install official/security/1password
hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force
hermes skills install well-known:https://mintlify.com/docs/.well-known/skills/mintlify
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md)
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (+ referenced support files)
hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none
hermes skills list --source hub # List hub-installed skills
hermes skills check # Check installed hub skills for upstream updates
@ -538,7 +546,7 @@ hermes skills tap add myorg/skills-repo # Add a custom GitHub source
| `official` | `official/security/1password` | Optional skills shipped with Hermes. |
| `skills-sh` | `skills-sh/vercel-labs/agent-skills/vercel-react-best-practices` | Searchable via `hermes skills search <query> --source skills-sh`. Hermes resolves alias-style skills when the skills.sh slug differs from the repo folder. |
| `well-known` | `well-known:https://mintlify.com/docs/.well-known/skills/mintlify` | Skills served directly from `/.well-known/skills/index.json` on a website. Search using the site or docs URL. |
| `url` | `https://sharethis.chat/SKILL.md` | Direct HTTP(S) URL to a single-file `SKILL.md`. Name resolution: frontmatter → URL slug → interactive prompt → `--name` flag. |
| `url` | `https://sharethis.chat/SKILL.md` | Direct HTTP(S) URL to `SKILL.md` plus explicitly referenced support files. Name resolution: frontmatter → URL slug → interactive prompt → `--name` flag. |
| `github` | `openai/skills/k8s` | Direct GitHub repo/path installs and custom taps. |
| `clawhub`, `lobehub`, `browse-sh` | Source-specific identifiers | Community or marketplace integrations. |
@ -670,11 +678,11 @@ Identifiers use the form `browse-sh/<hostname>/<task-id>` and match the slug exp
#### 9. Direct URL (`url`)
Install a single-file `SKILL.md` directly from any HTTP(S) URL — useful when an author hosts a skill on their own site (no hub listing, no GitHub path to type). Hermes fetches the URL, parses the YAML frontmatter, security-scans it, and installs.
Install `SKILL.md` directly from any HTTP(S) URL — useful when an author hosts a skill on their own site (no hub listing, no GitHub path to type). Hermes also fetches explicitly referenced files under `references/`, `templates/`, `scripts/`, `assets/`, and `examples/`, then scans and installs the complete bundle.
- Hermes source id: `url`
- Identifier: the URL itself (no prefix needed)
- Scope: **single-file `SKILL.md`** only. Multi-file skills with `references/` or `scripts/` need a manifest and should be published via one of the other sources above.
- Scope: `SKILL.md` plus exact referenced support files in the allowlisted directories. Hermes does not enumerate or copy unrelated files from the host.
```bash
hermes skills install https://sharethis.chat/SKILL.md

View file

@ -194,7 +194,7 @@ A form-based editor for `config.yaml`. All 150+ configuration fields are auto-di
- **agent** — max iterations, gateway timeout, service tier
- **delegation** — subagent limits, reasoning effort
- **memory** — provider selection, context injection settings
- **approvals** — dangerous command approval mode (ask/yolo/deny)
- **approvals** — dangerous command approval mode (smart/manual/off)
- And more — every section of config.yaml has corresponding form fields
Fields with known valid values (terminal backend, skin, approval mode, etc.) render as dropdowns. Booleans render as toggles. Everything else is a text input.

View file

@ -95,7 +95,7 @@ hermes skills install official/research/arxiv
# 在聊天会话中从 Hub 安装
/skills install official/creative/songwriting-and-ai-music
# 直接从任意 HTTP(S) URL 安装单文件 SKILL.md
# 从 HTTP(S) URL 安装 SKILL.md 及其引用的支持文件
hermes skills install https://sharethis.chat/SKILL.md
/skills install https://example.com/SKILL.md --name my-skill
```

View file

@ -820,7 +820,7 @@ hermes skills inspect official/security/1password
hermes skills inspect skills-sh/vercel-labs/json-render/json-render-react
hermes skills install official/migration/openclaw-migration
hermes skills install skills-sh/anthropics/skills/pdf --force
hermes skills install https://sharethis.chat/SKILL.md # 直接 URL单文件 SKILL.md
hermes skills install https://sharethis.chat/SKILL.md # 直接 URL含引用的支持文件
hermes skills install https://example.com/SKILL.md --name my-skill # frontmatter 无名称时覆盖名称
hermes skills check
hermes skills update
@ -835,7 +835,7 @@ hermes skills reset google-workspace --restore --yes
- `--source skills-sh` 搜索公共 `skills.sh` 目录。
- `--source well-known` 允许你将 Hermes 指向暴露 `/.well-known/skills/index.json` 的站点。
- `--source browse-sh` 搜索 [browse.sh](https://browse.sh) 包含 200+ 站点特定浏览器自动化 skill 的目录。标识符形如 `browse-sh/airbnb.com/search-listings-ddgioa`
- 传入 `http(s)://…/*.md` URL 可直接安装单文件 SKILL.md。当 frontmatter 没有 `name:` 且 URL slug 不是有效标识符时交互式终端会提示输入名称非交互式界面TUI 内的 `/skills install`、gateway 平台)需要改用 `--name <x>`
- 传入 `http(s)://…/*.md` URL 可安装 `SKILL.md`,以及其中明确引用且位于 `references/``templates/``scripts/``assets/``examples/` 下的文件。当 frontmatter 没有 `name:` 且 URL slug 不是有效标识符时交互式终端会提示输入名称非交互式界面TUI 内的 `/skills install`、gateway 平台)需要改用 `--name <x>`
## `hermes bundles`

View file

@ -270,9 +270,12 @@ Runs 接受简单的 `input` 字符串,以及可选的 `session_id`、`instruc
run 的工具调用进度、token 增量和生命周期事件的 Server-Sent Events 流。专为需要附加/分离而不丢失状态的仪表板和厚客户端设计。
未消费的事件缓冲区会在五分钟后过期,避免已断开的客户端导致内存无限增长。这里只会过期传输状态:仍在执行的 run 会继续保留在状态轮询、审批、停止控制和并发计数中,直到其 executor 工作真正退出。已连接的 SSE 订阅者会继续正常消费事件。
### POST /v1/runs/\{run_id\}/stop
中断正在运行的 agent 轮次。端点立即返回 `{"status": "stopping"}`,同时 Hermes 要求活跃 agent 在下一个安全中断点停止。
run 会保持 `stopping` 并继续被跟踪,直到 executor 支持的工作退出,然后进入 `cancelled`;停止请求不会隐藏仍在运行的 worker。
## Jobs API后台计划任务

View file

@ -204,6 +204,7 @@ metadata:
│ │ ├── references/ # Additional docs
│ │ ├── templates/ # Output formats
│ │ ├── scripts/ # Helper scripts callable from the skill
│ │ ├── examples/ # Referenced example outputs
│ │ └── assets/ # Supplementary files
│ └── vllm/
│ └── SKILL.md
@ -218,6 +219,8 @@ metadata:
└── .bundled_manifest # Tracks seeded bundled skills
```
通过第三方 URL 或 GitHub 安装时Hermes 会安装 `SKILL.md`,以及其中明确引用且位于 `references/``templates/``scripts/``assets/``examples/` 下的文件。未引用的仓库文件不会被复制。Hermes 会扫描完整的隔离捆绑包,并在 `skills/.hub/lock.json` 中记录来源 URL、精确内容哈希、扫描器版本、发现项、时间戳以及本次结果是新扫描还是缓存复用。
## 外部 Skill 目录
如果你在 Hermes 之外维护 skills——例如供多个 AI 工具使用的共享 `~/.agents/skills/` 目录——你可以告诉 Hermes 也扫描这些目录。
@ -388,7 +391,7 @@ hermes skills install openai/skills/k8s # Install with security scan
hermes skills install official/security/1password
hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force
hermes skills install well-known:https://mintlify.com/docs/.well-known/skills/mintlify
hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md)
hermes skills install https://sharethis.chat/SKILL.md # 直接 URL含引用的支持文件
hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none
hermes skills list --source hub # List hub-installed skills
hermes skills check # Check installed hub skills for upstream updates
@ -409,7 +412,7 @@ hermes skills tap add myorg/skills-repo # Add a custom GitHub source
| `official` | `official/security/1password` | Hermes 随附的可选 skills。 |
| `skills-sh` | `skills-sh/vercel-labs/agent-skills/vercel-react-best-practices` | 可通过 `hermes skills search <query> --source skills-sh` 搜索。当 skills.sh slug 与仓库文件夹不同时Hermes 会解析别名式 skills。 |
| `well-known` | `well-known:https://mintlify.com/docs/.well-known/skills/mintlify` | 直接从网站的 `/.well-known/skills/index.json` 提供的 skills。使用站点或文档 URL 搜索。 |
| `url` | `https://sharethis.chat/SKILL.md` | 指向单文件 `SKILL.md` 的直接 HTTP(S) URL。名称解析顺序frontmatter → URL slug → 交互式提示 → `--name` 标志。 |
| `url` | `https://sharethis.chat/SKILL.md` | 指向 `SKILL.md` 及其明确引用的支持文件的直接 HTTP(S) URL。名称解析顺序frontmatter → URL slug → 交互式提示 → `--name` 标志。 |
| `github` | `openai/skills/k8s` | 直接从 GitHub 仓库/路径安装以及基于 GitHub 的自定义 tap。 |
| `clawhub``lobehub``browse-sh``claude-marketplace` | 来源特定标识符 | 社区或市场集成。 |
@ -523,11 +526,11 @@ hermes skills install browse-sh/airbnb.com/search-listings-ddgioa
#### 9. 直接 URL`url`
直接从任何 HTTP(S) URL 安装单文件 `SKILL.md`——当作者在自己的站点上托管 skill 时非常有用(无 hub 列表,无需输入 GitHub 路径。Hermes 获取 URL解析 YAML frontmatter进行安全扫描并安装
直接从任何 HTTP(S) URL 安装 `SKILL.md`——当作者在自己的站点上托管 skill 时非常有用(无 hub 列表,无需输入 GitHub 路径。Hermes 还会获取其中明确引用且位于 `references/``templates/``scripts/``assets/``examples/` 下的文件,然后扫描并安装完整捆绑包
- Hermes 来源 id`url`
- 标识符URL 本身(无需前缀)
- 范围:**仅限单文件 `SKILL.md`**。包含 `references/``scripts/` 的多文件 skills 需要清单,应通过上述其他来源之一发布
- 范围:`SKILL.md` 加上允许目录中明确引用的支持文件。Hermes 不会枚举或复制托管站点上的其他文件
```bash
hermes skills install https://sharethis.chat/SKILL.md

View file

@ -95,7 +95,7 @@ Chat 标签页是每次 `hermes dashboard` 启动的一部分——内嵌的浏
- **agent** — 最大迭代次数、gateway 超时、服务层级
- **delegation** — 子 agent 限制、推理力度
- **memory** — 提供商选择、上下文注入设置
- **approvals** — 危险命令审批模式(ask/yolo/deny
- **approvals** — 危险命令审批模式(smart/manual/off
- 更多——config.yaml 的每个部分都有对应的表单字段
具有已知有效值的字段terminal 后端、皮肤、审批模式等)渲染为下拉菜单。布尔值渲染为开关。其余均为文本输入框。