feat: claude -p subprocess bypass for Anthropic subscription billing#1
Merged
Conversation
…or (NousResearch#35142) A git conflict resolution (reset --hard or merge) can revert hermes_cli/__init__.py to a stale __version__ while pyproject.toml stays current, so 'hermes --version' silently reports the wrong version. Nothing cross-checked the two files. Add a version-consistency check to the doctor 'Python Environment' section: reads the [project] version from pyproject.toml and compares it to hermes_cli.__version__. Reports OK when they match, fails with a re-sync hint when they drift, and is a silent no-op for installed wheels where pyproject.toml isn't present. Closes NousResearch#35070
The subagent spawn-observability overlay added a `(/agents)` hint, but
only on the standalone "Spawn tree" panel, gated behind `!inlineDelegateKey`
— it never showed for a single delegate_task call, and only appeared once
subagents had already registered. A nudge that arrives at the end (or only
after spawn) is useless for the actual goal: letting users open the live
monitor *while* delegation is running.
Surface it the moment delegation starts, on both surfaces:
TUI (ui-tui/src/components/thinking.tsx)
- Show `(/agents)` on any "Delegate Task" tool group as soon as it appears
(in-flight, before any subagent registers), not gated on subagents
already existing. Same `startsWith('Delegate Task')` predicate already
used for delegateGroups.
CLI (agent/tool_executor.py)
- Append `· /agents to monitor` to the delegate spinner label, which is
displayed for the full duration of the delegate_task call. The previous
attempt put the hint on the completion line (get_cute_tool_message),
which only renders after the call finishes — reverted.
TUI tsc clean (pre-existing execFileNoThrow type errors unrelated);
subagentTree 35/35; display.py reverted to upstream.
…udge-single-delegate fix: surface /agents nudge for single-delegate fan-out (TUI + CLI)
…search#35221) _download_image() wrapped every download attempt in a blanket `except Exception` and retried 3x with 2s/4s/8s backoff regardless of cause. A 404/403 image URL would never resolve on retry, so it just burned up to 6s of wall-clock + extra GETs before failing — inflating latency for a deterministic failure (issue NousResearch#32296, umbrella NousResearch#35114). Add _is_retryable_download_error(): 4xx client errors (except 429), website-policy PermissionError, and too-large/SSRF ValueError now raise on the first attempt. 429, 5xx, and unclassified network errors stay retryable. Removed the now-unreachable fall-through branch since the loop always returns on success or re-raises on the final/terminal attempt.
…erve UTF-8 When writing text to the clipboard via PowerShell (WSL2 and native Windows), the previous implementation piped text through stdin using `Set-Clipboard -Value $input`. PowerShell reads stdin using the Windows system's default ANSI code page (e.g. CP936 for Chinese Windows), causing all non-ASCII characters (CJK, emoji, accented) to become garbled. Fix: encode the text as base64 in Node.js and pass it as a command argument. PowerShell decodes it from base64 using explicit UTF-8, bypassing the code page issue entirely. Fixes NousResearch#35107
The per-entry psScript callback was identical for every PowerShell entry, so the function-valued union member added structure without behavior. Collapse WriteCmd to a plain stdin boolean and apply the one shared base64 script in the write loop. Document the CP936 root cause inline. Co-authored-by: BROCCOLO1D <279959838+BROCCOLO1D@users.noreply.github.com>
…n open Legacy kanban boards (pre-AUTOINCREMENT schema) crashed the gateway notifier on every tick — int(None) on a NULL id in unseen_events_for_sub — silently losing all kanban notifications. CREATE TABLE IF NOT EXISTS skips existing tables regardless of schema and _add_column_if_missing only adds columns, so neither could fix a drifted primary-key type. _rebuild_drifted_tables() detects the legacy shape via PRAGMA table_info and rebuilds task_events/task_comments/task_runs (TEXT PK -> INTEGER AUTOINCREMENT) and kanban_notify_subs.last_event_id (TEXT/NULL -> INTEGER NOT NULL DEFAULT 0), preserving data. The whole pass is one transaction so an interruption can't leave a table half-renamed, and recreates every index DROP TABLE would otherwise take down (including idx_events_run). Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
…on budget recompute_ready() previously reset consecutive_failures to 0 when auto-recovering a blocked task. This defeated the circuit-breaker: a task that repeatedly exhausted its iteration budget would cycle forever (block → auto-recover with counter=0 → respawn → budget exhausted → block → …) with no signal to the operator. Fix: don't auto-recover tasks whose consecutive_failures has reached the effective failure limit (per-task max_retries or DEFAULT_FAILURE_LIMIT). The counter is also preserved across recovery so the breaker can accumulate across cycles. Fixes NousResearch#35072
…ilure_limit Follow-up to the budget-exhaustion recovery fix. recompute_ready's new circuit-breaker guard resolved its effective limit from per-task max_retries -> DEFAULT_FAILURE_LIMIT, skipping the dispatcher's configured kanban.failure_limit. _record_task_failure resolves max_retries -> failure_limit(config) -> DEFAULT, so the two disagreed whenever an operator set kanban.failure_limit != 2: - config > 2: a task could get stuck at DEFAULT(2) before reaching its allowed retry count. - config < 2: a task the breaker already blocked could be auto-recovered back to ready, defeating the stricter limit. Thread the dispatcher's failure_limit through dispatch_once into recompute_ready so the guard and the breaker share one resolution order. Updated test_circuit_breaker_block_still_auto_promotes (it asserted a failures=5 block auto-recovers and resets the counter — that's the pre-NousResearch#35072 behavior the loop fix removes); it now exercises a below-limit transient block, with the at-limit case covered in test_kanban_db.py. Added two tests for the config-tier and per-task override resolution.
…sResearch#35227) * Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here' Adds a user-chosen compression boundary to the existing /compress command. /compress here [N] summarizes everything except the most recent N exchanges (default 2), which are preserved verbatim — letting the user pick the compression boundary instead of relying on the automatic token-budget heuristic. Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139, Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20 - hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation guard (shared by CLI and gateway). - cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression; compress only the head, re-append the verbatim tail through the seam guard. - Preserves message-flow role alternation (seam guard merges any illegal user->user / assistant->assistant adjacency). - Reuses the existing _compress_context session-rotation/lock machinery — no changes to the compression core. - Bare /compress (full) and /compress <focus> behavior unchanged. Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved tool-call transcript, degenerate/multimodal seams, real handler path). * feat(model-picker): group multi-endpoint providers under one row The interactive provider pickers (hermes model, setup wizard, Telegram /model) listed every provider slug flat, so vendors with several endpoints (Kimi/Moonshot, MiniMax, xAI Grok, Google Gemini, OpenAI, OpenCode, GitHub Copilot) each occupied multiple top-level rows. Now related slugs fold into one top-level row that drills down to the specific endpoint. - models.py: add PROVIDER_GROUPS table + group_providers() fold (display only — CANONICAL_PROVIDERS, slugs, --provider, /model <provider:model> all unchanged and individually addressable). - hermes model (main.py): group rows drill into a member sub-picker, then dispatch to the existing _model_flow_* unchanged. setup wizard inherits it. - Telegram /model: new mpg:<group> callback expands to member mp:<slug> buttons; single authenticated member degrades to a direct button. - Grouping is the single shared fold across all three surfaces. Validation: 163 targeted tests pass; E2E confirms group->member->model resolves to the correct concrete slug for all families.
…orks on pip installs mcp_serve.py was missing from the setuptools py-modules list, causing hermes mcp serve to crash with ModuleNotFoundError on standard pip installs. Fixes NousResearch#34871
…le read-only files in rmtree Two related bugs in tools/skills_sync.py affecting Nix-store and immutable-package installs: **NousResearch#34972 — reset_bundled_skill corrupts manifest on rmtree failure:** The function deleted the manifest entry BEFORE attempting rmtree. If rmtree failed (read-only files from Nix store), the function returned early — leaving the skill in a manifest-less limbo state where future syncs silently skip it forever. Fix: reorder steps — attempt rmtree FIRST, only delete manifest entry after rmtree succeeds. If rmtree fails, nothing is changed. **NousResearch#34860 — stale .bak directories after sync:** sync_skills() called shutil.rmtree(backup, ignore_errors=True) which silently failed on read-only files, leaving persistent .bak dirs. Fix: add _rmtree_writable() helper that makes files writable via an onerror callback before retrying removal. Used in both sync_skills() backup cleanup and reset_bundled_skill(). Fixes NousResearch#34972 Fixes NousResearch#34860
…just files The cherry-picked fix's onerror handler chmod'd only the failing path, but unlinking a child requires write permission on its PARENT directory. On a true Nix-store copy (r-xr-xr-x dirs + files) rmtree still failed. Now chmod the parent dir as well before retrying. Also rewrites the regression test: the original asserted the helper FAILS on a read-only dir (documenting the limitation), which is the wrong success criterion. Split into two tests — restore succeeds on a full read-only tree (real Nix case), and manifest is preserved when removal genuinely cannot proceed (monkeypatched).
The hermetic CI env (slice 4/6) redirects HERMES_HOME, so a post-restore
_read_manifest() can resolve to an empty/redirected manifest path and return
{}. Assert on sync_skills's in-memory return value (synced["copied"]) instead,
which is the resilient signal that the skill was re-copied and is no longer in
limbo.
…ousResearch#35252) * Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here' Adds a user-chosen compression boundary to the existing /compress command. /compress here [N] summarizes everything except the most recent N exchanges (default 2), which are preserved verbatim — letting the user pick the compression boundary instead of relying on the automatic token-budget heuristic. Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139, Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20 - hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation guard (shared by CLI and gateway). - cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression; compress only the head, re-append the verbatim tail through the seam guard. - Preserves message-flow role alternation (seam guard merges any illegal user->user / assistant->assistant adjacency). - Reuses the existing _compress_context session-rotation/lock machinery — no changes to the compression core. - Bare /compress (full) and /compress <focus> behavior unchanged. Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved tool-call transcript, degenerate/multimodal seams, real handler path). * fix(file-tools): make write_file/patch atomic (temp-file + rename) write_file streamed content straight into the target via `cat > path`, so a crash, SIGKILL, or truncated pipe mid-write left the file half-written and corrupt. patch_replace routes through write_file, so it shared the flaw. Now writes stream into a temp file in the SAME directory and `mv` it over the target — a real same-filesystem rename, which is atomic on POSIX and on every terminal backend (local/docker/ssh/modal). A failed write leaves the original byte-intact and leaks no temp file. The existing file's mode is preserved across the swap (stat + chmod, GNU/BSD), and content still rides stdin so there's no ARG_MAX limit. A trap cleans the temp on any error path. Tests: added TestAtomicWrite (real LocalEnvironment, no mocks) covering inode-change-on-overwrite, mode preservation, failed-write-leaves-original, no-temp-leak, special chars, and patch routing. Updated two mocks in test_file_operations.py that keyed on the literal `cat >` write command to key on the stdin_data behavioral signal instead. 200 file-tool tests green.
…sResearch#29700) Hermes installed via `uv tool install hermes-agent` lives outside any venv. `_cmd_update_pip` previously ran `uv pip install --upgrade`, which errors with `No virtual environment found; run uv venv ...`. The user hits this on the very first `hermes update` after a standard non-`--system` install with `uv` on PATH. Add `is_uv_tool_install()` in `hermes_cli/config.py`: fast path inspects `sys.prefix` for the standard `uv/tools/hermes-agent/` layout, falls back to `uv tool list` for non-standard prefixes. Both the user-facing `recommended_update_command_for_method("pip")` string and the actual subprocess invocation in `_cmd_update_pip` now switch to `uv tool upgrade hermes-agent` when detected. Non-tool installs and the no-`uv` fallback keep their existing commands unchanged.
Copilot review on PR NousResearch#29703 flagged two issues with the `uv tool list` fallback in `is_uv_tool_install`: 1. False positive: `uv tool list` returns the *machine*'s installed tools, not the active install. A regular pip/venv Hermes on a host that also has `uv tool install hermes-agent` available would be misclassified as a uv-tool install, and `hermes update` would upgrade the wrong copy. 2. Overhead: the subprocess call (up to a 15s timeout) was triggered even from `recommended_update_command_for_method`, which just computes a display string. Restrict detection to properties of the running interpreter (`sys.prefix` and `sys.executable` — both can carry the uv-tool layout marker depending on entry point). Drop the `uv tool list` fallback and the `uv_path` parameter entirely. `_cmd_update_pip` now also surfaces a clear hint when the runtime looks like a uv-tool install but `uv` is missing from PATH, instead of silently falling back to `python -m pip`.
Extends the uv-tool detection (briandevans, NousResearch#29703) to cover the remaining no-venv install layouts that hit the same uv 'No virtual environment found' error: - pipx-managed installs (sys.prefix under .../pipx/...) -> 'pipx upgrade', matching scripts/auto-update.sh (pipx-detection idea from inchargeautomation-lab, NousResearch#29852) - bare pip outside any venv -> 'uv pip install --system --upgrade' - venv (launcher shim) keeps the VIRTUAL_ENV overlay from NousResearch#35224 and never gets --system, so the install always targets the venv, not system Python The four branches are mutually exclusive; VIRTUAL_ENV is exported only for the uv-pip-in-venv path (uv tool / pipx upgrade ignore it). Co-authored-by: Joshua Kimbrell <incharge.automation@gmail.com>
The orphan reaper for stdio MCP subprocesses only tracked the direct child PID spawned by ``stdio_client`` (e.g. ``openclaw mcp serve``). When that wrapper itself spawned a helper (``claude mcp serve``) and then exited, the helper reparented to ``systemd --user`` and survived shutdown. The MCP SDK already spawns stdio children with ``start_new_session=True``, so the wrapper is its own pgroup leader and same-pgroup descendants are reachable via ``killpg``. Capture the pgid at spawn time and reap via ``killpg(pgid, sig)`` so reparented grandchildren are reaped alongside the direct child, even after the wrapper itself exits. Falls back to per-pid ``os.kill`` on Windows or when no pgid was recorded. Fixes part 2 (orphan ``claude mcp serve``) of NousResearch#23799. Part 1 (per-invocation respawn) was confirmed by the reporter to be an environmental artifact, not a code bug.
The salvaged grandchild-reaping tests reference os.getpgid/os.killpg and pytest.mark/skip/importorskip directly, but the file only imported asyncio, signal, and unittest.mock. Add the missing imports so collection succeeds on current main.
asyncio.create_subprocess_exec cannot run .cmd/.bat files on Windows because CreateProcess expects a valid PE executable. npm-installed LSP servers (intelephense, typescript-language-server, etc.) ship as .cmd shims on Windows, causing WinError 193 on spawn. Detect .cmd/.bat extensions and wrap with cmd.exe /c before spawning. Gated behind sys.platform == 'win32' — no code path changes elsewhere. Fixes NousResearch#34864
When a user switches models mid-session via /model, the gateway updates the in-memory agent and session overrides, but the database was never updated. The COALESCE(model, ?) in update_token_counts() only fills NULL values, so the dashboard always showed the original model. Fix: Add SessionDB.update_session_model() that unconditionally sets the model column, and call it from both the interactive picker and direct /model command paths in the gateway. Fixes NousResearch#34850
…t path Follow-up to LengR's NousResearch#35181 salvage: - gateway text-path uses getattr(self, '_session_db', None) to match the picker callback path (defensive for object.__new__() gateway test pattern). - add SessionDB.update_session_model test asserting it overwrites the COALESCE-pinned model and survives subsequent token updates (NousResearch#34850).
…ixes NousResearch#34827) In the concurrent tool-execution path, checkpoint preflight (write_file, patch, destructive terminal) fired BEFORE plugin guardrail block_result was computed. A blocked write_file could still dirty checkpoint state (doc_modified_this_turn, _last_write_file_call_id, turn_counter). Move checkpoint preflight to AFTER block_result computation, gated on `if block_result is None:` — matching the invariant the sequential path already enforces.
Normalize Gmail API message header names to lowercase before lookup so gmail get/search/reply populate to/subject/from regardless of the casing the message was stored with. Emit conventional MIME header casing (To/Subject/Cc/From) on send and reply. Fixes NousResearch#34806 Co-authored-by: Donovan Yohan <donovan-yohan@users.noreply.github.com>
…ncurrent check (NousResearch#35257) hermes update on Windows still aborted with 'Another hermes.exe is running', listing its own launcher shim(s) as concurrent instances (issues NousResearch#29341, NousResearch#34795). The distlib Scripts\hermes.exe launcher spawns python.exe and waits; detection runs in the python child, so the launcher shim shows up in process_iter. The prior fix walked the ancestor chain with per-hop current.parent() inside 'except: break' — the first psutil AccessDenied/NoSuchProcess (common on Windows across session/elevation boundaries) bailed the walk early, leaving the launcher in the candidate set and re-triggering the false positive. - Switch to proc.parents() (whole ancestor list in one call), evaluate each ancestor independently so one unreadable hop never strands the launcher. - Only exclude ancestors whose exe is itself a shim, so a genuine second hermes.exe under a non-Hermes parent (Desktop backend child) is still flagged. - Message now prints a copy-pasteable 'taskkill /PID … /F' for the exact stale PIDs so a user who already closed everything can self-remediate. Conservative shim-only ancestor approach credited to the parallel attempts in PRs NousResearch#29358 (xxxigm) and NousResearch#31808 (jquesnelle).
Sending an image to a vision model turns the user message into a list of
OpenAI-style content parts. When a /model or /reload-skills note was queued
for the same turn, the CLI did `note + "\n\n" + agent_message`, crashing the
agent thread with:
TypeError: can only concatenate str (not "list") to str
Repro: `/model gpt-5.5 --provider openai-codex`, then paste+send an image.
Add _prepend_note_to_message(), which folds the note into the first text
part of a content-parts list (or inserts a leading text part for image-only
messages) and keeps the plain-string path unchanged. Used for both the
model-switch and skills-reload notes.
Regression coverage for the multimodal-message TypeError: note folding into text parts, image-only insertion, empty-note passthrough, and unknown-shape fail-open.
Adopt the cleaner handling from PR NousResearch#37080: coerce/strip the note and skip the extra newlines when the underlying message (or text part) is empty, while keeping the safer fail-open behavior for unknown shapes.
Non-dispatch gateways no longer open per-board kanban DBs for notifier polling. Mirrors the existing dispatcher gate (config kanban.dispatch_in_gateway, default True; env override HERMES_KANBAN_DISPATCH_IN_GATEWAY) so multi-gateway setups collapse to a single process holding kanban.db file descriptors. Salvaged from PR NousResearch#31964 by @steveonjava; tests and docs trimmed during salvage.
…r path (NousResearch#32049) (NousResearch#32407) * fix(file-safety): extend sandbox-mirror guard to cover inner-container path (NousResearch#32049) Brian's shape-based guard (NousResearch#32213) catches paths that still carry the full sandboxes/<backend>/<task>/home/.hermes/… prefix on the host side. The inner-container case is not covered: when file tools execute inside Docker the bind-mount strips that prefix, so the guard receives plain /root/.hermes/… and passes through. The root:root ownership on the divergent SOUL.md in NousResearch#32049 confirms this is the primary failure mode. Add a ContextVar (_CONTAINER_HERMES_MIRROR) set by DockerEnvironment when persistent=True. classify_container_mirror_target / get_container_ mirror_warning detect any write whose resolved path falls under that prefix, using the same warning format and cross_profile=True bypass contract as the existing guards. Chain the new guard in _check_cross_profile_path after the two existing detectors. * fix(file-safety): derive Docker mirror guard from task --------- Co-authored-by: Ben <ben@nousresearch.com>
…7129) The arm64 PR build ran fully uncached because the previous gha cache backend's short-lived Azure SAS token expired mid-build on slow cold-cache arm64 runs and crashed before the smoke test. Uncached arm64 PR builds were ~45% slower than amd64 (median 553s vs 382s), making the arm64 job the one most often cancelled on supersede — surfacing as a red X in PR checks and reading as 'the arm64 build keeps failing'. Switch arm64 to a registry-backed cache on ghcr.io (type=registry, ref ghcr.io/nousresearch/hermes-agent:buildcache-arm64). Its credential is the job-lifetime GITHUB_TOKEN, not a time-boxed SAS token, so the cold-build-outlives-token failure mode cannot recur. - PR builds: cache-from only (read-only) — warm layers, no write races, no cache-ref pollution from rapid PR pushes. - main/release builds: cache-from + cache-to (mode=max) to populate the cache for subsequent PR/main builds and let the digest push reuse the smoke-test build's layers. - Add packages: write permission and a ghcr.io login for the cache. amd64 keeps its gha cache: it builds fast enough to stay inside the SAS token's lifetime, so it never hit this failure mode.
…oggles, hook creation, system stats (NousResearch#36736) * feat(dashboard): MCP catalog + enable/disable, webhook toggle, hook create/delete, system stats Backend for the comprehensive admin pass: - MCP: GET /api/mcp/catalog (browse Nous-approved optional-mcps), POST /api/mcp/catalog/install, PUT /api/mcp/servers/{name}/enabled - Webhooks: PUT /api/webhooks/{name}/enabled; gateway rejects disabled routes with 403 (hot-reloaded, no restart) - Hooks: POST/DELETE /api/ops/hooks — create (with consent approval) + remove; list now reports accurate allowlist status + valid events - System: GET /api/system/stats — OS/arch/python/cpu + psutil memory/disk/ uptime/process, stdlib fallback All gated by dashboard auth; secrets never returned. * feat(dashboard): MCP catalog UI, enable/disable toggles, hook create, system stats - McpPage: catalog section (browse Nous-approved MCPs, one-click install with env prompts) + per-server enable/disable toggle with gateway-restart note - WebhooksPage: per-subscription enable/disable toggle (muted + badge when off) - SystemPage: new Host stats section (OS/arch/python/cpu/mem/disk/uptime/load), shell-hook create modal + delete, 'Create backup' label - api.ts: client methods + types for catalog, toggles, hook CRUD, system stats * test(dashboard): cover catalog, toggles, hook CRUD, system stats, webhook toggle Adds tests for the comprehensive pass: MCP enable/disable + catalog list + catalog-install-unknown, hook create/delete with consent, system stats shape, and webhook enable/disable. 26 tests total, all green. * docs(dashboard): document the comprehensive admin pass + fresh screenshots Updates the MCP/Webhooks/Pairing/System sections for catalog browse+install, enable/disable toggles, hook creation, and host system stats; adds the new endpoints to the API table; replaces the screenshots with live captures of the rebuilt pages (real data, no dummies) including the hook-create modal. * feat(dashboard): curator, portal status, and prompt-size/dump/migrate ops Closes the last in-scope CLI gaps from the coverage audit: - Curator: GET /api/curator (status), PUT /api/curator/paused, POST /api/curator/run (background) - Portal: GET /api/portal (Nous auth + Tool Gateway routing, read-only) - Diagnostics: POST /api/ops/prompt-size, /api/ops/dump, /api/ops/config-migrate (backgrounded, tailed via action status) Host-bound commands (secrets/proxy/lsp/acp/computer-use/desktop/completion/ postinstall/uninstall/claw) remain CLI-only by design. * feat(dashboard): curator + portal + diagnostics UI, tests - SystemPage: Nous Portal status section (auth + Tool Gateway routing), Skill curator card (status + pause/resume + run now), and three new Operations buttons (prompt size, support dump, migrate config) - api.ts: client methods + CuratorStatus/PortalStatus types - tests: curator pause/resume, portal shape, system-stats shape, + auth-gate coverage for the new GET endpoints (31 tests total) * docs(dashboard): document curator, portal, and diagnostics + refresh System screenshots Updates the System section for the Nous Portal status, Skill curator controls, and the new prompt-size/dump/migrate operations; adds them to the API table; refreshes the System screenshots (now showing Portal + Curator) and adds a dedicated curator/gateway/memory capture. * feat(dashboard): session stats/export/prune + skills hub search endpoints Completes the existing tabs' backend depth (audit vs CLI): - Sessions: GET /api/sessions/stats (store stats), GET /api/sessions/{id}/export, POST /api/sessions/prune. /stats is registered before /{session_id} so the literal path isn't captured by the parameterized route. - Skills: GET /api/skills/hub/search — parallel multi-source hub search (threaded), returns installable identifiers - (rename via PATCH and cron-edit via PUT already existed; now surfaced in UI) * feat(dashboard): complete existing tabs — sessions mgmt, skills hub browse, cron edit Audited every existing tab against its CLI command and filled the gaps: - Sessions: store stats bar, per-row rename + export (JSON download), and a prune-old-sessions control (mirrors hermes sessions rename/export/prune/stats) - Skills: new 'Browse hub' view — search the skill hub across all sources, install by identifier with a live install log, and 'Update all' (mirrors hermes skills search/install/update) - Cron: per-job Edit modal (pre-filled) calling updateCronJob (hermes cron edit) - api.ts: renameSession/getSessionStats/exportSessionUrl/pruneSessions, updateCronJob, searchSkillsHub + types Models tab was already comprehensive (provider+model picker, dynamic per-provider lists, main + all 11 aux-task assignments, reset) — verified, no change needed. * test(dashboard): cover session stats/rename/export/prune + skills hub search Adds the route-shadowing guard for /api/sessions/stats (must not be captured by /api/sessions/{session_id}), rename/export/prune, and the empty-query short-circuit for hub search. 36 tests total, all green. * docs(dashboard): document enhanced Sessions, Skills hub, and Cron edit Sessions: stats bar, rename, export, prune (+ screenshot). Skills: new Browse hub view for search/install/update (+ screenshot). Cron: edit action. API table updated with the new endpoints.
…ace (NousResearch#37182) When a dispatcher-spawned worker (HERMES_KANBAN_TASK set) calls kanban_create without an explicit workspace, the new child now inherits the worker's own running-task workspace_kind/workspace_path instead of defaulting to scratch. A worker editing a dir:/worktree project that spawns a follow-up child keeps it in that project. Orchestrators (kanban toolset, no HERMES_KANBAN_TASK) and CLI/dashboard callers still default to scratch. An explicit workspace arg always wins.
…#35472) (NousResearch#36259) Save the original working directory before init scripts cd to /opt/data, then restore it before exec'ing the user command, so the container starts in the Docker -w directory instead of /opt/data. Adds regression test verifying cwd save/restore ordering in main-wrapper.sh.
… literal (NousResearch#37220) test_stale_m3_cache_dropped_and_reresolves_to_1m hardcoded assert ctx == 1_000_000. The test re-resolves M3 through the live models.dev registry (the seeded stale entry is dropped, so nothing short-circuits the lookup), and models.dev now reports MiniMax-M3 at 512,000 — a change-detector failure unrelated to any code change. The guard's actual contract is: a stale <=204,800 catch-all value for an M3 slug must be DROPPED and re-resolved to M3's real (large) context. Both sources satisfy that (hardcoded catalog 1,000,000; models.dev 512,000), so assert the invariant (ctx > 204,800, stale value gone) instead of a literal that external data can move. Renamed accordingly. 47/47 in test_minimax_provider.py pass.
…ousResearch#36813) (NousResearch#36905) Co-authored-by: alaamohanad169-ship-it <alaamohanad169-ship-it@users.noreply.github.com>
…el from the browser (NousResearch#37211) The /api/messaging/platforms endpoints (catalog, configure, test) shipped with the desktop app but never got a dashboard UI; the recent admin-panel PRs covered MCP/webhooks/hooks/system but skipped messaging channels. This adds the missing page so all 20+ channels (Telegram, Discord, Slack, Matrix, Mattermost, WhatsApp, Signal, BlueBubbles, Email, SMS, DingTalk, Feishu, WeCom, WeChat, QQ Bot, Yuanbao, plugin platforms, etc.) can be configured, enabled/disabled, tested, and connected entirely from the browser. - web/src/pages/ChannelsPage.tsx: per-platform list with live status, enable Switch, Test, and a Configure modal that renders each platform's exact setup fields (secrets masked, required validated, redacted display). - web/src/lib/api.ts: MessagingPlatform types + get/update/test client fns. - web/src/App.tsx: /channels route + nav tab (Radio icon, after MCP). - docs: Channels section + REST endpoints + screenshot. Frontend-only — reuses the existing env-write + config-enable backend, which auto-enables a platform once its required env vars are present and the gateway restarts. No core changes, no new tool schema.
The toolset config panel highlighted the first keyless provider (e.g.
Nous Portal) on load instead of the provider actually written to config.
The /api/tools/toolsets/{name}/config endpoint never reported which
provider was active, so the GUI's default-expand logic fell back to
"first configured" — and keyless providers are always "configured".
Backend now annotates each provider with is_active (via the same
_is_provider_active helper the CLI 'hermes tools' picker uses) plus a
top-level active_provider summary. The panel prefers that signal before
falling back to first-configured/first.
Adds a frontend regression test (active provider is expanded on load)
and backend coverage (config reports is_active/active_provider; selecting
a provider round-trips into the next config read).
… of hard-blocking (NousResearch#37245) A stray zero-width space (U+200B), BOM, or bidi control in loaded skill markdown permanently killed any cron that loaded it. The skills-attached assembled-prompt scan hard-blocked on any invisible-unicode char, even though skill bodies are already install-time vetted by skills_guard.py and the chars commonly appear in copy-pasted unicode docs / code examples. The skills path now strips invisibles (logging the codepoints) and runs the cleaned prompt. The raw user-prompt path (_scan_cron_prompt) keeps the hard block — that is the actual NousResearch#3968 injection surface, where a small directive prompt with a ZWSP is a smoking gun, not prose. Stripping does not let a real injection slip through: the directive still matches after sanitization. _scan_cron_skill_assembled now returns (cleaned_prompt, error).
…atting parity (NousResearch#37250) Introduce a typed agent→gateway delivery contract so the gateway (not the agent) decides how each streaming event is rendered per platform. Moves toward smart-agent/smart-gateway separation while reproducing today's behavior exactly in the base class. - gateway/stream_events.py: typed event vocabulary (MessageChunk/Stop, Commentary, ToolCallChunk/Finished, LongToolHint, GatewayNotice). - gateway/stream_dispatch.py: GatewayEventDispatcher routes events through the adapter; adapters can eat events they can't render (e.g. tool chrome on plain-text platforms). - gateway/platforms/base.py: render_message_event + format_tool_event default hooks reproduce the historical emoji/preview tool formatting and consumer delegation 1:1; adapters override for native rendering. - gateway/platforms/telegram.py: send_draft now applies MarkdownV2 (format_message + parse_mode) with a plain-text fallback on BadRequest, fixing the jarring raw-text→formatted shift when the draft finalizes as a real sendMessage. - gateway/config.py: default streaming transport edit → auto. Safe globally: adapters without draft support report supports_draft_streaming()==False and transparently use edit, so only Telegram DMs gain native drafts. Presentation-only contract — nothing rendered here is persisted to conversation history, preserving cache/message-flow invariants.
…rovider-selection-display fix(desktop): reflect active toolset provider in config panel
…Research#37285) The gateway reads top-level streaming.* with StreamingConfig defaults when the block is absent, so streaming was invisible — a user with no streaming block sees responses arrive as single messages and has no way to discover the toggle short of reading source. This materializes the block in config.yaml so it's discoverable, with values byte-identical to the dataclass defaults (no behavior change). - DEFAULT_CONFIG gains a root-level streaming block (enabled, transport, edit_interval, buffer_threshold, cursor, fresh_final_after_seconds), each documented inline. Values match gateway/config.py StreamingConfig() exactly. - _KNOWN_ROOT_KEYS gains 'streaming' so the validator accepts the root key. - No _config_version bump: load_config deep-merges DEFAULT_CONFIG over user YAML, so existing installs pick up the default automatically; no value migration needed. Does NOT touch the setup wizard — streaming stays opt-in, just discoverable.
The left-nav Skills pane and Settings > Skills & Tools rendered the same getSkills()/getToolsets() data with the same helpers and toggles — genuine duplication that drifted (different default category labels, sort orders). Make the left pane the single home: it keeps its category-tabbed browsing and now gains the functional bits it lacked — a real toolset enable/disable switch (was a read-only pill) and the expandable ToolsetConfigPanel for provider selection + per-key credential config. Remove the Tools section from Settings (nav item, view branch, query slot, type union entries) and delete tools-settings.tsx, migrating its toggle coverage into the skills pane test. Relabel the entry point to 'Skills & Tools' in the sidebar and command center.
…lidate-skills-tools-pane refactor(desktop): consolidate skills + tools management into one pane
…tings Command Center's Models section and Settings > Model rendered the same model state with identical persistence semantics — both write config and apply to new sessions only (POST /api/model/set). The Command Center UI was strictly better (provider catalog, curated model lists, friendly auxiliary-task labels, Nous-gateway auto-routing on main-provider switch), while Settings > Model was three barebones config fields. Extract that UI into a shared settings/model-settings.tsx (restyled with Settings primitives) and render it at the top of Settings > Model: main model picker via setModelAssignment + the 9 auxiliary task slots with per-task set-to-main / change / reset-all. model_context_length and fallback_providers stay as config fields below it; the raw auxiliary.* keys are dropped from Advanced (now covered by the panel). Strip the Models section from Command Center entirely (section, state, handlers, render, nav, search entry) leaving it focused on Sessions / System / Usage, and move the live store-sync callback (onMainModelChanged) from CommandCenterView to SettingsView. The composer's per-session model picker (the only live hot-swap, via /model) is unchanged.
…usResearch#37247) * feat(dashboard-auth): rotate dashboard sessions via refresh token The dashboard auth-code grant now issues a 24h rotating refresh token (server side: NousResearch/nous-account-service#293). This wires up the Hermes client half so an expired access token is transparently refreshed instead of bouncing the user to /login every 15 minutes. plugins/dashboard_auth/nous: - refresh_session() now POSTs grant_type=refresh_token to Portal's token endpoint and returns a Session carrying the ROTATED refresh token (was an unconditional RefreshExpiredError under the old "no RT in V1" contract). The RT is sent in BOTH the request body (Portal's schema requires it there) and the X-Refresh-Token header (log redaction) — verified against the NousResearch#293 preview deploy: header-only is rejected as invalid_request, body is accepted. - A 400 from Portal (expired / revoked / reuse-detected) maps to RefreshExpiredError so the middleware forces a clean re-login; network errors map to ProviderError; empty RT fast-fails without a network call. - complete_login now captures the initial refresh token Portal returns (forward-tolerant: empty string if a deploy omits it). - Extracted the shared token-response handling into _token_response_to_session, parameterised on the 400 exception type so the auth-code path raises InvalidCodeError and the refresh path raises RefreshExpiredError. - revoke_session stays a best-effort no-op: Portal exposes no public token-endpoint revocation grant (revocation is the authenticated /sessions UI, keyed by sessionId+userId), so logout is cookie-clearing and the 24h session expires on its own. Documented for a future revoke grant. hermes_cli/dashboard_auth/middleware: - On an expired/invalid access token the gate now attempts refresh via the session's RT BEFORE forcing re-login. On success it serves the request and re-sets the rotated cookies on the response (mandatory: Portal rotates the RT every refresh and reuse-detects, so a stale RT cookie would revoke the whole session on the next refresh). On RefreshExpiredError (or no RT) it falls through to clear-and-relogin. - ProviderError during refresh (Portal unreachable) forces a clean re-login rather than 500-ing the request. - Uses the existing REFRESH_SUCCESS / REFRESH_FAILURE audit events. Validation: - 176 dashboard-auth unit/integration tests pass. - Live E2E against the NousResearch#293 preview deploy: refresh_session(bad rt) -> RefreshExpiredError through the real token endpoint; live JWKS fetch + RS256 verification rejects a forged token; empty-RT fast-fail. The successful happy-path rotation is covered by unit tests (a live run needs an interactive browser OAuth round trip + registered agent:* client). Depends on: NousResearch/nous-account-service#293 (server-side RT issuance). * fix(dashboard-auth): use Portal's x-nous-refresh-token header name The refresh-token header must match Portal's REFRESH_TOKEN_HEADER exactly ("x-nous-refresh-token"); the initial cut used "X-Refresh-Token", which Portal silently ignores (harmless since the RT is also in the body, which is what the schema requires — but the header redaction was a no-op). Confirmed against the NAS token route + re-validated live against the NousResearch#293 preview deploy. * fix(dashboard-auth): refresh session when access-token cookie has been evicted The gated middleware bounced users to /login the instant the access-token cookie was absent, without ever consulting the refresh token: at, _rt = read_session_cookies(request) if not at: return _unauth_response(...) # bailed here This made transparent refresh effectively dead for the common case. The access-token cookie is set with Max-Age = access_token_expires_in (~15 min), so a real browser EVICTS hermes_session_at the moment the token lapses while hermes_session_rt persists (30-day Max-Age). From that point the browser sends only the refresh-token cookie — and the old guard rejected it before _attempt_refresh could run. The _attempt_refresh path only fired for a present-but-invalid access token, which never happens in a browser. Fix: only hard-bounce when NEITHER cookie is present. A request carrying just the refresh token now skips verification (no AT to verify) and flows into the existing refresh path, which rotates both cookies and serves the request transparently. A dead/expired RT still raises RefreshExpiredError and falls through to clear-and-relogin. This failure mode escaped the original tests + manual refresh button because both kept the access-token cookie present; only a real browser evicting the cookie at Max-Age exposes it. Added 3 regression tests covering: AT-evicted + RT-present (transparent refresh), no-cookies (still bounces), and RT-only with a dead RT (clean 401, no 500).
…d off) + dashboard toggles (NousResearch#37303) Streaming quality differs sharply by platform: Telegram has native animated draft streaming (sendMessageDraft) which is smooth, while Discord/Slack only have edit-based streaming (repeated editMessage) which visibly flickers. Ship defaults that match reality instead of one global flag. - hermes_cli/config.py: DEFAULT_CONFIG display.platforms now ships telegram.streaming=true and discord.streaming=false (was empty {}). These are gap-fillers — config deep-merge has user values win, so anyone who explicitly sets discord.streaming=true keeps it. The global streaming.enabled master switch still gates everything; these per-platform flags only take effect once streaming is on. - Dashboard exposure comes for free: the web settings schema is generated from DEFAULT_CONFIG, so display.platforms.telegram.streaming and .discord.streaming now surface as editable boolean toggles in the UI with no frontend change. (Previously the per-platform tree was {} and invisible.) - tests: pin the defaults, the resolver outcome (telegram on / discord off / unlisted platforms follow global), user-override-wins, and dashboard schema exposure. No _config_version bump: deep-merge fills the gap for existing installs; no value migration needed.
…phantom-shows (NousResearch#37404) The model picker now matches `hermes model` for OpenAI, and OpenRouter stops appearing as authenticated when only OPENAI_API_KEY is set. - models.py: provider_model_ids() for the default api.openai.com endpoint intersects the live /v1/models dump (120+ entries incl. embeddings, whisper, tts, dall-e, moderation, legacy chat) with the curated agentic list, preserving curated order. Custom OpenAI-compatible endpoints keep the live list verbatim so discovery still works. - providers.py: drop extra_env_vars=("OPENAI_API_KEY",) from the openrouter overlay. list_authenticated_providers reads extra_env_vars to decide whether a provider is authenticated, so any OpenAI user saw a phantom OpenRouter row. Runtime OpenRouter credential resolution still falls back to OPENAI_API_KEY (runtime_provider.py), independent of the overlay. - Regression tests for both paths.
…lidate-models-into-settings refactor(desktop): move model management from Command Center into Settings
- restore telegram command browser, menu, and inbox flows - keep v0.15 codex runtime implementation intact - preserve traceback logging improvement in conversation loop - add gateway coverage for command browser interactions
Route Anthropic-provider jobs/messages through `claude -p` subprocess instead of direct SDK calls to avoid third-party OAuth extra-usage limit. ## cron/scheduler.py - Add `_run_via_claude_subprocess()`: runs `claude -p <prompt>` with an isolated CLAUDE_CONFIG_DIR (~/.claude-hermes) and injects CLAUDE_CODE_OAUTH_TOKEN from HERMES_OAUTH_TOKEN env var. - In `run_job()`: detect `api_mode == "anthropic_messages"` before creating AIAgent; redirect to subprocess path when matched. Codex jobs (codex_responses api_mode) continue through AIAgent unchanged. ## gateway/run.py - In `run_sync()` (inside `_run_agent()`): after `turn_route` is resolved, check `HERMES_GATEWAY_CLAUDE_SUB=1` + provider == "anthropic". If matched, spawn `claude -p` subprocess with conversation history injected as prompt context and `--model <model>` flag when the resolved model is a Claude model. Supports `/model` session overrides for explicit model selection. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This was referenced Jun 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
배경
Anthropic의 서드파티 OAuth 정책 변경으로 구독 인증(PKCE OAuth)이 되어 있어도
extra usage 크레딧 없이는 API 호출이 HTTP 400으로 차단됨.
claude -psubprocess는 first-party Claude Code 경로로 실행되므로구독 정액제 내에서 추가 과금 없이 동작함.
변경 내용
cron/scheduler.py_run_via_claude_subprocess()추가: 격리된CLAUDE_CONFIG_DIR(~/.claude-hermes)와CLAUDE_CODE_OAUTH_TOKEN(←HERMES_OAUTH_TOKEN)으로claude -p실행run_job(): AIAgent 생성 전api_mode == "anthropic_messages"감지 시 subprocess 경로로 분기codex_responses)은 기존 AIAgent 경로 유지gateway/run.py_run_agent()내부run_sync():turn_route결정 후HERMES_GATEWAY_CLAUDE_SUB=1+provider == "anthropic"감지 시 bypass/model)의 Claude 모델명을--model플래그로 전달환경 변수
HERMES_OAUTH_TOKENprofiles/ronii/.envclaude setup-token으로 발급한 1년 유효 Bearer 토큰HERMES_GATEWAY_CLAUDE_SUBprofiles/ronii/.env1로 설정 시 gateway bypass 활성화격리 설정
~/.claude-hermes/settings.json: Telegram MCP만 활성화, SessionStart 훅 없음, CLAUDE.md 없음→ 호출된 claude -p subprocess가 상위 환경을 오염시키지 않음
테스트
dailyreport-missed-run-alert: claude -p 경로로 정상 실행 확인 (7s, HERMES_CLAUDE_SUB_OK)hermes chat): Nous provider 경로 그대로 영향 없음