sync: defer 75 commits (gateway contract + subagent stream port + 2 defer-flagged surfaces) - #2
Closed
alt-glitch wants to merge 76 commits into
Closed
sync: defer 75 commits (gateway contract + subagent stream port + 2 defer-flagged surfaces)#2alt-glitch wants to merge 76 commits into
alt-glitch wants to merge 76 commits into
Conversation
Relocate the model pill to the composer, left of the mic. A new ModelPill reuses the live ModelMenuPanel dropdown verbatim (single click target) and the formatModelStatusLabel "Model · Fast Med" label, anchored to its right edge so the menu doesn't drift with model-name length. modelMenuContent now flows to ChatView instead of useStatusbarItems, and the status-bar model-summary item is removed; the pill subscribes to the model atoms directly and falls back to the full picker when the gateway is closed.
Provider catalogs surface date-pinned snapshots (`…-20251101`) that the
picker rendered as standalone rows with the date baked into the name
("Opus 4 5 20251101"). Strip the trailing date from display names, and
fold a snapshot out of the list when its rolling alias is present so the
alias stays selectable/searchable while the exact dated id isn't shown
as its own row.
Each model remembers its own reasoning effort / fast mode (localStorage,
like model-visibility): editing a model's effort/fast in the submenu
writes its preset, and selecting a model restores its preset onto the
session (capability-gated, Hermes defaults when unset). Every row shows
its own remembered settings (grayed), and the row label and edit submenu
read the same effective value so they can't disagree.
Presets are desktop-client state only — applyModelPreset() no-ops without
a live session id, so selecting a model can't fall through to the
gateway's persistent agent.reasoning_effort / agent.service_tier writes.
Inactive variant `-fast` edits stay preset-only: toggleFast() records
{ fast } on the base model and only swaps models when the row is active,
and selectFamily() honors a saved variant-fast preset by selecting the
`-fast` sibling id.
External providers (Claude Code) store creds outside Hermes, so the disconnect API refuses them. The backend now hands the GUI a per-OS `disconnect_command` that clears the credential the same way the CLI's logout does (macOS Keychain entry + ~/.claude/.credentials.json), and the misleading "use claude setup-token" hint is corrected. Settings → Providers offers a Disconnect button for these: it confirms, leaves Settings, and runs the removal command in the embedded terminal via a new runInTerminal() (queues onto $terminalInjection; the terminal pane flushes and clears it once its session is live). The expanded list also gets its own "Other providers" header so it no longer reads as grouped under "Connected". API-managed providers keep the one-click (trash) disconnect.
…neric provider path When a provider's live /v1/models endpoint returns a stale or incomplete list (e.g. Z.AI missing glm-5.2), the generic profile-based code path returned only the live results, silently dropping curated models. Generalize the kimi-coding merge pattern to all providers: live entries come first (provider's preferred order), then curated-only entries are appended with case-insensitive dedup. This ensures models that the live endpoint omits still appear in /model picker. Fixes NousResearch#46850
…hen live API omits model When live /v1/models responds but omits a model that exists in the curated static catalog, validate_requested_model now accepts it with a note instead of rejecting. This covers the /model slash-command path (the picker path was already fixed in the parent commit). Addresses review feedback from potatogim on NousResearch#46857.
The picker no longer touches the profile default. Model/effort/fast live as plain UI state persisted in localStorage, so a pick follows across Cmd+N and restarts instead of snapping back. New chats ship that state through session.create as per-session overrides; live chats still scope switches to the current session. Settings -> Model remains the only surface that writes the profile default. The gateway now accepts those session.create overrides, builds the agent with them directly, reflects them in the immediate session.info payload, and writes the chat's own model_config into the lazy DB row so reconnect/resume restores that chat instead of the global default.
A live config.set model switch already moved the next API call to the new model, but the conversation could still restore an old sessions.system_prompt snapshot whose Model/Provider lines named the previous runtime. That made "what model are you?" answer from stale metadata even while inference ran on the new model. After a live switch we now refresh the stored system prompt and append a real system-history pivot (not a fake user turn) so the transcript itself records the new model/provider. Restore also rejects already-stale prompt snapshots when their Model/Provider lines disagree with the runtime, so existing bad sessions self-heal.
Clicking a model row in the composer dropdown now commits and closes the menu (via a close context); the hover-revealed reasoning/fast submenu stays open to tweak. The pill shows a quiet braille loader instead of literal "No model" until one resolves, and steer takes over the mic slot while typing into a running agent.
…odel-selector feat(desktop): composer model selector, per-model presets & external-provider disconnect
Rewrites the Shop personal-shopping-assistant skill to use the @shopify/shop-cli (with a full direct-API fallback in references/), replacing the previous curl-only shop-app skill. - Rename optional-skills/productivity/shop-app -> shop - Add references/: catalog-mcp.md, direct-api.md, safety.md, legal.md - Catalog discovery via Shopify Global Catalog MCP (search / lookup / get-product), device-authorization sign-in, UCP agent checkout with delegated spending budget, and order tracking / returns / reorder - One-product-per-message presentation rules + per-channel overrides - Expanded security, safety, and legal guidance Website docs are auto-generated from SKILL.md by CI (website/scripts/generate-skill-docs.py), so no docs are hand-edited here.
…viking Generalizes NousResearch#32663 (@ehz0ah). The slash-skill scaffolding pollution affected every auto-syncing memory provider — mem0, hindsight, retaindb, byterover, honcho, supermemory all store/embed the raw user turn, so a /skill invocation poisoned their stores with the full skill body, not just openviking. - Lift the contributor's parser into agent/skill_commands.py as the canonical extract_user_instruction_from_skill_message(), co-located with the message builders so the markers can't drift. - Strip once in MemoryManager.{prefetch_all,queue_prefetch_all,sync_all} — fixes the whole provider fan-out, bare /skill turns are skipped entirely. - OpenViking's _derive_openviking_user_text() now delegates to the shared helper as defense-in-depth (no duplicated marker literals). - Marker-drift regression now asserts against the canonical skill_commands constants; add manager-level coverage proving every provider gets clean text.
…ure-catalog helper in validation The generic live+curated merge (commit 630b438) seeded the merged list from live results, demoting curated-only models below live ones. That regressed NousResearch#46309, which deliberately surfaces the newest curated model (kimi-k2.7-code) FIRST in the native picker even when the live /models listing lags. Restore curated-first ordering: curated entries lead (in catalog order), live-only entries are appended for discovery. This keeps the NousResearch#46850 fix (zai glm-5.2 now appears) without the kimi regression. Also switch the validate_requested_model curated fallback (commit ee7b8a4) from provider_model_ids() — which triggers a second, uncached live /models fetch with its own 8s timeout and may resolve different credentials than the api_key/base_url just probed — to the pure-catalog helper _model_in_provider_catalog(). Membership is checked against the shipped catalog only, with no extra network call. Tests: restore the curated-first assertion in test_kimi_coding_live_catalog_does_not_hide_curated_k2_7_code; update the new merge tests to curated-first semantics; de-circularize the validation fallback tests to patch _PROVIDER_MODELS (the real source) instead of mocking the function under test.
…r-merge-live-static fix(models): merge live API results with curated static catalog in generic provider path
Z.ai released GLM 5.2 on 2026-06-15, available on OpenRouter: - https://openrouter.ai/z-ai/glm-5.2 GLM-5.2 is Z.ai's flagship for long-horizon tasks, shipping a 1M-token context window (up from 200K on GLM 5.1) and tool calling. Per the OpenRouter API: text-only, context_length 1048576, tools supported. No separate -fast variant exists. The 1M context length, native zai picker entry, setup wizard, and Z.ai coding-plan auth entries for glm-5.2 already landed on main. This fills the remaining gap: the two aggregator surfaces where glm-5.1 appears but glm-5.2 did not. Changes: hermes_cli/models.py - Add z-ai/glm-5.2 to the OpenRouter fallback snapshot (OPENROUTER_MODELS) and the Nous Portal curated list (_PROVIDER_MODELS["nous"]), newest flagship first. Live catalogs surface it automatically when reachable; the fallback lists matter when the manifest fetch fails. website/static/api/model-catalog.json - Regenerated via scripts/build_model_catalog.py (not hand-edited) so the manifest stays in sync with the source lists; guarded by tests/hermes_cli/test_model_catalog.py.
PROBLEM: Automatic context files such as SOUL.md and AGENTS.md were capped by a hardcoded CONTEXT_FILE_MAX_CHARS value. Amy's local fork had raised that constant from 20K to 25K so a larger SOUL.md would not be silently truncated, but the hardcoded 25K value changed upstream default behavior and made the patch less generally useful. SOLUTION: Restore the upstream-compatible 20K default, add a context_file_max_chars config setting for users who intentionally keep larger identity/project-context files, keep chat-visible truncation warnings, and document the new setting. Tests cover the default, config override, explicit max_chars precedence, and the warning text.
Follow-up to salvaged PR NousResearch#41619: replace the module-global _truncation_warnings list with a contextvars.ContextVar so concurrent gateway-session prompt builds can't drain or clear each other's pending warnings (cross-session leak). Adds a context-isolation test.
…rch#47060) * feat(desktop): stream subagent replies into watch windows A desktop watch window resumes a child session lazily (no full agent) and mirrors the parent-relayed `subagent.*` events into native child-session stream events. The child's streamed reply text was never relayed, so the window sat blank while the subagent "talked". - delegate_tool: forward the child's `run_conversation` stream tokens up the progress relay as `subagent.text` (inert under CLI/TUI — their progress handlers ignore non-tool event types; only a gateway watch window mirrors it). - server: mirror `subagent.text` -> `message.delta` on the child sid only, and skip the parent emit (per-token frames are meaningless on the parent session, which shows the child via the spawn tree). Demote `subagent.start` to a one-time goal header and drop the noisy `subagent.progress` mirror — tools already mirror natively. - server: guard `_start_agent_build` so a lazy watch session spectating an in-flight child stays lazy; incidental RPCs were upgrading it to a full agent mid-stream and silently killing the mirror. * fix(desktop): keep watch-window chat clear of titlebar chrome Secondary windows (new-session scratch, subagent watch, cmd-click pop-out) hide the titlebar tool cluster + session header, so the transcript ran to the window's top edge and streamed text slid up under the OS traffic lights. - Gate the hidden chrome on `isSecondaryWindow()` everywhere (app-shell, chat header, thread list) instead of the narrower new-session flag. - Add a fixed opaque drag-strip at the top of the secondary-window transcript: content padding alone scrolls away with the text, so the strip masks anything behind it and keeps the window draggable like the main header. * fix: WSL subagent window * fix: subagent window top padding --------- Co-authored-by: Austin Pickett <pickett.austin@gmail.com> Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
…-5.2 feat: add z-ai/glm-5.2 to OpenRouter and Nous model lists
Follow-up to salvaged PR NousResearch#41624: - Remove stray urllib.parse import in run_agent.py (cherry-pick cruft, unused) - Add tests: session:compress emits with correct context, no-callback is safe, and a callback exception does not break compression
Switch the default model for the xAI/Grok provider and the xAI web search backend from grok-4.3 to grok-build-0.1. grok-build-0.1 is already recognized by the model metadata, so no new model definition is required; grok-4.3 remains selectable.
Reflect the default-model change in the xAI Grok OAuth guide, the web search docs (EN + zh-Hans), and the web provider docstring. grok-4.3 is kept in the model tables as the previous default; the Nous/OpenRouter aggregator catalog still lists grok-4.3 and is left unchanged.
The NousResearch#45954 model-dedup builds `user_models` from every is_user_defined row, then strips those model IDs from every row where is_aggregator(slug) is True. But is_aggregator() returns True for *every* `custom:*` slug, and list_authenticated_providers emits named custom providers with slug `custom:<name>` and is_user_defined=True. So a user's own custom provider is treated as an aggregator and filtered against user_models — which holds exactly its own models (the row helped build that set). Every model is removed, the row drops to zero, and the provider disappears from the model picker. Guard the dedup loop to skip is_user_defined rows: a user's configured provider is never an aggregator duplicate of itself. Built-in aggregators (openrouter, etc.) are still deduped as before. Adds a regression test.
Salvage follow-up for PR NousResearch#46921 — CI matches contributor authorship on the commit email, which is the GitHub noreply form.
The model is callable via xAI OAuth but omitted from models.dev and /v1/models listings. Merge it into the curated xAI catalog so it appears in `hermes model` without requiring a custom model name.
…-deprecation-graceful fix(cli): deprecated `hermes login` fails gracefully for any provider
# Conflicts: # tests/test_tui_gateway_server.py
Owner
Author
|
Superseded by sid/opentui-sync-resolve (693028e): same full solution, rebased onto current upstream tip. This auto-defer correctly resolved the test conflict but left the two OpenTUI ports unported — the resolve branch has them, gate-green. |
alt-glitch
added a commit
that referenced
this pull request
Jun 19, 2026
…the engine (#6) * fix(dashboard): clean up upload temp file on client disconnect + pin python-multipart (NS-501) Follow-up to #47663 (streaming multipart upload), fixing two issues that landed with it. 1. Temp file leaked on client disconnect. The streaming upload endpoint's except chain caught only HTTPException / PermissionError / OSError — all Exception subclasses. asyncio.CancelledError, raised when a browser aborts a large upload mid-stream (the exact NS-501 scenario), is a BaseException, so it bypassed every except clause and reached a finally that only closed the file handle and never unlinked the temp file. Every aborted large upload orphaned a partial `.{name}.*.upload` file (up to ~100 MB) in the target directory. Cleanup now lives in finally, keyed on a `renamed` success flag, so the temp file is removed on every non-success exit including BaseException paths. Added test_stream_upload_cleans_temp_on_cancellation, which fails on the pre-fix code (leaks the temp file) and passes with the fix. 2. python-multipart pinned to ==0.0.27 instead of ==0.0.20. The package was already resolved at 0.0.27 transitively (via daytona) before #47663; the explicit ==0.0.20 pin in the [web] extra and the tool.dashboard lazy-install set downgraded it. Bumped both to ==0.0.27 and regenerated with `uv lock`, keeping the lockfile coherent. The base dependency stays >=0.0.9,<1. * fix(openviking): adapt memory provider for current api (cherry picked from commit cbb87389f33583518975fbf72671de3fd224bb28) * fix(install): resolve PowerShell host instead of bare `powershell` for uv The Windows installer's Install-Uv spawned the astral uv installer with a hardcoded bare `powershell -ExecutionPolicy ByPass -c "irm .../uv | iex"`. That name resolves only to Windows PowerShell, and only when its System32 directory is on PATH. Run under PowerShell 7+ (`pwsh`) — or any session where `powershell` isn't on PATH — the spawn dies with "The term 'powershell' is not recognized", and uv installation aborts (the installer then appears stuck). Add Get-PowerShellHostExe, which prefers the absolute path of the host we're already running in (PATH-independent), then falls back to powershell/pwsh via Get-Command, then to the bare name. Install-Uv now invokes that resolved exe. * test(install): lock uv installer to a resolved PowerShell host Source-level guard (install.ps1 only runs on Windows, so there's no Linux CI runner to execute it): the astral uv install line must be invoked via the call operator on a resolved host variable, the bare-`powershell` literal that produced the field-reported "The term 'powershell' is not recognized" must be gone, and the resolver must be PATH-independent (Get-Process -Id $PID) and pwsh-aware. * fix(install): relax native stderr handling in install.ps1 (#48352) * fix(#37878): scrub operator environment before launching cua-driver MCP - Use _sanitize_subprocess_env() to filter Hermes-managed credentials from the cua-driver subprocess environment (issue #37878) - Prevents credential exfiltration to the third-party cua-driver binary - Aligns with existing pattern used by browser-tool and other tools - Add regression test to verify environment sanitization The cua-driver is a lower-trust MCP subprocess per SECURITY.md §2.3. Its inherited environment is now scrubbed by default, removing provider API keys, gateway tokens, and platform credentials that should not leak to third-party binaries. Fixes #37878 * fix(#37878): Address review feedback — fix trailing whitespace and add ANTHROPIC_API_KEY test Review feedback from egilewski: 1. Remove trailing whitespace from test docstring and mock patches (lines 1430, 1469, 1476, 1482) 2. Expand test coverage: also verify ANTHROPIC_API_KEY is stripped (not just OPENAI_API_KEY) Changes: - Remove trailing whitespace from test file - Add ANTHROPIC_API_KEY to test environment - Add assertion verifying ANTHROPIC_API_KEY is stripped from cua-driver subprocess env - Syntax verified: python3 -m py_compile tests/tools/test_computer_use.py ✓ * chore(release): map iamlukethedev to AUTHOR_MAP * fix(skills): rmtree scope guard + default pre_update_backup to true (#48200) Defense-in-depth fix for the silent wipe of ~/.hermes/ documented in #48200. A `hermes update --yes` run silently destroyed a user's .env, MEMORY.md, kanban.db, custom skills, and scripts. Two changes: 1. `_rmtree_writable` in tools/skills_sync.py now refuses to rmtree anything outside SKILLS_DIR (the HERMES_HOME/skills/ root). All five call sites pass paths under SKILLS_DIR, so the guard is a no-op for current code and a loud, recoverable failure for any future regression (bad path join, malicious bundled manifest, stale path in scope after an exception). 2. The default `updates.pre_update_backup` flips from false to true in hermes_cli/config.py. A few minutes of zip per update is negligible compared to silent total data loss. Still overridable; --no-backup still works for one-off opt-out. Five new tests in TestRmtreeWritableScopeGuard (root path, hermes home, sibling dir, skills root itself, subdir) plus a flipped `test_default_enabled_creates_backup` in test_backup.py. 178/178 tests pass in the two affected files. Public method signatures unchanged, no test-stub blast radius. Closes #48200 * fix(skills): refuse SKILLS_DIR root in rmtree guard, not just outside-tree The salvaged guard allowed _rmtree_writable(SKILLS_DIR) itself. No call site ever passes the root — every site passes a skill subdir or its .bak sibling — so allowing the root only preserves the #48200 footgun (a dest that collapses to the root wipes every installed skill). Require a strict strict-child relationship and update the test that documented the nonexistent 'full reset' capability. * fix(tui): don't make Enter swallow trailing-space-only slash completions (#48425) * fix(tui): don't make Enter swallow trailing-space-only slash completions Submitting a slash command in the TUI took three Enter presses: one to complete the name (/ex → /exit), a second that only appended the trailing space the gateway adds to keep the classic-CLI prompt_toolkit dropdown open (/exit → "/exit "), and a third to actually submit. The composer's submit handler accepted the highlighted completion whenever applying it changed the input at all, so the whitespace-only delta ate an extra keypress. Treat a completion whose only change is trailing whitespace on an already-complete token as "already complete" and fall through to submit. Partial-name and argument completions (a real token change) still accept on Enter as before. The replace/accept logic is extracted into pure helpers (applyCompletion, completionToApplyOnSubmit) in domain/slash.ts. * test(tui): cover Enter/completion trailing-space behavior and isolate poller queue - completionApply.test.ts asserts completionToApplyOnSubmit accepts real token completions (partial command name, argument) but returns null for a trailing-space-only delta on an already-complete command, so Enter submits instead of needing extra presses. - test_notification_poller_delivers_completion / _skips_consumed previously shared the process-global process_registry.completion_queue. Their events carry no session_key, so a leaked/concurrent poller could dequeue and dispatch them to a fixture agent without run_conversation, flaking CI ("AttributeError: '_FakeAgent' object has no attribute 'run_conversation'"). Isolate the queue per test (fresh queue.Queue via monkeypatch), matching the sibling poller tests that already do this. * feat(mcp-catalog): add official Unreal Engine 5.8 MCP server Epic's experimental Unreal MCP plugin embeds an MCP server inside the Unreal Editor process, served over local HTTP (127.0.0.1:8000/mcp by default). HTTP transport, no auth, no install block — the user enables the plugin in-editor and Hermes connects to the URL. Also drops test_optional_mcps_manifests_ship_in_both_wheel_and_sdist: it asserted wheel/sdist packaging targets for pip/Homebrew/Nix installs, which Hermes does not support — installs run from the repo checkout, where the catalog is discovered by directory iteration with no packaging step. * feat(dashboard): surface full per-MCP catalog detail; fix pip-install doc (#48520) The dashboard MCP catalog only showed name/description/transport and a non-clickable source. Users couldn't see what an entry connects to or runs before installing — the exact detail the docs trust model tells them to vet. - /api/mcp/catalog now returns transport target (url, or command+args), auth_type, git install source/ref + bootstrap commands, default-enabled tool hint, and post-install guidance per entry. - McpPage renders the endpoint URL (http) or command+args (stdio), the git install source/ref, a collapsible bootstrap-commands list, setup notes, and the source as a clickable link when it's a URL. - Docs: drop the 'uv pip install -e .[mcp]' quick-start step (Hermes does not support pip installs; MCP ships with the standard install) and note the dashboard now surfaces this detail. - Strengthen the catalog endpoint test to assert the new inspection fields. * fix(install): fail fast when uv venv genuinely fails under relaxed EAP PR #48372 relaxes EAP=Stop around the uv venv call so PowerShell 5.1 doesn't mistake uv's 'Using CPython ...' stderr for a terminating NativeCommandError. But relaxing EAP also means a *genuine* uv venv failure (exit != 0) no longer aborts on its own — Install-Venv would continue and print 'Virtual environment ready', and in stage mode Invoke-Stage would report ok=true, even though no venv was created. Capture $LASTEXITCODE immediately after the relaxed call and throw on non-zero (Pop-Location first, matching the function's other exit paths), so the venv stage fails fast instead of falsely succeeding. This is the explicit guard originally proposed in #48463 (devorun), composed on top of #48372's reusable helper + regression test. Adds a regression test asserting the uv venv exit-code capture + throw. * feat(memory): batch operations for single-turn memory updates (#48507) The memory tool was strictly one-op-per-call. With the store running near its char limit by design, a new add that would overflow gets rejected with 'consolidate now, then retry' -- but the model could not consolidate and add in one call. It had to remove/replace across several turns, then retry the add, each turn re-sending the whole conversation context. Expensive thrash. Add an 'operations' array: a list of add/replace/remove ops applied atomically against the FINAL char budget. The model frees space and adds new entries in ONE call, even when an add alone would overflow. All-or-nothing: any bad op aborts the whole batch, nothing written. Root-cause note: the two agent-level memory interception sites (agent_runtime_helpers.py, tool_executor.py) silently dropped any param not in their explicit kwarg list, so 'operations' never reached the handler and batch calls failed with 'Unknown action None'. Both now pass it through and bridge each add/replace op to external memory providers. Also: success response is now terminal (done=true + 'do not repeat' note, no full-entries echo that invited re-edits); schema rewritten to lead with the batch mechanism and an explicit one-shot stop rule (2138 -> 1476 chars). Live-verified: near-full consolidate-and-add went 7 calls -> 1 call, stable across 3 reps. 103 memory/approval tests + 398 background-review/ run_agent tests green; 6 new batch tests added. * fix(desktop): never persist or restore a named custom provider as bare "custom" (#48547) * Port from cline/cline#11514: encourage parallel tool calls Add a universal system-prompt guidance block telling the model to batch independent tool calls (reads, searches, web fetches, read-only commands) into a single assistant turn instead of one call per turn. The runtime already executes independent batches concurrently (read-only tools always; non-overlapping path-scoped file ops); the open-source system prompt had nothing steering the model to PRODUCE the batch. Fewer round-trips means less resent context, which compounds over a long conversation. - prompt_builder.py: new PARALLEL_TOOL_CALL_GUIDANCE block (short, static, cache-amortised) modeled on TASK_COMPLETION_GUIDANCE. - system_prompt.py: inject right after the task-completion block, gated by agent.valid_tool_names + the new toggle. - agent_init.py: read agent.parallel_tool_call_guidance (default True). - config.py: add the default under the agent section. - test_prompt_builder.py: behavior-contract tests (batching steer, dependent carve-out, length bound) — invariants, not wording snapshots. Adapted from Cline's TypeScript tool-surface guidance to hermes-agent's Python prompt-assembly architecture and config-over-env conventions. * fix(desktop): never persist or restore a named custom provider as bare "custom" Custom providers vanish from the Desktop/TUI model picker with "No LLM provider configured" — repeatedly fixed (#44062, #44109, #45578) and repeatedly regressed (#44022, #47714) because every fix only recovered the entry identity from a persisted base_url. When a session is persisted/restored with the resolved provider "custom" and NO base_url, bare "custom" leaked through verbatim; resolve_runtime_provider("custom") routes to the OpenRouter default URL with no api_key, so the next turn/resume dies. Bare "custom" is the resolved billing class shared by every named providers:/ custom_providers: entry — it is not a routable identity. Centralize the "never let bare custom escape" invariant in one helper, runtime_provider.canonical_custom_identity(), and apply it at all four leak sites in tui_gateway/server.py: - _ensure_session_db_row — the ORIGIN: first DB write seeds the bad row - _runtime_model_config — live persist - _stored_session_runtime_overrides — resume restore (heals old rows; drops unrecoverable bare custom so resume falls back to config default) - _make_agent — rebuild / per-turn The helper recovers custom:<name> from the endpoint URL when present, else from config.model.provider (the durable identity left when no base_url survived). Regression tests in test_custom_provider_session_persistence.py lock the no-base_url vector at every site so it cannot regress again. * fix(prompt): dedupe parallel-tool-call steer; correct its rationale The universal PARALLEL_TOOL_CALL_GUIDANCE block already lives on main, but it shipped with two rough edges this change cleans up: - It duplicated the batching steer for Google models. The GOOGLE_MODEL_OPERATIONAL_GUIDANCE block still carried its own "Parallel tool calls" bullet, so Gemini/Gemma received the instruction twice in one prompt. Drop the redundant bullet — the universal block is now the single source. - Its comment claimed "nothing in the open-source system prompt encouraged batching," which was wrong: the steer existed for Google models only. Reword to say the gap was that every *other* model got nothing. - Tighten the test that asserts the steer (precedence-correct), and add an invariant guarding against re-introducing the Google duplicate. * fix(desktop,tui): surface self-improvement review summary + honor memory_notifications The "💾 Self-improvement review" summary (skill/memory updated) was invisible on two surfaces: - Desktop Electron app had no review.summary event handler — skill/memory writes happened silently. Now appends a persistent system message to the transcript (matching the Ink TUI's persistent-line semantics, not a transient toast that can be missed). - tui_gateway (backs both 'hermes --tui' and the desktop) never read display.memory_notifications, so it always behaved as 'on' and ignored a user who set 'off'/'verbose'. Added _load_memory_notifications() (mirrors the messaging gateway's bool->str normalization, defaults to 'on') and wired it to agent.memory_notifications, matching gateway/run.py and the CLI. Delivery chain now reaches all surfaces: background_review.py -> background_review_callback -> review.summary event -> desktop transcript / Ink TUI line / gateway message / CLI print. * fix(dashboard): use DS Button prefix/size API instead of inline icons @nous-research/ui@0.18.2 Button is grid-based: size=xs is an aspect-square icon-only box, and icons belong in prefix/suffix. The dashboard used shadcn-style size=xs + inline <Icon/> text children, which forced text buttons into broken tall squares (Configure, Run setup, Select, Save keys) and split icon/label across grid columns elsewhere (Schedule it, Prune/Delete actions). Move leading icons to prefix and size text buttons as sm/default. For the post-setup spinner, drive the spin from a button-level [&_svg]:animate-spin selector since the prefix slot clones the icon and overwrites its className. - ToolsetConfigDrawer: Select, Save keys, Run setup - SkillsPage: New skill, Configure - AutomationBlueprints: Schedule it - SessionsPage: Prune old sessions, Delete empty, Delete selected * feat(billing): /billing terminal billing — interactive TUI + CLI client (#45449) * feat(billing): nous_billing http client + BillingState core (phase 2b) Phase 2b terminal-billing client foundation: - hermes_cli/nous_billing.py: typed client for the 4 /api/billing/* endpoints (state/charge/poll/auto-top-up). Raises typed errors (BillingScopeRequired, BillingRateLimited, BillingAuthError) mapped from the live-verified contract; fail-open is the caller's job. Idempotency-Key enforced client-side. - agent/billing_view.py: surface-agnostic BillingState core + Decimal money parsing (server emits decimal strings, not 2dp), fail-open builder, idempotency-key gen, custom-amount validation. - 51 unit tests (decimal parse/format, payload tiering, error->exception matrix, fail-open, amount validation). Plan: docs/plans/2026-06-13-001-phase-2b-terminal-billing-tui-plan.md * feat(billing): billing:manage scope + lazy step-up re-auth (phase 2b) - NOUS_BILLING_MANAGE_SCOPE constant. - nous_token_has_billing_scope(): split-based scope check (no false-positive substring match). - step_up_nous_billing_scope(): re-runs the device flow requesting billing:manage, reusing the held credential's portal/inference URLs + client_id (so a preview stays a preview), persists like _login_nous but WITHOUT the model picker. Returns True iff the minted token carries the scope (False when NAS silently downscopes a non-admin / unticked grant). Lazy step-up (plan D-A): normal login path unchanged; 403 insufficient_scope from a billing call triggers this. 7 unit tests. * feat(billing): billing JSON-RPC methods for the TUI (phase 2b) billing.state / charge / charge_status / auto_reload / step_up in tui_gateway/server.py. Return STRUCTURED success envelopes (result.ok + result.error=<code>) rather than JSON-RPC-level errors, so the Ink rpc() promise always resolves and the TUI branches on the typed billing error code (insufficient_scope, rate_limited, no_payment_method, …) to render the right affordance. Money serialized as decimal STRINGS + display strings. charge mints + echoes an idempotency_key for retry reuse. 16 unit tests. * feat(billing): /billing CLI handler + command registry (phase 2b) - CommandDef("billing", subcommands=buy|auto-reload|limit), added to _SLACK_VIA_HERMES_ONLY so it routes via /hermes on Slack (keeps the 50-cap parity test green, same as /credits). - cli.py::_show_billing + screen helpers: all 5 screens (overview, buy→confirm→ poll, auto-reload, monthly-limit read-only). Reuses _prompt_text_input_modal / _prompt_text_input (D-C). Non-interactive (_app is None) renders text + portal deep-link, never prompts (R7). Decimal money end-to-end. 2s/5-min cancellable poll loop; 429/503 = retry not failure; settled = ledger truth. Lazy step-up on 403 insufficient_scope. no_payment_method treated as mainline funnel-to-portal. - 6 CLI tests; 156 command tests (incl. Slack/Telegram parity) green. * feat(billing): /billing Ink TUI screens + tests (phase 2b) - ui-tui/src/app/slash/commands/billing.ts: /billing TUI command covering all 5 screens — overview (text), buy <amt> → ConfirmReq → charge → non-blocking 2s/ 5-min poll loop → settled/failed/timeout branches, auto-reload <below> <to> → ConfirmReq → PATCH, limit (read-only). Reuses the existing ConfirmReq overlay (D-C) — no bespoke component. Typed-error envelope branching: insufficient_scope arms the lazy step-up confirm; no_payment_method/rate_limited/cap funnel to portal. Client-side amount validation mirrors the server (bounds + 2dp). - gatewayTypes.ts: Billing* response interfaces. - registry.ts: register billingCommands. - billingCommand.test.ts: 12 vitest cases (overview/gating/buy-confirm-poll- settled/no_payment_method/step-up/limit/auto-reload/validation). TUI build green; 12/12 vitest pass; slash tests pass once @hermes/ink is built. * docs(billing): scrub private cross-repo references NAS is a private repo — remove all references to it from the public PR: - drop the cross-repo planning doc (planning scaffolding, not a deliverable; the PR description documents the design) - replace 'NAS' / 'PR #412 preview' mentions in code + test comments with generic 'the server' / 'a preview deployment' * docs(billing): scrub final NAS reference in step-up docstring * docs(billing): drop dangling plan-doc refs The phase-2b plan doc was removed in the cross-repo scrub (300afcc0b) but two module docstrings still pointed at it. Drop the dead refs. * feat(billing): interactive /billing overlay + step-up UX, portal-URL & token fixes Adds the interactive /billing TUI overlay and hardens the terminal-billing client across CLI and TUI. - TUI: full /billing overlay state machine (overview to buy to confirm, auto-reload, read-only monthly limit) reusing the existing confirm overlay. - Step-up: surface the verification link in-transcript and open the browser via the TUI's own opener (the device flow runs in the headless gateway, so a printed URL was being dropped); run the step-up handler off the main loop and emit the link as an out-of-band event so the gateway stays responsive. - Step-up copy is scope-accurate ("Billing permission granted") and re-checks /state so it never claims "enabled" when the org kill-switch is still off. - Portal deep-links resolve to absolute URLs against the active portal base (the server emits them relative) - fixes a bare "/billing?topup=open" link. - Billing calls refresh an expired access token via the stored refresh token instead of reporting a false "not logged in". - Optimistic funnel: advise "set up a saved card on the portal" up front when no card is on file (advisory, not a hard gate). - Token resolution is cached briefly so the 2s charge poll loop stops re-locking + re-reading the auth store on every tick; 401 re-resolves fresh. - Remove the temporary demo-mode shims. Validation: 87 Python billing tests, 88 TS tests (billing command + gateway event handler), tsc clean, ink + ui-tui builds green. * docs(billing): add /billing TUI screenshots for PR * fix(cli): guard _last_invalidate on bare instances; update stale prompt-fallback test The UI-invalidate throttle read self._last_invalidate unconditionally, which raised AttributeError on HermesCLI instances built without __init__ (the thread-safety test's object.__new__ shell). Guard the read with getattr. The off-main-thread branch of _prompt_text_input was changed (#23185) to cancel cleanly to None instead of falling back to a bare input() that would hang on the slash-worker thread; the test still asserted the old direct-input fallback. Update it to assert the current intended behavior: returns None, calls neither run_in_terminal nor input(), and does not hang. * fix(agent): flush un-persisted messages before session rotation (#47202) compress_context() rotates the session (end_session -> create_session) mid-turn when auto-compress triggers, but never called _flush_messages_to_session_db() first. Messages generated during the current turn that hadn't been persisted to state.db were silently lost. The same bug existed in cli.py:new_session() (/new command). Both paths now flush un-persisted messages before ending the old session. * fix(gateway): preserve original transcript when /compress rotation is skipped The manual /compress handler called rewrite_transcript() unconditionally on the session id returned by _compress_context(). When rotation does not occur (e.g. _session_db unavailable, or the DB split raised), session_id is unchanged and rewrite_transcript() DELETEs the original messages and replaces them with only the compressed summary — permanent data loss (#44794, #39704). Guard the rewrite on actual rotation: only overwrite when _compress_context produced a new session id. Otherwise leave the original transcript intact and log a warning. * fix(thread): allow scrolling long user messages in chat history (#48619) * fix(picker): remove max_models=50 cap in interactive model pickers The interactive model pickers (Desktop REST API, TUI model.options, CLI /model) were hard-capped at max_models=50, which truncated large provider catalogs like Kilo Gateway (336 models) to just 50 entries. This made most models undiscoverable via the picker search box. Changes: - Change build_models_payload() default from max_models=50 to None (unlimited) - Change list_authenticated_providers() default from max_models=8 to None - Change list_picker_providers() default from max_models=8 to None - Fix all [:max_models] slicing to handle None as 'no limit' - Remove max_models=50 from 5 interactive picker callers: * web_server.py: get_model_options (Desktop /api/model/options) * web_server.py: get_recommended_default_model * model_switch.py: prewarm_picker_cache_async * tui_gateway/server.py: model.options JSON-RPC * cli.py: HermesCLI model picker - Telegram/Discord inline keyboard picker (gateway/slash_commands.py) still passes max_models=50 explicitly — unchanged behavior. The total_models field was already in the response payload and is now meaningful since models.length == total_models for interactive pickers. Fixes #48279 * fix(picker): keep max_models=0 distinct from unlimited; lock cap semantics Follow-up to the cap-removal salvage. The contributor guarded the new unlimited default with `[:max_models] if max_models else ...`, which conflates max_models=0 (used by slug-only callers that want an empty model list) with None (unlimited). Tighten to `is not None` at all five slicing sites in list_authenticated_providers / list_picker_providers, and add a regression test asserting the three-way contract: None=full, 0=empty, N=first N. * fix(gateway): resume follows the compression tip so post-compression replies render Auto-compression ends the live session and forks a continuation child (linked via parent_session_id). A long-lived parent keeps its own flushed message rows, so resolve_resume_session_id()'s empty-head walk never redirected it — resuming the parent id reloaded the pre-compression transcript and dropped every turn generated after compression, including the assistant's response. On the desktop this is the recurring "I sent a message, came back, and the reply isn't there" report on large sessions: the chat's routed id is the pre-rotation id, and both the gateway session.resume RPC and the REST /messages read anchored on it. Fix the resolver at the chokepoint: resolve_resume_session_id() now follows the compression-continuation chain forward via get_compression_tip() before its existing empty-head descendant walk. get_compression_tip() only follows children whose parent ended with end_reason='compression' (created after the parent was ended), so delegation/branch children never hijack a resume. This fixes every resume caller at once (REST /messages, CLI --resume, gateway /resume). session.resume in tui_gateway was the one resume path that never called the resolver — it used the raw target id directly. Route it through resolve_resume_session_id() too (non-lazy only; lazy watch windows must stay on their exact child branch). Resolving up front also re-anchors the live-session fast path so a still-live rotated session is reused by its new key instead of rebuilding a duplicate agent on the stale parent. Tests: - resolve_resume_session_id follows the tip even when the parent retains messages, and is not confused by a delegation child. - session.resume binds the agent to the continuation tip and returns the post-compression reply. * test: narrow db._conn before raw SQL so ty stops flagging None-union access The new compression-tip tests poke started_at/ended_at directly via db._conn to force deterministic lineage ordering. _conn is typed Optional[Connection], so ty flagged .execute/.commit as unresolved on None. Bind a local and assert it's non-None first to narrow the union. * feat(kanban): auto-subscribe calling session on kanban_create When a worker calls kanban_create from inside a session that has a persistent delivery channel, the originating session is now subscribed to the new task's completion/block events automatically. The agent that dispatched the task gets notified instead of having to poll. - Gateway sessions (telegram/discord/slack): HERMES_SESSION_PLATFORM + HERMES_SESSION_CHAT_ID ContextVars, set by the messaging gateway. - TUI / desktop sessions: HERMES_SESSION_KEY in the subprocess env. The TUI notification poller keys on platform='tui' + chat_id=<key>. - CLI / cron / test: no persistent channel, no subscription. Gated by kanban.auto_subscribe_on_create in config.yaml (default True). Disable to mirror pre-feature behaviour — users who want explicit kanban_notify-subscribe calls per task can set it to false. This config gate addresses the design concern that got PR #19718 reverted upstream (unconditional implicit auto-subscribe on tool-driven kanban_create was too aggressive for orchestrator users). HERMES_SESSION_ID is intentionally not a fallback channel — it is set by ACP/agent subprocess telemetry for every invocation, not just TUI, so treating it as a notification target would auto-subscribe every CLI session and re-introduce the over-eager behaviour. The kanban_create response now includes a 'subscribed' bool so orchestrators can react if subscription failed (e.g. by falling back to explicit kanban_notify-subscribe or to polling). Includes 6 tests covering the gateway / TUI / CLI / partial-context / gated / add_notify_sub-failure paths. All 90 tests in test_kanban_tools.py pass; 509 broader kanban tests pass. * fix(desktop): make session delete idempotent and id-resolving (#48641) DELETE /api/sessions/{id} was the only session endpoint that didn't resolve the id (detail, messages, rename, export all call resolve_session_id) and 404'd when the row was already gone. The desktop optimistically removes the sidebar row, then RESTORES it and shows the error on any failure — so deleting a session that had just been reaped (empty-session hygiene) or removed by a concurrent client resurrected a ghost row and surfaced "session not found". /goal + auto-compression churn leaves transient empty rows that race the sidebar snapshot, which is the exact "I deleted the empty one and got 'session not found'" report. Resolve exact ids / unique prefixes, and treat an already-absent session as an idempotent success — DELETE's contract is "ensure it's gone". This mirrors the bulk-delete endpoint, which already treats ghost ids as success. Tests: deleting an absent id is idempotent (200, not 404); delete resolves a unique prefix; a real session still deletes. * feat(prompt): configurable per-platform system-prompt hint overrides Add platform_hints config so an admin can append to or replace Hermes' built-in platform hint for a single messaging platform (WhatsApp, Slack, Telegram, ...) without affecting other platforms. Enables enterprise managed profiles to steer platform-aware skills (e.g. invoke a custom table-formatting skill on WhatsApp where Markdown tables don't render) while leaving Telegram/Slack/CLI behavior unchanged. - hermes_cli/config.py: document platform_hints in DEFAULT_CONFIG - agent/agent_init.py: load platform_hints -> agent._platform_hint_overrides - agent/system_prompt.py: _resolve_platform_hint() applies append/replace (replace wins; bare string = append shorthand); defensive on bad config - tests: 16 cases covering append/replace/shorthand/isolation/malformed Override only affects the platform-hint segment of the system prompt; SOUL/context/memory tiers and general instructions are unchanged. * docs(prompt): document platform_hints config override Adds a 'Customizing platform hints' section to the Prompt Assembly developer guide covering the append/replace/shorthand shapes, the defensive fallback, and the cache-stable lifecycle (stable tier, resolved at build time). * fix(npm): lock react-simple-icons to 13.11.1 suppress annoying message about engines that's completely benign but people seem to complain * fix(desktop): show Hindsight memory provider (#37546) * fix(desktop): show Hindsight memory provider * feat(desktop): configure Hindsight memory provider * fix(desktop): limit Hindsight modes to supported setup * refactor(desktop): generic memory-provider config surface Replace the bespoke Hindsight settings surface with a declarative, schema-driven path so adding a memory provider is pure declaration — no per-provider page, conditional, or endpoint. - memory_providers.py: declarative registry. Each provider lists its fields {key, label, kind, default, options, secret-vs-plain}. Hindsight's mode is a select(cloud, local_external), so rejecting local_embedded falls out of generic enum validation instead of a hand-written check. - One generic endpoint pair GET/PUT /api/memory/providers/{name}/config. GET returns declared fields + current values (secrets only as is_set, never read back); PUT validates selects against their options, writes plain fields to the provider config file, secrets to the env store, and flips memory.provider. - ProviderConfigPanel renders straight from the schema, replacing hindsight-settings.tsx and the memory.provider === 'hindsight' conditional in config-settings.tsx — same pattern as toolset-config-panel.tsx off env_vars. Scoped to memory providers; storage layout is unchanged so the runtime Hindsight plugin reads the same config.json / HINDSIGHT_API_KEY / provider keys as before. Tests cover the registry, endpoint behavior (defaults, write+secret, select rejection, unknown provider, secret-never-returned), and the generic panel. * feat(relay): WS-only inbound on the gateway adapter (Phase 3) (#48294) The connector now delivers inbound (messages + interrupts) over the gateway's OUTBOUND /relay WebSocket, not a signed HTTP POST to an inbound endpoint. The gateway needs no inbound HTTP port — which is what makes hosted gateways (no public IP) able to receive inbound at all. - gateway/relay/adapter.py: connect() wires set_interrupt_inbound_handler( self.on_interrupt) so connector->gateway interrupt_inbound frames bridge into the existing per-session interrupt path (the inbound message handler was already wired). Removed _maybe_start_inbound_receiver() + the _inbound_runner lifecycle — there is no HTTP receiver anymore. - gateway/relay/inbound_receiver.py: deleted (the signed-HTTP InboundDelivery receiver). - gateway/relay/__init__.py: removed relay_inbound_config() (dead with the receiver gone). The delivery key is still set in-process by self-provision for forward-compat but is no longer consumed for inbound. - docs/relay-connector-contract.md: §3 rewritten — inbound is the WS back-channel routed cross-instance via the connector's relay bus; §5 interrupt + §6 auth table updated; the old signed-HTTP-POST + per-tenant-delivery-key-signing path is documented as superseded. gatewayEndpoint noted as passthrough-plane only. Tests: stub_connector grows set_interrupt_inbound_handler + push_interrupt; new test_relay_interrupt case proves connect() wires BOTH inbound handlers and an interrupt_inbound frame over the WS cancels the right session. Removed the HTTP-receiver test; updated the crypto-shedding scan + self-provision delivery-key assertion. 88 relay tests pass. EXPERIMENTAL. Pairs with gateway-gateway (relay bus + WsGatewayDelivery) and the NAS GATEWAY_RELAY_URL stamp. The cross-repo E2E (connector repo) proves the full multi-instance path against this production adapter code. * fix(docker): support WebUI installs from read-only sources (#48541) * fix(relay): trigger self-provision on relay-config + NAS token, not is_managed() (#48724) self_provision_if_managed() gated on is_managed(), but is_managed() means "NixOS/package-manager-managed" (it keys on HERMES_MANAGED or a ~/.hermes/.managed marker) — NOT "NAS-hosted". A NAS-provisioned Fly agent sets NEITHER, so the gate was always False and relay self-provision SILENTLY no-oped on exactly the hosted agents it was built for. Caught live: a staging agent with GATEWAY_RELAY_URL correctly stamped logged "No messaging platforms enabled" and never dialed the connector; HERMES_MANAGED was unset on the machine. The unit tests had mocked is_managed()->True, so they passed while the real trigger never fired (mocked- trigger blind spot). Fix: drop the is_managed() gate and rename self_provision_if_managed -> self_provision_relay. The real trigger is now "relay_url() set + no pinned secret + a resolvable NAS token", which is both NAS-independent and self-guarding: - NAS-hosted agent: GATEWAY_RELAY_URL + no pinned secret + bootstrapped NAS token -> self-provisions. - Self-hosted + `hermes gateway enroll`: pinned GATEWAY_RELAY_SECRET -> skipped (existing secret-present guard). - Self-hosted, unenrolled, no NAS identity: resolve_nous_access_token() fails -> graceful no-op (existing fail-soft path). Security: unchanged trust model. The connector still derives tenant from the validated NAS token; this only broadens WHEN the provision attempt fires, and every broadened case is still guarded by token-resolution + pinned-secret-skip. Tests: replaced the (wrong) "skips when not managed" test with a regression test proving a NAS host where is_managed()==False STILL provisions; renamed all call sites; added a "no NAS token -> non-fatal skip" test for the self-hosted branch. 88 relay tests pass. Relay-adapter lane. EXPERIMENTAL. * fix(agent): handle missing trigram tokenizer without disabling FTS5 _is_fts5_unavailable_error only matched 'no such module: fts5', but SQLite builds that ship FTS5 without the optional trigram tokenizer raise 'no such tokenizer: trigram' instead. This caused SessionDB init to crash on those builds. Additionally, the trigram failure path called _warn_fts5_unavailable which set _fts_enabled = False, globally disabling full-text search even though the base FTS5 table was created successfully. Fix: - Extend _is_fts5_unavailable_error to also match 'no such tokenizer' - Add _is_tokenizer_unavailable_error to distinguish tokenizer-specific failures from whole-module absence - Only call _warn_fts5_unavailable for module-level failures; skip it for tokenizer-specific failures so base FTS5 remains usable Fixes #47002 * fix(agent): address review feedback on trigram tokenizer fallback - Scope 'no such tokenizer' matcher to trigram specifically (#779) - Decouple base FTS and trigram backfill in v11 migration (#1195) - CJK search falls back to LIKE when trigram unavailable (#3384/#3430) - Add _trigram_available tracking across init, migration, and startup - Add regression tests for migration backfill and CJK LIKE fallback - Add _is_trigram_unavailable_error and _warn_trigram_unavailable helpers * fix(agent): rebuild base fts without trigram * fix(cli): surface a visible warning when the session store is unavailable When SessionDB init fails, the CLI/Desktop previously continued live with only a buried log line. The chat looks healthy, but the transcript is never written to state.db — so resume later shows a truncated or empty session and the user only discovers the loss after the fact (#41386). Emit a prominent stderr banner at startup when the store is unavailable, making it explicit that the conversation will not be saved and cannot be resumed, with a pointer to fix the store. Also set _session_db_unavailable so downstream code can detect the degraded state. * chore(release): map chanyoung.kim@nota.ai -> channkim for #47049 salvage * feat(cli): lock hermes worktrees so concurrent processes can't clobber them git worktree lock at creation and unlock before removal. A locked worktree refuses 'git worktree remove' (and prune), so a second hermes process or a stray cleanup can't silently delete an in-use isolated worktree. Fail-soft on both paths — a lock/unlock error never blocks the session or cleanup. Salvaged from #47029 (Issue #46303). Unlock moved to the actual-removal path so a preserved (unpushed-commits) worktree stays locked while in use. * chore: add JoaoMarcos44 to AUTHOR_MAP * fix(dashboard): resolve chat TUI argv off event loop (#48561) * fix(dashboard): resolve chat TUI argv off event loop Dashboard chat now resolves its TUI launch command off the FastAPI/WebSocket event loop. The resolver can run `npm install` / `npm run build` through `_make_tui_argv()`, and doing that synchronously in `/api/pty` can block proxy keepalives and other dashboard WebSocket work long enough for reverse-proxy deployments to drop the chat connection. This keeps the current TUI build policy intact: normal production launches still run the correctness-first `npm run build` path, while `HERMES_TUI_DIR` remains the prebuilt/no-build path for distros and containers. The change only moves the potentially slow resolver work to a worker thread for the dashboard chat path, serialized by an `asyncio.Lock` so concurrent chat tabs preserve one-build-at-a-time behavior. `SystemExit` (node/npm missing) and the profile `HTTPException` path still propagate cleanly through `asyncio.to_thread()`. Salvaged from #26124 — rebased onto current main. The async wrapper now threads the `profile` parameter that `_resolve_chat_argv` gained on main since the PR was opened, so cross-profile chat is preserved. Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> * chore: add 0xdany to AUTHOR_MAP * fix(dashboard): bind chat-argv lock to app.state; cover error propagation Self-review hardening on top of the salvaged fix: - Move `_chat_argv_lock` from a module-level `asyncio.Lock()` onto `app.state` (initialised in `_lifespan`, lazy fallback via `_get_chat_argv_lock`), mirroring `event_lock`. A module-level `asyncio.Lock()` binds to whatever event loop is active at import time, which is the exact pattern `_get_event_state`'s docstring warns against (breaks across TestClient instances / uvicorn reloads). This keeps the lock on the running loop. - Add two tests exercising the real `_resolve_chat_argv_async` → `asyncio.to_thread` → lock → re-raise chain: `SystemExit` (node/npm missing) and `HTTPException` (invalid profile) both propagate out of the worker thread and are caught by `pty_ws`'s existing handlers. The prior tests mocked `asyncio.to_thread` away and never covered this path. * test(dashboard): dedupe pty error-propagation tests; assert close code simplify-code cleanup pass on the salvage stack: - Extract the shared scaffolding of the two pty_ws error-propagation tests into `_assert_pty_propagates`, keeping the two tests as distinct contracts for the `except SystemExit` and `except HTTPException` arms. - Assert the stable WebSocket close code (1011) instead of relying solely on the user-facing "Chat unavailable" notice wording — a behavior contract per the AGENTS.md "behavior contracts over snapshots" rule, robust to notice rewording. The detail substring ("unknown profile") is still checked for the HTTPException case since proving the detail survives the thread hop is the point of that test. No production-code change; the helper exercises the same real _resolve_chat_argv_async -> asyncio.to_thread -> lock -> re-raise chain. --------- Co-authored-by: draihan <draihan@student.ubc.ca> * feat(model-picker): add Refresh Models control to bust stale model cache (#48691) The desktop model picker had no way to force a fresh model fetch: model.options went through the 1h-cached provider_models_cache.json, and there was no flag to bust it. When a provider's cached list expired and its next live fetch failed, the picker fell back to the curated static list — silently dropping live-only models (e.g. OpenCode Zen's free tier like deepseek-v4-flash-free) the user had been using. - Thread refresh through model.options (RPC + REST /api/model/options) -> build_models_payload -> list_authenticated_providers, which calls clear_provider_models_cache() up front when set so every row re-fetches live. - Add a 'Refresh Models' control to the desktop picker (5-locale i18n, spinning sync icon). Normal opens leave refresh=false to stay snappy on the cache. Verified: stale cache hides deepseek-v4-flash-free -> refresh busts it -> live re-fetch surfaces it. refresh=false never touches the cache. * fix(agent): summarize structured provider error messages * Update Stripe Projects skill docs (#48673) Committed-By-Agent: codex Committed-By-Agent: codex Committed-By-Agent: codex Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com> * feat(image-gen): add image-to-image / editing to image_generate (#48705) * feat(image-gen): add image-to-image / editing to image_generate Brings image generation to parity with video generation: the unified image_generate tool now edits/transforms a source image (image-to-image) when given image_url / reference_image_urls, routing to each backend's edit endpoint, exactly as video_generate routes to image-to-video. - ImageGenProvider ABC: generate() gains keyword-only image_url + reference_image_urls; new capabilities() declares modalities + max_reference_images (defaults to text-only, backward compatible). success_response gains a modality field; adds normalize_reference_images. - image_generate tool: schema exposes image_url + reference_image_urls; dynamic schema reflects the active model's actual edit capability so the agent knows when image_url is honored. Handler + plugin dispatch forward the new inputs; legacy/text-only providers get a clear modality_unsupported error instead of silently dropping the source image. - In-tree FAL: 7 models gain edit endpoints (flux-2-klein, flux-2-pro, nano-banana-pro, gpt-image-1.5, gpt-image-2, ideogram/v3, qwen-image) with per-model edit_supports whitelists + reference caps; routes to the /edit endpoint and skips the upscaler for edits. - Plugins: openai (images.edit, 16 refs), xai (/v1/images/edits via grok-imagine-image-quality, JSON body per xAI docs), krea (image_style_references, 10 refs). openai-codex stays text-only and rejects edits with an actionable error. - Tests: 15 new (payload, routing, dispatch forwarding, dynamic schema, capabilities); updated 2 change-detector/lambda tests for the new schema. - Docs: image-generation feature page, image-gen provider plugin guide, tools reference. * fix(image-gen): preserve legacy passthrough in fal/krea plugin tests Two existing plugin tests asserted pre-image-to-image behavior: - fal: forward image_url/reference_image_urls only when supplied, so a text-to-image delegation stays byte-identical (no None kwargs). - krea: keep dict-shaped image_style_references refs verbatim (the unified string refs go through normalize_reference_images; legacy non-string ref objects pass through unchanged) — fixes KeyError when callers pass the richer Krea ref-object shape. * fix(image-gen): clearer not-capable message for text-to-image-only models When a text-to-image-only model (incl. gpt-image-2 on the Codex OAuth path, which can't do editing through the Responses image_generation tool) gets a source image, say 'this model is not capable of image-to-image / editing — provide a text-only prompt' rather than sending the user shopping for other backends. Applies to the openai-codex guard, the in-tree FAL no-edit-endpoint error, and the dynamic tool-schema text-only line. * fix(terminal): block gateway lifecycle commands from inside the gateway process systemctl --user restart hermes-gateway run via the terminal tool is a child of the gateway itself. When systemd delivers SIGTERM the gateway kills this subprocess before it can complete, so the service may never restart — reproducing issue #37453. The hermes gateway restart/stop guard (hermes_cli/gateway.py) and the cron-path guard (hermes_cli/cron.py) already block equivalent commands in their respective paths but the terminal tool had no such defense. Add a hard-block before command execution in terminal_tool: when _HERMES_GATEWAY=1 and the command matches _contains_gateway_lifecycle_command, return an error immediately. force=True cannot bypass it — unlike the normal dangerous-command approval flow, here even a user-approved restart would fail because the SIGTERM propagates to child processes. Also extend _GATEWAY_LIFECYCLE_PATTERNS to match systemctl with flags (e.g. systemctl --user restart) — the previous regex required the action word immediately after systemctl with no flags in between. Adds 9 regression tests: 6 blocked variants (parametrized), force bypass attempt, safe systemctl passthrough, and guard-inactive-outside-gateway. * fix(relay): make hosted gateways actually connect AND complete the inbound/outbound round-trip (#48828) * fix(relay): enable RELAY platform + normalize dial URL so hosted gateways actually connect Three bugs blocked a self-provisioned hosted gateway from ever establishing its inbound relay WS (found while standing up the live staging end-to-end). Each masked the next; all three are needed for inbound to work. 1. RELAY platform never enabled in config.platforms (gateway/config.py). register_relay_adapter() puts the adapter in the platform_registry, but start_gateway()'s connect loop iterates self.config.platforms — which never contained Platform.RELAY. So the adapter was "registered" but never connected (logs showed "relay adapter registered" then "No messaging platforms enabled"). Fix: _apply_env_overrides now enables Platform.RELAY (mirroring relay_url into extra for the connected-checker) when GATEWAY_RELAY_URL (env) or gateway.relay_url (yaml) is set. Absent -> no RELAY entry (direct/ single-tenant gateways unaffected). 2. URL scheme not converted for the WS dial (gateway/relay/ws_transport.py). The relay URL is configured once as the http(s):// base (used as-is for the provision POST), but websockets.connect rejects http(s):// with "scheme isn't ws or wss". Fix: _ws_dial_url converts https->wss / http->ws. 3. /relay path not appended (same helper). The connector mounts its WebSocketServer at path "/relay" and returns HTTP 400 on an upgrade to any other path. GATEWAY_RELAY_URL is the base (no /relay), so the dial hit "/" -> 400. Fix: _ws_dial_url ensures the path ends in /relay. Idempotent — a URL already carrying ws(s):// and/or /relay is unchanged, so provision's _provision_url (which derives /relay/provision from either form) still works. Why the cross-repo E2E missed #2/#3: the stub connector binds ws://host:port and its websockets.serve accepts ANY path, so neither the scheme nor the /relay path was exercised. Real connector needs both. Verified live on staging hermes-agent-stg-automated-perception-5054: after the fixes the gateway logs "Connecting to relay..." -> "✓ relay connected" -> "Gateway running with 1 platform(s)" against wss://gateway-gateway.staging-nousresearch.com/relay, stable. Tests: added _ws_dial_url scheme+path+idempotency cases (test_ws_transport.py) and RELAY-platform-enablement cases for env + yaml + absent (test_config.py). Full gateway/relay + config suites green (191 passed). Relay-adapter lane. EXPERIMENTAL. * fix(relay): re-attach guild_id to outbound so connector egress resolves the tenant The final bug in the hosted-relay round-trip. Inbound worked end to end (Discord -> connector -> bus -> agent WS -> agent runs -> reply), but the reply's egress was declined by the connector: "discord egress declined: target not routed to an onboarded tenant". Cause: the connector's routedEgressGuard resolves the owning tenant from the OUTBOUND action's metadata.guild_id (Discord's routing discriminator). The gateway's generic delivery path builds outbound metadata via run.py _thread_metadata_for_source, which only carries thread_id (and returns None entirely for a non-threaded message) — so guild_id never reached the connector, tenant resolution failed, and the shared bot refused to post. Fix (relay-adapter-local, no perturbation of the generic delivery path or other platforms): RelayAdapter learns chat_id -> guild_id from each inbound event (_capture_scope) and re-attaches it to the outbound action's metadata in send() (_with_scope) when not already present. No-op for chats we never saw inbound (e.g. DMs) and never overwrites an explicit guild_id. Verified live on staging hermes-agent-stg-automated-perception-5054: an @mention in #general now produces a visible bot reply — full multi-tenant relay round-trip (real Discord -> shared connector bot -> tenant routing -> agent WS -> reply egress -> Discord). Tests: _capture_scope/_with_scope reattach, no-scope no-op, explicit-guild_id preserved (test_relay_adapter.py). Full relay + config suites green (160 passed). Relay-adapter lane. EXPERIMENTAL. * fix(tui): restart dashboard chat on idle exit hotkeys * port(opentui): billing overlay, review.summary, Enter/slash fix into the engine Ports the four TUI features deferred in the last upstream sync into the OpenTUI (Solid) engine, on top of a fresh merge of upstream/main. No gateway/Python changes — engine-only, mirroring the Ink behavior. 1. Terminal billing /billing — new full-screen overlay (5-screen state machine: overview/buy/confirm/auto-reload/limit) as net-new Solid components (boundary RPC types + openExternalUrl + logic/billing RPC layer + Solid view + store openBilling/closeBilling/patchBilling + slash /billing + App Match). Follows the engine's overlay conventions (picker/sessionPicker/ agentsDashboard): state/ctx read reactively via getters (state()/ctx()), and theme read via useTheme() directly in each component (no theme prop-drilling). 2. review.summary — wire the self-improvement memory digest into the store reducer (schema already defined); honors display.memory_notifications. 3. Enter/slash-completion fix — completionMenu.acceptChangesToken gates the composer's Enter-accept so an already-complete command submits instead of being swallowed into "<cmd> " (Ink completionToApplyOnSubmit parity). 4. custom-provider session-info — verified already round-tripped; strengthened the test to assert a custom:<name> slug is kept verbatim. Adversarial review (Claude Code, 2 rounds, opentui+tmux skills + a live billing smoke harness driving the real overlay) found + fixed two billing bugs: - AutoReloadScreen mounted EMPTY (the engine <input> `value` is init-only) — added value={value()} so an existing config prefills; + a captureFrame test. - ConfirmScreen double-charge race on rapid Enter/Y — added a `paying` guard. Gate green: 852 engine tests, prettier+tsc+eslint clean, build.mjs builds dist. --------- Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Co-authored-by: qin-ctx <qinhaojie.exe@bytedance.com> Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com> Co-authored-by: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com> Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com> Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> Co-authored-by: Kewe63 <Kewe63@users.noreply.github.com> Co-authored-by: xxxigm <54813621+xxxigm@users.noreply.github.com> Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Co-authored-by: emozilla <emozilla@nousresearch.com> Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com> Co-authored-by: alelpoan <155192176+alelpoan@users.noreply.github.com> Co-authored-by: islam666 <islam666@users.noreply.github.com> Co-authored-by: flooryyyy <67979730+flooryyyy@users.noreply.github.com> Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com> Co-authored-by: ethernet <arilotter@gmail.com> Co-authored-by: Ben <62250174+benfrank241@users.noreply.github.com> Co-authored-by: Ben Barclay <ben@nousresearch.com> Co-authored-by: Evo <r2668940489@gmail.com> Co-authored-by: liuhao1024 <sunsky.lau@gmail.com> Co-authored-by: channkim <chanyoung.kim@nota.ai> Co-authored-by: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com> Co-authored-by: draihan <draihan@student.ubc.ca> Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com> Co-authored-by: colinwren-stripe <92538686+colinwren-stripe@users.noreply.github.com> Co-authored-by: codex <noreply@openai.com> Co-authored-by: AhmetArif0 <147827411+AhmetArif0@users.noreply.github.com> Co-authored-by: Shannon Sands <shannon.sands.1979@gmail.com>
alt-glitch
pushed a commit
that referenced
this pull request
Jun 19, 2026
…bound/outbound round-trip (NousResearch#48828) * fix(relay): enable RELAY platform + normalize dial URL so hosted gateways actually connect Three bugs blocked a self-provisioned hosted gateway from ever establishing its inbound relay WS (found while standing up the live staging end-to-end). Each masked the next; all three are needed for inbound to work. 1. RELAY platform never enabled in config.platforms (gateway/config.py). register_relay_adapter() puts the adapter in the platform_registry, but start_gateway()'s connect loop iterates self.config.platforms — which never contained Platform.RELAY. So the adapter was "registered" but never connected (logs showed "relay adapter registered" then "No messaging platforms enabled"). Fix: _apply_env_overrides now enables Platform.RELAY (mirroring relay_url into extra for the connected-checker) when GATEWAY_RELAY_URL (env) or gateway.relay_url (yaml) is set. Absent -> no RELAY entry (direct/ single-tenant gateways unaffected). 2. URL scheme not converted for the WS dial (gateway/relay/ws_transport.py). The relay URL is configured once as the http(s):// base (used as-is for the provision POST), but websockets.connect rejects http(s):// with "scheme isn't ws or wss". Fix: _ws_dial_url converts https->wss / http->ws. 3. /relay path not appended (same helper). The connector mounts its WebSocketServer at path "/relay" and returns HTTP 400 on an upgrade to any other path. GATEWAY_RELAY_URL is the base (no /relay), so the dial hit "/" -> 400. Fix: _ws_dial_url ensures the path ends in /relay. Idempotent — a URL already carrying ws(s):// and/or /relay is unchanged, so provision's _provision_url (which derives /relay/provision from either form) still works. Why the cross-repo E2E missed #2/#3: the stub connector binds ws://host:port and its websockets.serve accepts ANY path, so neither the scheme nor the /relay path was exercised. Real connector needs both. Verified live on staging hermes-agent-stg-automated-perception-5054: after the fixes the gateway logs "Connecting to relay..." -> "✓ relay connected" -> "Gateway running with 1 platform(s)" against wss://gateway-gateway.staging-nousresearch.com/relay, stable. Tests: added _ws_dial_url scheme+path+idempotency cases (test_ws_transport.py) and RELAY-platform-enablement cases for env + yaml + absent (test_config.py). Full gateway/relay + config suites green (191 passed). Relay-adapter lane. EXPERIMENTAL. * fix(relay): re-attach guild_id to outbound so connector egress resolves the tenant The final bug in the hosted-relay round-trip. Inbound worked end to end (Discord -> connector -> bus -> agent WS -> agent runs -> reply), but the reply's egress was declined by the connector: "discord egress declined: target not routed to an onboarded tenant". Cause: the connector's routedEgressGuard resolves the owning tenant from the OUTBOUND action's metadata.guild_id (Discord's routing discriminator). The gateway's generic delivery path builds outbound metadata via run.py _thread_metadata_for_source, which only carries thread_id (and returns None entirely for a non-threaded message) — so guild_id never reached the connector, tenant resolution failed, and the shared bot refused to post. Fix (relay-adapter-local, no perturbation of the generic delivery path or other platforms): RelayAdapter learns chat_id -> guild_id from each inbound event (_capture_scope) and re-attaches it to the outbound action's metadata in send() (_with_scope) when not already present. No-op for chats we never saw inbound (e.g. DMs) and never overwrites an explicit guild_id. Verified live on staging hermes-agent-stg-automated-perception-5054: an @mention in #general now produces a visible bot reply — full multi-tenant relay round-trip (real Discord -> shared connector bot -> tenant routing -> agent WS -> reply egress -> Discord). Tests: _capture_scope/_with_scope reattach, no-scope no-op, explicit-guild_id preserved (test_relay_adapter.py). Full relay + config suites green (160 passed). Relay-adapter lane. EXPERIMENTAL.
alt-glitch
pushed a commit
that referenced
this pull request
Jun 21, 2026
…id (NousResearch#38763) Context compression today rewrites the message list AND rotates the session id — it ends the session, forks a parent_session_id child, and renumbers the title (name -> name #2). That moving identity key is the root cause of a whole bug cluster: /goal lost (NousResearch#33618), pending response lost at the split (NousResearch#14238), orphan sessions (NousResearch#33907), TUI sid desync (NousResearch#36777), FTS search gaps + duplicate sidebar entries (NousResearch#45117), null continuation cwd (NousResearch#42228), and title-rename dead-ends (NousResearch#48989). It also forced a large defensive apparatus (compression lock, contextvar/env/ logging triple-sync, orphan finalization, gateway SessionEntry re-propagation, tip projection) whose only job is surviving a mid-conversation id change. Add a compression.in_place config flag (default False during rollout). When True, compaction rewrites the transcript and rebuilds the system prompt but keeps the SAME session_id: no end_session, no child row, no title renumber, no contextvar/logging re-sync, no memory/context-engine session-switch. The conversation keeps one durable id for life, like Claude Code / Codex. Compaction is lossy by design — the pre-compaction transcript is summarized away, not archived. The rotation path is unchanged when the flag is off (moved verbatim into an else branch). Staged rollout: this PR ships the option behind a default-off flag for live validation; a follow-up flips the default and deletes the now-redundant rotation machinery, superseding the 14 open band-aid PRs in this area. - hermes_cli/config.py: add compression.in_place (default False), documented - agent/agent_init.py: resolve the flag -> agent.compression_in_place - agent/conversation_compression.py: branch compress_context() on the flag - tests/run_agent/test_in_place_compaction.py: in-place invariants + rotation regression guard + config default The pre-flush of current-turn messages (NousResearch#47202) runs in BOTH modes, so no boundary data loss. Prompt-cache invariant preserved: the system-prompt rebuild is the same single sanctioned invalidation that already happens during compaction — no NEW invalidation. Message alternation preserved.
alt-glitch
pushed a commit
that referenced
this pull request
Jun 30, 2026
…_id signature churn Two independent bugs evicted the cached gateway AIAgent on every turn, preventing the prompt cache from ever warming: 1. Model normalization mismatch: the post-run fallback-eviction check compared _agent.model (stripped in AIAgent.__init__) against the raw _resolve_gateway_model() config string. For vendor-prefixed config on native providers (e.g. 'deepseek/deepseek-v4-pro' vs 'deepseek-v4-pro') this was always unequal, so the agent was evicted after every successful run. Normalize _cfg_model the same way (skip aggregators). 2. Discord triggering message_id leaked into the cached system prompt via build_session_context_prompt()'s Discord IDs block. message_id changes every turn, so the agent-cache signature (computed from the ephemeral prompt) changed every Discord turn -> rebuild every message. The id is now injected per-turn into the user message (where per-turn content belongs and does not touch the cache signature); the cached IDs block carries a static pointer to it, preserving reply/react/pin via the discord tools. Adapted from NousResearch#28846. Bug #1 fix is the contributor's; bug #2 reworked to be non-destructive (keeps the triggering-id capability instead of deleting it). Redundant auto-reset eviction (already on main via NousResearch#9893/NousResearch#48031) and the wrong-premise reset_context_note plumbing from the original PR were dropped. Co-authored-by: Hermes Agent <hermes@nousresearch.com>
alt-glitch
pushed a commit
that referenced
this pull request
Jul 1, 2026
… fail on '(empty)' sentinel Two related bugs caused subagent delegation to silently return empty summaries with 0 tokens when the user configured delegation.provider=bedrock alongside delegation.base_url=https://bedrock-runtime.<region>.amazonaws.com. Root cause #1 — misrouting in _resolve_delegation_credentials(): The configured_base_url branch unconditionally forced provider='custom' and api_mode='chat_completions', only specializing for chatgpt.com, anthropic, and kimi hosts. Bedrock (and other native-SDK providers) fell through as 'custom' + chat_completions, which then POSTed OpenAI-shaped JSON at Bedrock's native API. Bedrock rejected the payload and returned nothing, which looked like an empty LLM response to the child agent. Fix: when provider is one of {bedrock, vertex, google, google-genai}, skip the base_url short-circuit and fall through to resolve_runtime_provider(), which knows how to construct the proper SDK client. base_url can still be forwarded through that path for regional overrides. Root cause #2 — '(empty)' sentinel accepted as success: After N retries of empty LLM responses, run_agent.py emits the literal string '(empty)' as final_response. _run_single_child then hit `elif summary:` — '(empty)' is truthy, so status became 'completed' and the parent surfaced a blank result with no error. Users saw api_calls=4, tokens=0, duration~0.4s, status=completed. Fix: treat final_response.strip() == '(empty)' as a failure so the parent surfaces it instead of silently accepting zero-content 'success'. Both paths were reproduced in a live Hermes TUI session on us-west-2 Bedrock (provider=bedrock, model=us.anthropic.claude-sonnet-4-6) and are covered by new tests in tests/tools/test_delegate.py.
alt-glitch
pushed a commit
that referenced
this pull request
Jul 16, 2026
Completes the review's ask for "adapter-to-session-key integration coverage for Discord and a non-Discord platform" on NousResearch#20096. Drives a concrete adapter's real BasePlatformAdapter.build_source with an injected gateway_runner, asserts the matched route's profile is stamped on the source, and that build_session_key scopes the key under agent:<profile>: (versus the shared agent:main: namespace). Covers Discord and Telegram — the Telegram case is the bug-#2 path that previously fell through to default. Adds a regression anchor: without gateway_runner, profile stays None and the key lands in agent:main (the silent fallback the fix removes for non-Discord). Co-Authored-By: Claude <noreply@anthropic.com>
alt-glitch
pushed a commit
that referenced
this pull request
Jul 16, 2026
…st (NousResearch#65214) Moves the fireworks entry in CANONICAL_PROVIDERS from its old slot (after GMI Cloud) to directly below Nous Portal, ahead of OpenRouter. Order propagates automatically to hermes model, the setup wizard, Telegram /model, and the desktop provider catalog.
alt-glitch
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.
alt-glitch
pushed a commit
that referenced
this pull request
Jul 21, 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.
alt-glitch
added a commit
that referenced
this pull request
Jul 21, 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.
alt-glitch
added a commit
that referenced
this pull request
Jul 21, 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.
* …
alt-glitch
pushed a commit
that referenced
this pull request
Jul 21, 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>
alt-glitch
pushed a commit
that referenced
this pull request
Jul 21, 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.
alt-glitch
pushed a commit
that referenced
this pull request
Jul 27, 2026
…ch#67140) The background write guard decided ownership from `isinstance(usage_rec, dict)`, so a local skill with NO usage record passed. That successful write called bump_patch(), which created a `created_by: null` record — and the identical write was refused from then on. "Allowed exactly once, then never" is a race with our own bookkeeping, not a policy. Reproduced on main: patch #1 succeeds, patch #2 with the same arguments is refused. Option B from the issue. Option A (split `session_review` from `scheduled_curator` and let the session fork patch user-owned skills it consulted) would widen autonomous write permission onto skills the user owns with no user present to consent — wrong direction for a no-user-present actor. - skill_manager_tool: missing and explicit-null records now resolve IDENTICALLY, both fail closed. The refusal names the reason and points at `hermes curator adopt <name>`. - background_review: both review prompts told the reviewer to patch any skill consulted in the session and claimed pinned skills could be improved, while enforcement refused both. Prompts now list pinned, external, and user-owned skills as protected, and tell the reviewer to RECOMMEND adoption instead of attempting a write that will be refused. - skill_usage: document that `created_by` is a curator-management policy flag, not a provenance claim, and add `is_curator_managed()` so call sites read as the question they ask. Field name retained — it is on disk in every `.usage.json` and renaming would strand those records. - curator CLI: `hermes curator list-unmanaged` itemizes unmanaged skills with the reason each is unmanaged (completes the NousResearch#67139 spec). Foreground writes are untouched: a user-directed edit to a user-owned skill still works, including on pinned skills. Sibling tests: 9 failures in test_skill_manager_tool.py were fixtures that created record-less skills to exercise OTHER guards (consolidation-delete, read-before-write) and relied on ownership falling through. Fixed at the fixture, since the real curator only ever operates on managed sediment. One test asserted the old "manually authored" wording; rewritten to assert the behavior contract instead of the string. Validation: 274 targeted tests + all 7 background-review files (60 tests) pass. E2E on a temp HERMES_HOME (30 checks) covers the flip, foreground writes, adoption unblocking, pin semantics, prompt/enforcement parity, and the new verb. Each new test sabotage-verified: revert the fix, confirm it goes red. Fixes NousResearch#67140
alt-glitch
pushed a commit
that referenced
this pull request
Jul 27, 2026
…hat tile (NousResearch#71969) * fix: Branch button is a dead no-op inside a branched chat tile session-tile.tsx wired onBranchInNewChat to () => undefined for tiled/branched sessions (nested branching isn't supported there), but the button in AssistantMessage's action bar rendered unconditionally regardless of whether a real handler was supplied. The button looked clickable but silently did nothing, with no visual feedback. - AssistantMessage now only renders the Branch button when onBranchInNewChat is actually provided, matching the existing pattern used for onDismissError/onRestoreToMessage. - session-tile.tsx no longer passes a no-op handler; the prop is simply omitted so the button doesn't render in tiles. - onBranchInNewChat is now optional on ChatViewProps, and the latestChatActions passthrough wrapper uses the existing latestOptional helper instead of an unconditional call. * test: assert Branch button visibility matches handler presence Adds coverage for the bug #2 fix: renders Thread with and without an onBranchInNewChat handler and asserts the Branch in new chat button is shown only when a real handler is supplied, hidden otherwise - covering both the normal open-chat case and the session-tile (branched chat) case that used to leave a dead, clickable button.
alt-glitch
pushed a commit
that referenced
this pull request
Aug 4, 2026
… a broken chat A completely unconfigured install previously booted into a working-looking chat (banner showed model 'unknown'), accepted a message, spun ~30s, then failed with 'Set OPENROUTER_API_KEY' — a provider the user never chose — and never offered setup. - HermesCLI.run() now probes provider readiness at startup (TTY only) and offers the shared provider picker (hermes model flow, which fronts Quick Setup / Nous Portal OAuth) when nothing is configured. Decline is respected; picker state re-syncs into the live CLI so the next turn works without a restart. - New silent probe _runtime_credentials_ready(): no printing, no state mutation; handles keyless local endpoints and callable bearer providers. - The empty-api-key error is provider-aware: names the actual resolved provider and points at 'hermes model' / 'hermes setup' instead of hardcoding OPENROUTER_API_KEY. - Banner: unconfigured installs render 'no model configured — run /model' in red instead of the silent 'unknown' model slug. Consumer-onboarding audit finding #2 (sev 5), Aug 2026.
alt-glitch
pushed a commit
that referenced
this pull request
Aug 4, 2026
A wedged adapter transport (network hang, dead websocket) previously blocked _check_session_stalls forever: sibling candidates in the same pass were never evaluated and the watcher stopped ticking. Wrap the send in asyncio.wait_for (15s); on timeout log a WARNING and do NOT latch, so the next tick retries. Regression uses a never-resolving fake adapter and proves the pass completes, a healthy sibling candidate is still notified in the same pass, and the watcher ticks again (sabotage-verified against the unbounded send).
alt-glitch
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>
alt-glitch
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>
alt-glitch
pushed a commit
that referenced
this pull request
Aug 17, 2026
Two independent bugs let a deleted profile reappear / leave orphaned resources on next launch: 1. hermes_cli/profiles.py's backend-process scanner required argv[0] to resolve to an executable literally named "hermes". Electron's pool-backend spawn resolves the hermes console-script shim's path and execs it via the interpreter directly (python3 /path/to/hermes ...), so argv[0] reports as "python3" and the scanner never matched the running backend -- delete removed the profile's files but left its live backend process running (still bound to a port via uvicorn), which accumulates across repeated delete/recreate cycles. 2. The desktop sidebar's ProfileRail only refreshed its cached profile list once, on mount, so a delete/create/rename from another surface (another window, or the CLI) left a stale ghost entry until something unrelated triggered a refetch. Note: a delete via this window's own Manage-Profiles view already refreshes the shared $profiles atom ProfileRail subscribes to (confirmed by reading refreshProfiles() and handleConfirmDelete()) -- this fix only covers the cross-window/cross- process staleness gap, not a duplicate of the already-merged NousResearch#57329's Manage-Profiles rail-refresh work. Fix 1: recognize a python-interpreter argv[0] exec'ing a hermes-named console-script shim via argv[1]. Fix 2: refresh the profile list on window focus/visibilitychange, matching the existing pattern used elsewhere in the sidebar (sidebar/index.tsx, use-background-sync.ts, star-map.tsx, use-gateway-boot.ts all use the same focus+visibilitychange pattern). ## Related work already on main PR NousResearch#57329 (merged) fixed the *headline* symptom from issue NousResearch#52279 (deleted profile respawns) via a different, non-overlapping mechanism: routing profile-delete through the primary backend instead of spawning a fresh pool backend, plus a separate recreation guard in ensure_hermes_home() (NousResearch#49435, merged) that makes a backend spawned into a deleted profile's directory raise FileNotFoundError instead of silently recreating it. This PR is NOT a duplicate of that fix. Verified: even with both of those merged, a backend process that survives because of gap #1 above still holds a bound port via uvicorn -- it just can no longer resurrect the profile directory. That's real resource-hygiene, not a symptom already covered. Gap #2 touches a different file/component (ProfileRail / profile-switcher.tsx) than NousResearch#57329's rail-refresh half (which touched the Manage-Profiles view's own $profiles.ts / index.tsx) and covers a distinct staleness path (cross-window/cross-process, not same-window delete-then-refresh). Tests: tests/hermes_cli/test_profiles.py -- 156 passed (existing + regression coverage for the argv[0] python-interpreter detection case). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
sync: defer — 75 upstream commits (gateway contract + subagent-stream port + 2 defer-flagged surfaces)
Decision: DEFER. The
sid/opentuilive branch was left untouched — glitch's running install is unaffected. This branch (sync/defer-*) holds the merged-and-resolved result for review.Scope
upstream/maincommits since the fork tip (963ada53).Why deferred (any one of these is an always-defer trigger)
1. Two
!defer-flagged surfaces (contract / cache risk)bd7fc8fdcd67feat(gateway): inject stable human-readable message timestamps — surfaceagent-loop-core!defer. Touches the core message/prompt path (13 files). Timestamp injection into context is exactly the kind of change that can perturb the byte-stable system-prompt / per-conversation cache invariant. A follow-up36ae958473b8gates it behind opt-in (default off), but the core path change still needs a human eye on the OpenTUI side. Needs review, not auto-merge.7d938cc5c9c7fix(desktop): keep live model switch metadata truthful — surfacetui_gateway!defer. Gateway live model-switch metadata contract.2. Two real
needs_portgateway/desktop features (no auto-port envelope match)cb6b4127e795refactor(desktop): make composer model picker sticky session state (16 files, surfacetui_gateway). Adds newsession.createoverrides (model,provider,reasoning_effort,fast) that the gateway records as per-session overrides, builds the agent with directly, reflects in the immediatesession.info, and persists into the lazy DB row. This is a gateway contract change the OpenTUI engine's composer +boundary/does not yet send or handle. Porting requires the engine composer to ship these onsession.createand the boundary schema to decode the new info shape — real engineering, not additive wiring onto an existing scaffold.44e5848e7418feat(desktop): stream subagent activity into watch windows (feat(desktop): stream subagent activity into watch windows NousResearch/hermes-agent#47060, 9 files, surfacetui_gateway). Introduces a newsubagent.textgateway event mirrored tomessage.deltaon the child sid (per-token frames), plus lazy watch-session guards. Per-token streaming events MUST coalesce in the engine reducer or they flood the trace, and the OpenTUI watch-window child-session scaffold needs verification before mirroring. This is the streaming case the playbook explicitly says to defer when coalescing safety is unproven.3. The rest
surface: other(desktop Electron, model-list/picker fixes, skills, telegram, auth, curator, dashboard). They merged cleanly and individually look fine, but they ride along with the defer-flagged + needs-port commits above; advancing them would require splitting the range, which is glitch's call.What I auto-resolved (so this branch is coherent for review)
tests/test_tui_gateway_server.py— additive test-file conflict. diff3 base section was empty;oursaddedTestProcessCompletionCard(glitch, 2026-06-14 background-process completion card),theirsaddedtest_session_create_records_ui_model_as_session_override+test_start_agent_build_passes_session_model_override(upstream, the sticky-picker tests). Resolved by keeping both sides verbatim, deleting only the conflict markers.py_compilepasses; both glitch's class and upstream's two functions are present.tui_gateway/server.pyauto-merged textually (no markers) — this is where the sticky-pickersession.createoverride logic lands; flagged for human review as part of the needs-port decision above.What I did NOT do
sid/opentui. Did not restart the gateway.What glitch needs to decide
bd7fc8fdcd67): confirm it's cache-safe on the OpenTUI path (it's opt-in/default-off upstream — likely safe to take, but core path).cb6b4127e795): port thesession.createoverrides into the OpenTUI composer + boundary, or take the gateway-side change only and wire the engine later.44e5848e7418): decide on thesubagent.text→message.deltamirror + per-token coalescing in the OpenTUI store before porting.othercommits ahead of the harder three.Worktree was torn down after pushing this branch.