Skip to content

hermes_cli: add nssm_truth helper for service-state-vs-reality checks - #2

Closed
bbasketballer75 wants to merge 399 commits into
mainfrom
pr-nssm-truth-helper
Closed

hermes_cli: add nssm_truth helper for service-state-vs-reality checks#2
bbasketballer75 wants to merge 399 commits into
mainfrom
pr-nssm-truth-helper

Conversation

@bbasketballer75

Copy link
Copy Markdown
Owner

Problem

The NSSM false-positive pattern is real and recurring: when a service gets into the PAUSED state on Windows NSSM 2.24, the actual python process is often still alive and bound to its port. NSSM's state-lag means the service reports PAUSED while:

  1. The PID file (gateway.lock / gateway.pid) still points to a live python
  2. The port (e.g. 9720) is still bound and serving HTTP 200

Three scripts in this codebase have hit this pattern and copy-pasted the same PID-file + alive-check logic to handle it:

  • scripts/nssm-self-heal.py
  • scripts/fix-gateway-paused-state.py
  • scripts/audit-tools.py

Fix

This PR consolidates the duplicated logic into a single shared helper module at hermes_cli/nssm_truth.py:

from hermes_cli.nssm_truth import is_hermes_service_actually_running

ok, detail = is_hermes_service_actually_running("HermesGateway")
if ok:
    # Live gateway per PID file — treat NSSM PAUSED as benign
    ...
else:
    # Confirmed dead — force recovery
    ...

The helper checks (in order):

  1. gateway.lock / gateway.pid for a live python with hermes_cli/main + gateway in argv (source of truth)
  2. nssm status <service> as fallback (may report PAUSED false-positive)

Returns (is_running: bool, detail: str) tuple.

Why a separate PR from #1

PR #1 (memory_tool: auto-consolidate on overflow) is feature work. This PR is a refactor with 20 tests. Keeping them separate makes review easier.

Tests

20 cases in tests/hermes_cli/test_nssm_truth.py cover:

  • PID alive/dead detection (Windows: OpenProcess + GetExitCodeProcess, exit code 259 = STILL_ACTIVE)
  • Lock file parsing (gateway.lock preferred over gateway.pid)
  • Malformed JSON handling (returns None, no crash)
  • Missing file handling (returns False)
  • PID-file takes precedence over nssm state (NSSM PAUSED does NOT trigger if PID file has live gateway)
  • nssm not in PATH graceful fallback
  • Service name override
  • NSSM state transitions (RUNNING/PAUSED/STOPPED)

Net effect

  • ~5088 chars of duplicated inline logic eliminated
  • 7528 chars of single-source-of-truth helper added
  • 469 insertions, 0 deletions (refactor is additive)
  • 20 new tests

Risk

Low. Pure refactor with no behavior change beyond consolidation. The 3 call sites need to be updated separately (not in this PR) to use the new helper, which I'll do as a follow-up to keep this PR focused.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces several security, robustness, and platform compatibility enhancements, including user-defined command deny rules, an opt-in egress lockdown toggle (docker_network) for Docker environments, graceful character-budget truncation for file reads, and a unified service-running check (nssm_truth.py). The code review feedback highlights critical cross-platform compatibility issues in the new nssm_truth.py module—such as Windows-only imports and APIs being called on POSIX systems—along with a hardcoded personal directory path and potential crashes on malformed JSON. Additionally, the reviewer noted a type-handling issue with the new deny rules configuration and false-positive warnings in the file truncation logic when a line matches the maximum character limit exactly.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread hermes_cli/nssm_truth.py
import json
import os
import subprocess
from ctypes import wintypes

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

On non-Windows platforms (Linux/macOS), from ctypes import wintypes raises an ImportError because wintypes is only available on Windows. Since this module is imported or scanned during startup/testing, it will crash the application on non-Windows systems.

Please guard this import with a try/except block to ensure cross-platform compatibility.

Suggested change
from ctypes import wintypes
try:
from ctypes import wintypes
except ImportError:
wintypes = None

Comment thread hermes_cli/nssm_truth.py
Comment on lines +60 to +81
def is_pid_alive(pid: int) -> bool:
"""Windows: check if PID is still in the process table.

Uses OpenProcess + GetExitCodeProcess. Exit code 259 (STILL_ACTIVE)
means the process is alive.
"""
if pid <= 0:
return False
try:
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
except OSError:
return False
h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not h:
return False
try:
code = wintypes.DWORD()
if kernel32.GetExitCodeProcess(h, ctypes.byref(code)):
return code.value == STILL_ACTIVE
finally:
kernel32.CloseHandle(h)
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There are two issues here:

  1. POSIX Support: On non-Windows platforms, we should provide a fallback implementation using os.kill(pid, 0) so that the process check works cross-platform.
  2. ctypes Pointer Truncation: On 64-bit Windows, calling OpenProcess, GetExitCodeProcess, and CloseHandle without explicitly defining their argtypes and restype can lead to pointer truncation (since ctypes defaults to 32-bit integers for return values and arguments). This can cause crashes or invalid handle errors.

Let's define the argtypes and restype for these functions, and add a POSIX fallback.

def is_pid_alive(pid: int) -> bool:
    """Check if PID is still in the process table.

    On Windows, uses OpenProcess + GetExitCodeProcess.
    On POSIX, uses os.kill(pid, 0).
    """
    if pid <= 0:
        return False
    if os.name != "nt":
        try:
            os.kill(pid, 0)
            return True
        except OSError:
            return False

    if wintypes is None:
        return False

    try:
        kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
        kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
        kernel32.OpenProcess.restype = wintypes.HANDLE
        kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
        kernel32.GetExitCodeProcess.restype = wintypes.BOOL
        kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
        kernel32.CloseHandle.restype = wintypes.BOOL
    except OSError:
        return False

    h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
    if not h:
        return False
    try:
        code = wintypes.DWORD()
        if kernel32.GetExitCodeProcess(h, ctypes.byref(code)):
            return code.value == STILL_ACTIVE
    finally:
        kernel32.CloseHandle(h)
    return False

Comment thread hermes_cli/nssm_truth.py
Comment on lines +43 to +46
candidates = [
Path(r"C:\Users\bbask\AppData\Local\hermes"),
Path.home() / "AppData/Local/hermes",
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The path C:\Users\bbask\AppData\Local\hermes contains a hardcoded personal username (bbask). This will fail for any other user on Windows.

We should remove this hardcoded path and instead dynamically resolve the local AppData directory using the LOCALAPPDATA environment variable.

    local_appdata = os.environ.get("LOCALAPPDATA")
    candidates = []
    if local_appdata:
        candidates.append(Path(local_appdata) / "hermes")
    candidates.append(Path.home() / "AppData/Local/hermes")

Comment thread hermes_cli/nssm_truth.py
Comment on lines +90 to +93
try:
return json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the lock file contains a valid JSON value that is not an object (e.g., [] or "string"), json.loads will return a list or string. This will cause _lock_file_indicates_live_gateway to crash with an AttributeError when it tries to call .get("pid").

We should defensively verify that the parsed JSON is a dictionary before returning it.

Suggested change
try:
return json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
continue
try:
data = json.loads(p.read_text(encoding="utf-8"))
if isinstance(data, dict):
return data
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
continue

Comment thread tools/approval.py
Comment on lines +485 to +486
globs = [p.strip() for p in deny_patterns
if isinstance(p, str) and p.strip()]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If deny in config.yaml is misconfigured as a string instead of a list (e.g., deny: "git push"), deny_patterns will be a string. Iterating over a string yields individual characters, which means globs will contain single-character patterns like ['g', 'i', 't', ' ', 'p', 'u', 's', 'h']. This can lead to unexpected blocking of commands.

We should explicitly check if deny_patterns is a string and wrap it in a list, or ignore it if it's not an iterable collection.

    if isinstance(deny_patterns, str):
        deny_patterns = [deny_patterns]
    elif not isinstance(deny_patterns, (list, tuple, set)):
        return None
    globs = [p.strip() for p in deny_patterns
             if isinstance(p, str) and p.strip()]

Comment thread tools/file_tools.py
Comment on lines +1228 to +1248
trimmed, lines_kept, _ = _truncate_to_char_budget(
result_dict["content"], max_chars
)
next_offset = offset + lines_kept
shown_end = offset + lines_kept - 1
result_dict["content"] = trimmed
result_dict["truncated"] = True
result_dict["truncated_by"] = "bytes"
result_dict["next_offset"] = next_offset
result_dict["hint"] = (
f"Output truncated at the {max_chars:,}-char read budget "
f"after {lines_kept} line(s) (showing lines {offset}-"
f"{shown_end} of {total_lines}). Use offset={next_offset} "
"to continue."
)
if len(trimmed.split("\n", 1)[0]) >= max_chars:
result_dict["hint"] += (
" Note: the first line alone exceeded the budget and "
"was clamped mid-line; its remainder is not "
"retrievable via offset."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The check len(trimmed.split("\n", 1)[0]) >= max_chars will trigger the "clamped mid-line" warning even when the first line is exactly max_chars long and was fully read without being clamped.

To avoid this false warning, we should check if the first line of the original content was strictly greater than max_chars before we overwrite result_dict["content"].

                    first_line_clamped = len(result_dict["content"].split("\n", 1)[0]) > max_chars
                    trimmed, lines_kept, _ = _truncate_to_char_budget(
                        result_dict["content"], max_chars
                    )
                    next_offset = offset + lines_kept
                    shown_end = offset + lines_kept - 1
                    result_dict["content"] = trimmed
                    result_dict["truncated"] = True
                    result_dict["truncated_by"] = "bytes"
                    result_dict["next_offset"] = next_offset
                    result_dict["hint"] = (
                        f"Output truncated at the {max_chars:,}-char read budget "
                        f"after {lines_kept} line(s) (showing lines {offset}-"
                        f"{shown_end} of {total_lines}). Use offset={next_offset} "
                        "to continue."
                    )
                    if first_line_clamped:
                        result_dict["hint"] += (
                            " Note: the first line alone exceeded the budget and "
                            "was clamped mid-line; its remainder is not "
                            "retrievable via offset."
                        )

Comment thread tools/file_tools.py
Comment on lines +1359 to +1379
trimmed, lines_kept, _ = _truncate_to_char_budget(
result.content or "", max_chars
)
next_offset = offset + lines_kept
shown_end = offset + lines_kept - 1
result.content = trimmed
result_dict["content"] = trimmed
result_dict["truncated"] = True
result_dict["truncated_by"] = "bytes"
result_dict["next_offset"] = next_offset
result_dict["hint"] = (
f"Output truncated at the {max_chars:,}-char read budget after "
f"{lines_kept} line(s) (showing lines {offset}-{shown_end} of "
f"{total_lines}). Use offset={next_offset} to continue."
)
if len(trimmed.split("\n", 1)[0]) >= max_chars:
result_dict["hint"] += (
" Note: the first line alone exceeded the budget and was "
"clamped mid-line; its remainder is not retrievable via "
"offset."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the previous finding, checking len(trimmed.split("\n", 1)[0]) >= max_chars triggers a false "clamped mid-line" warning when the first line is exactly max_chars long and was not clamped.

We should check if the first line of the original content was strictly greater than max_chars before overwriting result.content.

            first_line_clamped = len((result.content or "").split("\n", 1)[0]) > max_chars
            trimmed, lines_kept, _ = _truncate_to_char_budget(
                result.content or "", max_chars
            )
            next_offset = offset + lines_kept
            shown_end = offset + lines_kept - 1
            result.content = trimmed
            result_dict["content"] = trimmed
            result_dict["truncated"] = True
            result_dict["truncated_by"] = "bytes"
            result_dict["next_offset"] = next_offset
            result_dict["hint"] = (
                f"Output truncated at the {max_chars:,}-char read budget after "
                f"{lines_kept} line(s) (showing lines {offset}-{shown_end} of "
                f"{total_lines}). Use offset={next_offset} to continue."
            )
            if first_line_clamped:
                result_dict["hint"] += (
                    " Note: the first line alone exceeded the budget and was "
                    "clamped mid-line; its remainder is not retrievable via "
                    "offset."
                )

@FriendlyCityJohnstown FriendlyCityJohnstown left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 hermes automated review

  • 40 files changed, 2178 additions, 73 deletions
  • ⚠️ tests not run (no test command detected)

Issues found

  • Large PR (>500 additions). Consider splitting into smaller PRs.

Review generated by hermes pr-reviewer. Trust but verify.

@FriendlyCityJohnstown FriendlyCityJohnstown left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 hermes automated review

  • 40 files changed, 2178 additions, 73 deletions
  • ⚠️ tests not run (no test command detected)

Issues found

  • Large PR (>500 additions). Consider splitting into smaller PRs.

Review generated by hermes pr-reviewer. Trust but verify.

@FriendlyCityJohnstown FriendlyCityJohnstown left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 hermes automated review

  • 40 files changed, 2178 additions, 73 deletions
  • ⚠️ tests not run (no test command detected)

Issues found

  • Large PR (>500 additions). Consider splitting into smaller PRs.

Review generated by hermes pr-reviewer. Trust but verify.

@FriendlyCityJohnstown FriendlyCityJohnstown left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 hermes automated review

  • 40 files changed, 2178 additions, 73 deletions
  • ⚠️ tests not run (no test command detected)

Issues found

  • Large PR (>500 additions). Consider splitting into smaller PRs.

Review generated by hermes pr-reviewer. Trust but verify.

@FriendlyCityJohnstown FriendlyCityJohnstown left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 hermes automated review

  • 40 files changed, 2178 additions, 73 deletions
  • ⚠️ tests not run (no test command detected)

Issues found

  • Large PR (>500 additions). Consider splitting into smaller PRs.

Review generated by hermes pr-reviewer. Trust but verify.

@FriendlyCityJohnstown FriendlyCityJohnstown left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 hermes automated review

  • 40 files changed, 2178 additions, 73 deletions
  • ⚠️ tests not run (no test command detected)

Issues found

  • Large PR (>500 additions). Consider splitting into smaller PRs.

Review generated by hermes pr-reviewer. Trust but verify.

@FriendlyCityJohnstown FriendlyCityJohnstown left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 hermes automated review

  • 40 files changed, 2178 additions, 73 deletions
  • ⚠️ tests not run (no test command detected)

Issues found

  • Large PR (>500 additions). Consider splitting into smaller PRs.

Review generated by hermes pr-reviewer. Trust but verify.

@bbasketballer75 bbasketballer75 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 hermes automated review

  • 40 files changed, 2178 additions, 73 deletions
  • ⚠️ tests not run (no test command detected)

Issues found

  • Large PR (>500 additions). Consider splitting into smaller PRs.

Review generated by hermes pr-reviewer. Trust but verify.

@bbasketballer75 bbasketballer75 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 hermes automated review

  • 40 files changed, 2178 additions, 73 deletions
  • ⚠️ tests not run (no test command detected)

Issues found

  • Large PR (>500 additions). Consider splitting into smaller PRs.

Review generated by hermes pr-reviewer. Trust but verify.

fanyangCS and others added 20 commits July 6, 2026 18:12
Infer the concrete auxiliary auth provider from the selected client base
URL so provider:auto routes can refresh Copilot/Codex/Anthropic/Nous
credentials after auth errors, instead of skipping refresh because
resolved_provider stayed 'auto'. Adds the copilot branch to
_refresh_provider_credentials and evicts the stale auto-route cache
before retrying.

Fixes NousResearch#20832. Salvaged from PR NousResearch#20837, reapplied surgically onto current
main (branch predated the _retry_same_provider_sync/async extraction).
…tead of aborting

A fallback candidate can itself carry a stale credential (e.g. an
expired ANTHROPIC_TOKEN picked up by _try_anthropic). Its 401 previously
propagated out of the fallback call site and aborted the auxiliary task
— for compression: a 60s cooldown + context marker while the session
kept growing past the context cap. Live case: mattalachia debug dump
(Jul 2026), Codex timeout → Anthropic 401 x5 → 296K 'Cannot compress
further'.

Now each fallback candidate call is wrapped: on auth error, refresh the
candidate's provider credentials and retry once; if unrefreshable, mark
the provider unhealthy and walk the discovery chain again so the next
viable candidate serves. Sync + async paths. Non-auth errors still
raise unchanged.
Z.AI Coding Plan GLM-5.2 reports server overload as HTTP 429 code 1305
("temporarily overloaded"). classify_api_error routes that to
FailoverReason.overloaded (so a valid credential pool isn't burned), but
the adaptive Z.AI backoff was gated on is_rate_limited — which excludes
overloaded — so it never ran (policy=default) and the request failed after
a few quick short retries.

Two compounding causes, both fixed here:

1. Detect the Z.AI overload 429 directly and let its adaptive backoff run
   on the overloaded path, not only the rate_limit path.
2. Raise the retry ceiling for this narrow case via
   zai_coding_overload_retry_ceiling(). The long-backoff tier
   (30/60/90/120s) starts after short_attempts (3) retries, but the default
   api_max_retries is also 3, so the loop always gave up before the long
   tier could run — leaving the whole long-backoff schedule as dead code.

Scope is limited to the existing narrow is_zai_coding_overload_error match,
so other providers' 429/503/529 handling is unchanged.
Assert the invariant that the Z.AI overload retry ceiling exceeds the
short-retry threshold (the original bug had them equal, so the long tier
was dead code), and walk the attempt range the retry loop actually
traverses to prove the full 30/60/90/120s long-backoff schedule now runs.
…ange-detector assert

Follow-up on the salvage of NousResearch#59523. Two low-risk cleanups surfaced by review:

- Extract _ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS as a module constant so
  adaptive_rate_limit_backoff() and zai_coding_overload_retry_ceiling()
  share one source of truth. Previously both hardcoded short_attempts=3
  independently; tuning one without the other would silently desync the
  retry ceiling from the backoff schedule.
- Replace the tautological formula-mirroring assert in
  test_zai_overload_retry_ceiling_exceeds_short_attempts with a behavior
  invariant (ceiling leaves headroom for every long-backoff entry), per the
  repo's contracts-over-snapshots testing rule.
…ton token on account_id read failure

Follow-up hardening on the cherry-picked pool-fallback fix. The original
_resolve_codex_usage_credentials wrapped BOTH resolve_codex_runtime_credentials()
and the separate _read_codex_tokens() account_id read in one broad
'except Exception: pass', which had three problems:

1. A transient refresh/network failure (non-AuthError) from the resolver was
   silently swallowed and downgraded to pool.select(), which could report
   /usage limits for a DIFFERENT pool account than the one actually running.
   On main that error surfaced. This is a real behavior regression for the
   multi-account/pool case.
2. If the resolver succeeded but only the account_id read raised, the whole
   singleton tier was abandoned in favor of a pool token that carries no
   ChatGPT-Account-Id header (PooledCredential has no account_id concept),
   risking a wrong-account read or 401.
3. 'except Exception' masked genuine programming errors.

Fix: narrow the outer catch to AuthError (the documented 'no creds' failure
mode of both functions), and read account_id in a best-effort inner try so a
partial/missing singleton store can't sink an otherwise-usable credential.
Transient errors now propagate and fail open via the outer fetch_account_usage
guard rather than mis-routing to the wrong account. Adds debug breadcrumbs and
a comment characterizing when the tier-3 pool path actually fires.

Guard tests: a non-AuthError resolver failure must NOT swap to the pool
(fail-open, no snapshot); an account_id read failure keeps the singleton token.
Updated the existing pool-fallback test to use AuthError (the real failure
mode) instead of a generic RuntimeError.
The salvaged commits from NousResearch#32824 use a bare
spiky02plateau@users.noreply.github.com (no numeric-id + prefix), which the
contributor-check.yml gate does not auto-resolve (it only skips the
<id>+<user>@users.noreply form). Add the explicit mapping so attribution CI
passes and release notes credit @spiky02plateau.
…3-zai-overload-backoff

fix(agent): run Z.AI Coding overload adaptive backoff on the overloaded path
NousResearch#59983)

The stage2-hook auth.json seed is first-boot-only ([ ! -f auth.json ]) to avoid
clobbering rotated refresh tokens on restart. That guard means a container whose
Nous bootstrap session took a terminal invalid_grant (tokens cleared,
providers.nous.last_auth_error.relogin_required stamped) cannot recover from a
restart — it stays unauthenticated until the credential is replaced.

Add a self-heal path: an orchestrator that manages the container supplies a
freshly-issued session via HERMES_AUTH_JSON_REBOOTSTRAP (distinct from the
create-only *_BOOTSTRAP var). On boot, scripts/docker_rebootstrap_nous_session.py
swaps ONLY the providers.nous entry, and ONLY when the on-disk entry is provably
terminal (quarantine marker + no usable tokens). Healthy/rotating/absent/
unparseable auth.json is always a no-op, so the env is safe to leave set across
restarts and never clobbers a good token. Pure stdlib, runs as its own
subprocess, always exits 0 so a re-seed error never fails the boot.

Reuses the same terminal predicate as get_nous_session_validity() so we re-seed
only a session that is genuinely dead.
…eath is visible

A NAS-hosted Fly agent's Nous bootstrap session can take a terminal
invalid_grant and get quarantined in _quarantine_nous_oauth_state, which
clears the dead tokens from auth.json. Until now this quarantine was
completely silent: the only signal was a downstream "No access token found"
WARNING once the credential pool was already empty, which is too late to
root-cause. Because the Fly log drain is WARNING-only, nothing about the
terminal death reached centralized logging, and a real incident could not be
diagnosed because the evidence was never recorded.

Emit a WARNING+ forensic record AT the quarantine point, before the token
material is cleared. Fields: refresh_token hash prefix (12-char SHA-256 hex,
correlates to NAS's refreshTokenHash), client_id, agent_key_id, error code,
reason, auth.json path/size/mtime/exists, and whether the token was already
past its own expiry. WARNING level is deliberate — INFO never reaches the Fly
drain.

Redaction safety (load-bearing): the log dict is built only from computed
values (hash prefix, sizes, booleans). No raw refresh_token, access_token, or
agent_key bytes are ever passed into the log call, avoiding Hermes's known
credential-literal corruption bug class. A test asserts the raw refresh token
substring is absent from all emitted log output.

Note: no session_id field exists on Nous auth state; provenance is captured
via client_id + agent_key_id, which are non-secret routing identifiers.
The test_module_resolves_to_this_worktree guard asserted auth.__file__ contained
'worktrees/bootstrap-h2-logging' — a local dev crutch to defeat the editable-
install trap (venv points at the main checkout). In CI the code lives at
/home/runner/work/... so the assertion always fails. It never belonged in the
committed suite; the 5 behavioural tests are what matter.
…nt self-heal

A hosted agent whose Nous bootstrap session dies terminally (invalid_grant /
quarantine) looks HEALTHY to every liveness/connectivity probe — the machine,
relay ws, and dashboard all stay up — yet every inference turn hard-fails with
a provider-auth error until a human re-logs-in. Nothing currently surfaces that
condition to NAS.

Add get_nous_session_validity() (valid|terminal|unknown), classified from local
auth-store state (no working token required), and report it on the public
/api/status payload. NAS's 2-min health sweep reads it and re-mints the
bootstrap session in place on 'terminal'.

Anti-flap: only a terminal failure (relogin_required / persisted quarantine
marker with tokens cleared) maps to 'terminal'; transient/mid-rotation blips and
merely-expiring tokens report 'unknown' so a healthy box never triggers a
spurious re-mint.

Part of the hosted-agent bootstrap-session self-heal (NAS side reads this field).
…Research#60079)

When skill_view loads a supporting file (references/, scripts/,
templates/) instead of the main SKILL.md, the CLI quiet-mode line and
the friendly tool labels now show 'name → file_path' so it's clear
which file was actually read.
…#59321)

Co-authored-by: markoub <2418548+markoub@users.noreply.github.com>
dexhunter and others added 16 commits July 9, 2026 02:41
Fixes NousResearch#3356

Build the skills snapshot manifest in one directory walk, avoid importing gateway session context during CLI prompt startup, and reuse direct platform-list matching for snapshot entries.

(cherry picked from commit 1a64c2e)
Deep review of the cherry-picked NousResearch#16454 found the ad-hoc flush thread
raced new_session()'s inline on_session_switch(reset=True): memory
providers key off internal _session_id state (MemoryManager.on_session_end
takes no session id), so a late off-thread extraction ran against
post-rotation bindings — misattributing the old transcript to the new
session id, double-ingesting the old turn buffer (supermemory), or
double-committing (openviking already async-finalizes in
on_session_switch).

Redesign: new MemoryManager.commit_session_boundary_async queues
on_session_end + on_session_switch as ONE task on the manager's existing
single-worker background executor (the same worker sync_all already
uses). This preserves the strict end→switch ordering providers depend on,
serializes against per-turn syncs FIFO, keeps /new non-blocking, and
degrades to inline (pre-NousResearch#16454 behavior) when the executor is
unavailable. No ad-hoc threads; no per-provider changes needed.

The context-engine on_session_end half stays synchronous in
_launch_session_boundary_memory_flush (cheap, must land before
reset_session_state rebinds the engine).

Exit durability: _run_cleanup calls the manager's existing
flush_pending(timeout=10) barrier before shutdown, so '/new then quit'
doesn't drop the queued extraction (shutdown_all's own drain is ~5s and
cancels queued tasks). Bounded well inside the 30s exit watchdog.

Tests: ordering invariant with slow (LLM-like) extraction, FIFO
serialization vs sync_all, switch-fires-even-if-end-raises, no-provider
no-op, CLI snapshot handoff + inline-switch fallback, sync engine
boundary, cleanup flush_pending.
- Return the boundary snapshot from
  _launch_session_boundary_memory_flush as a local value instead of
  staging it on self._session_boundary_snapshot. The instance-attr
  handoff could leak (no memory manager configured) or mis-fire a
  stale snapshot on a later /new if an exception hit between staging
  and consumption. A local variable eliminates the class; the helper
  also returns None when no memory manager is configured so
  new_session takes the inline-switch path.
- Drop the now-dead session_id kwarg from commit_memory_session:
  after the redesign no production caller passes it (gateway, TUI,
  compression all use the default), and speculative params are
  rejected per AGENTS.md. The explicit-old-session need is served by
  cli.py's direct engine call + commit_session_boundary_async.
- Drop the dead providers snapshot in commit_session_boundary_async
  (only the emptiness check used it).
- Tests updated accordingly (dead-kwarg test removed, snapshot
  assertion now covered by return-value contract).

Phase-2 gates: 2a tests/cli 1048 passed + 6 memory files 137 passed;
2b programmatic live smoke 0.38ms non-blocking caller, end→switch→sync
ordering verified; 2c structured 4-angle review — no Criticals, these
warnings fixed.
The model often emits a follow-up batch of tool calls as its own
assistant message with no prose or reasoning. On screen those rows look
like one continuous run, but assistant-ui only groups tool calls within a
single message, so the auto-scrolling tool window never triggered on them
(e.g. two batches of two searches read as 2 + 2, never reaching the
threshold).

Coalesce each settled tool-only assistant message into the preceding
assistant message in the render pipeline so its calls join that message's
tool group. Render-only (never touches the $messages store) and
settle-only (pending messages are skipped) so a live turn is never
merged/un-merged mid-stream; merged results are cached by source identity
so a stable turn yields stable objects with no re-render churn.
…ol-window-merge

feat(desktop): group tool calls across text-less assistant messages
we should run this as part of merges at some point :)
…in favor of grok-4.5 (NousResearch#61097)

grok-4.5 is GA and is now the single curated Grok entry on the
aggregator lists. grok-4.3 is NOT retired upstream — it remains fully
usable by typing the model name (validated against the live catalogs);
this only removes it from the short curated picker snapshots. The
xAI-direct list is models.dev-cache-driven and unaffected.
Electron 40 ships Node 24.15, where tsx's ESM load hook returns null and
crashes with ERR_INVALID_RETURN_PROPERTY_VALUE. Bundle main+preload via
esbuild for `npm run dev` and always load the JS preload from dist/.
…p-tsx-electron40

fix(desktop): stop using tsx to boot Electron main in dev
Groq wasn't in PROVIDER_REGISTRY despite having a working credential in
credential_pool, causing 'Unknown provider' errors when referenced
directly (e.g. from MoA presets). Registers it with the standard
api_key auth pattern matching its OpenAI-compatible endpoint.
…rocess/secrets modules, script test coverage

Snapshot commit to preserve substantial uncommitted work found before
running hermes update (2026-07-08), so nothing is at risk of being
overwritten or discarded by the update process. Includes:
- hermes_cli/web_server.py: allow hermes.theporadas.com as a trusted
  tunnel host so the Cloudflare Tunnel dashboard-access setup works
- hermes_cli/safe_subprocess.py (new): Windows PATH-resolution wrapper
  fixing subprocess.run's stale-PATH and .CMD/.PS1 resolution issues
- hermes_cli/secrets.py (new): new secrets-handling module
- scripts/install.ps1, install.sh: install script updates
- Test coverage added for most local scripts/*.py utilities

Needs a proper review/split into logical commits before any upstream
PR — this is a safety-net commit, not a finished changeset.
The NSSM false-positive pattern (service reports PAUSED while the actual
python process is alive and serving traffic) has caused 3+ scripts to
unnecessarily attempt destructive Stop+Start cycles that fail because the
port is still bound. Today (2026-07-05) three scripts copy-pasted the same
PID-file + alive-check logic to handle this:
  - scripts/nssm-self-heal.py
  - scripts/fix-gateway-paused-state.py
  - scripts/audit-tools.py

This consolidates the logic into one shared helper module:

  from hermes_cli.nssm_truth import is_hermes_service_actually_running
  ok, detail = is_hermes_service_actually_running("HermesGateway")

The helper checks (in order):
1. gateway.lock / gateway.pid for a live python with hermes_cli/main + gateway
   in argv (source of truth)
2. nssm status as fallback

Returns (is_running, detail) tuple.

20 tests in tests/hermes_cli/test_nssm_truth.py cover:
- PID alive/dead detection (Windows: OpenProcess + GetExitCodeProcess)
- Lock file parsing (gateway.lock preferred over gateway.pid)
- Malformed JSON handling
- Missing file handling
- PID-file takes precedence over nssm state
- nssm-not-in-path graceful fallback
- Service name override
- NSSM state transitions (RUNNING/PAUSED/STOPPED)

Net effect: -5088 chars of duplicated inline logic eliminated, 7528 chars
of single-source-of-truth helper added.
Copilot AI review requested due to automatic review settings July 9, 2026 11:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

bbasketballer75 pushed a commit that referenced this pull request Jul 17, 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.
bbasketballer75 pushed a commit that referenced this pull request Jul 18, 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.
@bbasketballer75
bbasketballer75 deleted the pr-nssm-truth-helper branch July 23, 2026 03:01
bbasketballer75 pushed a commit that referenced this pull request Jul 23, 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.
bbasketballer75 pushed a commit that referenced this pull request Jul 23, 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.

* …
bbasketballer75 pushed a commit that referenced this pull request Jul 23, 2026
…arch#65919)

* fix(desktop): preserve interim assistant text wiped at message.complete

When the agent emits interim text (commentary alongside tool calls, or the
attempted final answer before a verify-on-stop nudge), all UI surfaces
streamed it live but then wiped it at message.complete — keeping only the
final response. The user saw text appear during inference, then disappear.

This is the complete fix across all three layers: agent core, gateway
transport, and all UI surfaces (desktop + Ink TUI).

The verify-on-stop and pre_verify paths flagged the assistant's attempted
final answer as _verification_stop_synthetic, suppressing it from both
state.db and the UI. The user only saw the terse post-verification reply.

Now the assistant response is real content: it's persisted to state.db and
emitted as an interim message via _emit_interim_assistant_message(force_display=True)
before the verification loop runs. Only the synthetic nudge messages keep
the synthetic flags. The turn finalizer drops nudges from live history and
compares content (not just role) to avoid duplicating a published candidate.
Message sequence repair collapses verification candidates in the
consecutive-assistant merge.

Wire agent.interim_assistant_callback both at construction (_agent_cbs())
and per-turn (defense-in-depth), emitting a new message.interim event with
{text, already_streamed}. Gated on display.interim_assistant_messages
(default true). Cleared in the finally block so a stale closure can't
fire on a later turn.

Add message.interim to the GatewayEventName union (apps/shared) and a
typed payload to the TUI's GatewayEvent discriminated union.

The TUI already had the segment-anchoring machinery (flushStreamingSegment +
finalTail) but had no handler for message.interim. Added recordInterimMessage
+ interimBoundaryIndex to seal segments mid-turn, and updated
recordMessageComplete to only dedupe segments after the interim boundary.

Replaced the fragile sealed-set approach with a proper interimBoundaryPending
state flag on ClientSessionState. finalizeInterimAssistantMessage finalizes
the streaming bubble in place (or creates a standalone one), rotates the
stream ID so next deltas create a new bubble, and sets the flag. When the
final text equals an already-sealed interim, they stay as distinct messages.

Extracted mergeFinalAssistantText() as a pure function in chat-messages.ts,
used by both completeAssistantMessage and finalizeInterimAssistantMessage.
Split the bidirectional dedup predicate: reasoning is a restatement only when
the final FULLY covers it. A short final ("Done.") no longer swallows a
longer reasoning block that merely starts with it.

Honor display.interim_assistant_messages (default true) across all layers:
the tui_gateway gates the callback, the desktop wires it to a nanostores
atom via use-hermes-config. Updated hermes_cli/config.py and
cli-config.yaml.example comments to document the Desktop behavior.

_split_segment_tokens now accepts posix=False and _find_ad_hoc_match tries
both posix modes so ad-hoc verification scripts with Windows backslash
paths are matched correctly. (response_previewed forwarding from NousResearch#53553
is not included — our emit-interim + persist approach makes it unnecessary
since the attempted answer is now surfaced before the verification loop.)

- tsc: clean (desktop + TUI + shared)
- vitest desktop: 73/73 pass (7 interim-sealing + 5 mergeFinalAssistantText + 4 config atom)
- vitest TUI: 83/83 pass (4 new message.interim tests)
- python: 390 tests pass (340 tui_gateway + 33 verification/finalizer + 6 config gating + 3 evidence + 8 continuation budget)

Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>

* fix: prefix-match interim streamed content to avoid benign duplicate bubbles

_interim_content_was_streamed used exact equality (streamed == visible_content),
so a final response that was the streamed text plus a trailing delta — or a
partial stream before the verify nudge fired — failed the match and left
_response_was_previewed false. The turn then showed two bubbles (interim +
identical final) instead of settling the interim in place.

Relax to a prefix check (visible_content.startswith(streamed)) in both the
core match and the desktop's settle-in-place gate. The TUI already used
prefix matching via finalTail. The reverse direction (streamed longer than
final) is intentionally not matched — that could suppress a needed resend
in the gateway path where already_streamed=True calls on_segment_break().

* test(desktop): add partial-stream-then-nudge dedup edge case

Third edge case for the interim-sealing dedup: model streams part of its
answer via message.delta, verify nudge fires, interim seals the streamed
prefix, then the final response is the same text plus a trailing delta.
Asserts one bubble (not two) containing the full final text.

Acceptance protocol #2 — covers all three dedup edges:
  1. interim == final (existing)
  2. interim = strict prefix of final (existing)
  3. partial-stream-then-nudge (this commit)

---------

Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
bbasketballer75 pushed a commit that referenced this pull request Jul 23, 2026
Blocking #1 — gateway-connecting-overlay.tsx reduced-motion regression:
the top `if (reduce) setPhase('gone')` fired unconditionally on mount
whenever reduce-motion was on, so every OS reduced-motion user lost the
CONNECTING overlay during cold boot entirely (jumped to 'gone' before the
gateway was even open). The intent was to skip the exit *choreography*,
not to skip showing the overlay. Removed the unconditional top block and
the redundant nested preview block; kept only the third branch
(`gatewayState === 'open' && shownRef.current` → `reduce ? 'gone' :
'text-out'`) which correctly gates the short-circuit on connect. Also
fixed `if(reduce)` missing-space, 6-space misindent, and the same 3-line
comment pasted three times.

Nit #1 — tsconfig excludes e2e, so specs were never typechecked in CI.
Added tsconfig.e2e.json (extends base, includes e2e/ + playwright.config.ts,
adds @playwright/test types) and wired it into the typecheck script. This
surfaced three latent type errors that are fixed in the same commit:
  - fix-electron-tracing.ts: `app._context` and `electron._playwright` are
    private APIs — added `as any` on the access before the existing cast.
  - playwright.config.ts: `reducedMotion: 'reduce'` directly under `use:`
    is not a valid UseOptions property in playwright 1.58; it's a
    BrowserContextOption accessed via `contextOptions: { reducedMotion:
    'reduce' }`. The old form was silently ignored at runtime, so
    reduced-motion emulation wasn't actually active — screenshots could
    catch overlays mid-fade (exactly what the comment warned about).

Nit #2 — fix-electron-tracing.ts reaches into Playwright internals
(_playwright, _allContexts, _context) with no public contract. Added a
header comment calling out the `@playwright/test` exact pin (=1.58.2) so a
future bump knows to re-verify the private symbols still exist.

Nit #3 — main.ts TEST_WORKER_INDEX block had stray 6-space indentation.

Verified: tsc -p . && tsconfig.electron && tsconfig.e2e → 0 errors;
vitest boot-failure-overlay (3/3) + boot-failure-reauth (21/21) pass;
npm run build clean; playwright e2e/boot-failure.spec.ts 2/2 pass.
bbasketballer75 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>
bbasketballer75 pushed a commit that referenced this pull request Aug 15, 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.