fix(stale-call): drop is_local_endpoint inf-timeout bypass - #2
Merged
Conversation
The non-streaming and streaming stale-call detectors both disabled
themselves entirely (`_stale_timeout = float("inf")`) when the base URL
matched `is_local_endpoint(...)`, on the theory that a local LLM might
legitimately take 5+ min for prefill.
That same branch silently disabled the watchdog for **local proxies**
that forward to a cloud provider (the OCPlatform billing proxy on
127.0.0.1:18801 fronting Anthropic). When the upstream stream stalled,
the proxy had nothing to forward, the watchdog never fired, and the call
wedged indefinitely while the anthropic SDK racked up hundreds of
internal retries.
Sub-agents calling through the local proxy were the worst-affected:
sessions like 20260418_190654_a04cb15f hung 40 minutes with 0 completed
API calls and 0 cache reads, then returned status="interrupted".
Fix: remove the `is_local_endpoint`-keyed bypass in both the
non-streaming path (run_agent.py L5135) and the streaming path
(L5861). Apply the same sliding-scale (300/450/600s non-stream,
180/240/300s stream) for every base_url. Operators running an actual
local LLM that needs longer than the bumped ceiling can override via
HERMES_API_CALL_STALE_TIMEOUT or HERMES_STREAM_STALE_TIMEOUT.
Also touched:
- tests/agent/test_anthropic_adapter.py + tests/run_agent/test_run_agent.py
Three pre-existing assertions hardcoded `{"type": "ephemeral"}` for the
cache-control marker, which became stale after the W1 1h-cache-TTL
default landed. Replace with `make_cache_marker()` so the assertions
track CACHE_TTL going forward.
Patch note: hermes-patches/subagent-stale-call-local-bypass.md
Validation context: hermes-patches/w1-w13-live-validation-probes.md
(V12 step now requires confirming child completed before evaluating
cache numbers — the wedge bug was producing false-negative readings)
exiao
pushed a commit
that referenced
this pull request
Apr 23, 2026
…#13354) Classic-CLI /steer typed during an active agent run was queued through self._pending_input alongside ordinary user input. process_loop, which drains that queue, is blocked inside self.chat() for the entire run, so the queued command was not pulled until AFTER _agent_running had flipped back to False — at which point process_command() took the idle fallback ("No agent running; queued as next turn") and delivered the steer as an ordinary next-turn user message. From Utku's bug report on PR NousResearch#13205: mid-run /steer arrived minutes later at the end of the turn as a /queue-style message, completely defeating its purpose. Fix: add _should_handle_steer_command_inline() gating — when _agent_running is True and the user typed /steer, dispatch process_command(text) directly from the prompt_toolkit Enter handler on the UI thread instead of queueing. This mirrors the existing _should_handle_model_command_inline() pattern for /model and is safe because agent.steer() is thread-safe (uses _pending_steer_lock, no prompt_toolkit state mutation, instant return). No changes to the idle-path behavior: /steer typed with no active agent still takes the normal queue-and-drain route so the fallback "No agent running; queued as next turn" message is preserved. Validation: - 7 new unit tests in tests/cli/test_cli_steer_busy_path.py covering the detector, dispatch path, and idle-path control behavior. - All 21 existing tests in tests/run_agent/test_steer.py still pass. - Live PTY end-to-end test with real agent + real openrouter model: 22:36:22 API call #1 (model requested execute_code) 22:36:26 ENTER FIRED: agent_running=True, text='/steer ...' 22:36:26 INLINE STEER DISPATCH fired 22:36:43 agent.log: 'Delivered /steer to agent after tool batch' 22:36:44 API call #2 included the steer; response contained marker Same test on the tip of main without this fix shows the steer landing as a new user turn ~20s after the run ended.
exiao
pushed a commit
that referenced
this pull request
Apr 23, 2026
The MCP circuit breaker previously had no path back to the closed state: once _server_error_counts[srv] reached _CIRCUIT_BREAKER_THRESHOLD the gate short-circuited every subsequent call, so the only reset path (on successful call) was unreachable. A single transient 3-failure blip (bad network, server restart, expired token) permanently disabled every tool on that MCP server for the rest of the agent session. Introduce a classic closed/open/half-open state machine: - Track a per-server breaker-open timestamp in _server_breaker_opened_at alongside the existing failure count. - Add _CIRCUIT_BREAKER_COOLDOWN_SEC (60s). Once the count reaches threshold, calls short-circuit for the cooldown window. - After the cooldown elapses, the *next* call falls through as a half-open probe that actually hits the session. Success resets the breaker via _reset_server_error; failure re-bumps the count via _bump_server_error, which re-stamps the open timestamp and re-arms the cooldown. The error message now includes the live failure count and an "Auto-retry available in ~Ns" hint so the model knows the breaker will self-heal rather than giving up on the tool for the whole session. Covers tests 1 (half-opens after cooldown) and 2 (reopens on probe failure); test 3 (cleared on reconnect) still fails pending fix #2.
exiao
pushed a commit
that referenced
this pull request
May 1, 2026
* ci(nix): auto-fix stale npm hashes on push to main When a PR merges to main with updated package-lock.json or package.json in ui-tui/ or web/, the new auto-fix-main job detects stale npmDepsHash values and pushes a fix commit directly to main. This eliminates the recurring manual hash-bump PRs (NousResearch#15420, NousResearch#15314, NousResearch#15272, NousResearch#15244) by reusing the existing fix-lockfiles --apply pipeline. The fix commit only touches nix/*.nix files, which are outside the push path filter (package-lock.json / package.json), so it cannot re-trigger itself. Closes NousResearch#15314 * fix(ci): use GitHub App token for auto-fix-main push GITHUB_TOKEN commits are invisible to workflow triggers (GitHub's infinite-loop prevention). The auto-fix-main job pushes directly to main, so the fix commit never triggered downstream nix.yml verification. Mint a short-lived token via the repo's GitHub App (daimon-nous, APP_ID + APP_PRIVATE_KEY secrets) so the push is treated as a real event and nix.yml fires to verify the corrected hashes. Tested via workflow_dispatch dry-run: app token minted successfully, checkout with app token succeeded, fix job correctly gated. Resolves review feedback from Bugbot (r3144569551). * ci(nix): rename lockfile check job for required status check Rename 'check' → 'nix-lockfile-check' so the status check name is unambiguous when added as a required check on main. * fix(ci): harden auto-fix-main against races, loops, and silent failures Address adversarial review findings: 1. Race condition (#1): Job-level concurrency with cancel-in-progress collapses back-to-back pushes; ref: main checkout always gets latest branch state; explicit push target (origin HEAD:main). 2. Loop prevention (#2): File-whitelist check before commit aborts if any file outside nix/{tui,web}.nix was modified, preventing accidental self-triggering. 3. Silent infra failures (#8): nix-lockfile-check now fails explicitly when fix-lockfiles exits without reporting stale status (catches nix setup failures, network errors, script bugs that bypass continue-on-error). 4. Commit traceability (#11): Auto-fix commits include source SHA and workflow run URL in the commit body. 5. Explicit push target (#12): git push origin HEAD:main instead of bare git push. --------- Co-authored-by: alt-glitch <alt-glitch@users.noreply.github.com>
exiao
pushed a commit
that referenced
this pull request
May 14, 2026
Adds a TestCheckSendMessage class with 7 focused tests pinning the
four passing conditions and the failure modes:
- HERMES_KANBAN_TASK grants access (the new branch)
- HERMES_KANBAN_TASK short-circuits before consulting
session_context or gateway.status (so workers don't depend on
those import paths being healthy)
- HERMES_SESSION_PLATFORM=telegram grants access
- HERMES_SESSION_PLATFORM=local falls through to gateway check
- is_gateway_running()=True grants access
- All signals absent → False
- gateway.status ImportError is swallowed → False
Pinning the short-circuit (test #2) is the load-bearing one — it
documents the contract that worker-side availability cannot regress
to depending on gateway-side state lookups.
exiao
pushed a commit
that referenced
this pull request
May 14, 2026
…rch#25071) * tui: make URLs clickable + hover-highlight in any terminal Problem ------- URLs printed by `hermes --tui` were not clickable in basic macOS Terminal.app. Cmd+click did nothing, the cursor didn't change shape — like nothing was detected — even though arrow buttons and other Box onClick handlers worked fine. Root cause ---------- Two layers of dead plumbing: 1. `<Link>` only emitted the underlying `<ink-link>` (which carries the hyperlink metadata into the screen buffer) when `supportsHyperlinks()` said yes. On Apple_Terminal that's false, so the per-cell hyperlink field stayed empty, so `Ink.getHyperlinkAt()` had nothing to return on click. The visible underline was just decorative. 2. `Ink.openHyperlink()` calls `this.onHyperlinkClick?.(url)`, but `onHyperlinkClick` was never assigned anywhere in the codebase. The click pipeline (`App.tsx → onOpenHyperlink → Ink.openHyperlink`) ran but bailed silently on the optional chain. Bonus discovery: even when wired up, there was no hover affordance — terminal apps can't change the system mouse cursor, so users had no visual signal that a cell was clickable. Arrow buttons in the chrome worked because they had explicit `<Box onClick>` styling; inline link URLs didn't. Fix --- - `Link.tsx`: always emit `<ink-link>` regardless of terminal capability. The renderer's `wrapWithOsc8Link` already gates the actual OSC 8 escape on `supportsHyperlinks()` further down — so terminals that don't understand OSC 8 still don't see the escape, but the screen-buffer metadata (which the click dispatcher reads) is now populated everywhere. - `ink.tsx + root.ts`: add `onHyperlinkClick?: (url: string) => void` to `Options` / `RenderOptions`, wire it to the existing `Ink.onHyperlinkClick` field in the constructor. - `src/lib/openExternalUrl.ts`: small platform-aware opener using `child_process.spawn` with arg-array (no shell) — http(s) only, rejects `file:`, `javascript:`, `data:`, etc., so a hostile model can't trigger arbitrary local handlers via `<Link url="file:///...">`. Detached + stdio ignore so closing the TUI doesn't kill the browser and Chrome stderr doesn't leak into the alt screen. - `entry.tsx`: pass `onHyperlinkClick: openExternalUrl` to `ink.render`. - `hyperlinkHover.ts` + Ink hover wiring: track the URL under the pointer in `Ink.hoveredHyperlink`, update it from `dispatchHover`, and inverse- highlight every cell of the matching link in the render-pass overlay (same pattern as `applySearchHighlight`). This is the cursor-hover affordance for clickable links — terminals don't expose cursor shape, so we light up the link itself. - `types/hermes-ink.d.ts`: add `onHyperlinkClick` to the `RenderOptions` shim so consumers (`entry.tsx`) type-check against the new option. Tests ----- - `src/lib/openExternalUrl.test.ts` (15 cases): http(s) accepted; file/js/ data/mailto/ftp/ssh rejected; macOS open(1), Windows cmd.exe start with empty title slot, Linux xdg-open dispatch; shell-metacharacter URLs pass through unmolested as a single argv element; synchronous spawn failure returns false. Verified empirically in Apple Terminal 455.1 (macOS 15.7.3): clicking a URL opens in default browser, hovering inverts the link cells, and moving away clears the highlight. Full TUI suite: 713 passing, 0 type errors. Reverts ------- The earlier attempt that version-gated Apple_Terminal in `supports-hyperlinks.ts` was based on a wrong assumption — Terminal.app silently strips OSC 8 sequences but does not render them as clickable hyperlinks. Reverted to the original allowlist. * tui: address Copilot review — explorer.exe on win32 + comment fixes - openExternalUrl: switch win32 from `cmd.exe /c start` to `explorer.exe`. cmd.exe's `start` builtin reparses the URL through cmd's tokenizer, so `&`, `|`, `^`, `<`, `>` either split the command or get reinterpreted — breaking both the protocol-allowlist safety story AND plain http(s) URLs with `&` in query strings. `explorer.exe <url>` invokes the registered protocol handler directly with no shell. - openExternalUrl.test.ts: rename the win32 test to reflect the new contract and add two regression tests — one with `&|^<>` metachars, one with the common analytics-URL `&` query-param pattern — both pinned to single-argv-element delivery via explorer.exe. - Link.tsx: fix misleading comment. OSC 8 escapes are emitted unconditionally by the renderer (`wrapWithOsc8Link` in render-node-to-output.ts, `oscLink` in log-update.ts). Non-supporting terminals silently strip the sequence, which is why hover/click affordance has to come from the in-process overlay rather than the terminal's own link rendering. Verified: 715/715 tests pass, type-check + build clean. * tui: address Copilot review #2 — async spawn errors + hover scope + docs 1. openExternalUrl: attach a no-op `'error'` listener on the spawned child BEFORE unref(). spawn() returns a ChildProcess synchronously even when the binary is missing (ENOENT on xdg-open / explorer.exe), unreachable, or otherwise unusable; the failure surfaces later as an 'error' event. An unhandled 'error' on an EventEmitter crashes Node, which would tear down the whole TUI. The listener is a deliberate no-op — we already returned `true` synchronously and the user just doesn't see the browser pop. 2. openExternalUrl.test.ts: add a regression test using a real EventEmitter to simulate the async-error path. Pins both the listener-attached contract and the "doesn't throw on emit" behavior. Was 17/17, now 18/18. 3. ink.tsx dispatchHover: bypass `getHyperlinkAt()` and read `cellAt(...).hyperlink` directly. `getHyperlinkAt` falls back to `findPlainTextUrlAt` for cells without an OSC 8 hyperlink, but the render-pass overlay (`applyHyperlinkHoverHighlight`) only matches on `cell.hyperlink === hoveredUrl` — so plain-text URLs would burn re-renders without ever producing the highlight. Hover is now a strictly 1:1 fit for what the overlay can paint. Plain-text URLs still get the click action via the existing dispatch path. 4. root.ts + ink.tsx doc comments: replace the misleading "typically `open` / `xdg-open` / `start` shell" wording with the actual safe recipe — argv-array spawn into `open` / `xdg-open` / `explorer.exe`, with an explicit warning that `cmd.exe /c start` reparses the URL through cmd's tokenizer and is unsafe + breaks `&`-query URLs. Verified: 716/716 tests pass, type-check + build clean. * tui: address Copilot review #3 — hover damage, alt-screen cleanup, opener allowlist 1. ink.tsx onRender: stop folding steady-state hover into hlActive. hlActive forces a full-screen damage diff so previous-frame inverted cells get re-emitted when the highlight set changes. The transition IS the trigger — enter / leave / change-to-other-link. While the pointer just sits on a link the painted cells don't change and the per-cell diff handles the no-op. Folding the steady state in would burn a full-screen diff on every frame. Added a lastRenderedHoveredHyperlink tracker and gate the hlActive bump on `hovered !== lastRendered`. 2. ink.tsx setAltScreenActive: clear hoveredHyperlink (and the tracker) when toggling alt-screen state. Hover dispatch is alt-screen-gated, so once we leave there's no path to clear it. Without this, remounting <AlternateScreen> would paint a phantom hover from the previous session until the next mouse-move arrived. 3. openExternalUrl.ts openCommand: allowlist linux + the BSD family for xdg-open and return null for everything else (aix, sunos, cygwin, haiku, etc.). Previously the default-fallback always returned xdg-open, which made the caller's `if (!command) return false` dead and yielded a misleading `true` on platforms that probably don't have xdg-open. New tests cover the null path AND the openExternalUrl-returns-false-without-spawning behavior. Verified: 718/718 tests pass, type-check + build clean. * tui: address Copilot review #4 — doc comment accuracy 1. openExternalUrl return-value doc: now lists all three false paths (URL rejected / no opener for platform / synchronous spawn throw) plus a note that async 'error' events still return true because the spawn was attempted. 2. ink.tsx onHyperlinkClick field doc: clarifies the callback receives either an OSC 8 hyperlink OR a plain-text URL detected by findPlainTextUrlAt — App.tsx routes both into the same callback. 3. hyperlinkHover applyHyperlinkHoverHighlight doc: drops the misleading 'caller forces full-frame damage' promise. Caller decides; for hover the current caller only forces full damage on transitions. No behavior change. 718/718 tests pass. * tui: address Copilot review #5 — lint fixes 1. ink.tsx: reorder `./hyperlinkHover.js` import before `./screen.js` to satisfy perfectionist/sort-imports. 2. Link.tsx: drop unused `fallback` parameter destructuring + the trailing `void (null as ...)` dead-statement (would trip no-unused-expressions). Kept `fallback?: ReactNode` on the Props interface as a documented compat shim so existing call sites still compile, with a comment explaining why it's no longer wired up. 3. openExternalUrl.test.ts: replace `typeof import('node:child_process').spawn` inline annotations (forbidden by @typescript-eslint/consistent-type-imports) with a `SpawnLike` type alias backed by a real `import type { spawn as SpawnFn }`. No behavior change. 718/718 tests pass, type-check clean, lint clean on all modified files.
exiao
pushed a commit
that referenced
this pull request
May 14, 2026
…ex models (NousResearch#24182) * feat(codex-runtime): scaffold optional codex app-server runtime Foundational commit for an opt-in alternate runtime that hands OpenAI/Codex turns to a 'codex app-server' subprocess instead of Hermes' tool dispatch. Default behavior is unchanged. Lands in three pieces: 1. agent/transports/codex_app_server.py — JSON-RPC 2.0 over stdio speaker for codex's app-server protocol (codex-rs/app-server). Spawn, init handshake, request/response, notification queue, server-initiated request queue (for approval round-trips), interrupt-friendly blocking reads. Tested against real codex 0.130.0 binary end-to-end during development. 2. hermes_cli/runtime_provider.py: - Adds 'codex_app_server' to _VALID_API_MODES. - Adds _maybe_apply_codex_app_server_runtime() helper, called at the end of _resolve_runtime_from_pool_entry(). Inert unless 'model.openai_runtime: codex_app_server' is set in config.yaml AND provider in {openai, openai-codex}. Other providers cannot be rerouted (anthropic, openrouter, etc. preserved). 3. tests/agent/transports/test_codex_app_server_runtime.py — 24 tests covering api_mode registration, the rewriter helper (default-off, case-insensitive, opt-in, non-eligible providers preserved), version parser, missing-binary handling, error class. Does NOT require codex CLI installed. This commit is wire-only: the api_mode is recognized but AIAgent does not yet branch on it. Followup commits add the session adapter, event projector, approval bridge, transcript projection (so memory/skill review still works), plugin migration, and slash command. Existing tests remain green: - tests/cli/test_cli_provider_resolution.py (29 passed) - tests/agent/test_credential_pool_routing.py (included above) * feat(codex-runtime): add codex item projector for memory/skill review The translator that lets Hermes' self-improvement loop keep working under the Codex runtime: converts codex 'item/*' notifications into Hermes' standard {role, content, tool_calls, tool_call_id} message shape that agent/curator.py already knows how to read. Item taxonomy (matches codex-rs/app-server-protocol/src/protocol/v2/item.rs): - userMessage → {role: user, content} - agentMessage → {role: assistant, content: text} - reasoning → stashed in next assistant's 'reasoning' field - commandExecution → assistant tool_call(name='exec_command') + tool result - fileChange → assistant tool_call(name='apply_patch') + tool result - mcpToolCall → assistant tool_call(name='mcp.<server>.<tool>') + tool result - dynamicToolCall → assistant tool_call(name=<tool>) + tool result - plan/hookPrompt/etc → opaque assistant note, no fabricated tool_calls Invariants preserved: - Message role alternation never violated: each tool item produces at most one assistant + one tool message in that order, correlated by call_id. - Streaming deltas (item/<type>/outputDelta, item/agentMessage/delta) don't materialize messages — only item/completed does. Mirrors how Hermes already only writes the assistant message after streaming ends. - Tool call ids are deterministic (codex item id-based) so replays produce identical messages and prefix caches stay valid (AGENTS.md pitfall #16). - JSON args use sorted_keys for the same reason. Real wire formats verified against codex 0.130.0 by capturing live notifications from thread/shellCommand and including one as a fixture (COMMAND_EXEC_COMPLETED). 23 new tests, all green: - Streaming deltas don't materialize (3 paths) - Turn/thread frame events are silent - commandExecution: 5 tests including non-zero exit annotation + deterministic id stability across replays - agentMessage + reasoning attachment + reasoning consumption - fileChange: summary without inlined content - mcpToolCall: namespaced naming + error surfacing - userMessage: text fragments only (drops images/etc) - opaque items: no fabricated tool_calls - Helpers: deterministic id stability + sorted JSON args - Role alternation invariant across all four tool-shaped item types This commit is a pure addition. AIAgent integration (the wire that uses the projector) is the next commit. * feat(codex-runtime): add session adapter + approval bridge The third self-contained module: CodexAppServerSession owns one Codex thread per Hermes session, drives turn/start, consumes streaming notifications via CodexEventProjector, handles server-initiated approval requests, and translates cancellation into turn/interrupt. The adapter has a single public per-turn method: result = session.run_turn(user_input='...', turn_timeout=600) # result.final_text → assistant text for the caller # result.projected_messages → list ready to splice into AIAgent.messages # result.tool_iterations → tick count for _iters_since_skill nudge # result.interrupted → True on Ctrl+C / deadline / interrupt # result.error → error string when the turn cannot complete # result.turn_id, thread_id → for sessions DB / resume Behavior: - ensure_started() spawns codex, does the initialize handshake, and issues thread/start with cwd + permissions profile. Idempotent. - run_turn() blocks until turn/completed, drains server-initiated requests (approvals) before reading notifications so codex never deadlocks waiting for us, projects every item/completed via the projector, and increments tool_iterations for the skill nudge gate. - request_interrupt() is thread-safe (threading.Event); the next loop iteration issues turn/interrupt and unwinds. - turn_timeout deadlock guard issues turn/interrupt and records an error if the turn never completes. - close() escalates terminate → kill via the underlying client. Approval bridge: Codex emits server-initiated requests for execCommandApproval and applyPatchApproval. The adapter translates Hermes' approval choice vocabulary onto codex's decision vocabulary: Hermes 'once' → codex 'approved' Hermes 'session' or 'always' → codex 'approvedForSession' Hermes 'deny' / anything else → codex 'denied' Routing precedence: 1. _ServerRequestRouting.auto_approve_* flags (cron / non-interactive) 2. approval_callback wired by the CLI (defers to tools.approval.prompt_dangerous_approval()) 3. Fail-closed denial when neither is wired Unknown server-request methods are answered with JSON-RPC error -32601 so codex doesn't hang waiting for us. Permission profile mapping mirrors AGENTS.md: Hermes 'auto' → codex 'workspace-write' Hermes 'approval-required' → codex 'read-only-with-approval' Hermes 'unrestricted/yolo' → codex 'full-access' 20 new tests, all green. Combined with prior commits this PR now has 67 tests across three modules: - test_codex_app_server_runtime.py: 24 (api_mode + transport surface) - test_codex_event_projector.py: 23 (item taxonomy projections) - test_codex_app_server_session.py: 20 (turn loop + approvals + interrupts) Full tests/agent/transports/ directory: 249/249 pass — no regressions to existing transport tests. Still no wire into AIAgent.run_conversation(); that integration commit is small and goes next. * feat(codex-runtime): wire codex_app_server runtime into AIAgent The integration commit. AIAgent.run_conversation() now early-returns to a new helper _run_codex_app_server_turn() when self.api_mode == 'codex_app_server', bypassing the chat_completions tool loop entirely. Three small surgical edits to run_agent.py (~105 LOC total): 1. Line ~1204 (constructor api_mode validation set): Add 'codex_app_server' so an explicit api_mode='codex_app_server' passed to AIAgent() isn't silently rewritten to 'chat_completions'. 2. Line ~12048 (run_conversation, just before the while loop): Early-return to _run_codex_app_server_turn() when self.api_mode is 'codex_app_server'. Placed AFTER all standard pre-loop setup — logging context, session DB, surrogate sanitization, _user_turn_count and _turns_since_memory increments, _ext_prefetch_cache, memory manager on_turn_start — so behavior outside the model-call loop is identical between paths. Default Hermes flow is unchanged when the flag is off. 3. End-of-class (line ~15497): New method _run_codex_app_server_turn(). Lazy-instantiates one CodexAppServerSession per AIAgent (reused across turns), runs the turn, splices projected_messages into messages, increments _iters_since_skill by tool_iterations (since the chat_completions loop normally does that per iteration), fires _spawn_background_review on the same cadence as the default path. Counter accounting: _turns_since_memory ← already incremented at run_conversation:11817 (gated on memory store configured) — codex helper does NOT touch it (would double-count). _user_turn_count ← already incremented at run_conversation:11793 — codex helper does NOT touch it. _iters_since_skill ← incremented in the chat_completions loop per tool iteration. Codex helper increments by turn.tool_iterations since the loop is bypassed. User message: ALREADY appended to messages by run_conversation pre-loop (line 11823) before the early-return reaches us. Helper does NOT append again. Regression test test_user_message_not_duplicated guards this. Approval callback wiring: Lazy-fetches tools.terminal_tool._get_approval_callback at session spawn time, passes to CodexAppServerSession. CLI threads with prompt_toolkit get interactive approvals; gateway/cron contexts get the codex-side fail-closed deny. Error path: Codex session exceptions become a 'partial' result with completed=False and a final_response that explicitly tells the user how to switch back: 'Codex app-server turn failed: ... Fall back to default runtime with /codex-runtime auto.' Same return-dict shape as the chat_completions path so all callers (gateway, CLI, batch_runner, ACP) work unchanged. 9 new integration tests in tests/run_agent/test_codex_app_server_integration.py: - api_mode='codex_app_server' is accepted on AIAgent construction - run_conversation returns the expected codex shape (final_response, codex_thread_id, codex_turn_id, completed, partial) - Projected messages are spliced into messages list - _iters_since_skill ticks per tool iteration - _user_turn_count delegated to standard flow (not double-counted) - User message appears exactly once (regression guard) - _spawn_background_review IS invoked (memory/skill review keeps working) - chat.completions.create is NEVER called (loop fully bypassed) - Session exception → partial result with /codex-runtime auto hint - Interrupted turn → partial result with error preserved Adjacent test runs confirm no regressions: - tests/run_agent/test_memory_nudge_counter_hydration.py: green - tests/run_agent/test_background_review.py: green - tests/run_agent/test_fallback_model.py: green - tests/agent/transports/: 249/249 green Still missing for full feature: /codex-runtime slash command, plugin migration helper, docs page, live e2e test gated on codex binary. Those are the remaining followup commits. * feat(codex-runtime): add /codex-runtime slash command (CLI + gateway) User-facing toggle for the optional codex app-server runtime. Follows the 'Adding a Slash Command (All Platforms)' pattern from AGENTS.md exactly: single CommandDef in the central registry → CLI handler → gateway handler → running-agent guard → all surfaces (autocomplete, /help, Telegram menu, Slack subcommands) update automatically. Surface: /codex-runtime — show current state + codex CLI status /codex-runtime auto — Hermes default runtime /codex-runtime codex_app_server — codex subprocess runtime /codex-runtime on / off — synonyms Files changed: hermes_cli/codex_runtime_switch.py (new): Pure-Python state machine shared by CLI and gateway. Parse args, read/write model.openai_runtime in the config dict, gate enabling behind a codex --version check (don't let users opt in to a runtime they have no binary for; print npm install hint instead). Returns a CodexRuntimeStatus dataclass that callers render however suits their surface. hermes_cli/commands.py: Single CommandDef entry, no aliases (codex-runtime is its own thing). cli.py: Dispatch in process_command() + _handle_codex_runtime() handler that delegates to the shared module and renders results via _cprint. gateway/run.py: Dispatch in _handle_message() + _handle_codex_runtime_command() that returns a string (gateway sends as message). On a successful change that requires a new session, _evict_cached_agent() forces the next inbound message to construct a fresh AIAgent with the new api_mode — avoids prompt-cache invalidation mid-session. gateway/run.py running-agent guard: /codex-runtime joins /model in the early-intercept block so a runtime flip mid-turn can't split a turn across two transports. Tests: tests/hermes_cli/test_codex_runtime_switch.py — 25 tests covering the state machine: arg parsing (10 cases incl. case-insensitive and synonyms), reading current runtime (5 cases incl. malformed configs), writing runtime (3 cases), apply() entry point covering read-only, no-op, codex-missing-blocked, codex-present-success, disable-no-binary-check, and persist-failure paths (8 cases). All green. Adjacent test suites confirm no regressions: - tests/hermes_cli/test_commands.py + test_codex_runtime_switch.py: 167/167 green - tests/agent/transports/: 283/283 green when combined with prior commits Still missing: plugin migration helper, docs page, live e2e test gated on codex binary. Followup commits. * feat(codex-runtime): auto-migrate Hermes MCP servers to ~/.codex/config.toml Translates the user's mcp_servers config from ~/.hermes/config.yaml into the TOML format codex's MCP client expects. Wired into the /codex-runtime codex_app_server enable path so users get their MCP tool surface in the spawned subprocess automatically. The migration runs on every enable. Failures are non-fatal — the runtime change still proceeds and the user gets a warning so they can fix the codex config manually. What translates (mapping verified against codex-rs/core/src/config/edit.rs): Hermes mcp_servers.<n>.command/args/env → codex stdio transport Hermes mcp_servers.<n>.url/headers → codex streamable_http transport Hermes mcp_servers.<n>.timeout → codex tool_timeout_sec Hermes mcp_servers.<n>.connect_timeout → codex startup_timeout_sec Hermes mcp_servers.<n>.cwd → codex stdio cwd Hermes mcp_servers.<n>.enabled: false → codex enabled = false What does NOT translate (warned + skipped per server): Hermes-specific keys (sampling, etc.) — codex's MCP client has no equivalent. Listed in the per-server skipped[] field of the report. What's NOT migrated (intentional): AGENTS.md — codex respects this file natively in its cwd. Hermes' own AGENTS.md (project-level) is already in the worktree, so codex picks it up without translation. No code needed. Idempotency design: All managed content lives between a 'managed by hermes-agent' marker and the next non-mcp_servers section header. _strip_existing_managed_block removes the prior managed region cleanly, preserving any user-added codex config (model, providers.openai, sandbox profiles, etc.) above or below. Files added: hermes_cli/codex_runtime_plugin_migration.py — pure-Python migration helper. Public API: migrate(hermes_config, codex_home=None, dry_run=False) returns MigrationReport with .migrated/.errors/ .skipped_keys_per_server. No external TOML dependency — minimal formatter handles strings/numbers/booleans/lists/inline-tables. tests/hermes_cli/test_codex_runtime_plugin_migration.py — 39 tests covering: - per-server translation (12): stdio/http/sse, cwd, timeouts, enabled flag, command+url precedence, sampling drop, unknown keys - TOML formatter (8): types, escaping, inline tables, error case - existing-block stripping (4): no marker, alone, with user content above, with user content below - end-to-end migrate() (8): empty, dry-run, round-trip, idempotent re-run, preserves user config, error reporting, invalid input, summary formatting Files changed: hermes_cli/codex_runtime_switch.py — apply() now calls migrate() in the codex_app_server enable branch. Migration failure logs a warning in the result message but does NOT fail the runtime change. Disable path (auto) explicitly skips migration. tests/hermes_cli/test_codex_runtime_switch.py — 3 new tests: test_enable_triggers_mcp_migration, test_disable_does_not_trigger_migration, test_migration_failure_does_not_block_enable. All 325 feature tests green: - tests/agent/transports/: 249 (incl. 67 new) - tests/run_agent/test_codex_app_server_integration.py: 9 - tests/hermes_cli/test_codex_runtime_switch.py: 28 (3 new) - tests/hermes_cli/test_codex_runtime_plugin_migration.py: 39 (new) * perf(codex-runtime): cache codex --version check within apply() Single /codex-runtime invocation could spawn 'codex --version' up to 3 times (state report, enable gate, success message). Each spawn is ~50ms, so the cumulative cost wasn't a crisis, but it was wasteful and turned a trivial slash command into something noticeably laggy on slower systems. Refactored to lazy-once via a closure over a nonlocal cache. First call spawns; subsequent calls in the same apply() reuse the result. Behavior unchanged — same return shape, same error handling, same install hint when codex is missing. Just one subprocess per call instead of three. Two regression-guard tests added: - test_binary_check_cached_within_apply: enable path → call_count == 1 - test_binary_check_cached_on_read_only_call: state-report path → call_count == 1 Total tests for /codex-runtime now 30 (was 28); all 143 codex-runtime tests still green. * fix(codex-runtime): correct protocol field names found via live e2e test Three real bugs caught only by running a turn end-to-end against codex 0.130.0 with a real ChatGPT subscription. Unit tests passed because they asserted on our own (incorrect) wire shapes; the wire format from codex-rs/app-server-protocol/src/protocol/v2/* is the source of truth and my initial reading of the README was incomplete. Bug 1: thread/start.permissions wire format Was sending {"profileId": "workspace-write"}. Real format per PermissionProfileSelectionParams enum (tagged union): {"type": "profile", "id": "workspace-write"} AND requires the experimentalApi capability declared during initialize. AND requires a matching [permissions] table in ~/.codex/config.toml or codex fails the request with 'default_permissions requires a [permissions] table'. Fix: stop overriding permissions on thread/start. Codex picks its default profile (read-only unless user configures otherwise), which matches what codex CLI users expect — they configure their default permission profile in ~/.codex/config.toml the standard way. Trying to be clever about profile selection broke every turn we tested. Live error before fix: 'Invalid request: missing field type' on every turn/start, even though our turn/start payload was correct — the field codex was complaining about was inside the permissions sub-object we shouldn't have been sending. Bug 2: server-request method names Was matching 'execCommandApproval' and 'applyPatchApproval'. Real names per common.rs ServerRequest enum: item/commandExecution/requestApproval item/fileChange/requestApproval item/permissions/requestApproval (new third method) Fix: match the documented names. Added handler for item/permissions/requestApproval that always declines — codex sometimes asks to escalate permissions mid-turn and silent acceptance would surprise users. Live symptom before fix: agent.log showed 'Unknown codex server request: item/commandExecution/requestApproval' and codex stalled because we replied with -32601 (unsupported method) instead of an approval decision. The agent reported back 'The write command was rejected' even though Hermes never showed the user an approval prompt. Bug 3: approval decision values Was sending decision strings 'approved'/'approvedForSession'/'denied'. Real values per CommandExecutionApprovalDecision enum (camelCase): accept, acceptForSession, decline, cancel (also AcceptWithExecpolicyAmendment and ApplyNetworkPolicyAmendment variants we don't currently use). Fix: rename _approval_choice_to_codex_decision return values; update auto_approve_* fallbacks; update fail-closed default from 'denied' to 'decline'. Test mapping table updated to match. Live test verified after fixes: $ hermes (with model.openai_runtime: codex_app_server) > Run the shell command: echo hermes-codex-livetest > .../proof.txt then read it back Approval prompt fired with 'Codex requests exec in <cwd>'. User chose 'Allow once'. Codex executed the command, wrote the file, read it back. Final response: 'Read back from proof.txt: hermes-codex-livetest'. File contents on disk match. agent.log confirms: codex app-server thread started: id=019e200e profile=workspace-write cwd=/tmp/hermes-codex-livetest/workspace All 20 session tests still green after wire-format updates. * fix(codex-runtime): correct apply_patch approval params + ship docs Live e2e revealed FileChangeRequestApprovalParams doesn't carry the changeset (just itemId, threadId, turnId, reason, grantRoot) — Codex's 'reason' field describes what the patch wants to do. Test config and display logic updated to use it. The first 'apply_patch (0 change(s))' display from the live test is now 'apply_patch: <reason>'. Adds website/docs/user-guide/features/codex-app-server-runtime.md covering enable/disable, prerequisites, approval UX, MCP migration behavior, permission profile delegation to ~/.codex/config.toml, known limitations, and the architecture diagram. Wired into the Automation category in sidebars.ts. Live e2e validation across the path matrix: ✓ thread/start handshake ✓ turn/start with text input ✓ commandExecution items + projection ✓ item/commandExecution/requestApproval → Hermes UI → response ✓ Approve once → command runs ✓ Deny → command rejected, codex falls back to read-only message ✓ Multi-turn (codex remembers prior turn's results) ✓ apply_patch via Codex's fileChange path ✓ item/fileChange/requestApproval → Hermes UI ✓ MCP server migration loads inside spawned codex (verified via 'use the filesystem MCP tool' prompt) ✓ /codex-runtime auto → codex_app_server toggle cycle ✓ Disable doesn't trigger migration ✓ Enable with codex CLI present succeeds + migrates ✓ Hermes-side interrupt path (turn/interrupt request issued cleanly even if codex finishes before the interrupt lands) Known live-validated limitations now documented in the docs page: - delegate_task subagents unavailable on this runtime - permission profile selection delegated to ~/.codex/config.toml - apply_patch approval prompt has no inline changeset (codex protocol doesn't expose it) 145/145 codex-runtime tests still green. * feat(codex-runtime): native plugin migration + UX polish (quirks 2/4/5/10/11) Major: migrate native Codex plugins (#7 in OpenClaw's PR list) Discovers installed curated plugins via codex's plugin/list RPC and writes [plugins."<name>@<marketplace>"] entries to ~/.codex/config.toml so they're enabled in the spawned Codex sessions. This is the 'YouTube-video-worthy' bit Pash highlighted: when a user has google-calendar, github, etc. installed in their Codex CLI, those plugins activate automatically when they enable Hermes' codex runtime. Implementation: - hermes_cli/codex_runtime_plugin_migration.py: new _query_codex_plugins() helper spawns 'codex app-server' briefly and walks plugin/list. Returns (plugins, error) — failures are non-fatal so MCP migration still works. - render_codex_toml_section() now takes plugins + permissions args. - migrate() defaults: discover_plugins=True, default_permission_profile= 'workspace-write'. Explicit None on either disables that side. - _strip_existing_managed_block() now also strips [plugins.*] and [permissions]/[permissions.*] sections inside the managed block, so re-runs replace plugins cleanly without touching codex's own config. Quirk fixes: #2 Default permissions profile written on enable. Without this, Codex's read-only default kicks in and EVERY write triggers an approval prompt. Now writes [permissions] default = 'workspace-write' so the runtime feels normal out of the box. Set default_permission_profile=None to opt out. #4 apply_patch approval prompt now shows what's changing. Codex's FileChangeRequestApprovalParams doesn't carry the changeset. Session adapter now caches the fileChange item from item/started notifications and looks it up by itemId when codex requests approval. Prompt shows '1 add, 1 update: /tmp/new.py, /tmp/old.py' instead of 'apply_patch (0 change(s))'. Side benefit: also drains pending notifications BEFORE handling a server request, so the projector and per-turn caches are up to date when the approval decision fires. Bounded to 8 notifications per loop iter to avoid starving codex's response. #5/#10 Exec approval prompt never shows empty cwd. When codex omits cwd in CommandExecutionRequestApprovalParams, fall back to the session's cwd. If somehow neither is available, show '<unknown>' explicitly instead of an empty string. Also surfaces 'reason' from the approval params when codex provides it — gives users more context on why codex wants to run something. #11 Banner indicates the codex_app_server runtime when active. New 'Runtime: codex app-server (terminal/file ops/MCP run inside codex)' line appears in the welcome banner only when the runtime is on. Default banner is unchanged. Tests: - 7 new tests in test_codex_runtime_plugin_migration.py covering plugin discovery (mocked), failure handling, dry-run skip, opt-out flag, idempotent re-runs, and permissions writing. - 3 new tests in test_codex_app_server_session.py covering the enriched approval prompts: cwd fallback, change summary on apply_patch, fallback when no item/started cache exists. - All 26 session tests + 46 migration tests green; 153 total in PR. * feat(codex-runtime): hermes-tools MCP callback + native plugin migration The big architectural addition: when codex_app_server runtime is on, Hermes registers its own tool surface as an MCP server in ~/.codex/config.toml so the codex subprocess can call back into Hermes for tools codex doesn't ship with — web_search, browser_*, vision, image_generate, skills, TTS. Also: 'migrate native codex plugins' (Pash's YouTube-video-worthy bit) — when the user has plugins like Linear, GitHub, Gmail, Calendar, Canva installed via 'codex plugin', Hermes discovers them via plugin/list and writes [plugins.<name>@openai-curated] entries so they activate automatically. New module: agent/transports/hermes_tools_mcp_server.py FastMCP stdio server exposing 17 Hermes tools. Each call dispatches through model_tools.handle_function_call() — same code path as the Hermes default runtime. Run with: python -m agent.transports.hermes_tools_mcp_server [--verbose] Exposed: web_search, web_extract, browser_navigate / _click / _type / _press / _snapshot / _scroll / _back / _get_images / _console / _vision, vision_analyze, image_generate, skill_view, skills_list, text_to_speech. NOT exposed (deliberately): - terminal/shell/read_file/write_file/patch — codex has built-ins - delegate_task/memory/session_search/todo — _AGENT_LOOP_TOOLS in model_tools.py:493, require running AIAgent context. Documented as a limitation and surfaced in the slash command output. Migration changes (hermes_cli/codex_runtime_plugin_migration.py): - _query_codex_plugins() spawns 'codex app-server' briefly to walk plugin/list and pull installed openai-curated plugins. Failures are non-fatal — MCP migration still completes. - render_codex_toml_section() now takes plugins + permissions args AND wraps the managed block with a MIGRATION_END_MARKER comment so the stripper can reliably find both ends, even when the block contains top-level keys (default_permissions = ...). - migrate() defaults: discover_plugins=True, expose_hermes_tools=True, default_permission_profile=':workspace' (built-in codex profile name — must be prefixed with ':'). All three opt-out via explicit args. - _build_hermes_tools_mcp_entry() builds the codex stdio entry with HERMES_HOME and PYTHONPATH passthrough so a worktree-launched Hermes points the MCP subprocess at the same module layout. Live-caught wire bugs fixed during this turn: 1. Permission profile config key is top-level , NOT a [permissions] table. The [permissions] table is for *user-defined* profiles with structured fields. Built-in profile names start with ':' (':workspace', ':read-only', ':danger-no-sandbox'). Was emitting which codex rejected with 'invalid type: string "X", expected struct PermissionProfileToml'. 2. Built-in profile is , NOT . Codex rejected with 'unknown built-in profile'. 3. Codex's MCP layer sends for tool-call confirmation. We weren't handling it, so codex stalled and returned 'MCP tool call was rejected'. Now: auto-accept for our own hermes-tools server (user already opted in by enabling the runtime), decline for third-party servers. Quirk fixes shipped (from the limitations list): #2 default permissions: workspace profile written on enable. No more approval prompt on every write. #4 apply_patch approval shows what's changing: cache fileChange items from item/started, look up by itemId when codex sends item/fileChange/requestApproval. Prompt: '1 add, 1 update: /tmp/new.py, /tmp/old.py' instead of '0 change(s)'. #5/#10 exec approval cwd never empty: fall back to session cwd, then '<unknown>'. Also surfaces 'reason' from codex when present. #11 banner shows 'Runtime: codex app-server' line when active so users understand why tool counts may not match what's reachable. Tests: - 5 new tests in test_codex_runtime_plugin_migration.py covering plugin discovery, expose_hermes_tools entry generation, idempotent re-runs, opt-out flag, permissions profile. - 3 new tests in test_codex_app_server_session.py covering enriched approval prompts (cwd fallback, fileChange summary). - 2 new tests for mcpServer/elicitation/request handling (accept hermes-tools, decline others). - New test file test_hermes_tools_mcp_server.py covering module surface, EXPOSED_TOOLS safety invariants (no shell/file_ops, no agent-loop tools), and main() error paths. - 166 codex-runtime tests total, all green. Live e2e validated against codex 0.130.0 + ChatGPT subscription: ✓ /codex-runtime codex_app_server enables, migrates filesystem MCP, registers hermes-tools, writes default_permissions = ':workspace' ✓ Banner shows 'Runtime: codex app-server' line in subsequent sessions ✓ Shell command runs without approval prompt (workspace profile works) ✓ Multi-turn — codex remembers prior turn's results ✓ apply_patch path via fileChange request approval ✓ web_search via hermes-tools MCP callback returns real Firecrawl results: 'OpenAI Codex CLI – Getting Started' end-to-end in 13s ✓ Disable cycle clean Docs updated: website/docs/user-guide/features/codex-app-server-runtime.md Full re-write covering native plugin migration, the hermes-tools callback architecture, the prerequisites change ('codex login is separate from hermes auth login codex'), the trade-off table now reflecting which Hermes tools work via callback, and the limitations list updated with what's actually unavailable on this runtime. * feat(codex-runtime): pin user-config preservation invariant for quirk #6 Quirk #6 from the limitations list — user MCP servers / overrides / codex-only sections in ~/.codex/config.toml that live OUTSIDE the hermes-managed block must survive re-migration verbatim. This already worked thanks to the MIGRATION_MARKER + MIGRATION_END_MARKER pair I added when fixing the default_permissions wire format (so the strip can find both ends of the managed region even with top-level keys like default_permissions). But it was an emergent property without a test pinning it. Now explicitly tested: - User MCP server above the managed block survives migration - User MCP server below the managed block survives migration - Both above + below survive a second re-migration - User content (model, providers, sandbox, otel, etc.) outside our region is left untouched Docs added a section "Editing ~/.codex/config.toml safely" explaining the marker contract — so users know they can add their own MCP servers, override permissions, configure codex-only options, etc. without fear of Hermes overwriting their work. 167 codex-runtime tests, all green. * docs(codex-runtime): clarify the actual tool surface — shell covers terminal/read/write/find Previous docs and PR description undersold what codex's built-in toolset actually provides. apply_patch alone made it sound like the runtime could only edit files in patch format — implying you'd lose terminal use, read_file, write_file, search/find. That was wrong. Codex's 'shell' tool runs arbitrary shell commands inside the sandbox, which covers everything you'd do in bash: cat/head/tail (read), echo> or heredocs (write), find/rg/grep (search), ls/cd (navigate), build/ test/git/etc. apply_patch is for structured multi-file edits on top of that. update_plan is its in-runtime todo. view_image loads images. And codex has its own web_search built in (in addition to the Firecrawl-backed one Hermes exposes via MCP callback). Docs now have a 'What tools the model actually has' section right after Why, breaking the surface into three clearly-labeled buckets: 1. Codex's built-in toolset (always on) — shell, apply_patch, update_plan, view_image, web_search; covers everything terminal- adjacent. 2. Native Codex plugins (auto-migrated from your codex plugin install) — Linear, GitHub, Gmail, Calendar, Outlook, Canva, etc. 3. Hermes tool callback (MCP server in ~/.codex/config.toml) — web_search/web_extract via Firecrawl, browser_*, vision_analyze, image_generate, skill_view/skills_list, text_to_speech. Plus a 'What's NOT available' callout listing the four agent-loop tools (delegate_task, memory, session_search, todo) that need running AIAgent context and can't reach the codex runtime. Trade-offs table broken out: shell, apply_patch, update_plan, view_image, sandbox each get their own row with a one-line description so users can see at a glance what's available natively. Architecture diagram updated to list the codex built-ins by name instead of 'apply_patch + shell + sandbox'. No code changes — purely docs clarification. 167 codex-runtime tests still green. * fix(codex-runtime): _spawn_background_review signature + review fork api_mode downgrade Two real bugs in the self-improvement loop integration that the previous test mocked away. Bug 1: wrong call signature The codex helper was calling self._spawn_background_review() with no args after every turn. That function actually requires: messages_snapshot=list (positional or keyword) review_memory=bool (at least one trigger must be True) review_skills=bool So the call would have raised TypeError at runtime — except the only test that exercised this path mocked _spawn_background_review entirely and just asserted spawn.called, so the wrong-arg shape never surfaced. Bug 2: review fork inherits codex_app_server api_mode The review fork is constructed with: api_mode = _parent_runtime.get('api_mode') So when the parent is codex_app_server, the review fork ALSO runs as codex_app_server. But the review fork's whole job is to call agent-loop tools (memory, skill_manage) which require Hermes' own dispatch — they short-circuit with 'must be handled by the agent loop' on the codex runtime. So the review fork would have run, decided to save something, called memory or skill_manage, and silently no-op'd. Fixed in run_agent.py:_spawn_background_review() — when the parent api_mode is 'codex_app_server', the review fork is downgraded to 'codex_responses' (same OAuth credentials, same openai-codex provider, but talks to OpenAI's Responses API directly so Hermes owns the loop). Also rewrote the codex helper's review wiring to match the chat_completions path: - Computes _should_review_memory in the pre-loop block (was already being computed; now passed through to the helper as an arg). - Computes _should_review_skills AFTER the codex turn returns + counters tick (line ~15432 pattern in chat_completions). - Calls _spawn_background_review(messages_snapshot=, review_memory=, review_skills=) only when at least one trigger fires. - Adds the external memory provider sync (_sync_external_memory_for_turn) that the chat_completions path runs after every turn. Tests: Replaced the broken test_background_review_invoked (which only asserted spawn.called) with three sharper tests: - test_background_review_NOT_invoked_below_threshold: single turn at default thresholds → no review fires (would have caught the original 'every turn calls spawn with no args' bug) - test_background_review_skill_trigger_fires_above_threshold: 10 tool_iterations at threshold=10 → review fires with messages_snapshot=list, review_skills=True, counter resets - test_background_review_signature_never_breaks: regression guard asserting positional args are always empty and kwargs include messages_snapshot New TestReviewForkApiModeDowngrade class: - test_codex_app_server_parent_downgrades_review_fork: drives the real _spawn_background_review function (no mock at that level), asserts the review_agent gets api_mode='codex_responses' when the parent was codex_app_server. Live-validated against real run_conversation: - Counter ticked from 0 to 5 after a 5-tool-iteration turn - _spawn_background_review fired exactly once with kwargs-only signature - review_skills=True, review_memory=False - messages_snapshot was 12 entries (5 assistant tool_calls + 5 tool results + 1 final assistant + initial system/user) - Counter reset to 0 after fire 170 codex-runtime tests, all green. Docs: added a Self-improvement loop section to the codex runtime page explaining both how the trigger logic stays equivalent and that the review fork is auto-downgraded to codex_responses for the agent-loop tools. Also clarified that apply_patch and update_plan ARE codex's built-in tools (the previous version made it sound like they were separate from 'codex's stuff' — they're not, all five tools listed in 'What tools the model actually has' section 1 are codex built-ins). * feat(codex-runtime): expose kanban tools through Hermes MCP callback Kanban workers spawn as separate hermes chat -q subprocesses that read the user's config.yaml. If model.openai_runtime: codex_app_server is set globally (which is the whole point of opt-in), every dispatched worker ALSO comes up on the codex runtime. That mostly works — codex's built-in shell + apply_patch + update_plan do the actual task work fine — but it had one critical break: the worker handoff tools (kanban_complete, kanban_block, kanban_comment, kanban_heartbeat) are Hermes-registered tools, not codex built-ins. On the codex runtime, codex builds its own tool list and these never reach the model, so the worker would do the work but not be able to report back, hanging until the dispatcher's timeout escalates it as zombie. Fix: add all 9 kanban tools to the EXPOSED_TOOLS list in the Hermes MCP callback. They dispatch statelessly through handle_function_call() just like web_search and the others — they read HERMES_KANBAN_TASK from env (set by the dispatcher), gate correctly (worker tools require the env var, orchestrator tools require it unset), and write to ~/.hermes/kanban.db. Why kanban tools work via stateless dispatch when delegate_task/memory/ session_search/todo don't: those four are listed in _AGENT_LOOP_TOOLS (model_tools.py:493) and short-circuit in handle_function_call() with 'must be handled by the agent loop' — they need to mutate AIAgent's mid-loop state. Kanban tools have no such requirement; they're pure side-effect functions against the kanban.db plus state_meta. Tools exposed: Worker handoff (require HERMES_KANBAN_TASK): kanban_complete, kanban_block, kanban_comment, kanban_heartbeat Read-only board queries: kanban_show, kanban_list Orchestrator (require HERMES_KANBAN_TASK unset): kanban_create, kanban_unblock, kanban_link Tests: - test_kanban_worker_tools_exposed: complete/block/comment/heartbeat in EXPOSED_TOOLS (regression guard for the would-hang-worker bug) - test_kanban_orchestrator_tools_exposed: create/show/list/unblock/link Docs: - New 'Workflow features' section in the docs page covering /goal, kanban, and cron behavior on this runtime - /goal: works fully via run_conversation feedback; only caveat is approval-prompt noise on long writes-heavy goals (mitigated by the default :workspace permission profile) - Kanban: enumerated which tools are reachable via the callback and why the env var propagates correctly through the codex subprocess to the MCP server subprocess - Cron: documented as 'not specifically tested' — same rules as the CLI apply since cron runs through AIAgent.run_conversation - Trade-offs table gained rows for /goal, kanban worker, kanban orchestrator 172/172 codex-runtime tests green (+2 from kanban tests). * docs(codex-runtime): wire /codex-runtime into slash-commands ref + flag aux token cost Three docs gaps caught during a final audit: 1. /codex-runtime was only in the feature docs page, not in the slash-commands reference. Added rows to both the CLI section and the Messaging section so users discover it where they'd look for slash command syntax. 2. CODEX_HOME and HERMES_KANBAN_TASK weren't in environment-variables.md. CODEX_HOME lets users redirect Codex CLI's config dir (the migration honors it). HERMES_KANBAN_TASK is set by the kanban dispatcher and propagates to the codex subprocess + the hermes-tools MCP subprocess so kanban worker tools gate correctly — documented as 'don't set manually' since it's an internal handoff. 3. Aux client behavior on this runtime. When openai_runtime= codex_app_server is on with the openai-codex provider, every aux task (title generation, context compression, vision auto-detect, session search summarization, the background self-improvement review fork) flows through the user's ChatGPT subscription by default. This is true for the existing codex_responses path too, but it's more visible / important here because users explicitly opted in for subscription billing. Added a 'Auxiliary tasks and ChatGPT subscription token cost' section to the docs page with a YAML example showing how to override specific aux tasks to a cheaper model (typically google/gemini-3-flash-preview via OpenRouter). Also documents how the self-improvement review fork gets auto-downgraded from codex_app_server to codex_responses by the fix earlier in this PR. No code changes — pure docs. 172 codex-runtime tests still green. * docs+test(codex-runtime): pin HOME passthrough, document multi-profile + CODEX_HOME OpenClaw hit a real footgun in openclaw/openclaw#81562: when spawning codex app-server they were synthesizing a per-agent HOME alongside CODEX_HOME. That made every subprocess codex's shell tool launches (gh, git, aws, npm, gcloud, ...) see a fake $HOME and miss the user's real config files. They had to back it out in PR NousResearch#81562 — keep CODEX_HOME isolation, leave HOME alone. Audit confirms Hermes' codex spawn doesn't have this problem. We do os.environ.copy() and only overlay CODEX_HOME (when provided) and RUST_LOG. HOME passes through unchanged. But it was an emergent property without a test pinning it, so adding a regression guard: test_spawn_env_preserves_HOME — confirms parent HOME survives intact in the subprocess env test_spawn_env_sets_CODEX_HOME_when_provided — confirms codex_home arg still isolates codex state correctly Docs additions: 'HOME environment variable passthrough' section — calls out the contract explicitly: CODEX_HOME isolates codex's own state, HOME stays user-real so gh/git/aws/npm/etc. find their normal config. Cites openclaw#81562 as the cautionary tale. 'Multi-profile / multi-tenant setups' section — addresses the related concern: profiles share ~/.codex/ by default. For users who want per-profile codex isolation (separate auth, separate plugins), documents the manual CODEX_HOME=<profile-scoped-dir> approach. Explains why we DON'T auto-scope CODEX_HOME per profile: doing so would silently invalidate existing codex login state for anyone upgrading to this PR with tokens already at ~/.codex/auth.json. Opt-in is safer than surprising users. 174 codex-runtime tests (+2 from HOME guards), all green. * fix(codex-runtime): TOML control-char escapes + atomic config.toml write Two footguns caught in a final audit pass before merge. Bug 1: TOML control characters not escaped The _format_toml_value() helper escaped backslashes and double quotes but passed literal control characters (\n, \t, \r, \f, \b) through unchanged. TOML basic strings don't allow literal control characters — a path or env var containing a newline would produce invalid TOML that codex refuses to load. Realistic exposure: pathological cases like a HERMES_HOME with a trailing newline (env var concatenation accident), or a PYTHONPATH with a tab from a multi-line shell heredoc. Fix: escape all five TOML basic-string control sequences (\b \t \n \f \r) in addition to \\ and \" that we already did. Order matters — backslash must come first or the other escapes get re-escaped. Bug 2: config.toml write wasn't atomic If the python process crashed between target.mkdir() and the write_text() finishing, a half-written config.toml could be left behind. On NFS / Windows / some FUSE mounts this is a real concern; on ext4/APFS small writes are usually atomic in practice but not guaranteed. Fix: write to a tempfile.mkstemp() temp file in the same directory, then Path.replace() (atomic same-dir rename on POSIX, ReplaceFile on Windows). On rename failure, clean up the temp file so repeated failed migrations don't pile up .config.toml.* files. Tests: - test_string_with_newline_escaped — \n in value → \n in output - test_string_with_tab_escaped — \t in value → \t in output - test_string_with_other_controls_escaped — \r, \f, \b - test_windows_path_escaped_correctly — backslash doubling - test_atomic_write_no_temp_leak_on_success — no .config.toml.* left over after a successful write - test_atomic_write_cleanup_on_rename_failure — temp file removed when Path.replace raises (simulated disk full) 180 codex-runtime tests, all green (+6 from this commit). Footguns audited but NOT fixed (with rationale): - Concurrent migrations race. Two Hermes processes hitting /codex-runtime codex_app_server within seconds of each other could cause one writer to lose entries. Low probability (you'd have to enable from two surfaces simultaneously) and low impact (just re-run migration). Adding fcntl/msvcrt locking is more code than it's worth here. The atomic rename above means each individual write is consistent — only the merge step is racy. - Codex protocol version drift. We pin MIN_CODEX_VERSION=0.125 and check at runtime but don't reject too-new versions. Right call — the protocol has been stable through 0.125 → 0.130. If OpenAI breaks it later we'd see the error in test_codex_app_server_runtime on CI before users hit it.
exiao
pushed a commit
that referenced
this pull request
May 27, 2026
… contract Three test classes lock in the NousResearch#30963 fix: 1. TestPartialStreamStubFinishReason — drives _interruptible_streaming_api_call through the two recovery branches and asserts: - text-only partial → finish_reason="length" (the new behaviour), - mid-tool-call partial → finish_reason="stop" (unchanged on purpose). 2. TestLengthContinuationPromptBranching — pure-Python check on the branch that picks the continuation prompt by response.id. Locks the network error wording for partial-stream-stub vs. the output-length wording for everything else. 3. TestConversationLoopPartialStreamContinuation — feeds a stub + continuation pair into run_conversation, verifies the loop makes a second API call (instead of exiting with text_response(stop)), confirms the network-error continuation prompt actually reaches the model on call #2, and that final_response stitches both halves. Refs: NousResearch#30963
exiao
pushed a commit
that referenced
this pull request
Jun 3, 2026
… OAuth gates
Two parallel public-path allowlists drifted: _PUBLIC_API_PATHS in
hermes_cli/web_server.py (legacy _SESSION_TOKEN middleware) and
_GATE_PUBLIC_PREFIXES in hermes_cli/dashboard_auth/middleware.py
(OAuth gate). The legacy list included /api/status (documented as a
non-sensitive read-only liveness target); the OAuth gate's list did not.
Effect: every wildcard-subdomain agent surfaced as STARTING/down to the
portal even though the dashboard was serving correctly. Nous account
service (src/server/agents/fly-provider.ts
getInstanceRuntimeStatus) fetches ``/api/status`` without a cookie
as its sole liveness probe; the OAuth gate's 401 looked identical to
'agent dead' on the portal side.
Fix: lift the allowlist into hermes_cli/dashboard_auth/public_paths.py
and have both middlewares import it. _path_is_public now consults
the shared frozenset first, then falls back to the gate's
auth-bootstrap/static prefix list. Future additions to the public list
hit both gates automatically.
Endpoint inventory (verified safe to remain public):
* /api/status — version, gateway state, active session count,
auth-gate shape. Portal liveness probe target.
* /api/config/defaults — config-defaults feed for the SPA's Config page
* /api/config/schema — config schema for the SPA's Config page
* /api/model/info — model catalogue metadata (context windows)
* /api/dashboard/themes — theme manifests for the skin engine
* /api/dashboard/plugins — plugin manifests for the dashboard
No user data, no session content, no secrets. Same shape an external
monitoring agent would hit on /healthz.
Tests:
* New: test_gated_status_is_public (regression guard with the NAS
fly-provider.ts liveness-probe rationale spelled out in the docstring)
* New: test_other_public_api_paths_are_public_under_gate (parametrised
over the rest of PUBLIC_API_PATHS — proves 401 / 302-to-login is
never the response)
* New: docker integration check #3 in
test_dashboard_oauth_gate_engaged_by_default — /api/status
remains 200 under the gate AND reports auth_required=True so the
portal can distinguish modes
* Updated: test_full_login_round_trip_unlocks_gated_api now probes
/api/sessions instead of /api/status (status is public, so it
can no longer distinguish 'logged in' from 'gate accidentally
disabled')
* Updated: TestApi401Envelope (the no-cookie / invalid-cookie /
dead-cookie tests) probes /api/sessions for the same reason
* Updated: docker integration check #2 in
test_dashboard_oauth_gate_engaged_by_default probes
/api/sessions to prove the gate is intercepting
* Removed: dead _login() helper in
test_dashboard_auth_status_endpoint.py (no longer needed since
/api/status is reachable cold)
Companion to docs/handover/hermes-agent-dashboard-s6-insecure-fix.md
(the --insecure flag fix that shipped earlier).
exiao
pushed a commit
that referenced
this pull request
Jun 3, 2026
…NousResearch#34192) (NousResearch#34382) NousResearch#34192 reports Hostinger's 'Hermes WebUI' catalog crashes on startup with: /usr/bin/tini: No such file or directory The image moved from tini to s6-overlay as PID 1 (/init) earlier in 2026. Orchestration templates that still pin /usr/bin/tini as the entrypoint \u2014 like the Hostinger Hermes WebUI catalog \u2014 have no binary to exec and the container crashes immediately. Hermes has no control over the Hostinger catalog template, but we can make the image backward-compatible by symlinking /usr/bin/tini -> /init during the s6-overlay install step. External wrappers that exec /usr/bin/tini will land on the same s6-overlay reaper they would have landed on if they'd used the canonical /init entrypoint. The image's own ENTRYPOINT continues to be /init verbatim \u2014 the shim is purely for legacy external wrappers, not for the image's own runtime path. Once affected catalogs are updated, the symlink can be removed. Other issues NousResearch#34192 raises that are NOT addressed by this PR: * Problem #2 (UID 1024 vs 10000 mismatch): already fixed by NousResearch#33148 (S6_KEEP_ENV=1) and NousResearch#32412 (with-contenv shebangs). The Hostinger template likely needs to update its env-var propagation. * Problem #3 (incompatible session formats): RFC for pluggable SessionDB is tracked in NousResearch#23717. * Problem #4 (Telegram polling conflict): an operations problem on Hostinger's side, not in this codebase. This PR is scoped to the one issue that can be fixed inside Dockerfile: the missing /usr/bin/tini binary. Tests (3 in test_dockerfile_tini_compat_shim.py): - test_tini_compat_symlink_present Guard: the symlink line must exist in Dockerfile. - test_tini_compat_comment_explains_why The NousResearch#34192 anchor comment must be present so future readers know why the shim is there (avoid accidental removal). - test_entrypoint_still_init_not_tini Sanity check: ENTRYPOINT remains /init (s6-overlay). The shim is only for external wrappers. Refs: NousResearch#34192 Partial fix: addresses the immediate tini-binary crash. Catalog-side fixes still needed by Hostinger for the UID and session-format problems documented in the issue. Co-authored-by: Cursor <cursoragent@cursor.com>
exiao
pushed a commit
that referenced
this pull request
Jun 8, 2026
…bes + test-leak fix (NousResearch#40909) * fix(gateway,windows): reliability — supervisor task, JOB breakaway, status --deep Three coordinated fixes for the Windows gateway reliability story: 1. CREATE_BREAKAWAY_FROM_JOB on every detached spawn The 'hermes update' triggered from the Electron Desktop GUI ran inside Electron's job object. Without breakaway, the post-update gateway watcher spawned by update — already DETACHED_PROCESS — was still reaped when Electron's job tore down, so the gateway never came back after a GUI-initiated update. Adds CREATE_BREAKAWAY_FROM_JOB (0x01000000) to: - hermes_cli/_subprocess_compat.py::windows_detach_flags() — used by every helper that calls windows_detach_popen_kwargs(), including launch_detached_profile_gateway_restart() - The watcher subprocess's own respawn snippet in hermes_cli/gateway.py (inlined flags so the watcher's child respawn also breaks away) _spawn_detached() in gateway_windows.py already had the flag; this change brings the rest of the codebase to parity. 2. Per-minute supervisor Scheduled Task — Windows equivalent of systemd Restart=always Introduces hermes_cli/gateway_supervisor.py and registers it as a second Scheduled Task ('Hermes_Gateway_Supervisor', SC MINUTE /MO 1, LIMITED rights) alongside the existing ONLOGON task. Every minute, the supervisor uses the same gateway.status.get_running_pid() probe as 'hermes gateway status' and, if no gateway is alive, calls gateway_windows._spawn_detached() (which now includes BREAKAWAY) to bring one back. Covers every crash mode, not just 'machine rebooted': taskkill, OOM, GUI update SIGTERM, parent job teardown. Cheap — one pythonw startup per minute when down, one PID-existence check per minute when up. Wired into both the schtasks-success and Startup-folder-fallback install paths via _install_supervisor_best_effort(), and removed in uninstall(). Best-effort: a failing supervisor install logs a warning but doesn't roll back the primary install. 3. 'hermes gateway status --deep' shows per-probe PASS/FAIL Replaces the existing terse '--deep' output (which only printed paths) with an actual diagnostic table: [1] PID file present [2] Lock file held by a live process [3] get_running_pid() result [4] _pid_exists(pid) — OS-level liveness [5] gateway_state.json (state + age) [6] Last lifecycle event from gateway-exit-diag.log When the high-level summary disagrees with reality, the user can see exactly which signal is lying. Test-leak fix ------------- tests/hermes_cli/test_gateway_wsl.py::TestGatewayCommandWSLMessages monkey-patched is_linux/is_wsl/supports_systemd_services to simulate WSL but did NOT stub is_windows(). On a Windows host, the dispatcher in _gateway_command_inner takes the is_windows() branch BEFORE the WSL guidance branch, so the test invoked gateway_windows.install() for real. install() writes to %APPDATA%\...\Startup\Hermes_Gateway.cmd — the REAL user Startup folder, never sandboxed by tmp_path — pointing at the test's pytest-of-<user>/pytest-<N>/.../gateway-service/ wrapper. When pytest tore down the tmp_path, every subsequent Windows login flashed a cmd.exe window that failed to find the missing target. Stubs is_windows=False on all four affected tests: test_install_wsl_no_systemd test_start_wsl_no_systemd test_status_wsl_running_manual test_status_wsl_not_running Defense-in-depth: _build_startup_launcher() now prefixes the launcher with 'if not exist <target> exit /b 0', so any future stale Startup entry silently no-ops instead of flashing a console window. Status enhancements ------------------- - status() now reports supervisor task presence alongside the existing schtasks/Startup info, and nudges the user to reinstall if the supervisor isn't registered. - Deep mode dumps both the supervisor task name + script path. * fix(gateway,windows): drop the per-minute supervisor task — keep breakaway + deep probes Earlier in this branch we added a per-minute schtasks-based supervisor to respawn the gateway after crashes / GUI-update SIGTERMs. The implementation flashed a brief console window on every firing, which stole window focus. We tried several variants: - cmd.exe wrapper invoking pythonw -> flashes (cmd.exe is console-subsystem) - schtasks /TR pointing at pythonw -> flashes (uv venv launcher pythonw is actually subsystem=Console, not GUI; it respawns the real pythonw) - schtasks /TR pointing at base uv -> still flashes (Task Scheduler-side conhost preallocation; documented Windows quirk) - XML registration with <Hidden>true> -> still flashes (<Hidden> only hides the task in the Task Scheduler UI, not the spawned window) Researched what leading projects do: - Ollama: GUI-subsystem tray exe + Startup-folder shortcut. No supervisor. - Tailscale: real Windows Service via SCM. Session 0, no console possible. - Syncthing: --no-console flag inside the binary + Startup folder. - openclaw: VBS Run(..., 0, False) wrapper. Suppresses the *window* but Super User Q971162 confirms focus-steal still occurs in some cases. None of these use a per-minute polling scheduled task. The 'auto-restart on crash' responsibility belongs INSIDE the daemon (Tailscale's in-process recovery / Ollama's monitor+worker pair) OR is delegated to the Windows Service Control Manager — not Task Scheduler. So this commit drops the supervisor entirely. The CREATE_BREAKAWAY_FROM_JOB fix in _subprocess_compat.py (from commit c1e5fa4) survives — that is the *real* fix for problem #2 (GUI-update kills gateway): the post-update watcher in launch_detached_profile_gateway_restart() now breaks out of Electron's job object, so the gateway respawn watcher survives the GUI quit and successfully respawns the gateway. Surviving from c1e5fa4: * CREATE_BREAKAWAY_FROM_JOB in hermes_cli/_subprocess_compat.py (fixes #2) * Inlined breakaway flag in the watcher respawn snippet in gateway.py * hermes gateway status --deep PASS/FAIL probes (fixes #1 — visibility) * 'if not exist <target> exit /b 0' guard in _build_startup_launcher (fixes #3 — silent no-op for stale Startup entries) * tests/hermes_cli/test_gateway_wsl.py is_windows=False stubs (root cause of #3 — pytest WSL tests no longer leak Startup entries on Win hosts) Removed in this commit: * hermes_cli/gateway_supervisor.py (entire file) * Supervisor section in hermes_cli/gateway_windows.py (~180 lines): get_supervisor_task_name, get_supervisor_script_path, _build_supervisor_cmd_script, _write_supervisor_script, _install_supervisor_task, is_supervisor_task_registered, _install_supervisor_best_effort * _install_supervisor_best_effort() calls in install() (3 spots) * supervisor cleanup block in uninstall() * supervisor display lines in status() / status(deep=True) Future direction (out of scope for this PR): the right place for Windows 'Restart=always' semantics is a real Windows Service installed via pywin32's win32serviceutil.ServiceFramework — session-0 isolation, SCM auto-restart, no console window possible. That's a meaningful next-PR project, not a band-aid. Tests: 51 pass / 2 pre-existing failures in tests/hermes_cli/test_gateway_{windows,wsl}.py (the 2 failures are TestSupportsSystemdServicesWSL cases that fail on origin/main too — unrelated to this PR).
exiao
pushed a commit
that referenced
this pull request
Jun 24, 2026
…bound/outbound round-trip (NousResearch#48828) * fix(relay): enable RELAY platform + normalize dial URL so hosted gateways actually connect Three bugs blocked a self-provisioned hosted gateway from ever establishing its inbound relay WS (found while standing up the live staging end-to-end). Each masked the next; all three are needed for inbound to work. 1. RELAY platform never enabled in config.platforms (gateway/config.py). register_relay_adapter() puts the adapter in the platform_registry, but start_gateway()'s connect loop iterates self.config.platforms — which never contained Platform.RELAY. So the adapter was "registered" but never connected (logs showed "relay adapter registered" then "No messaging platforms enabled"). Fix: _apply_env_overrides now enables Platform.RELAY (mirroring relay_url into extra for the connected-checker) when GATEWAY_RELAY_URL (env) or gateway.relay_url (yaml) is set. Absent -> no RELAY entry (direct/ single-tenant gateways unaffected). 2. URL scheme not converted for the WS dial (gateway/relay/ws_transport.py). The relay URL is configured once as the http(s):// base (used as-is for the provision POST), but websockets.connect rejects http(s):// with "scheme isn't ws or wss". Fix: _ws_dial_url converts https->wss / http->ws. 3. /relay path not appended (same helper). The connector mounts its WebSocketServer at path "/relay" and returns HTTP 400 on an upgrade to any other path. GATEWAY_RELAY_URL is the base (no /relay), so the dial hit "/" -> 400. Fix: _ws_dial_url ensures the path ends in /relay. Idempotent — a URL already carrying ws(s):// and/or /relay is unchanged, so provision's _provision_url (which derives /relay/provision from either form) still works. Why the cross-repo E2E missed #2/#3: the stub connector binds ws://host:port and its websockets.serve accepts ANY path, so neither the scheme nor the /relay path was exercised. Real connector needs both. Verified live on staging hermes-agent-stg-automated-perception-5054: after the fixes the gateway logs "Connecting to relay..." -> "✓ relay connected" -> "Gateway running with 1 platform(s)" against wss://gateway-gateway.staging-nousresearch.com/relay, stable. Tests: added _ws_dial_url scheme+path+idempotency cases (test_ws_transport.py) and RELAY-platform-enablement cases for env + yaml + absent (test_config.py). Full gateway/relay + config suites green (191 passed). Relay-adapter lane. EXPERIMENTAL. * fix(relay): re-attach guild_id to outbound so connector egress resolves the tenant The final bug in the hosted-relay round-trip. Inbound worked end to end (Discord -> connector -> bus -> agent WS -> agent runs -> reply), but the reply's egress was declined by the connector: "discord egress declined: target not routed to an onboarded tenant". Cause: the connector's routedEgressGuard resolves the owning tenant from the OUTBOUND action's metadata.guild_id (Discord's routing discriminator). The gateway's generic delivery path builds outbound metadata via run.py _thread_metadata_for_source, which only carries thread_id (and returns None entirely for a non-threaded message) — so guild_id never reached the connector, tenant resolution failed, and the shared bot refused to post. Fix (relay-adapter-local, no perturbation of the generic delivery path or other platforms): RelayAdapter learns chat_id -> guild_id from each inbound event (_capture_scope) and re-attaches it to the outbound action's metadata in send() (_with_scope) when not already present. No-op for chats we never saw inbound (e.g. DMs) and never overwrites an explicit guild_id. Verified live on staging hermes-agent-stg-automated-perception-5054: an @mention in #general now produces a visible bot reply — full multi-tenant relay round-trip (real Discord -> shared connector bot -> tenant routing -> agent WS -> reply egress -> Discord). Tests: _capture_scope/_with_scope reattach, no-scope no-op, explicit-guild_id preserved (test_relay_adapter.py). Full relay + config suites green (160 passed). Relay-adapter lane. EXPERIMENTAL.
exiao
pushed a commit
that referenced
this pull request
Jun 24, 2026
When context compression rotates a session, the original is ended and the continuation is auto-numbered (e.g. "name" -> "name #2"). The session list projects the ended root behind its live tip, so the user never sees the predecessor. But set_session_title's uniqueness check compared against ALL sessions, so renaming the visible tip back to "name" dead-ended with "Title 'name' is already in use by session <id the user can't find>". When the conflicting title is held by a compression ancestor of the session being renamed, transfer the title instead of raising: clear it from the ended predecessor and apply it to the continuation. Uniqueness is preserved (still exactly one session carries the title) and the parent-link lineage is untouched, so resume-by-title and tip projection keep working. Genuine conflicts with unrelated sessions, and with non-compression children (delegate/branch), still raise as before.
exiao
pushed a commit
that referenced
this pull request
Jun 24, 2026
…id (NousResearch#38763) Context compression today rewrites the message list AND rotates the session id — it ends the session, forks a parent_session_id child, and renumbers the title (name -> name #2). That moving identity key is the root cause of a whole bug cluster: /goal lost (NousResearch#33618), pending response lost at the split (NousResearch#14238), orphan sessions (NousResearch#33907), TUI sid desync (NousResearch#36777), FTS search gaps + duplicate sidebar entries (NousResearch#45117), null continuation cwd (NousResearch#42228), and title-rename dead-ends (NousResearch#48989). It also forced a large defensive apparatus (compression lock, contextvar/env/ logging triple-sync, orphan finalization, gateway SessionEntry re-propagation, tip projection) whose only job is surviving a mid-conversation id change. Add a compression.in_place config flag (default False during rollout). When True, compaction rewrites the transcript and rebuilds the system prompt but keeps the SAME session_id: no end_session, no child row, no title renumber, no contextvar/logging re-sync, no memory/context-engine session-switch. The conversation keeps one durable id for life, like Claude Code / Codex. Compaction is lossy by design — the pre-compaction transcript is summarized away, not archived. The rotation path is unchanged when the flag is off (moved verbatim into an else branch). Staged rollout: this PR ships the option behind a default-off flag for live validation; a follow-up flips the default and deletes the now-redundant rotation machinery, superseding the 14 open band-aid PRs in this area. - hermes_cli/config.py: add compression.in_place (default False), documented - agent/agent_init.py: resolve the flag -> agent.compression_in_place - agent/conversation_compression.py: branch compress_context() on the flag - tests/run_agent/test_in_place_compaction.py: in-place invariants + rotation regression guard + config default The pre-flush of current-turn messages (NousResearch#47202) runs in BOTH modes, so no boundary data loss. Prompt-cache invariant preserved: the system-prompt rebuild is the same single sanctioned invalidation that already happens during compaction — no NEW invalidation. Message alternation preserved.
exiao
pushed a commit
that referenced
this pull request
Jun 24, 2026
…eation snapshot (NousResearch#44585) An unpinned cron job follows the global default provider (config.yaml model.default + resolve_runtime_provider). If that global state is changed after the job is created — e.g. a temporary switch to a paid provider like nous/claude-fable-5 — the job silently inherits it on its next tick and spends real money. This is the reported $7.73 incident: a job created under a free/default provider later inherited a temporary paid switch. Fix (ask #1 only) preserves the legitimate "unpinned job should follow model.default" use case by detecting *drift* rather than freezing the model: - create_job (cron/jobs.py): for UNPINNED, agent-backed jobs (no explicit provider, not no_agent), snapshot the provider that resolution WOULD pick right now into a new optional `provider_snapshot` field, resolved via the same resolve_runtime_provider() path the ticker uses. Fail-open to None on any resolution error so job creation never breaks. - run_job (cron/scheduler.py): right after runtime resolution, if the job has a provider_snapshot AND is unpinned AND the currently-resolved provider DIFFERS from the snapshot, fail closed for that run — make no paid call and deliver a loud, actionable alert naming both providers and telling the user to pin explicitly (`cronjob action=update job_id=.. provider=..`). Back-compat: jobs with no snapshot (pre-existing jobs, no_agent jobs, or any job whose creation-time resolution failed) behave exactly as before — the guard only engages when a snapshot exists. Explicitly-pinned jobs (job.provider set) are unaffected since they don't drift with global state. Tests: tests/cron/test_cron_provider_pin.py covers snapshot-matches (runs), snapshot-differs (fail closed, no agent constructed), no-snapshot back-compat, None-snapshot back-compat, explicitly-pinned (runs regardless), plus create_job snapshot capture/skip/fail-open. The fail-closed case is load-bearing (fails without the guard). Issue NousResearch#44585 asks #2-4 (hard-stop a running job, gateway-stop containment, fail-closed on provider mutation) are out of scope for this change.
This was referenced Jun 26, 2026
exiao
added a commit
that referenced
this pull request
Jul 2, 2026
Addresses claude review item #2 on PR #84: title/assignee/body/card_id/text that arrive as int/object/list previously hit an uncaught AttributeError on .strip() and reset the connection. Now rejected up front with a clean 400. +3 tests (26 total). Items #1 (log redaction) and #3 (omit 'none') already landed in f1c4671/6ca32f0c.
exiao
added a commit
that referenced
this pull request
Jul 13, 2026
Codex second P1 on PR #99 (source-#3 gap): dropping creds to None under a secret scope did not actually suppress the global Claude Code fallback. A scoped profile with only ANTHROPIC_API_KEY (or no Anthropic secret) still reached _resolve_claude_code_token_from_credentials(None), which re-reads the global ~/.claude file and returns the DEFAULT profile's token before the scoped API-key fallback — authenticating /usage and Anthropic calls as the wrong profile. Skip source #3 entirely while a secret scope is active so only the profile's own scoped secrets (sources #1/#2/#4/#5) resolve. Adds a regression test for a scoped ANTHROPIC_API_KEY with a refreshable global Claude Code cred present (fails without the guard: returns GLOBAL-DEFAULT; passes with it).
exiao
added a commit
that referenced
this pull request
Jul 14, 2026
…ecret scope (#99) * fix(anthropic): scope resolve_anthropic_token to active profile secret scope Under gateway profile multiplexing, /usage for a secondary Anthropic profile could show the DEFAULT profile's limits. resolve_anthropic_token read ANTHROPIC_TOKEN / CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY from os.getenv directly, never via agent.secret_scope.get_secret, so even with the scope contextvar propagated into the worker thread the token still resolved to the process/default value. Route the three reads through get_secret(..., '') so the active _SECRET_SCOPE wins under multiplexing; with no scope / multiplex-off get_secret transparently reads os.environ (single-profile deployments and non-gateway callers stay byte-identical). run_oauth_setup_token's os.getenv reads are left alone (interactive setup, not a per-turn multiplexed path). Regression test mirrors tests/gateway/test_credits_usage_profile_scope.py: scoped profile-B token wins, no-scope falls back to os.environ. Verified fail-before/pass-after. Patch note: ~/.hermes/plans/hermes-patches/anthropic-token-secret-scope.md Task: t_601f23d1 (follow-up to PR #98 Codex Anthropic P1) * fix(anthropic): don't let global Claude creds override scoped token under multiplexing Codex P1 on PR #99: resolve_anthropic_token reads the host's global ~/.claude / Keychain credentials, then _prefer_refreshable_claude_code_token could return that DEFAULT-profile refreshable credential in place of the requesting profile's scoped ANTHROPIC_TOKEN (or resolve it as source #3), authenticating /usage and Anthropic calls as the wrong profile. Under an active secret scope the global cred record is not the requesting profile's, so drop it to None; only the profile's own scoped secrets resolve. Adds a regression test with refreshable global creds present (the prior test stubbed creds to None and missed this). * fix(anthropic): skip global Claude Code source under active secret scope Codex second P1 on PR #99 (source-#3 gap): dropping creds to None under a secret scope did not actually suppress the global Claude Code fallback. A scoped profile with only ANTHROPIC_API_KEY (or no Anthropic secret) still reached _resolve_claude_code_token_from_credentials(None), which re-reads the global ~/.claude file and returns the DEFAULT profile's token before the scoped API-key fallback — authenticating /usage and Anthropic calls as the wrong profile. Skip source #3 entirely while a secret scope is active so only the profile's own scoped secrets (sources #1/#2/#4/#5) resolve. Adds a regression test for a scoped ANTHROPIC_API_KEY with a refreshable global Claude Code cred present (fails without the guard: returns GLOBAL-DEFAULT; passes with it). * fix(anthropic): scope global-cred suppression to non-default profiles Codex P2 on PR #99: under gateway.multiplex_profiles the DEFAULT profile's own turns also run inside _profile_runtime_scope, so current_secret_scope() is non-None even for the profile that owns the global ~/.claude / Keychain credential. The prior guard suppressed the global Claude Code source for ANY active scope, breaking Anthropic auth and /usage for a default profile that relies on Claude Code OAuth (rather than .env/pool creds). Gate the suppression on _scope_is_non_default_profile(): only a non-default scoped profile drops the global creds and skips the source-#3 fallback; the default profile (owner of ~/.claude) keeps resolving it. get_active_profile_name reads the per-turn HERMES_HOME override, so it reflects the active profile. Updates the two prior regression tests to a non-default profile scope and adds a default-profile regression (fails with the old scope-active gate: returns None; passes now: resolves the global Claude Code credential). * fix: isolate scoped Anthropic pool credentials * fix: honor suppressed profile OAuth credentials * fix: preserve scoped Anthropic credential priority * fix: preserve local OAuth pool precedence * fix: scope usage lookups without breaking cron * fix: preserve default env token under scoped usage * fix(anthropic): prune stale env OAuth pool entries under explicit API key On the profile_only pool path, an explicit ANTHROPIC_API_KEY only dropped the hermes_pkce entry, so an auto-seeded env:ANTHROPIC_TOKEN / env:CLAUDE_CODE_OAUTH_TOKEN OAuth singleton left in the profile auth.json could still be returned by source #4 — reviving the Claude Code OAuth masquerade that picking the API-key path explicitly opts out of. Match load_pool()'s api_key_path_explicit pruning: require the API key be set AND no OAuth env tokens present, then drop every auto-seeded OAuth entry (env:* sources + hermes_pkce + claude_code) while keeping manual pool entries and the api_key entry. Regression test seeds a stale env:ANTHROPIC_TOKEN OAuth entry + a manual entry in a non-default profile pool with a scoped API key and asserts the stale env OAuth token is not what resolves (red before: it was returned). * fix(anthropic): honor a blank scoped Anthropic slot as an explicit clear _read_anthropic_secret tested scope values for stripped truthiness, so a profile that intentionally disabled a path by writing a blank slot to its .env (ANTHROPIC_TOKEN= / ANTHROPIC_API_KEY=, as the setup helpers do) looked like a scope with no Anthropic secret. The default-profile fallback then read os.environ, letting a service-level ANTHROPIC_TOKEN override the profile's explicit clear. Treat any Anthropic slot PRESENT in the scope (blank or not) as the profile having spoken and suppress the process-env fallback. The empty-scope ({}, no slot declared) case still falls back, which is the legitimate default-profile-from-service-env path. Regression test: a default-profile scope with a blank ANTHROPIC_TOKEN slot must resolve to None, not the service-env token (red before). * fix(anthropic): keep manual pool OAuth precedence for default multiplex scope The pool-consult gate skipped _resolve_anthropic_pool_token whenever a scoped ANTHROPIC_API_KEY was present and the scope was NOT a non-default profile. Under multiplexing the DEFAULT profile also runs inside a secret scope and owns ~/.hermes/auth.json, so a manually-added OAuth entry there lost its documented source-#4-before-#5 precedence and /usage + Anthropic calls resolved the API key instead of the configured OAuth credential. Consult the pool for any multiplex scope (default included) while keeping a non-multiplex single-profile cron scope's API key authoritative (no borrowed global pool token). Regression test added (red before/green after). * fix(anthropic): normalize pool priorities on profile-only reads The profile_only pool path builds CredentialPool directly (to skip load_pool's global seeders on the isolated profile) but also skipped _normalize_pool_priorities, so a manually-added OAuth entry that hermes auth add appended with a larger priority number sorted behind a seeded singleton (env:ANTHROPIC_TOKEN / hermes_pkce) and the seeded token resolved first. Apply the same manual-over-seeded normalization load_pool runs before constructing the pool, restoring manual precedence. Regression test: a profile pool with a seeded OAuth (priority 0) and a manual OAuth (priority 1) must resolve to the manual token (red before: seeded won).
exiao
pushed a commit
that referenced
this pull request
Jul 15, 2026
Completes the review's ask for "adapter-to-session-key integration coverage for Discord and a non-Discord platform" on NousResearch#20096. Drives a concrete adapter's real BasePlatformAdapter.build_source with an injected gateway_runner, asserts the matched route's profile is stamped on the source, and that build_session_key scopes the key under agent:<profile>: (versus the shared agent:main: namespace). Covers Discord and Telegram — the Telegram case is the bug-#2 path that previously fell through to default. Adds a regression anchor: without gateway_runner, profile stays None and the key lands in agent:main (the silent fallback the fix removes for non-Discord). Co-Authored-By: Claude <noreply@anthropic.com>
This was referenced Jul 16, 2026
exiao
pushed a commit
that referenced
this pull request
Jul 20, 2026
…st (NousResearch#65214) Moves the fireworks entry in CANONICAL_PROVIDERS from its old slot (after GMI Cloud) to directly below Nous Portal, ahead of OpenRouter. Order propagates automatically to hermes model, the setup wizard, Telegram /model, and the desktop provider catalog.
exiao
pushed a commit
that referenced
this pull request
Jul 20, 2026
…etry Combines the two salvaged fixes so they compose instead of conflict: _persist_session_title (NousResearch#50575) now writes through set_auto_title_if_empty (NousResearch#51483) when the store provides it — the collision-dedup retry and the manual-/title race protection apply together. Predicate failure (a manual title landed while generation was in flight) returns None: nothing written, no callback. Legacy stores without the atomic method keep the plain set_session_title path, including the vanished-session RuntimeError. Tests cover both store shapes plus the race-skip path; E2E verified against a real SQLite SessionDB (collision -> 'Weekly Report #2', manual title preserved, cron dedup, blank guard). AUTHOR_MAP entry for rasitakyol.
exiao
pushed a commit
that referenced
this pull request
Jul 20, 2026
…er (NousResearch#66432) Mirror CANONICAL_PROVIDERS so Fireworks sits directly under Nous Portal (always visible) ahead of OpenRouter across onboarding, Settings → Providers, and the API-key catalog.
exiao
pushed a commit
that referenced
this pull request
Jul 20, 2026
…, /topup, terminal-billing UX) (NousResearch#51639) * feat(tui): rename /billing slash command to /topup Behavior-preserving rename of the /billing command surface to /topup. Changes: billing.ts → topup.ts (export topupCommands, name 'topup', new help string), registry.ts import+spread updated, billingOverlay.tsx overview header 'Usage credits' → 'Top up credits', billingCommand.test.ts → topupCommand.test.ts with import/lookup/call updated. RPC method names (billing.state, billing.charge, etc.) and component/symbol names unchanged. * refactor(tui): extract overlay primitives to shared module Lift MenuRow, ActionRow, footer, and barCells() out of billingOverlay.tsx into overlayPrimitives.tsx so the upcoming subscriptionOverlay.tsx can import them instead of duplicating. spendBar now calls barCells() — output is byte-identical. Pure behavior-preserving refactor. * feat(tui): add /subscription + /topup CTAs to /usage output Every /usage render now ends with 'Run /subscription to change plan · /topup to add credits' — both the healthy (with-calls) and depleted (no-calls) paths. Strings-only change, no WS1 dependency. * feat(tui): add subscription wire types Add SubscriptionTierOption, SubscriptionStateResponse, and SubscriptionManageLinkResponse to gatewayTypes.ts. Type-only — no usages yet. Mirrors the BillingStateResponse conventions (snake_case, Decimals as strings) and reuses BillingErrorPayload for error mapping. * feat(gateway): add subscription.state + subscription.manage_link RPCs - agent/subscription_view.py: SubscriptionState dataclass + fail-open build_subscription_state() (mirrors billing_view pattern) + get_subscription_manage_link() for the Stripe deep-link. - hermes_cli/nous_billing.py: get_subscription_state() + post_subscription_manage_link() HTTP helpers for the two NAS endpoints (WS1 Phase A/C). The manage-link endpoint raises BillingScopeRequired when Remote-Spending is missing (Phase 4 step-up trigger). - tui_gateway/server.py: _serialize_subscription_state() + subscription.state RPC (fail-open) + subscription.manage_link RPC (returns {ok,kind,url} or typed error envelope via _serialize_billing_error). NOT added to _LONG_HANDLERS — synchronous HTTP round-trip, not a device flow. * feat(tui): add subscription overlay state types + store slot Add SubscriptionScreen, SubscriptionOverlayCtx, SubscriptionOverlayState to interfaces.ts and a 'subscription' slot to OverlayState. Wire it into overlayStore.ts (buildOverlayState + $isBlocked). NOT added to resetFlowOverlays preserve list — flow-scoped like billing, drops on turn end. * feat(tui): build SubscriptionOverlay — overview + confirm + handoff Pure-render Ink component mirroring billingOverlay.tsx's structure. Overview screen covers all 5 states (free-upgradeable, mid-tier, top-tier, not-admin, downgrade-pending) + dunning. Confirm screen is y/n deep-link to Stripe (NO in-terminal charge). Handoff is the transient 'Opening Stripe' screen. Imports shared primitives from overlayPrimitives.tsx. 8 render tests via renderSync covering every state. * feat(tui): add /subscription command + overlay wiring - subscription.ts: SubscriptionOverlayCtx closure (openManageLink, refreshState, requestRemoteSpending) + run handler that fetches subscription.state and opens the overlay. Alias /upgrade. - registry.ts: spread subscriptionCommands into SLASH_COMMANDS. - appOverlays.tsx: render SubscriptionOverlay when overlay.subscription set. - useInputHandlers.ts: Esc closes subscription overlay; promptOverlay OR includes subscription so input is intercepted while open. - subscriptionCommand.test.ts: 4 tests (fetch+open, logged-out sys line, /upgrade alias, /subscription resolves). * fix(tui/subscription): stop saying Stripe in deep-link copy + fix manage link kind type Replace all user-facing 'Stripe' mentions in the /subscription overlay and sys messages with 'your subscription page' — the deep-link target is NAS's own /manage-subscription page, not the Stripe hosted portal. Stripe only legitimately appears later at actual Checkout. Also add 'manage' to the SubscriptionManageLinkResponse.kind union (NAS emits kind:'manage'; was previously missing from the TypeScript type causing silent narrowing errors). * feat(tui/subscription): render cancellation-scheduled note with headline precedence Parse cancelAtPeriodEnd + cancellationEffectiveAt from the NAS contract (camelCase) in the agent parser (_parse_current), emit cancel_at_period_end + cancellation_effective_at from the gateway serializer, extend the SubscriptionStateResponse type, and render a warn note in OverviewScreen: 'Cancels on {date} — your plan stays active until then.' Headline precedence when multiple flags co-occur: past-due > cancel-scheduled > downgrade-pending > active The downgradeNote guard is tightened to suppress when cancel is scheduled, so at most one status line renders at a time. * feat(tui/subscription): team-context screen — redirect to /topup for team orgs Parse the NAS context:'personal'|'team' field (defaults to 'personal' for unknown/missing values), emit it on the gateway wire, add it to SubscriptionStateResponse. When context is 'team', SubscriptionOverlay renders a dedicated read-only screen instead of the tier picker: 'This terminal is connected to {org_name}. Teams run on shared credits — use /topup to add funds. Personal subscriptions live on your personal account.' The screen closes on Enter or Esc. The personal/tier-picker path is unchanged. * fix(subscription): drop manage-link gateway RPC, build URL locally The NAS POST /api/billing/subscription/manage-link endpoint was dropped (it added no server work — the target is the static /manage-subscription page, not a Stripe-minted secret). Build the URL client-side instead: {portal_base}/manage-subscription?org_id=<org.id>. - Remove subscription.manage_link gateway RPC (server.py) - Remove get_subscription_manage_link helper (subscription_view.py) - Remove post_subscription_manage_link (nous_billing.py) - Remove SubscriptionManageLinkResponse type (gatewayTypes.ts) - Add org_id to SubscriptionState + wire through serializer + TS type - openManageLink() builds the URL locally via buildManageUrl(), opens it with the existing openExternalUrl(), no gateway round-trip - Drop targetTierId param from openManageLink (v1 sends everyone to /manage-subscription; no tier deep-link needed) - Fix stale test expectations (Stripe copy → subscription page copy) * chore(subscription): drop unused format_money import * feat(cli): /subscription + /upgrade, /billing→/topup rename, /usage CTAs Add the classic-CLI half of the terminal billing surface to match the TUI: - /subscription (alias /upgrade) command + /topup (renamed /billing, keeps 'billing' as a back-compat alias) in the command registry. - Drop the stale 'billing' entry from _SLACK_VIA_HERMES_ONLY (now cli_only). * feat(subscription): CLI /subscription handler, drop dunning, current:null no-plan - CLI _show_subscription mirrors the TUI overlay (plan read + tier list + usage bar + browser deep-link via subscription_manage_url); credits render as counts. - Adapt to the updated NAS read contract: remove is_past_due/dunning everywhere (a card-failing subscriber returns as a normal plan now), and treat no-plan as current:null (parser returns None) rather than an all-null object. - HERMES_DEV_SUBSCRIPTION_FIXTURE env-driven fixtures + ui-tui fixture harness drive every state (CLI + live TUI) with no portal. Verified against handoff 2026-06-24_subscription-tui-handoff.md. * feat(billing): CF-4 Remote-Spending revoked-terminal UX (NAS PR NousResearch#481) Wire the Remote-Spending gate denial contract end to end: - nous_billing: BillingRemoteSpendingRevoked (403 remote_spending_revoked → reconnect) + BillingSessionRevoked (401 session_revoked → re-login), distinct from insufficient_scope; capture actor/code/recovery; 503 stays transient. - gateway _serialize_billing_error threads the new typed kinds + actor/code/ recovery to the TUI. - TUI renderBillingError: actor-aware revoke copy, kills the spend overlay immediately (no 15-min zombie button), handles session_revoked, the dual- emitted cli_billing_disabled/remote_spending_disabled, role_required, idempotency_conflict; poll treats a mid-poll revoke as ambiguous (check balance before retry), not a failure. - CLI _billing_render_charge_error: same denial matrix, actor-aware copy. Tests: gate-contract mapping + envelope (py) and revoke/session/disabled (TUI). Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md. * refactor(subscription): remove dead step-up scaffolding from /subscription /subscription only opens a browser deep-link to manage-subscription — that needs no billing scope, so it can never hit insufficient_scope. Drop the never-fired 'stepup' screen type, requestRemoteSpending ctx fn, and resumeScreen bookkeeping (leftovers from a superseded plan). The resumable step-up lives on /topup, where the charge actually gets gated. * feat(tui/topup): resumable 'Allow Remote Spending' step-up on the charge path Phase 4: when a charge returns insufficient_scope, the /topup modal no longer tears down with a 'run /billing again' ConfirmReq. Instead it stays MOUNTED and switches to a step-up screen: - charge() is now awaitable, returning a discriminated outcome (submitted | needs_remote_spending | error) so the overlay can route without closing. - StepUpScreen: 'Allow Remote Spending' → await the device-flow grant (browser opens via the existing out-of-band billing.step_up.verification event) → replay the held charge (pendingCharge.amount) and settle, with no command re-run. Never surfaces the raw billing:manage scope. - armStepUp's fire-and-forget ConfirmReq replaced by requestRemoteSpending(); the leaky 'billing:manage' / 'Re-authorize' / 'run /billing again' copy is gone. Tests: charge-outcome routing, step-up grant/deny, and a render test asserting the step-up copy holds the amount and never leaks billing:manage. Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md §2 (Grady #6). * feat(billing): shared dollar usage model + two-bar view (drop "credits") Single source of truth for the /usage and /subscription usage bars across TUI + CLI. Reads the NAS account-info dollar fields (subscription/top-up/total remaining, monthly allowance, renewal) and produces a surface-agnostic model: two full-resolution bars (plan allowance + purchased top-up), a status classification (free | healthy | low | depleted), and a human renewal date. - agent/billing_usage.py: UsageModel/UsageBar, usage_model_from_account (fail-open), build_usage_model (HERMES_DEV_CREDITS_FIXTURE-aware), format_renews (ISO -> "Jul 24, 2026", Windows-safe), $5 low-balance threshold. - tui_gateway/server.py: _serialize_usage_model/_serialize_usage_bar, a usage.bars RPC, and the model embedded into subscription.state so the overlay renders the same bars from its single fetch. - Dollars only, never "credits"; two separate bars (not a crammed three-segment one) for legibility at terminal widths. - tests/agent/test_billing_usage.py: status classification, bar math (clamp/over-cap), NaN/Inf rejection, fail-open invariants. * feat(tui): dollar usage bars on /usage + /subscription, drop tier picker Render the shared two-bar dollar model in both overlays; strip "credits" and the in-terminal tier selection per UX feedback. - overlayPrimitives.tsx: UsageBars (themed plan/top-up bars — gold allowance, green top-up) + usageBarsText for the /usage panel. Plan name labels the bar; "$X left of $Y · N% used" (disambiguated so the % matches); top-up "never expires". - subscriptionOverlay.tsx: status line dedupes ($X left once; bar carries the breakdown), human renewal date, state-matched nudges (free upsell / <$5 low alert) with box-safe ASCII markers (! / >) instead of the width-unstable emoji that broke the border. Tier picker removed — overview shows usage + plan, then "Manage on portal" / "Close" (free users get "Start a subscription"). No "credits" anywhere. - session.ts: /usage renders the dollar bars + balance summary, falling back to the legacy credits lines only when the model is unavailable; CTA reworded. - gatewayTypes.ts: UsageModelData/UsageBarData wire types + usage on SessionUsageResponse/SubscriptionStateResponse. - Tests updated to the new contract (no "credits", "left of", dedup, markers). * feat(cli): mirror dollar usage bars on /usage + /subscription CLI parity with the TUI billing rework, from the same shared usage model. - _print_nous_credits_block (/usage) and _subscription_overview render the two-bar dollar view (plan name on the bar, "$X left of $Y · N% used", top-up "never expires", total spendable) instead of the credits-worded block. - Dollars only — dropped the tier catalog (no more "$N/mo (… credits)") and every user-facing "credits"; team copy says "shared balance". - Human renewal date via the shared format_renews; status line dedupes the "$X left"; free upsell + <$5 low alert with ASCII markers. - /subscription manage modal no longer dumps the raw manage-subscription URL in its detail — the [1] Open / [2] Copy link / [3] Cancel options carry it. Title is "Manage your subscription" (no in-terminal plan change). The raw URL stays only in the non-interactive / not-admin fallbacks, which have no menu. - /usage token-usage panel (model, tokens, cost, context) left untouched. * feat(billing): embed dollar usage model into billing.state for /topup The /topup overview renders the same two-bar dollar usage (plan + top-up) as /usage and /subscription. Embed the shared usage model into the billing.state RPC payload (mirrors subscription.state) so the overlay gets the bars from its single fetch, and add the `usage` field to BillingStateResponse. * feat(tui/topup): reorder overview + in-flight reauth with press-Enter resume Reworks the /topup overlay per the Jun 19 review and the no-preflight decision. Overview: - Balance leads in the title ("Top up · balance $X"); the shared two-bar dollar usage (plan + top-up) renders below. Dropped the old monthly-cap spend bar. - "Add funds" is the first action (was "Buy credits"); auto-reload / monthly limit / manage-on-portal follow. Dollars only — no "credits" anywhere. - No "Enable terminal billing" menu item and NO scope preflight: whether the terminal can charge is discovered reactively at pay time. (We deliberately do not read/refresh the OAuth token to gate UI.) Step-up (reached only on a charge's insufficient_scope 403): - New 4-phase flow that keeps the modal mounted: prompt (one-time-setup heads-up) → waiting (browser authorize) → granted (explicit "Press Enter to resume") → replay the held charge → settle. The press-Enter beat is the reassuring "you're back, finish your purchase" moment. - Renamed user copy "Allow Remote Spending" → "Enable terminal billing"; never leaks the raw billing:manage scope (guarded by the render test). - topup.ts error copy de-crufted to terminal-billing wording, emoji removed. Tests: step-up prompt copy, the no-raw-scope invariant, and new overview tests (balance-in-title, Add-funds-first, two-bar usage, no "credits"). * feat(cli/topup): mirror overview reorder + in-flight reauth resume CLI parity with the TUI /topup rehaul, from the same shared usage model. - _billing_overview: balance in the title, the two-bar dollar usage (plan name on the plan bar, top-up "never expires") in place of the old cap spend bar, "Add funds" first, dollars throughout — no "credits", no scope preflight. - _billing_handle_scope_required: now takes the held amount + idempotency key and runs the in-flight flow — "Enable terminal billing" → browser device-flow → re-check the org kill-switch → press-Enter to resume → replay the held charge (reusing the key so a double-submit collapses to one). Stops leaking the raw billing:manage scope. - Charge-error + buy/auto-reload copy de-crufted to terminal-billing/dollars. - Tests updated to the new overview + buy copy. * fix(billing): guard non-JSON 2xx responses in the billing HTTP client A 2xx response with a non-JSON body — e.g. a reverse-proxy / SPA fallback HTML page served when a billing route isn't actually mounted on a deployment — hit json.loads() on the success path of _request() and raised a raw json.JSONDecodeError. That escaped the typed-BillingError contract, so callers' `except BillingError` missed it and fell through to a generic fail-open that rendered as a misleading "not logged in" (observed when /api/billing/subscription was briefly unshipped on staging: 200 text/html, x-matched-path /[...notFound]). Now a non-JSON 2xx body raises a typed BillingError(error="endpoint_unavailable") so surfaces degrade gracefully ("could not load …") instead of crashing or mislabeling a valid session as logged-out. The 4xx/5xx path already guarded its .json(); this closes the same hole on the success path. Test: tests/hermes_cli/test_nous_billing_request.py — non-JSON 2xx → typed error (not JSONDecodeError, not BillingAuthError), empty body → {}, valid JSON parses. * feat(billing/dev): add HERMES_DEV_BILLING_FIXTURE for offline card/scope testing build_billing_state short-circuits to a fixture when HERMES_DEV_BILLING_FIXTURE is set (mirrors HERMES_DEV_CREDITS_FIXTURE for the usage model). States: nocard | card | card-autoreload | notadmin | billing-off | logged-out — so the card-on-file gate, admin role, and kill-switch paths are exercisable offline without a live portal. Env-var gated; returns None when unset (no prod leak). Adds 8 behavior tests asserting the card/admin/billing-on contract per state. * refactor(billing): fold /credits into /topup /credits is redundant now that /topup shows the dollar balance + portal handoff. Make 'credits' (and 'billing') aliases of /topup so typing /credits still works, resolving to topup everywhere (CLI, gateway, Slack, TUI, autocomplete, help). Remove the standalone /credits surface across 6 places: - CLI _show_credits handler + dispatch - gateway _handle_credits_command -> renamed _handle_topup_command, copy softened to 'Manage billing on the portal' (the messaging billing surface; /topup is now gateway-available so messaging keeps billing — credits was the only one before) - TUI commands/credits.ts + creditsCommand.test.ts (deleted), registry entry - tui_gateway credits.view RPC + the CreditsViewResponse type - Slack _SLACK_VIA_HERMES_ONLY: credits -> topup Sweep user-facing /credits -> /topup (usage-block hint, depletion notice) and stale doc-comments. OpenRouter's /credits endpoint URL left untouched. Tests updated (test_credits_folds_into_topup) or pruned for the removed symbols. * fix(billing): card-on-file heads-up, no-card portal gate, /usage bar ordering, modal glyph In-terminal charge (POST /charge against the org's server-held card, no card ref leaves the client): - card present: confirm screen shows 'Your card saved on the portal will be charged' + a 'Manage on portal' escape option (CLI); heads-up line (TUI) - no card on file: /topup overview + buy flow detect it and route to the portal to add a card, instead of offering a charge that 403s no_payment_method /usage bar ordering: route the dollar block through _cprint consistently. The Plan: line (_cprint) and the bar (raw print) flushed to different buffers under patch_stdout and interleaved nondeterministically; now Plan: -> bar -> status/CTA is stable across all states. Modal glyph: strip the leading emoji from bordered _prompt_text_input_modal titles — it measures 1 char but renders 2 columns, shifting the box's right border (the stray '|'). Includes the f-string 'Pay $X?' title. Small /credits -> /topup string bits in cli.py ride along with the surrounding charge edits (the fold lives in the sibling refactor commit). * refactor(billing): apply safe simplify-pass fixes Three low-risk cleanups from a parallel simplify review (reuse/quality/efficiency): - dev fixture portal URL: reuse the prod host (was drifted to staging-* — a real mismatch vs subscription_view's _DEV_FIXTURE_PORTAL) - TUI billingOverlay choose(): collapse two byte-identical branches (needsCard + the not-full else both = portal-or-close at index 0) into one tail; the only divergent path (full && !needsCard → buy/auto/limit) stays explicit - /topup overview comment: correct the stale 'buy_flow detects no_payment_method' note (the overview's no-card gate fires first, so reaching Add funds implies a card on file) Skipped (judgment): the orphaned CreditsView.depleted field (harmless, on a live dataclass), the defensive card gates in _billing_buy_flow/_confirm_and_charge (cheap correct defense on the money path), and folding the no-card handoff into a shared helper (touches 4 money-path sites for tidiness — not worth the risk here). * fix(billing): reactive charge gating — drop card preflight, react to 403 (scope→reauth, no-card→portal) * refactor(billing): drop the /credits alias entirely The /credits fold made it an alias of /topup; now remove that too. Typing /credits is an unknown command, not a silent redirect — billing lives only on /topup (with /billing kept as the old command's back-compat name). Dropped the alias from the registry CommandDef and the TUI topup.ts; updated the test to assert /credits resolves to nothing (no command, no alias). * docs(billing): fix stale comment in _billing_overview — describe reactive no-card path The comment still described the removed overview-level card gate ('no-card case handled above'). Corrected to: the buy flow reacts to the server's no_payment_method 403 and hands off to the portal at charge time (no preflight). * refactor(billing): simplify-pass — share usage-payload helper, drop dead bar wire fields + redundant admin gate * refactor(billing): drop the /billing alias too — /topup is the only billing command Following /credits removal, retire the old /billing name as well. /topup now has NO aliases — both /credits and /billing are unknown commands. Dropped the alias from the registry CommandDef and TUI topup.ts; fixed the one live user-facing straggler (the not-logged-in message said 'then /billing' → /topup) and the _show_billing docstring/default-arg references. Test asserts /topup carries no aliases and neither old name resolves. * fix(billing): code-review fixes — money-path + parity bugs Money path (TUI): - auto-reload "Turn off" now echoes current threshold/top_up_amount so the PATCH succeeds (was sending {enabled:false} → invalid_request → stayed ON) - charge poll honors the 5-min cap on the 429/503 throttle branch too (was rescheduling forever); cap folded into one timedOut() helper - step-up resume reacts to the replay outcome instead of unconditionally closing on a reassuring line with no charge made - synchronous submit guard on Confirm so two key events can't double-charge Gateway: - billing.step_up routes typed errors through _serialize_billing_error (was a raw {error:'error'} dict → generic copy for session_revoked) - billing.state / subscription.state / usage.bars / session.usage moved to _LONG_HANDLERS (blocking portal HTTP no longer stalls the main stdin loop) CLI: - _billing_render_charge_error handles insufficient_scope without leaking the raw billing:manage scope name on a post-grant replay re-raise Python model: - subscription_view tier parse None-coalesces tierOrder/dollarsPerMonth so a free tier's 0 survives ($0, not "—"; correct sort order) TUI parity/robustness: - /usage shows formatted renews_display, not raw ISO renews_at - subscription overview guards a null pending_downgrade_at (was "on null.") - subscription overview surfaces a message instead of silently closing when portal_url is missing - buildManageUrl wraps new URL() so a malformed portal_url can't throw out of the Ink key handler * fix(billing): cross-surface bar direction, formatted cancel/downgrade dates, Slack alias gating - CLI plan bar now fills by REMAINING (fuel-gauge), matching the shared model's fill_fraction, the top-up bar, and the TUI — same account renders identically on both surfaces (#8) - subscription serializer emits cancellation_effective_display / pending_downgrade_display (format_renews); TUI shows 'Jul 1, 2026' not raw ISO (#14b) - _SLACK_VIA_HERMES_ONLY now includes the 'billing' alias so it follows its canonical /topup via /hermes instead of leaking a native Slack slot (#9) * fix(billing): thread idempotency key through the TUI step-up replay (#2) Mint a stable idempotency key when the purchase amount is chosen; it rides pendingCharge into both the Confirm charge and the post-grant step-up replay, so a retried charge dedups server-side (the gateway already echoes the key). A fresh amount selection gets a fresh key. Combined with the sync submit guard, a double-submit now collapses to one charge. * refactor(billing): remove dead /subscription tier-picker scaffolding (#18) The in-terminal plan picker was cut (deep-link only), leaving a whole unreached state machine. Removed end-to-end: - TUI: ConfirmScreen, HandoffScreen, the 'confirm'/'handoff' screen types, pendingTargetTierId, and the now-dead onPatch threading (collapsed the dispatch to a single overview screen + folded the duplicate Box wrapper) - gateway: the tiers serialization + SubscriptionTierOption wire type - model: SubscriptionTier, _parse_tier, _coalesce, _dev_tiers and the tiers field (never displayed on either surface, so this supersedes the tier-parse fix) - tests: dropped the confirm/handoff/tier-passthrough tests; slimmed the overview render tests Net: a large dead-code cull (no behavior change — the picker never ran). * test(billing): parametrize usage-model tests; drop dead is_low/is_free props Collapse the fail-open + status-classification cases into parametrized tables (same coverage, ~80 fewer lines) and remove the now-unused UsageModel.is_low / is_free properties (only a test pinned them). * fix(billing): revert dead 'billing' Slack-via-hermes entry — the alias was dropped #9 was based on a stale review diff: /billing is no longer an alias of /topup (dropped earlier), so routing it via /hermes filtered a name that doesn't exist. * test(billing): cull redundant TUI billing tests (parametrize, merge dupes) usageCommand: collapse 3 CTA tests into one + a panel helper. billingStepUp: merge the two step-up render asserts. topupCommand: parametrize requestRemoteSpending + the revoked-actor pair, drop the redundant happy-path-submitted test. Money-path + error-mapping coverage preserved. * refactor(billing): extract _usage_bar_lines — one source of truth for the CLI bars The plan + top-up bar format was copy-pasted across _print_nous_credits_block, _subscription_overview, and _billing_overview. Extract a helper returning the ready-to-print lines; each caller keeps its own print fn (the _cprint-ordering constraint stays) and resolves its plan-name label. Centralizes the format so the three surfaces can't drift. * feat(billing): NAS V3 subscription-change HTTP client wrappers Add the four write-side wrappers for the V3 subscription contract to nous_billing, each a thin _request() call (reusing auth, JSON, 401-retry, typed errors): - post_subscription_preview → POST /subscription/preview (chargeless quote) - put_subscription_pending_change→ PUT /subscription/pending-change (downgrade/cancel) - delete_subscription_pending_change → DELETE .../pending-change (resume/undo) - post_subscription_upgrade → POST /subscription/upgrade (the money route) pending-change takes a discriminated body (tier_change | cancellation); upgrade requires an Idempotency-Key (mandatory, validated client-side before any I/O). Tests assert the exact method/path/body/header each wrapper puts on the wire. * feat(billing): subscription tier catalog + change-preview models Reinstate the catalog the in-terminal picker needs (was culled when /subscription was deep-link-only): SubscriptionTier + SubscriptionState.tiers + _parse_tier, with _coalesce so the free tier's 0 tierOrder/price survives a falsy-or. Parse the catalog from GET /subscription's tiers and seed _dev_tiers into every fixture. Add SubscriptionChangePreview + subscription_change_preview_from_payload for the POST /preview quote (effect/amountDueNowCents/effectiveAt/reason + tier delta); a malformed/missing effect fails safe to 'blocked' so a bad quote never reads as a charge. Module docstring updated: the overlay is no longer deep-link-only. * feat(billing): gateway RPCs for the V3 subscription change flow Add subscription.preview / .change / .resume / .upgrade RPCs, each wrapping its nous_billing call and reusing _serialize_billing_error for the typed envelope (so a 403 still drives the device step-up). upgrade mints + echoes the idempotency key and surfaces status + recovery_url so the TUI can route an SCA/decline to the portal. Re-add the tier catalog to _serialize_subscription_state (price pre-formatted) for the picker. All four are pool-routed (_LONG_HANDLERS) — preview + upgrade hit Stripe and must not stall the main stdin loop. * feat(billing): in-terminal subscription change flow (TUI) /subscription is no longer deep-link-only: it drives the change in-terminal against the V3 contract via the new gateway RPCs. The overlay is a state machine overview → picker → confirm → result: - picker lists the tier catalog with upgrade/downgrade hints (current + free excluded; free=cancel, on the overview); - confirm shows the previewed effect — pay $X now (upgrade) / scheduled at date (downgrade) / cancel at period end / blocked-with-reason — then applies it; - an upgrade's SCA/decline routes to the portal via the result screen's recovery link; resume/cancel/downgrade are chargeless. Starting a NEW subscription still deep-links (needs a fresh card). insufficient_scope points to /topup (the step-up stays there, not duplicated here). Adds the wire types (tiers + preview/upgrade responses), widens the overlay ctx + screen state, and threads onPatch. Render tests cover every screen. * feat(billing): in-terminal step-up + clearer scheduled-change UX (TUI) Two improvements to the /subscription overlay: Step-up re-auth in place. When a mutation (preview/change/upgrade/resume) returns insufficient_scope, route to a new 'stepup' screen that grants terminal billing via billing.step_up and AUTO-REPLAYS the held action on grant — no bounce to /topup. Scope routing is centralized in previewAndRoute/applyPendingAndRoute/ resumeAndRoute (shared by the picker, confirm, overview + the step-up replay). The browser opens via the shared global verification handler; copy never leaks the raw billing:manage scope. Make a scheduled change unmissable. A downgrade/cancel was one buried warn line that read as 'nothing happened'. Now the overview leads with a banner (⏳ Scheduled change · Ultra ──▶ Plus · <date> · you keep Ultra until then), the status line echoes the transition (Plan: Ultra → Plus), 'Keep <tier> (undo)' is promoted to the first olive action, the result screen says 'your plan doesn't change today', and confirm gets a charged-now / scheduled chip. * feat(billing): full in-terminal subscription change flow in the classic CLI Bring the CLI to parity with the TUI overlay — /subscription is no longer deep-link-only. A paid admin/owner gets picker → preview → confirm → apply, mirroring the /topup buy flow's modal idioms: - _subscription_change_menu (change / undo-or-cancel / manage-on-portal), - _subscription_pick_tier (catalog with upgrade/downgrade hints), - _subscription_preview_and_confirm (POST /preview → effect-aware confirm), - _subscription_apply (schedule / cancel / resume chargeless; upgrade charges the sub's card, SCA/decline → portal), - _subscription_handle_scope_required (insufficient_scope → step_up_nous_billing_scope inline, then replays the held preview/mutation — reusing the upgrade idempotency key). Also the scheduled-change UX fix: the overview leads with a prominent banner (⏳ Scheduled change · Super ──▶ Plus · <date> · you keep Super until then) and the status line echoes the transition, matching the TUI. Members / non-interactive / free still deep-link. Tests drive every branch via a mocked modal + nous_billing. * fix(billing): close TUI subscription money-path holes (ultracode review) - Un-consented charge (P1): the step-up now HOLDS at a 'granted' phase requiring an explicit Continue, and an abortedRef gates the grant's late .then — a cancel during the browser flow can no longer replay the held upgrade + charge. - Missing idempotency key (P2): mint it when building an upgrade 'pending' so it rides into confirm AND the step-up replay (was always undefined → gateway minted a fresh key per call, defeating dedup). - Navigate-away re-charge (P2): confirm 'back' is guarded by submittingRef while an apply is in flight. - Ambiguous charge (P2): a transport-null upgrade is reported as 'may or may not have charged — re-check', never a flat failure that invites a blind retry. - Typed step-up denial (P2): requestRemoteSpending returns {granted,error,message}; the screen maps session_revoked / remote_spending_revoked / rate_limited to the right recovery instead of always 'an admin must allow it'. * fix(billing): close CLI subscription money-path holes (ultracode review) - Bounded step-up (P2): bust the 30s token cache after a grant (it held the pre-grant unscoped token; _request only busts on 401, not 403) and replay ONCE with allow_stepup=False so a still-denied scope can't re-prompt/re-open in a loop. - Stray-keystroke charge (P3→near-P2): the upgrade confirm defaults to 'Go back', not 'Pay ' — a bare Enter can't move money. - Fail-open on unknown effect (P3→near-P2): an unrecognized preview effect now fails SAFE (portal hand-off) instead of scheduling a real PUT. - 'cancel' word collision (P3): the Close row uses value 'close' so typing 'cancel' can't hit it and falsely report 'Cancelled'. - blocked effect re-offers the portal; undo is promoted to the first row when a change is pending (TUI parity). * fix(billing): guard the step-up resume against double-fire (2nd ultracode pass, BUG A) The P1 fix split the auto-replay into a user-triggered resume() on the granted screen, where the default row is the charging action — but resume() had no re-entrancy guard, so a double-Enter fired two replays (the upgrade dedups on the shared key, but schedule/cancel/resume replays carry none → duplicate PUT/DELETEs). Mirror billingOverlay.resume(): flip to a 'resuming' phase + a resumingRef so it fires at most once, and block 'back' once resuming (no re-mount → no second submit). * fix(billing): CLI charge-route ambiguous-charge caveat (2nd ultracode pass, BUG B) The TUI hardened upgradeResult(null) but the CLI charging route did not: a transport/timeout/500 (or unknown 2xx status) on post_subscription_upgrade — after NAS may have already prorated + charged — printed a flat failure, and a manual re-run mints a FRESH idempotency key the server can't dedup → a real second charge. Now the charge route reports 'your card may or may not have been charged — re-run /subscription to check before trying again' and steers away from a blind retry (the CLI can't persist the key across a command re-run). Also thread allow_stepup through the preview→apply replay (BUG C.1) and route the requires_action/ payment_failed portal lines through _cprint for deterministic ordering. * fix(billing): cap the TUI step-up replay to avoid a resume-deadlock (final pass, R1) The round-2 resume guard ('resuming' phase + resumingRef) could deadlock: on a REPEAT insufficient_scope during the post-grant replay, the route helpers did onPatch({screen:'stepup'}) — a no-op since we're already mounted on stepup (no key → no remount) — leaving phase='resuming'/resumingRef=true frozen on 'Applying your change…'. Thread allowStepUp through previewAndRoute/applyPendingAndRoute/ resumeAndRoute; the resume() replay passes false, so a repeat scope denial surfaces a 'still isn't enabled' result instead (mirrors the CLI's allow_stepup=False cap). Also: applyPendingAndRoute(pending=null) now routes to overview, not a stranded Promise.resolve(). * fix(billing): narrow the CLI ambiguous-charge catch to indeterminate outcomes (final pass, R2) The round-2 fix caught EVERY non-scope BillingError as 'may or may not have been charged' — but typed pre-charge rejections (BillingRateLimited 429, BillingSessionRevoked 401, BillingRemoteSpendingRevoked 403, role_required/no_payment_method 4xx) never reached Stripe, so the ambiguity copy was wrong and dropped their real recovery hints. Now route those to _subscription_render_error, and reserve the ambiguous copy for genuinely indeterminate outcomes (network_error / endpoint_unavailable / status None / 5xx). Tests: rate-limit stays deterministic; a real transport failure stays ambiguous. * feat(billing): card visibility + guided add-card path in /topup and /subscription Consume the NAS card-resolver contract (card.resolvedVia + chargeability) across both surfaces, degrading cleanly on today's NAS (fields absent → prior behavior): - WHICH card: the payment lines render provenance — 'Visa ····4242 — the card on your subscription' (resolvedVia → label; unknown rung/older NAS → masked card + the old generic line). Link payment methods render the brand alone (last4 is empty — never 'Link ····'). - Presence at a glance: the /topup overview now shows 'Card: …' or 'No saved card on file' for the full-menu case, plus a warning when the resolver marks the card needs_repair (failing auto-reloads) on overview/buy/confirm. - Add-card path: with no card on file, 'Add funds' becomes a guided screen — open the portal billing page, then 'I've added it — check again' re-fetches billing state and continues straight into the purchase (also recovers a transient display miss). Cards are never entered in-terminal. - /subscription upgrade confirm names the exact card ('Visa ····4242 — the card on your subscription — will be charged'), best-effort via billing.state and only when the resolution rung matches what a subscription charge actually uses (subPin/customerDefault, mirroring Stripe's precedence); otherwise the generic line stands. Fail-soft: any lookup error keeps the generic line. - Gateway serializes display/resolved_via/needs_repair; TUI ctx gains refreshState (topup) + fetchCard (subscription); new offline fixtures card-sub / card-repair. Tests: TUI ctx mocks extended; CLI suites cover provenance + repair-warning render, the Link guard, the add-card path (continue-after-recheck + abandon), the sub-confirm card line, and keep the confirm-time lookup offline in tests. * fix(billing): consume server canChangePlan, preserve distinct refusal codes, drop dead chargeability - Parse canChangePlan verbatim from NAS payloads into BillingState and SubscriptionState; fall back to the legacy OWNER/ADMIN check only when the server omits the field (FINANCE_ADMIN stops being locked out where NAS authorizes it). Role model updated to the 5-role enum. - Add the autoReload.card union (canonical | distinct | none) end-to-end: parse + gateway serialization, distinct carries payment_method_id/brand/last4 with nullable display fields. - stripe_unavailable (503, transient) and upgrade_cap_exceeded (429, daily cap) now survive to the wire as their own codes instead of collapsing into rate_limited; new exception types subclass BillingRateLimited so existing backoff call sites keep working. - Remove card.chargeability / needs_repair parsing, serialization, fixtures and the cli warning blocks: NAS NousResearch#670 removed the field, so the repair path was permanently dead. The future card-health signal belongs to the NAS W1/W3 work. - Tests: five-role fixtures, canChangePlan override/fallback, all three auto-reload card variants, 429-vs-503 code preservation end-to-end. * feat(tui): render the full NAS billing refusal surface - billingOverlay: divergence notice when auto-refill charges a distinct card (portal deep-link to reconcile); needs_repair warnings removed with the field. - topup: explicit copy for consent_required, org_access_denied, upgrade_cap_exceeded, auto_top_up_disabled_failures and stripe_unavailable (honors retry_after); processing_error is an explicit charge-failure case; transport loss during charge polling now reads as an unconfirmed outcome (check balance before retrying), matching the revocation path. - subscriptionOverlay: branch on upgrade reason, not status, so an SCA-needing upgrade routes to portal verification even while NAS pre-NousResearch#711 labels it payment_failed; after an upgrade, poll subscription state until the tier flips (bounded), rendering applying/still-applying rather than assuming immediacy. - Capability-neutral refusal copy (owner, admin, or finance admin) replaces the stale org admin/owner wording. - gatewayTypes: BillingAutoReload.card union added, needs_repair removed. * docs(billing): client-side billing state and refusal lifecycle table Enumerates, from the code, every billing.state shape and typed refusal the gateway serves and the exact TUI copy + recovery each renders. Acceptance from the billing-integration handoff: no NAS billing state or typed refusal falls through to a generic toast; unknown codes still degrade to the default branch that surfaces the server message.
exiao
pushed a commit
that referenced
this pull request
Jul 20, 2026
* feat(tui): rename /billing slash command to /topup
Behavior-preserving rename of the /billing command surface to /topup.
Changes: billing.ts → topup.ts (export topupCommands, name 'topup', new
help string), registry.ts import+spread updated, billingOverlay.tsx
overview header 'Usage credits' → 'Top up credits', billingCommand.test.ts
→ topupCommand.test.ts with import/lookup/call updated. RPC method names
(billing.state, billing.charge, etc.) and component/symbol names unchanged.
* refactor(tui): extract overlay primitives to shared module
Lift MenuRow, ActionRow, footer, and barCells() out of billingOverlay.tsx
into overlayPrimitives.tsx so the upcoming subscriptionOverlay.tsx can
import them instead of duplicating. spendBar now calls barCells() —
output is byte-identical. Pure behavior-preserving refactor.
* feat(tui): add /subscription + /topup CTAs to /usage output
Every /usage render now ends with 'Run /subscription to change plan
· /topup to add credits' — both the healthy (with-calls) and depleted
(no-calls) paths. Strings-only change, no WS1 dependency.
* feat(tui): add subscription wire types
Add SubscriptionTierOption, SubscriptionStateResponse, and
SubscriptionManageLinkResponse to gatewayTypes.ts. Type-only — no
usages yet. Mirrors the BillingStateResponse conventions (snake_case,
Decimals as strings) and reuses BillingErrorPayload for error mapping.
* feat(gateway): add subscription.state + subscription.manage_link RPCs
- agent/subscription_view.py: SubscriptionState dataclass + fail-open
build_subscription_state() (mirrors billing_view pattern) +
get_subscription_manage_link() for the Stripe deep-link.
- hermes_cli/nous_billing.py: get_subscription_state() +
post_subscription_manage_link() HTTP helpers for the two NAS endpoints
(WS1 Phase A/C). The manage-link endpoint raises BillingScopeRequired
when Remote-Spending is missing (Phase 4 step-up trigger).
- tui_gateway/server.py: _serialize_subscription_state() +
subscription.state RPC (fail-open) + subscription.manage_link RPC
(returns {ok,kind,url} or typed error envelope via
_serialize_billing_error). NOT added to _LONG_HANDLERS — synchronous
HTTP round-trip, not a device flow.
* feat(tui): add subscription overlay state types + store slot
Add SubscriptionScreen, SubscriptionOverlayCtx, SubscriptionOverlayState
to interfaces.ts and a 'subscription' slot to OverlayState. Wire it into
overlayStore.ts (buildOverlayState + $isBlocked). NOT added to
resetFlowOverlays preserve list — flow-scoped like billing, drops on
turn end.
* feat(tui): build SubscriptionOverlay — overview + confirm + handoff
Pure-render Ink component mirroring billingOverlay.tsx's structure.
Overview screen covers all 5 states (free-upgradeable, mid-tier,
top-tier, not-admin, downgrade-pending) + dunning. Confirm screen is
y/n deep-link to Stripe (NO in-terminal charge). Handoff is the
transient 'Opening Stripe' screen. Imports shared primitives from
overlayPrimitives.tsx. 8 render tests via renderSync covering every
state.
* feat(tui): add /subscription command + overlay wiring
- subscription.ts: SubscriptionOverlayCtx closure (openManageLink,
refreshState, requestRemoteSpending) + run handler that fetches
subscription.state and opens the overlay. Alias /upgrade.
- registry.ts: spread subscriptionCommands into SLASH_COMMANDS.
- appOverlays.tsx: render SubscriptionOverlay when overlay.subscription set.
- useInputHandlers.ts: Esc closes subscription overlay; promptOverlay OR
includes subscription so input is intercepted while open.
- subscriptionCommand.test.ts: 4 tests (fetch+open, logged-out sys line,
/upgrade alias, /subscription resolves).
* fix(tui/subscription): stop saying Stripe in deep-link copy + fix manage link kind type
Replace all user-facing 'Stripe' mentions in the /subscription overlay and
sys messages with 'your subscription page' — the deep-link target is NAS's
own /manage-subscription page, not the Stripe hosted portal. Stripe only
legitimately appears later at actual Checkout. Also add 'manage' to the
SubscriptionManageLinkResponse.kind union (NAS emits kind:'manage'; was
previously missing from the TypeScript type causing silent narrowing errors).
* feat(tui/subscription): render cancellation-scheduled note with headline precedence
Parse cancelAtPeriodEnd + cancellationEffectiveAt from the NAS contract
(camelCase) in the agent parser (_parse_current), emit cancel_at_period_end
+ cancellation_effective_at from the gateway serializer, extend the
SubscriptionStateResponse type, and render a warn note in OverviewScreen:
'Cancels on {date} — your plan stays active until then.'
Headline precedence when multiple flags co-occur:
past-due > cancel-scheduled > downgrade-pending > active
The downgradeNote guard is tightened to suppress when cancel is scheduled,
so at most one status line renders at a time.
* feat(tui/subscription): team-context screen — redirect to /topup for team orgs
Parse the NAS context:'personal'|'team' field (defaults to 'personal' for
unknown/missing values), emit it on the gateway wire, add it to
SubscriptionStateResponse. When context is 'team', SubscriptionOverlay
renders a dedicated read-only screen instead of the tier picker:
'This terminal is connected to {org_name}. Teams run on shared
credits — use /topup to add funds. Personal subscriptions live
on your personal account.'
The screen closes on Enter or Esc. The personal/tier-picker path is
unchanged.
* fix(subscription): drop manage-link gateway RPC, build URL locally
The NAS POST /api/billing/subscription/manage-link endpoint was dropped
(it added no server work — the target is the static /manage-subscription
page, not a Stripe-minted secret). Build the URL client-side instead:
{portal_base}/manage-subscription?org_id=<org.id>.
- Remove subscription.manage_link gateway RPC (server.py)
- Remove get_subscription_manage_link helper (subscription_view.py)
- Remove post_subscription_manage_link (nous_billing.py)
- Remove SubscriptionManageLinkResponse type (gatewayTypes.ts)
- Add org_id to SubscriptionState + wire through serializer + TS type
- openManageLink() builds the URL locally via buildManageUrl(), opens
it with the existing openExternalUrl(), no gateway round-trip
- Drop targetTierId param from openManageLink (v1 sends everyone to
/manage-subscription; no tier deep-link needed)
- Fix stale test expectations (Stripe copy → subscription page copy)
* chore(subscription): drop unused format_money import
* feat(cli): /subscription + /upgrade, /billing→/topup rename, /usage CTAs
Add the classic-CLI half of the terminal billing surface to match the TUI:
- /subscription (alias /upgrade) command + /topup (renamed /billing, keeps
'billing' as a back-compat alias) in the command registry.
- Drop the stale 'billing' entry from _SLACK_VIA_HERMES_ONLY (now cli_only).
* feat(subscription): CLI /subscription handler, drop dunning, current:null no-plan
- CLI _show_subscription mirrors the TUI overlay (plan read + tier list + usage
bar + browser deep-link via subscription_manage_url); credits render as counts.
- Adapt to the updated NAS read contract: remove is_past_due/dunning everywhere
(a card-failing subscriber returns as a normal plan now), and treat no-plan as
current:null (parser returns None) rather than an all-null object.
- HERMES_DEV_SUBSCRIPTION_FIXTURE env-driven fixtures + ui-tui fixture harness
drive every state (CLI + live TUI) with no portal.
Verified against handoff 2026-06-24_subscription-tui-handoff.md.
* feat(billing): CF-4 Remote-Spending revoked-terminal UX (NAS PR #481)
Wire the Remote-Spending gate denial contract end to end:
- nous_billing: BillingRemoteSpendingRevoked (403 remote_spending_revoked →
reconnect) + BillingSessionRevoked (401 session_revoked → re-login), distinct
from insufficient_scope; capture actor/code/recovery; 503 stays transient.
- gateway _serialize_billing_error threads the new typed kinds + actor/code/
recovery to the TUI.
- TUI renderBillingError: actor-aware revoke copy, kills the spend overlay
immediately (no 15-min zombie button), handles session_revoked, the dual-
emitted cli_billing_disabled/remote_spending_disabled, role_required,
idempotency_conflict; poll treats a mid-poll revoke as ambiguous (check
balance before retry), not a failure.
- CLI _billing_render_charge_error: same denial matrix, actor-aware copy.
Tests: gate-contract mapping + envelope (py) and revoke/session/disabled (TUI).
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md.
* refactor(subscription): remove dead step-up scaffolding from /subscription
/subscription only opens a browser deep-link to manage-subscription — that needs
no billing scope, so it can never hit insufficient_scope. Drop the never-fired
'stepup' screen type, requestRemoteSpending ctx fn, and resumeScreen bookkeeping
(leftovers from a superseded plan). The resumable step-up lives on /topup, where
the charge actually gets gated.
* feat(tui/topup): resumable 'Allow Remote Spending' step-up on the charge path
Phase 4: when a charge returns insufficient_scope, the /topup modal no longer
tears down with a 'run /billing again' ConfirmReq. Instead it stays MOUNTED and
switches to a step-up screen:
- charge() is now awaitable, returning a discriminated outcome (submitted |
needs_remote_spending | error) so the overlay can route without closing.
- StepUpScreen: 'Allow Remote Spending' → await the device-flow grant (browser
opens via the existing out-of-band billing.step_up.verification event) →
replay the held charge (pendingCharge.amount) and settle, with no command
re-run. Never surfaces the raw billing:manage scope.
- armStepUp's fire-and-forget ConfirmReq replaced by requestRemoteSpending();
the leaky 'billing:manage' / 'Re-authorize' / 'run /billing again' copy is gone.
Tests: charge-outcome routing, step-up grant/deny, and a render test asserting
the step-up copy holds the amount and never leaks billing:manage.
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md §2 (Grady #6).
* feat(billing): shared dollar usage model + two-bar view (drop "credits")
Single source of truth for the /usage and /subscription usage bars across
TUI + CLI. Reads the NAS account-info dollar fields (subscription/top-up/total
remaining, monthly allowance, renewal) and produces a surface-agnostic model:
two full-resolution bars (plan allowance + purchased top-up), a status
classification (free | healthy | low | depleted), and a human renewal date.
- agent/billing_usage.py: UsageModel/UsageBar, usage_model_from_account
(fail-open), build_usage_model (HERMES_DEV_CREDITS_FIXTURE-aware),
format_renews (ISO -> "Jul 24, 2026", Windows-safe), $5 low-balance threshold.
- tui_gateway/server.py: _serialize_usage_model/_serialize_usage_bar, a
usage.bars RPC, and the model embedded into subscription.state so the overlay
renders the same bars from its single fetch.
- Dollars only, never "credits"; two separate bars (not a crammed
three-segment one) for legibility at terminal widths.
- tests/agent/test_billing_usage.py: status classification, bar math
(clamp/over-cap), NaN/Inf rejection, fail-open invariants.
* feat(tui): dollar usage bars on /usage + /subscription, drop tier picker
Render the shared two-bar dollar model in both overlays; strip "credits" and
the in-terminal tier selection per UX feedback.
- overlayPrimitives.tsx: UsageBars (themed plan/top-up bars — gold allowance,
green top-up) + usageBarsText for the /usage panel. Plan name labels the
bar; "$X left of $Y · N% used" (disambiguated so the % matches); top-up
"never expires".
- subscriptionOverlay.tsx: status line dedupes ($X left once; bar carries the
breakdown), human renewal date, state-matched nudges (free upsell / <$5
low alert) with box-safe ASCII markers (! / >) instead of the width-unstable
emoji that broke the border. Tier picker removed — overview shows usage +
plan, then "Manage on portal" / "Close" (free users get "Start a
subscription"). No "credits" anywhere.
- session.ts: /usage renders the dollar bars + balance summary, falling back
to the legacy credits lines only when the model is unavailable; CTA reworded.
- gatewayTypes.ts: UsageModelData/UsageBarData wire types + usage on
SessionUsageResponse/SubscriptionStateResponse.
- Tests updated to the new contract (no "credits", "left of", dedup, markers).
* feat(cli): mirror dollar usage bars on /usage + /subscription
CLI parity with the TUI billing rework, from the same shared usage model.
- _print_nous_credits_block (/usage) and _subscription_overview render the
two-bar dollar view (plan name on the bar, "$X left of $Y · N% used",
top-up "never expires", total spendable) instead of the credits-worded block.
- Dollars only — dropped the tier catalog (no more "$N/mo (… credits)") and
every user-facing "credits"; team copy says "shared balance".
- Human renewal date via the shared format_renews; status line dedupes the
"$X left"; free upsell + <$5 low alert with ASCII markers.
- /subscription manage modal no longer dumps the raw manage-subscription URL
in its detail — the [1] Open / [2] Copy link / [3] Cancel options carry it.
Title is "Manage your subscription" (no in-terminal plan change). The raw URL
stays only in the non-interactive / not-admin fallbacks, which have no menu.
- /usage token-usage panel (model, tokens, cost, context) left untouched.
* feat(billing): embed dollar usage model into billing.state for /topup
The /topup overview renders the same two-bar dollar usage (plan + top-up) as
/usage and /subscription. Embed the shared usage model into the billing.state
RPC payload (mirrors subscription.state) so the overlay gets the bars from its
single fetch, and add the `usage` field to BillingStateResponse.
* feat(tui/topup): reorder overview + in-flight reauth with press-Enter resume
Reworks the /topup overlay per the Jun 19 review and the no-preflight decision.
Overview:
- Balance leads in the title ("Top up · balance $X"); the shared two-bar dollar
usage (plan + top-up) renders below. Dropped the old monthly-cap spend bar.
- "Add funds" is the first action (was "Buy credits"); auto-reload / monthly
limit / manage-on-portal follow. Dollars only — no "credits" anywhere.
- No "Enable terminal billing" menu item and NO scope preflight: whether the
terminal can charge is discovered reactively at pay time. (We deliberately do
not read/refresh the OAuth token to gate UI.)
Step-up (reached only on a charge's insufficient_scope 403):
- New 4-phase flow that keeps the modal mounted: prompt (one-time-setup
heads-up) → waiting (browser authorize) → granted (explicit "Press Enter to
resume") → replay the held charge → settle. The press-Enter beat is the
reassuring "you're back, finish your purchase" moment.
- Renamed user copy "Allow Remote Spending" → "Enable terminal billing"; never
leaks the raw billing:manage scope (guarded by the render test).
- topup.ts error copy de-crufted to terminal-billing wording, emoji removed.
Tests: step-up prompt copy, the no-raw-scope invariant, and new overview tests
(balance-in-title, Add-funds-first, two-bar usage, no "credits").
* feat(cli/topup): mirror overview reorder + in-flight reauth resume
CLI parity with the TUI /topup rehaul, from the same shared usage model.
- _billing_overview: balance in the title, the two-bar dollar usage (plan name
on the plan bar, top-up "never expires") in place of the old cap spend bar,
"Add funds" first, dollars throughout — no "credits", no scope preflight.
- _billing_handle_scope_required: now takes the held amount + idempotency key
and runs the in-flight flow — "Enable terminal billing" → browser device-flow
→ re-check the org kill-switch → press-Enter to resume → replay the held
charge (reusing the key so a double-submit collapses to one). Stops leaking
the raw billing:manage scope.
- Charge-error + buy/auto-reload copy de-crufted to terminal-billing/dollars.
- Tests updated to the new overview + buy copy.
* fix(billing): guard non-JSON 2xx responses in the billing HTTP client
A 2xx response with a non-JSON body — e.g. a reverse-proxy / SPA fallback HTML
page served when a billing route isn't actually mounted on a deployment — hit
json.loads() on the success path of _request() and raised a raw
json.JSONDecodeError. That escaped the typed-BillingError contract, so callers'
`except BillingError` missed it and fell through to a generic fail-open that
rendered as a misleading "not logged in" (observed when /api/billing/subscription
was briefly unshipped on staging: 200 text/html, x-matched-path /[...notFound]).
Now a non-JSON 2xx body raises a typed BillingError(error="endpoint_unavailable")
so surfaces degrade gracefully ("could not load …") instead of crashing or
mislabeling a valid session as logged-out. The 4xx/5xx path already guarded its
.json(); this closes the same hole on the success path.
Test: tests/hermes_cli/test_nous_billing_request.py — non-JSON 2xx → typed
error (not JSONDecodeError, not BillingAuthError), empty body → {}, valid JSON
parses.
* feat(billing/dev): add HERMES_DEV_BILLING_FIXTURE for offline card/scope testing
build_billing_state short-circuits to a fixture when HERMES_DEV_BILLING_FIXTURE
is set (mirrors HERMES_DEV_CREDITS_FIXTURE for the usage model). States:
nocard | card | card-autoreload | notadmin | billing-off | logged-out — so the
card-on-file gate, admin role, and kill-switch paths are exercisable offline
without a live portal. Env-var gated; returns None when unset (no prod leak).
Adds 8 behavior tests asserting the card/admin/billing-on contract per state.
* refactor(billing): fold /credits into /topup
/credits is redundant now that /topup shows the dollar balance + portal handoff.
Make 'credits' (and 'billing') aliases of /topup so typing /credits still works,
resolving to topup everywhere (CLI, gateway, Slack, TUI, autocomplete, help).
Remove the standalone /credits surface across 6 places:
- CLI _show_credits handler + dispatch
- gateway _handle_credits_command -> renamed _handle_topup_command, copy softened
to 'Manage billing on the portal' (the messaging billing surface; /topup is now
gateway-available so messaging keeps billing — credits was the only one before)
- TUI commands/credits.ts + creditsCommand.test.ts (deleted), registry entry
- tui_gateway credits.view RPC + the CreditsViewResponse type
- Slack _SLACK_VIA_HERMES_ONLY: credits -> topup
Sweep user-facing /credits -> /topup (usage-block hint, depletion notice) and
stale doc-comments. OpenRouter's /credits endpoint URL left untouched. Tests
updated (test_credits_folds_into_topup) or pruned for the removed symbols.
* fix(billing): card-on-file heads-up, no-card portal gate, /usage bar ordering, modal glyph
In-terminal charge (POST /charge against the org's server-held card, no card ref
leaves the client):
- card present: confirm screen shows 'Your card saved on the portal will be
charged' + a 'Manage on portal' escape option (CLI); heads-up line (TUI)
- no card on file: /topup overview + buy flow detect it and route to the portal
to add a card, instead of offering a charge that 403s no_payment_method
/usage bar ordering: route the dollar block through _cprint consistently. The
Plan: line (_cprint) and the bar (raw print) flushed to different buffers under
patch_stdout and interleaved nondeterministically; now Plan: -> bar -> status/CTA
is stable across all states.
Modal glyph: strip the leading emoji from bordered _prompt_text_input_modal
titles — it measures 1 char but renders 2 columns, shifting the box's right
border (the stray '|'). Includes the f-string 'Pay $X?' title.
Small /credits -> /topup string bits in cli.py ride along with the surrounding
charge edits (the fold lives in the sibling refactor commit).
* refactor(billing): apply safe simplify-pass fixes
Three low-risk cleanups from a parallel simplify review (reuse/quality/efficiency):
- dev fixture portal URL: reuse the prod host (was drifted to staging-* — a real
mismatch vs subscription_view's _DEV_FIXTURE_PORTAL)
- TUI billingOverlay choose(): collapse two byte-identical branches (needsCard +
the not-full else both = portal-or-close at index 0) into one tail; the only
divergent path (full && !needsCard → buy/auto/limit) stays explicit
- /topup overview comment: correct the stale 'buy_flow detects no_payment_method'
note (the overview's no-card gate fires first, so reaching Add funds implies a
card on file)
Skipped (judgment): the orphaned CreditsView.depleted field (harmless, on a live
dataclass), the defensive card gates in _billing_buy_flow/_confirm_and_charge
(cheap correct defense on the money path), and folding the no-card handoff into a
shared helper (touches 4 money-path sites for tidiness — not worth the risk here).
* fix(billing): reactive charge gating — drop card preflight, react to 403 (scope→reauth, no-card→portal)
* refactor(billing): drop the /credits alias entirely
The /credits fold made it an alias of /topup; now remove that too. Typing
/credits is an unknown command, not a silent redirect — billing lives only on
/topup (with /billing kept as the old command's back-compat name). Dropped the
alias from the registry CommandDef and the TUI topup.ts; updated the test to
assert /credits resolves to nothing (no command, no alias).
* docs(billing): fix stale comment in _billing_overview — describe reactive no-card path
The comment still described the removed overview-level card gate ('no-card case
handled above'). Corrected to: the buy flow reacts to the server's
no_payment_method 403 and hands off to the portal at charge time (no preflight).
* refactor(billing): simplify-pass — share usage-payload helper, drop dead bar wire fields + redundant admin gate
* refactor(billing): drop the /billing alias too — /topup is the only billing command
Following /credits removal, retire the old /billing name as well. /topup now has
NO aliases — both /credits and /billing are unknown commands. Dropped the alias
from the registry CommandDef and TUI topup.ts; fixed the one live user-facing
straggler (the not-logged-in message said 'then /billing' → /topup) and the
_show_billing docstring/default-arg references. Test asserts /topup carries no
aliases and neither old name resolves.
* fix(billing): code-review fixes — money-path + parity bugs
Money path (TUI):
- auto-reload "Turn off" now echoes current threshold/top_up_amount so the
PATCH succeeds (was sending {enabled:false} → invalid_request → stayed ON)
- charge poll honors the 5-min cap on the 429/503 throttle branch too (was
rescheduling forever); cap folded into one timedOut() helper
- step-up resume reacts to the replay outcome instead of unconditionally
closing on a reassuring line with no charge made
- synchronous submit guard on Confirm so two key events can't double-charge
Gateway:
- billing.step_up routes typed errors through _serialize_billing_error (was a
raw {error:'error'} dict → generic copy for session_revoked)
- billing.state / subscription.state / usage.bars / session.usage moved to
_LONG_HANDLERS (blocking portal HTTP no longer stalls the main stdin loop)
CLI:
- _billing_render_charge_error handles insufficient_scope without leaking the
raw billing:manage scope name on a post-grant replay re-raise
Python model:
- subscription_view tier parse None-coalesces tierOrder/dollarsPerMonth so a
free tier's 0 survives ($0, not "—"; correct sort order)
TUI parity/robustness:
- /usage shows formatted renews_display, not raw ISO renews_at
- subscription overview guards a null pending_downgrade_at (was "on null.")
- subscription overview surfaces a message instead of silently closing when
portal_url is missing
- buildManageUrl wraps new URL() so a malformed portal_url can't throw out of
the Ink key handler
* fix(billing): cross-surface bar direction, formatted cancel/downgrade dates, Slack alias gating
- CLI plan bar now fills by REMAINING (fuel-gauge), matching the shared model's
fill_fraction, the top-up bar, and the TUI — same account renders identically
on both surfaces (#8)
- subscription serializer emits cancellation_effective_display /
pending_downgrade_display (format_renews); TUI shows 'Jul 1, 2026' not raw ISO (#14b)
- _SLACK_VIA_HERMES_ONLY now includes the 'billing' alias so it follows its
canonical /topup via /hermes instead of leaking a native Slack slot (#9)
* fix(billing): thread idempotency key through the TUI step-up replay (#2)
Mint a stable idempotency key when the purchase amount is chosen; it rides
pendingCharge into both the Confirm charge and the post-grant step-up replay,
so a retried charge dedups server-side (the gateway already echoes the key).
A fresh amount selection gets a fresh key. Combined with the sync submit guard,
a double-submit now collapses to one charge.
* refactor(billing): remove dead /subscription tier-picker scaffolding (#18)
The in-terminal plan picker was cut (deep-link only), leaving a whole unreached
state machine. Removed end-to-end:
- TUI: ConfirmScreen, HandoffScreen, the 'confirm'/'handoff' screen types,
pendingTargetTierId, and the now-dead onPatch threading (collapsed the dispatch
to a single overview screen + folded the duplicate Box wrapper)
- gateway: the tiers serialization + SubscriptionTierOption wire type
- model: SubscriptionTier, _parse_tier, _coalesce, _dev_tiers and the tiers field
(never displayed on either surface, so this supersedes the tier-parse fix)
- tests: dropped the confirm/handoff/tier-passthrough tests; slimmed the overview
render tests
Net: a large dead-code cull (no behavior change — the picker never ran).
* test(billing): parametrize usage-model tests; drop dead is_low/is_free props
Collapse the fail-open + status-classification cases into parametrized tables
(same coverage, ~80 fewer lines) and remove the now-unused UsageModel.is_low /
is_free properties (only a test pinned them).
* fix(billing): revert dead 'billing' Slack-via-hermes entry — the alias was dropped
#9 was based on a stale review diff: /billing is no longer an alias of /topup
(dropped earlier), so routing it via /hermes filtered a name that doesn't exist.
* test(billing): cull redundant TUI billing tests (parametrize, merge dupes)
usageCommand: collapse 3 CTA tests into one + a panel helper.
billingStepUp: merge the two step-up render asserts.
topupCommand: parametrize requestRemoteSpending + the revoked-actor pair, drop
the redundant happy-path-submitted test. Money-path + error-mapping coverage
preserved.
* refactor(billing): extract _usage_bar_lines — one source of truth for the CLI bars
The plan + top-up bar format was copy-pasted across _print_nous_credits_block,
_subscription_overview, and _billing_overview. Extract a helper returning the
ready-to-print lines; each caller keeps its own print fn (the _cprint-ordering
constraint stays) and resolves its plan-name label. Centralizes the format so
the three surfaces can't drift.
* feat(billing): NAS V3 subscription-change HTTP client wrappers
Add the four write-side wrappers for the V3 subscription contract to nous_billing,
each a thin _request() call (reusing auth, JSON, 401-retry, typed errors):
- post_subscription_preview → POST /subscription/preview (chargeless quote)
- put_subscription_pending_change→ PUT /subscription/pending-change (downgrade/cancel)
- delete_subscription_pending_change → DELETE .../pending-change (resume/undo)
- post_subscription_upgrade → POST /subscription/upgrade (the money route)
pending-change takes a discriminated body (tier_change | cancellation); upgrade
requires an Idempotency-Key (mandatory, validated client-side before any I/O).
Tests assert the exact method/path/body/header each wrapper puts on the wire.
* feat(billing): subscription tier catalog + change-preview models
Reinstate the catalog the in-terminal picker needs (was culled when /subscription
was deep-link-only): SubscriptionTier + SubscriptionState.tiers + _parse_tier, with
_coalesce so the free tier's 0 tierOrder/price survives a falsy-or. Parse the
catalog from GET /subscription's tiers and seed _dev_tiers into every fixture.
Add SubscriptionChangePreview + subscription_change_preview_from_payload for the
POST /preview quote (effect/amountDueNowCents/effectiveAt/reason + tier delta); a
malformed/missing effect fails safe to 'blocked' so a bad quote never reads as a
charge. Module docstring updated: the overlay is no longer deep-link-only.
* feat(billing): gateway RPCs for the V3 subscription change flow
Add subscription.preview / .change / .resume / .upgrade RPCs, each wrapping its
nous_billing call and reusing _serialize_billing_error for the typed envelope
(so a 403 still drives the device step-up). upgrade mints + echoes the
idempotency key and surfaces status + recovery_url so the TUI can route an
SCA/decline to the portal. Re-add the tier catalog to _serialize_subscription_state
(price pre-formatted) for the picker. All four are pool-routed (_LONG_HANDLERS) —
preview + upgrade hit Stripe and must not stall the main stdin loop.
* feat(billing): in-terminal subscription change flow (TUI)
/subscription is no longer deep-link-only: it drives the change in-terminal
against the V3 contract via the new gateway RPCs. The overlay is a state machine
overview → picker → confirm → result:
- picker lists the tier catalog with upgrade/downgrade hints (current + free
excluded; free=cancel, on the overview);
- confirm shows the previewed effect — pay $X now (upgrade) / scheduled at date
(downgrade) / cancel at period end / blocked-with-reason — then applies it;
- an upgrade's SCA/decline routes to the portal via the result screen's recovery
link; resume/cancel/downgrade are chargeless.
Starting a NEW subscription still deep-links (needs a fresh card). insufficient_scope
points to /topup (the step-up stays there, not duplicated here). Adds the wire
types (tiers + preview/upgrade responses), widens the overlay ctx + screen state,
and threads onPatch. Render tests cover every screen.
* feat(billing): in-terminal step-up + clearer scheduled-change UX (TUI)
Two improvements to the /subscription overlay:
Step-up re-auth in place. When a mutation (preview/change/upgrade/resume) returns
insufficient_scope, route to a new 'stepup' screen that grants terminal billing
via billing.step_up and AUTO-REPLAYS the held action on grant — no bounce to
/topup. Scope routing is centralized in previewAndRoute/applyPendingAndRoute/
resumeAndRoute (shared by the picker, confirm, overview + the step-up replay). The
browser opens via the shared global verification handler; copy never leaks the raw
billing:manage scope.
Make a scheduled change unmissable. A downgrade/cancel was one buried warn line
that read as 'nothing happened'. Now the overview leads with a banner
(⏳ Scheduled change · Ultra ──▶ Plus · <date> · you keep Ultra until then), the
status line echoes the transition (Plan: Ultra → Plus), 'Keep <tier> (undo)' is
promoted to the first olive action, the result screen says 'your plan doesn't
change today', and confirm gets a charged-now / scheduled chip.
* feat(billing): full in-terminal subscription change flow in the classic CLI
Bring the CLI to parity with the TUI overlay — /subscription is no longer
deep-link-only. A paid admin/owner gets picker → preview → confirm → apply,
mirroring the /topup buy flow's modal idioms:
- _subscription_change_menu (change / undo-or-cancel / manage-on-portal),
- _subscription_pick_tier (catalog with upgrade/downgrade hints),
- _subscription_preview_and_confirm (POST /preview → effect-aware confirm),
- _subscription_apply (schedule / cancel / resume chargeless; upgrade charges
the sub's card, SCA/decline → portal),
- _subscription_handle_scope_required (insufficient_scope → step_up_nous_billing_scope
inline, then replays the held preview/mutation — reusing the upgrade idempotency key).
Also the scheduled-change UX fix: the overview leads with a prominent banner
(⏳ Scheduled change · Super ──▶ Plus · <date> · you keep Super until then) and the
status line echoes the transition, matching the TUI. Members / non-interactive /
free still deep-link. Tests drive every branch via a mocked modal + nous_billing.
* fix(billing): close TUI subscription money-path holes (ultracode review)
- Un-consented charge (P1): the step-up now HOLDS at a 'granted' phase requiring
an explicit Continue, and an abortedRef gates the grant's late .then — a cancel
during the browser flow can no longer replay the held upgrade + charge.
- Missing idempotency key (P2): mint it when building an upgrade 'pending' so it
rides into confirm AND the step-up replay (was always undefined → gateway minted
a fresh key per call, defeating dedup).
- Navigate-away re-charge (P2): confirm 'back' is guarded by submittingRef while an
apply is in flight.
- Ambiguous charge (P2): a transport-null upgrade is reported as 'may or may not
have charged — re-check', never a flat failure that invites a blind retry.
- Typed step-up denial (P2): requestRemoteSpending returns {granted,error,message};
the screen maps session_revoked / remote_spending_revoked / rate_limited to the
right recovery instead of always 'an admin must allow it'.
* fix(billing): close CLI subscription money-path holes (ultracode review)
- Bounded step-up (P2): bust the 30s token cache after a grant (it held the
pre-grant unscoped token; _request only busts on 401, not 403) and replay ONCE
with allow_stepup=False so a still-denied scope can't re-prompt/re-open in a loop.
- Stray-keystroke charge (P3→near-P2): the upgrade confirm defaults to 'Go back',
not 'Pay ' — a bare Enter can't move money.
- Fail-open on unknown effect (P3→near-P2): an unrecognized preview effect now
fails SAFE (portal hand-off) instead of scheduling a real PUT.
- 'cancel' word collision (P3): the Close row uses value 'close' so typing 'cancel'
can't hit it and falsely report 'Cancelled'.
- blocked effect re-offers the portal; undo is promoted to the first row when a
change is pending (TUI parity).
* fix(billing): guard the step-up resume against double-fire (2nd ultracode pass, BUG A)
The P1 fix split the auto-replay into a user-triggered resume() on the granted
screen, where the default row is the charging action — but resume() had no
re-entrancy guard, so a double-Enter fired two replays (the upgrade dedups on the
shared key, but schedule/cancel/resume replays carry none → duplicate PUT/DELETEs).
Mirror billingOverlay.resume(): flip to a 'resuming' phase + a resumingRef so it
fires at most once, and block 'back' once resuming (no re-mount → no second submit).
* fix(billing): CLI charge-route ambiguous-charge caveat (2nd ultracode pass, BUG B)
The TUI hardened upgradeResult(null) but the CLI charging route did not: a
transport/timeout/500 (or unknown 2xx status) on post_subscription_upgrade — after
NAS may have already prorated + charged — printed a flat failure, and a manual
re-run mints a FRESH idempotency key the server can't dedup → a real second charge.
Now the charge route reports 'your card may or may not have been charged — re-run
/subscription to check before trying again' and steers away from a blind retry
(the CLI can't persist the key across a command re-run). Also thread allow_stepup
through the preview→apply replay (BUG C.1) and route the requires_action/
payment_failed portal lines through _cprint for deterministic ordering.
* fix(billing): cap the TUI step-up replay to avoid a resume-deadlock (final pass, R1)
The round-2 resume guard ('resuming' phase + resumingRef) could deadlock: on a
REPEAT insufficient_scope during the post-grant replay, the route helpers did
onPatch({screen:'stepup'}) — a no-op since we're already mounted on stepup (no key
→ no remount) — leaving phase='resuming'/resumingRef=true frozen on 'Applying your
change…'. Thread allowStepUp through previewAndRoute/applyPendingAndRoute/
resumeAndRoute; the resume() replay passes false, so a repeat scope denial surfaces
a 'still isn't enabled' result instead (mirrors the CLI's allow_stepup=False cap).
Also: applyPendingAndRoute(pending=null) now routes to overview, not a stranded
Promise.resolve().
* fix(billing): narrow the CLI ambiguous-charge catch to indeterminate outcomes (final pass, R2)
The round-2 fix caught EVERY non-scope BillingError as 'may or may not have been
charged' — but typed pre-charge rejections (BillingRateLimited 429, BillingSessionRevoked
401, BillingRemoteSpendingRevoked 403, role_required/no_payment_method 4xx) never
reached Stripe, so the ambiguity copy was wrong and dropped their real recovery hints.
Now route those to _subscription_render_error, and reserve the ambiguous copy for
genuinely indeterminate outcomes (network_error / endpoint_unavailable / status None /
5xx). Tests: rate-limit stays deterministic; a real transport failure stays ambiguous.
* feat(billing): card visibility + guided add-card path in /topup and /subscription
Consume the NAS card-resolver contract (card.resolvedVia + chargeability) across
both surfaces, degrading cleanly on today's NAS (fields absent → prior behavior):
- WHICH card: the payment lines render provenance — 'Visa ····4242 — the card on
your subscription' (resolvedVia → label; unknown rung/older NAS → masked card +
the old generic line). Link payment methods render the brand alone (last4 is
empty — never 'Link ····').
- Presence at a glance: the /topup overview now shows 'Card: …' or 'No saved
card on file' for the full-menu case, plus a warning when the resolver marks
the card needs_repair (failing auto-reloads) on overview/buy/confirm.
- Add-card path: with no card on file, 'Add funds' becomes a guided screen —
open the portal billing page, then 'I've added it — check again' re-fetches
billing state and continues straight into the purchase (also recovers a
transient display miss). Cards are never entered in-terminal.
- /subscription upgrade confirm names the exact card ('Visa ····4242 — the card
on your subscription — will be charged'), best-effort via billing.state and
only when the resolution rung matches what a subscription charge actually
uses (subPin/customerDefault, mirroring Stripe's precedence); otherwise the
generic line stands. Fail-soft: any lookup error keeps the generic line.
- Gateway serializes display/resolved_via/needs_repair; TUI ctx gains
refreshState (topup) + fetchCard (subscription); new offline fixtures
card-sub / card-repair.
Tests: TUI ctx mocks extended; CLI suites cover provenance + repair-warning
render, the Link guard, the add-card path (continue-after-recheck + abandon),
the sub-confirm card line, and keep the confirm-time lookup offline in tests.
* feat(desktop): add desktop-local billing wire types
* feat(desktop): billing gateway API client and refusal taxonomy
* feat(desktop): register billing settings tab with skeleton view
* feat(desktop): wire billing tab to live gateway reads with fail-open states
* feat(desktop): buy-credits charge flow with settlement poller
* fix(desktop): keep About last in settings nav, billing above it
* feat(desktop): auto-refill editing and billing step-up verification flow
* fix(desktop): clamp overdrawn subscription credits and pin USD symbol formatting
* fix(desktop): move billing next to notifications in settings nav
* feat(desktop): usage-bar state colors and dev fixture simulator
* feat(desktop): wide usage bars with top-up bar and refresh affordance
* fix(desktop): disable buy controls without a card, neutral tracks for bar-less usage rows
* polish(desktop): usage-grid alignment, tabular numerals, legible tracks and danger states
* polish(desktop): dithered empty and depleted usage-bar tracks per app bar idiom
* fix(billing): consume server canChangePlan, preserve distinct refusal codes, drop dead chargeability
- Parse canChangePlan verbatim from NAS payloads into BillingState and
SubscriptionState; fall back to the legacy OWNER/ADMIN check only when the
server omits the field (FINANCE_ADMIN stops being locked out where NAS
authorizes it). Role model updated to the 5-role enum.
- Add the autoReload.card union (canonical | distinct | none) end-to-end:
parse + gateway serialization, distinct carries payment_method_id/brand/last4
with nullable display fields.
- stripe_unavailable (503, transient) and upgrade_cap_exceeded (429, daily cap)
now survive to the wire as their own codes instead of collapsing into
rate_limited; new exception types subclass BillingRateLimited so existing
backoff call sites keep working.
- Remove card.chargeability / needs_repair parsing, serialization, fixtures and
the cli warning blocks: NAS #670 removed the field, so the repair path was
permanently dead. The future card-health signal belongs to the NAS W1/W3 work.
- Tests: five-role fixtures, canChangePlan override/fallback, all three
auto-reload card variants, 429-vs-503 code preservation end-to-end.
* feat(tui): render the full NAS billing refusal surface
- billingOverlay: divergence notice when auto-refill charges a distinct card
(portal deep-link to reconcile); needs_repair warnings removed with the field.
- topup: explicit copy for consent_required, org_access_denied,
upgrade_cap_exceeded, auto_top_up_disabled_failures and stripe_unavailable
(honors retry_after); processing_error is an explicit charge-failure case;
transport loss during charge polling now reads as an unconfirmed outcome
(check balance before retrying), matching the revocation path.
- subscriptionOverlay: branch on upgrade reason, not status, so an SCA-needing
upgrade routes to portal verification even while NAS pre-#711 labels it
payment_failed; after an upgrade, poll subscription state until the tier
flips (bounded), rendering applying/still-applying rather than assuming
immediacy.
- Capability-neutral refusal copy (owner, admin, or finance admin) replaces
the stale org admin/owner wording.
- gatewayTypes: BillingAutoReload.card union added, needs_repair removed.
* refactor(shared): move terminal-billing wire types to @hermes/shared
The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.
The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.
* test(cli): pin nous_billing wire-layer status-to-exception mapping
The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.
Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.
Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).
* fix(tui_gateway): delete dead credits.view RPC
The handler assigns into an undefined `usage` variable, so any call
would raise NameError (the except swallows the first hit, then the
return re-raises it uncaught). Nothing can reach it: the TUI command
registry removed /credits (pinned by test_credits_command_fully_removed)
and no client sends the RPC. The live credit view is
agent/account_usage.py::build_credits_view via the remote gateway's
/topup command, which is untouched.
* fix(cli): normalize read-phase timeouts to the typed billing error
urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.
* fix(shared): stop typing mutation success payloads as error payloads
BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.
* feat(shared): typed billing refusal and charge-failure unions
- BillingRefusalCode covers every code the gateway serializes today, with a
(string & {}) arm so unknown future codes (the NAS W3 card-health family)
stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
emits; capability comments updated (canChangePlan is capability-based, not
an OWNER/ADMIN role gate).
* docs(billing): client-side billing state and refusal lifecycle table
Enumerates, from the code, every billing.state shape and typed refusal the
gateway serves and the exact TUI copy + recovery each renders. Acceptance from
the billing-integration handoff: no NAS billing state or typed refusal falls
through to a generic toast; unknown codes still degrade to the default branch
that surfaces the server message.
* refactor(desktop): consume @hermes/shared billing types, full refusal copy, divergence notice
- billing/types.ts becomes a re-export shim over @hermes/shared/billing (keeps
the desktop-only bounds field via a local BillingAutoReload extension);
needs_repair is gone with the shared type.
- resolveRefusal gains specific copy for consent_required, org_access_denied,
upgrade_cap_exceeded, stripe_unavailable (transient, honors retry_after) and
processing_error; BillingErrorKind now IS the shared BillingRefusalCode.
Default fallback unchanged.
- Auto-refill row surfaces the distinct-card divergence: caption naming the
charging card (or 'a different card' when brand/last4 are null) and a
Reconcile portal deep-link instead of the inline edit form.
- Fixtures/tests updated for the required auto_reload.card union; new
auto-refill-divergent dev fixture.
* fix(desktop): auto-refill-divergent fixture must be enabled to exercise the divergence row
* refactor(billing): explicit BillingTransient trait, drop broken credits.view, public token-cache invalidation
- BillingRateLimited / BillingStripeUnavailable / BillingUpgradeCapExceeded
become siblings under a new BillingTransient trait (deterministic non-charge
outcome, safe to retry) instead of the false is-a chain that made a Stripe
outage 'a kind of rate limiting'. Catch sites that meant 'any deterministic
pre-charge transient' now say so explicitly; the gateway serializer
dispatches on the trait and emits the preserved raw code.
- Delete the credits.view RPC handler left broken by the /topup rename (its
body referenced an undefined variable; no caller remains).
- invalidate_cached_token() replaces the CLI's reach into the private
_token_cache global after a billing step-up.
* refactor(cli): extract CLIBillingMixin; charge gates follow the server capability
- Move the ~1,400-line billing/subscription handler family out of cli.py into
hermes_cli/cli_billing_mixin.py, following the existing HermesCLI mixin
pattern (lazy cli imports, verbatim bodies).
- can_charge and the CLI billing-action gates now route through
can_change_plan (server capability with legacy role fallback) instead of the
deprecated 3-role is_admin — a FINANCE_ADMIN the server authorizes can now
add funds, matching the plan-change path.
- Render the spend bar from the UsageBar model's fill_fraction instead of the
deleted _billing_spend_bar re-derivation; fix a stale docstring.
* refactor(tui): promote useMenu to overlay primitives, type pendingTierId end-to-end
- useMenu (arrow/number/Enter/Esc menu hook) moves to overlayPrimitives with
an onKey escape hatch; billingOverlay's Overview and Limit screens drop
their verbatim copies. BuyScreen keeps its bespoke handler (typing mode +
stale-selection clamp don't fit the shared contract cleanly).
- SubscriptionResult carries pendingTierId directly; the shadow
SubscriptionResultWithPending interface and the ResultScreen cast are gone,
so the apply-poll field is type-tracked through finish().
* docs(billing): correct the CLI-parity row — the CLI has the full in-terminal change flow
* refactor(shared): move terminal-billing wire types to @hermes/shared
The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.
The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.
* test(cli): pin nous_billing wire-layer status-to-exception mapping
The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.
Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.
Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).
* fix(cli): normalize read-phase timeouts to the typed billing error
urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.
* fix(shared): stop typing mutation success payloads as error payloads
BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.
* feat(shared): typed billing refusal and charge-failure unions
- BillingRefusalCode covers every code the gateway serializes today, with a
(string & {}) arm so unknown future codes (the NAS W3 card-health family)
stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
emits; capability comments updated (canChangePlan is capability-based, not
an OWNER/ADMIN role gate).
* feat(shared): closed Known* halves for the refusal and charge-failure unions
- KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets,
so classification tables, copy maps and tests can be Record-exhaustive and
break at compile time when a code is added but not mapped. The wire types
keep the (string & {}) open arm for unknown future codes.
- Add network_error (client-originated transport code the gateway already
serializes) to the known set.
- Export the union types from the root barrel alongside the other billing
names.
* feat(shared): canonical billing refusal policy and charge-settlement driver
- billing-policy.ts: one exhaustive Record<KnownBillingRefusalCode,
BillingRefusalPolicy> classifying every known code (recovery kind,
mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code
fallback. Surfaces keep their own copy; the behavior classification now
has a single home that breaks the build when a new code goes unmapped.
- charge-settlement.ts: the settlement poll state machine (2s cadence,
5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a
pure dependency-injected driver returning a discriminated outcome.
- The TUI's pollCharge becomes a thin renderer over the shared driver —
byte-identical output, and the desktop poller can now share the same
machine instead of a drifting copy.
* fix(desktop): real auto-reload bounds, shared refusal policy and settlement driver
- Delete the phantom BillingAutoReload.bounds plumbing: nothing ever populated
it, so the auto-reload amount validation it fed was silently dead. The
editor and validators now enforce the gateway's real top-level
min_usd/max_usd (new test pins the $10 minimum actually rejecting), and
types.ts collapses to a plain re-export shim over @hermes/shared/billing.
- Delete the test-only BillingRpcResponse envelope family; BillingResult is
the one response model.
- Refusal copy speaks desktop: reconnect/sign-in route to Settings → Gateway
instead of the TUI's /portal command; the dead processing_error refusal
case is gone (it is a charge-failure reason, already rendered by the
poller).
- Adopt @hermes/shared billing-policy + charge-settlement: the poll loop is
the shared driver, revocation-ambiguity comes from the policy table
(insufficient_scope mid-poll now counts, per the ruling), and all
policy-retry codes back off during polling instead of failing hard.
errors.test.ts is Record-exhaustive over KnownBillingRefusalCode again.
* refactor(shared): move terminal-billing wire types to @hermes/shared
The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.
The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.
* test(cli): pin nous_billing wire-layer status-to-exception mapping
The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.
Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.
Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).
* fix(cli): normalize read-phase timeouts to the typed billing error
urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.
* fix(shared): stop typing mutation success payloads as error payloads
BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.
* feat(shared): typed billing refusal and charge-failure unions
- BillingRefusalCode covers every code the gateway serializes today, with a
(string & {}) arm so unknown future codes (the NAS W3 card-health family)
stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
emits; capability comments updated (canChangePlan is capability-based, not
an OWNER/ADMIN role gate).
* feat(shared): closed Known* halves for the refusal and charge-failure unions
- KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets,
so classification tables, copy maps and tests can be Record-exhaustive and
break at compile time when a code is added but not mapped. The wire types
keep the (string & {}) open arm for unknown future codes.
- Add network_error (client-originated transport code the gateway already
serializes) to the known set.
- Export the union types from the root barrel alongside the other billing
names.
* feat(shared): canonical billing refusal policy and charge-settlement driver
- billing-policy.ts: one exhaustive Record<KnownBillingRefusalCode,
BillingRefusalPolicy> classifying every known code (recovery kind,
mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code
fallback. Surfaces keep their own copy; the behavior classification now
has a single home that breaks the build when a new code goes unmapped.
- charge-settlement.ts: the settlement poll state machine (2s cadence,
5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a
pure dependency-injected driver returning a discriminated outcome.
- The TUI's pollCharge becomes a thin renderer over the shared driver —
byte-identical output, and the desktop poller can now share the same
machine instead of a drifting copy.
* refactor(shared): move terminal-billing wire types to @hermes/shared
The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.
The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.
* test(cli): pin nous_billing wire-layer status-to-exception mapping
The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.
Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.
Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).
* fix(cli): normalize read-phase timeouts to the typed billing error
urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.
* fix(shared): stop typing mutation success payloads as error payloads
BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.
* feat(shared): typed billing refusal and charge-failure unions
- BillingRefusalCode covers every code the gateway serializes today, with a
(string & {}) arm so unknown future codes (the NAS W3 card-health family)
stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
emits; capability comments updated (canChangePlan is capability-based, not
an OWNER/ADMIN role gate).
* feat(shared): closed Known* halves for the refusal and charge-failure unions
- KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets,
so classification tables, copy maps and tests can be Record-exhaustive and
break at compile time when a code is added but not mapped. The wire types
keep the (string & {}) open arm for unknown future codes.
- Add network_error (client-originated transport code the gateway already
serializes) to the known set.
- Export the union types from the root barrel alongside the other billing
names.
* feat(shared): canonical billing refusal policy and charge-settlement driver
- billing-policy.ts: one exhaustive Record<KnownBillingRefusalCode,
BillingRefusalPolicy> classifying every known code (recovery kind,
mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code
fallback. Surfaces keep their own copy; the behavior classification now
has a single home that breaks the build when a new code goes unmapped.
- charge-settlement.ts: the settlement poll state machine (2s cadence,
5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a
pure dependency-injected driver returning a discriminated outcome.
- The TUI's pollCharge becomes a thin renderer over the shared driver —
byte-identical output, and the desktop poller can now share the same
machine instead of a drifting copy.
* chore: retrigger CI with the current base SHA (stale base pin flagged a false CI-sensitive change)
* refactor(shared): move terminal-billing wire types to @hermes/shared
The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.
The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.
* test(cli): pin nous_billing wire-layer status-to-exception mapping
The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.
Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.
Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).
* fix(cli): normalize read-phase timeouts to the typed billing error
urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.
* …
exiao
pushed a commit
that referenced
this pull request
Jul 28, 2026
…ch#67140) The background write guard decided ownership from `isinstance(usage_rec, dict)`, so a local skill with NO usage record passed. That successful write called bump_patch(), which created a `created_by: null` record — and the identical write was refused from then on. "Allowed exactly once, then never" is a race with our own bookkeeping, not a policy. Reproduced on main: patch #1 succeeds, patch #2 with the same arguments is refused. Option B from the issue. Option A (split `session_review` from `scheduled_curator` and let the session fork patch user-owned skills it consulted) would widen autonomous write permission onto skills the user owns with no user present to consent — wrong direction for a no-user-present actor. - skill_manager_tool: missing and explicit-null records now resolve IDENTICALLY, both fail closed. The refusal names the reason and points at `hermes curator adopt <name>`. - background_review: both review prompts told the reviewer to patch any skill consulted in the session and claimed pinned skills could be improved, while enforcement refused both. Prompts now list pinned, external, and user-owned skills as protected, and tell the reviewer to RECOMMEND adoption instead of attempting a write that will be refused. - skill_usage: document that `created_by` is a curator-management policy flag, not a provenance claim, and add `is_curator_managed()` so call sites read as the question they ask. Field name retained — it is on disk in every `.usage.json` and renaming would strand those records. - curator CLI: `hermes curator list-unmanaged` itemizes unmanaged skills with the reason each is unmanaged (completes the NousResearch#67139 spec). Foreground writes are untouched: a user-directed edit to a user-owned skill still works, including on pinned skills. Sibling tests: 9 failures in test_skill_manager_tool.py were fixtures that created record-less skills to exercise OTHER guards (consolidation-delete, read-before-write) and relied on ownership falling through. Fixed at the fixture, since the real curator only ever operates on managed sediment. One test asserted the old "manually authored" wording; rewritten to assert the behavior contract instead of the string. Validation: 274 targeted tests + all 7 background-review files (60 tests) pass. E2E on a temp HERMES_HOME (30 checks) covers the flip, foreground writes, adoption unblocking, pin semantics, prompt/enforcement parity, and the new verb. Each new test sabotage-verified: revert the fix, confirm it goes red. Fixes NousResearch#67140
exiao
pushed a commit
that referenced
this pull request
Jul 28, 2026
…hat tile (NousResearch#71969) * fix: Branch button is a dead no-op inside a branched chat tile session-tile.tsx wired onBranchInNewChat to () => undefined for tiled/branched sessions (nested branching isn't supported there), but the button in AssistantMessage's action bar rendered unconditionally regardless of whether a real handler was supplied. The button looked clickable but silently did nothing, with no visual feedback. - AssistantMessage now only renders the Branch button when onBranchInNewChat is actually provided, matching the existing pattern used for onDismissError/onRestoreToMessage. - session-tile.tsx no longer passes a no-op handler; the prop is simply omitted so the button doesn't render in tiles. - onBranchInNewChat is now optional on ChatViewProps, and the latestChatActions passthrough wrapper uses the existing latestOptional helper instead of an unconditional call. * test: assert Branch button visibility matches handler presence Adds coverage for the bug #2 fix: renders Thread with and without an onBranchInNewChat handler and asserts the Branch in new chat button is shown only when a real handler is supplied, hidden otherwise - covering both the normal open-chat case and the session-tile (branched chat) case that used to leave a dead, clickable button.
exiao
pushed a commit
that referenced
this pull request
Aug 3, 2026
… a broken chat A completely unconfigured install previously booted into a working-looking chat (banner showed model 'unknown'), accepted a message, spun ~30s, then failed with 'Set OPENROUTER_API_KEY' — a provider the user never chose — and never offered setup. - HermesCLI.run() now probes provider readiness at startup (TTY only) and offers the shared provider picker (hermes model flow, which fronts Quick Setup / Nous Portal OAuth) when nothing is configured. Decline is respected; picker state re-syncs into the live CLI so the next turn works without a restart. - New silent probe _runtime_credentials_ready(): no printing, no state mutation; handles keyless local endpoints and callable bearer providers. - The empty-api-key error is provider-aware: names the actual resolved provider and points at 'hermes model' / 'hermes setup' instead of hardcoding OPENROUTER_API_KEY. - Banner: unconfigured installs render 'no model configured — run /model' in red instead of the silent 'unknown' model slug. Consumer-onboarding audit finding #2 (sev 5), Aug 2026.
exiao
pushed a commit
that referenced
this pull request
Aug 3, 2026
A wedged adapter transport (network hang, dead websocket) previously blocked _check_session_stalls forever: sibling candidates in the same pass were never evaluated and the watcher stopped ticking. Wrap the send in asyncio.wait_for (15s); on timeout log a WARNING and do NOT latch, so the next tick retries. Regression uses a never-resolving fake adapter and proves the pass completes, a healthy sibling candidate is still notified in the same pass, and the watcher ticks again (sabotage-verified against the unbounded send).
exiao
pushed a commit
that referenced
this pull request
Aug 11, 2026
…on delegation callbacks (NousResearch#82592) * fix(gateway): stop frozen-preview finals and dropped idle-session delegation callbacks Two relay-plane delivery losses from the 2026-08-09 staging incident: 1. stream_consumer: the skip-redundant-finalize branch recorded _accumulated as the delivered turn-final payload even when the last ACKED edit was an earlier throttled preview snapshot, so delivered_final_matches reconciled True and the gateway suppressed the corrective final send — the user was left with a cut-off message ending in the streaming cursor. Extracted _mark_skip_redundant_finalize(): records the last acked wire payload (cursor-stripped), so a preview/final mismatch now returns False and the normal final send fires. 2. run.py: _classify_completion_target classified every ended parent session terminal unless it ended by compression. Idle/timeout session ends are the norm on scale-to-zero relay deployments and the chat route remains valid; completed async delegation results were terminally dropped. Ended parents now classify deliver unless the end was an explicit user boundary (session_reset / user_exit / session_switch). * fix(relay): drain in-flight outbound frames before transport teardown disconnect() failed every pending outbound future immediately with 'relay transport closed', so a trailing finalize edit racing turn teardown was lost even though the connector socket could still serve it. Bounded drain grace (5s) lets in-flight requests resolve; silent connectors still tear down promptly. asyncio.wait (not gather+wait_for) so a timeout doesn't cancel futures owned by the fail-remaining loop. * fix(gateway): route completion injection through the alias-aware transport resolver Third relay-plane delivery loss from the 2026-08-09 staging incidents: a delegation batch completed while the gateway was up, the watcher drained the event, and delivery vanished with no log line. _inject_watch_notification resolved its adapter with a literal p.value == platform_name scan of self.adapters — a relay-fronted gateway registers ONE adapter under Platform.RELAY fronting N logical platforms, so 'slack' never matched and the injection returned None ('no gateway route'), silently dropping the completion. The handoff path already documents this exact trap and uses resolve_delivery_transport; the injection path now does the same (native wins; relay eligible only when it fronts the logical platform), with the literal scan kept as fallback for stub runners and exotic platforms. * fix(relay): clamp disconnect drain grace to the runner's adapter-disconnect budget Review finding (JoaoMarcos44, NousResearch#82592): a fixed 5.0s drain in front of the three 1.0s sequential teardown awaits gives an 8.0s worst case inside the runner's 5.0s asyncio.wait_for(adapter.disconnect()) — tripping it cancels teardown mid-drain, skips the fail-pending loop, and leaves outbound callers blocked until _OUTBOUND_TIMEOUT_S (30s). The effective grace is now budget - 3*TEARDOWN - margin (env-aware via the same HERMES_GATEWAY_ADAPTER_DISCONNECT_TIMEOUT the runner reads), so the drain can never push teardown past its caller's budget; a budget too small for any drain disables it cleanly. * test(gateway): pin the final-send suppression contract across a behaviour matrix The gateway skips its own final send when the stream consumer claims the turn final already reached the user. Every incident in that family — NousResearch#71643 (stale finalize snapshot), NousResearch#78541 (payload-less multi-message split), NousResearch#82656 (frozen preview left with a visible cursor) — is the same failure: the consumer claimed delivery for text the platform never rendered, so the corrective send was suppressed and the answer was lost with no retry. Each was fixed with a scenario test pinned to one branch of GatewayStreamConsumer.run(). The got_done handler now has five sibling branches that each set the suppression flags and record a turn-final payload, and nothing checks them as a group: a new branch, or a new early `return True` in _send_or_edit, can reintroduce the class without failing a test. Pin the invariant instead of the branch — if the consumer offers the gateway any signal it would trust, the complete final text must have reached the wire — and assert it across {edit always / dies / never / lies} x {send always / never} x {fresh-final on / off} x {clean / interrupted stream}. The adapter records only frames that actually rendered, so an ACK the platform drops does not count as delivery. 24 honest-transport scenarios hold the invariant as a hard assertion. The 16 lying-transport scenarios are checked too; the single combination that still violates it is reported as an expected failure documenting the open exposure rather than asserting it away. Refs NousResearch#82656 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(gateway,relay): prime relay egress routing for synthetic injections + cap stale completion replay Defect #4 from the 2026-08-09 staging incidents (upgrade-robustness): after every gateway restart the durable async-delegation replay injected completions correctly (post-741663cf1) but their replies bounced at the connector — 'slack egress declined: target not routed to an onboarded tenant'. The relay adapter re-attaches tenant discriminators (metadata.scope_id / metadata.user_id) from per-chat caches warmed ONLY by inbound traffic; synthetic turns race those cold caches on every deploy, scale-to-zero wake, and crash recovery. - relay adapter: prime_routing_cache() — feeds a synthetic event's session-store origin through the same _capture_scope used for real inbound (never raises). - run.py injection path: prime the resolved adapter before handle_message (duck-typed; native adapters unaffected). - async_delegation: 48h staleness cap in restore_undelivered_completions — a pending completion older than the cap is terminally dropped (payload stays queryable) instead of re-run as a fresh full-context turn; the post-restart replay of a July session burned a 102K-token context. Also carried: JoaoMarcos44's suppression behaviour-matrix harness (cherry-picked from NousResearch#82676, authorship preserved) — 39 passed + 1 xfail (the documented ACK-then-drop transport-honesty residue). * test: use recent timestamps in restored-ownership fixtures test_restore_stamps_restored_flag persisted its completion with epoch-era toy timestamps (dispatched_at=1.0), which the new 48h replay staleness cap correctly classifies as stale — the fixture then exercised the cap instead of the restored-flag contract (CI slice 4 failure). Timestamps are now now-relative; the staleness behavior itself is pinned separately in test_relay_injection_egress_priming.py. * fix(gateway,relay): close four review findings on the relay delivery fixes Review follow-ups on this branch (NousResearch#82592): 1. HIGH — classifier/resolver mismatch (falsely-acknowledged loss). _classify_completion_target now returns "deliver" for idle-ended parents, but _resolve_async_delegation_session still dropped every non-compression-ended pin: the durable row was acked at adapter acceptance, then the injection died inside the pipeline with no retry — strictly worse than the honest terminal drop on main, and the delivery leg defect #2's fix depends on did not exist. The resolver now retargets non-user-boundary ends (idle/timeout/ lifecycle) to the chat's current session — session_entry already IS the routing key's current session for the same chat — while user boundaries (session_reset / new_session / user_exit / session_switch) stay fail-closed. Both sides share one module-level _USER_BOUNDARY_END_REASONS so the verdict and the routing decision cannot drift again; a coherence test asserts deliver-verdicts resolve non-None across representative end reasons. 2. HIGH — drain clamp missed adapter-level spend. The effective drain grace budgeted drain + 3x teardown, but RelayAdapter.disconnect spends revocation-monitor teardown + go_idle time BEFORE the transport drain inside the same runner wait_for; worst case still blew the budget and cancelled teardown mid-drain (skipping the fail-pending loop). The adapter now measures its own elapsed time and threads the REMAINING budget into transport.disconnect(budget_s=...); legacy/stub transports without the keyword fall back to the no-arg signature. 3. P1 — _request_response racing disconnect() could register a future after the fail-pending loop already ran, stranding the caller for the full _OUTBOUND_TIMEOUT_S (30s). Fail fast with the same "relay transport closed" error once _closing is set. 4. P1 — _build_process_event_source's last-resort reconstruction dropped scope_id, so a scoped relay completion whose session-store origin was unavailable primed no tenant discriminator and could still bounce off the connector's fail-closed egress guard. scope_id now threads through the reconstructed SessionSource, with a warning when a scoped chat reconstructs without one. All four: RED reproduced with the fix reverted, GREEN after; relay/ delegation delivery families pass (43 + 71 + 179 across the touched suites); full tests/gateway run shows only failures already failing identically on merge base 2446c8b (env/dep issues). * fix(gateway,relay): make pending-frame failure cancellation-safe; persist completion routing origin Two remaining review findings on this branch (NousResearch#82592): 1. Cancellation could strand outbound waiters past the fail-pending loop. transport.disconnect() failed pending futures only at the END of the drain + three teardown awaits; a cancellation landing mid-drain (the runner's wait_for budget, an outer cleanup deadline) skipped the loop entirely and left registered futures unresolved — their callers blocked until _OUTBOUND_TIMEOUT_S (30s). The budget threading added earlier shrinks the window but is not a hard guarantee. The fail-pending loop (and the going_idle ack failure) now run in a `finally`, so no exit path — normal, error, or cancelled — can leave a registered future unresolved. Idempotent: done futures are skipped, a second disconnect() pass is a no-op. 2. Durable completions did not persist their routing origin, so the scope_id threading in the fallback SessionSource reconstruction had nothing to carry on the exact path it exists for (restart replay with session store + source cache gone): the async-delegation event producers never populated scope_id and the durable rows never stored it. Dispatch now snapshots the originating turn's scope_id/user_id/user_name from the session context (_capture_routing_origin — a new HERMES_SESSION_SCOPE_ID contextvar bound by the gateway at session-bind time alongside the existing vars), stores them in the existing task_json payload (no schema migration), and re-attaches them to all three completion-event shapes (live single, live batch, crash-recovery rebuild). The gateway's fallback reconstruction then primes both discriminators after a restart. Tests: cancellation mid-drain -> every pending future resolves with "relay transport closed" (mutation: moving the loop out of the finally goes RED); second-pass disconnect idempotence; end-to-end dispatch -> owner-death recovery -> event carries scope_id -> fallback SessionSource primes it (mutations: dropping the dispatch capture or the task_json persistence both go RED); live completion event carries the origin. 94 passed + 1 xfailed across the delivery/delegation suites; tests/tools delegation family 73 passed (2 collection errors pre-existing on merge base 2446c8b). --------- Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Ben Barclay <ben@nousresearch.com>
exiao
pushed a commit
that referenced
this pull request
Aug 17, 2026
Addresses both review findings on the remote-gateway download PR: 1. Unbounded buffering (finding #1). fetchBuffer / fetchBufferViaOauthSession accumulated the entire response (then copied it again via Buffer.concat) before saveGatewayFile even opened the save dialog, so a large gateway file could exhaust the native process. Both auth paths now stream: once response headers arrive the connect timeout is cleared, the filename is derived, the save dialog is shown, and the body is piped to the chosen destination with backpressure. A read/write error tears down the stream and unlinks the partial file. The byte-moving, data-URL decoding, and filename/path helpers are extracted into gateway-file-download.ts so they're unit-testable without Electron. 2. No fallback for older gateways (finding #2). saveGatewayFile required the new /api/fs/download route. Desktop and the remote gateway update independently, so a gateway predating this PR 404s. Added a 404-only compatibility fallback to the existing capped /api/fs/read-data-url route (bounded, so it only serves smaller files — enough to keep older backends working). Tests: gateway-file-download.test.ts covers streaming, backpressure, error-cleanup (unlink on write/response error), data-URL decoding, filename derivation (incl. traversal reduction), and 404 detection; gateway-file-download-transport.test.ts asserts both transports stream (no whole-body Buffer.concat) and that the 404 fallback is wired. Both registered in the desktop platform test list. Server-side /api/fs/download tests (streaming + sensitive-file reject) already pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
What
Drops the
is_local_endpoint-keyed_stale_timeout = float("inf")bypass in both the non-streaming and streaming API paths. Replaces
both with the existing sliding-scale (300/450/600s non-stream,
180/240/300s stream).
Why
The bypass treated "loopback / RFC-1918 base URL" as a proxy for
"slow local LLM that needs a long-prefill grace period." That
heuristic also matches local proxies that forward to a cloud
provider — like the OpenClaw billing proxy at
127.0.0.1:18801fronting Anthropic.
When the upstream stalled, the proxy had nothing to forward, the
watchdog never fired, and the call wedged indefinitely while the
anthropic SDK racked up internal retries (1,400+ retry lines for a
single hung call observed in
agent.log).Sub-agents calling through the local proxy were the worst-affected.
Recent example, session
20260418_190654_a04cb15f:delegate_taskcallinterruptedA second sub-agent in the same period ran 23 min, made 14 unauthorized
patchcalls during the wedge, and never returned a summary.Fix
Both stale detectors now apply the same sliding scale for every
base_url. Operators running an actual local LLM that legitimately
needs longer than the bumped ceiling should set
HERMES_API_CALL_STALE_TIMEOUT(non-streaming) orHERMES_STREAM_STALE_TIMEOUT(streaming) explicitly.Test plan
tests/agent/test_stale_call_local_bypass.py(16 tests)get bounded timeouts (no
float("inf"))300s stream at >100k tokens)
{"type": "ephemeral"}for cache-control markers, which became staleafter the W1 1h-cache-TTL default landed. Replaced with
make_cache_marker()so they trackCACHE_TTLgoing forward.pytest tests/run_agent/ tests/agent/→ 2128 passed,2 pre-existing parallel-order-pollution failures unrelated to this
branch (verified by re-running the same 2 tests in isolation on
clean
live-config— they pass).Patch notes
~/.hermes/plans/hermes-patches/subagent-stale-call-local-bypass.mdFull incident analysis + fix narrative.
~/.hermes/plans/hermes-patches/w1-w13-live-validation-probes.mdV12 cache_summary probe was producing false negatives because the
wedge bug made
cache_read_tokens=0look like a W12 regression. TheV12 step now requires confirming
status=completed && api_calls>=1before evaluating cache numbers, plus a cold-then-warm pair as the
real W1+W12 acceptance gate.
_superseded-subagent-runaway-deadline.md(an earlierdraft that proposed a 15-min wall-clock kill on
delegate_task. Realroot cause was the watchdog being disabled, not missing — that note
was treating the symptom).
Out of scope
killed by Hermes' watchdog before it can rack up hundreds of retries.
read_only=Trueflag ondelegate_task(would have prevented theunauthorized
patchcalls during the wedge). Useful, but orthogonalto the wedge itself — separate patch if wanted.
Different code path, different bug.