feat(web): Keenable integration — native provider (keyless), CLI skill, MCP catalog - #1
feat(web): Keenable integration — native provider (keyless), CLI skill, MCP catalog#1IlyaGusev wants to merge 4385 commits into
Conversation
Code Review by Qodo
1. Missing keenable skill test
|
|
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-import |
181 |
unresolved-attribute |
159 |
invalid-argument-type |
95 |
invalid-assignment |
34 |
not-subscriptable |
29 |
unsupported-operator |
27 |
invalid-method-override |
19 |
invalid-return-type |
10 |
unresolved-reference |
5 |
unused-type-ignore-comment |
3 |
call-non-callable |
3 |
no-matching-overload |
2 |
invalid-raise |
1 |
not-iterable |
1 |
First entries
plugins/platforms/telegram/adapter.py:2220: [unresolved-attribute] unresolved-attribute: Attribute `VOICE` is not defined on `None` in union `Unknown | None`
tests/agent/test_turn_context.py:132: [invalid-argument-type] invalid-argument-type: Argument to function `build_turn_context` is incorrect: Expected `int | float | None`, found `Unknown | str | None | ... omitted 6 union elements`
tests/cron/test_scheduler_provider.py:171: [not-subscriptable] not-subscriptable: Cannot subscript object of type `float` with no `__getitem__` method
tests/gateway/test_internal_event_never_interrupts_busy_session.py:53: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `Platform`, found `MagicMock`
tests/tools/test_memory_tool.py:21: [unresolved-attribute] unresolved-attribute: Attribute `lower` is not defined on `dict[str, str | dict[str, dict[str, str | list[str]] | dict[str, str] | dict[str, str | dict[str, str | dict[str, dict[str, str | list[str]] | dict[str, str]] | list[str]]]] | list[str]]` in union `str | dict[str, str | dict[str, dict[str, str | list[str]] | dict[str, str] | dict[str, str | dict[str, str | dict[str, dict[str, str | list[str]] | dict[str, str]] | list[str]]]] | list[str]]`
tests/hermes_cli/test_mcp_reload_confirm_gate.py:33: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `str`, `list[Unknown]`, `list[str]`, `None`, `int`, `float` in union `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 34 union elements`
plugins/platforms/telegram/adapter.py:134: [unresolved-import] unresolved-import: Module `telegram` has no member `InlineKeyboardButton`
plugins/platforms/telegram/adapter.py:2220: [unresolved-attribute] unresolved-attribute: Attribute `Sticker` is not defined on `None` in union `Unknown | None`
tests/tools/test_mcp_elicitation.py:14: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
plugins/platforms/whatsapp/adapter.py:355: [invalid-argument-type] invalid-argument-type: Argument to constructor `Path.__new__` is incorrect: Expected `str | PathLike[str]`, found `str | None`
tests/agent/test_onboarding.py:311: [not-subscriptable] not-subscriptable: Cannot subscript object of type `float` with no `__getitem__` method
tests/tools/test_clarify_tool.py:220: [unused-type-ignore-comment] unused-type-ignore-comment: Unused blanket `type: ignore` directive
tests/hermes_cli/test_cron_fire_dashboard.py:17: [unresolved-import] unresolved-import: Cannot resolve imported module `starlette.testclient`
tests/plugins/memory/test_openviking_provider.py:2721: [invalid-assignment] invalid-assignment: Object of type `<class 'StubClient'>` is not assignable to attribute `_VikingClient` of type `<class '_VikingClient'>`
tools/delegate_tool.py:2554: [invalid-argument-type] invalid-argument-type: Argument to function `dispatch_async_delegation_batch` is incorrect: Expected `list[str]`, found `list[Any | str | None | list[str]]`
plugins/platforms/wecom/callback_adapter.py:209: [unresolved-attribute] unresolved-attribute: Attribute `post` is not defined on `None` in union `Unknown | None`
plugins/platforms/slack/adapter.py:3640: [invalid-return-type] invalid-return-type: Function can implicitly return `None`, which is not assignable to return type `str`
tests/tools/test_approval_interrupt.py:131: [invalid-assignment] invalid-assignment: Object of type `() -> dict[str, int]` is not assignable to attribute `_get_approval_config` of type `def _get_approval_config() -> dict[Unknown, Unknown]`
tests/gateway/relay/test_descriptor_from_entry.py:24: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `((Any, /) -> bool) | None`, found `str | ((cfg) -> None) | (() -> bool) | int`
tests/tools/test_browser_console.py:342: [not-subscriptable] not-subscriptable: Cannot subscript object of type `float` with no `__getitem__` method
plugins/platforms/dingtalk/adapter.py:1286: [unresolved-attribute] unresolved-attribute: Attribute `RobotReplyEmotionHeaders` is not defined on `None` in union `Unknown | None`
plugins/platforms/feishu/adapter.py:5390: [invalid-argument-type] invalid-argument-type: Argument to function `save_env_value` is incorrect: Expected `str`, found `Unknown | str | None`
tests/plugins/test_hindsight_root_guard.py:12: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/cron/test_scheduler.py:2774: [invalid-assignment] invalid-assignment: Object of type `def in_flight_cancel() -> Unknown` is not assignable to attribute `cancel` of type `def cancel(self) -> bool`
plugins/platforms/dingtalk/adapter.py:1068: [unresolved-attribute] unresolved-attribute: Attribute `CreateCardRequestImRobotOpenSpaceModel` is not defined on `None` in union `Unknown | None`
... and 544 more
✅ Fixed issues (266):
| Rule | Count |
|---|---|
unresolved-attribute |
98 |
unresolved-import |
64 |
invalid-argument-type |
41 |
invalid-method-override |
20 |
invalid-assignment |
13 |
unsupported-operator |
12 |
invalid-return-type |
5 |
call-non-callable |
3 |
unknown-argument |
2 |
unresolved-reference |
2 |
no-matching-overload |
2 |
unresolved-global |
1 |
invalid-raise |
1 |
unused-type-ignore-comment |
1 |
not-subscriptable |
1 |
First entries
hermes_cli/web_server.py:4093: [invalid-argument-type] invalid-argument-type: Argument to function `_build_catalog_entry` is incorrect: Expected `str`, found `Literal["telegram", "discord", "whatsapp", "whatsapp_cloud", "slack", ... omitted 17 literals] | set[Unknown]`
gateway/platforms/matrix.py:1858: [invalid-method-override] invalid-method-override: Invalid override of method `send_document`: Definition is incompatible with `BasePlatformAdapter.send_document`
gateway/platforms/telegram.py:2805: [unresolved-attribute] unresolved-attribute: Attribute `send_message` is not defined on `None` in union `Unknown | None`
gateway/platforms/feishu.py:2993: [unresolved-import] unresolved-import: Cannot resolve imported module `lark_oapi.api.im.v1`
gateway/session.py:257: [invalid-argument-type] invalid-argument-type: Argument to bound method `PlatformRegistry.get` is incorrect: Expected `str`, found `Literal["local", "telegram", "discord", "whatsapp", "whatsapp_cloud", ... omitted 18 literals] | set[Unknown]`
gateway/platforms/dingtalk.py:1107: [unresolved-attribute] unresolved-attribute: Attribute `DeliverCardHeaders` is not defined on `None` in union `Unknown | None`
gateway/platforms/dingtalk.py:1192: [unresolved-attribute] unresolved-attribute: Attribute `StreamingUpdateRequest` is not defined on `None` in union `Unknown | None`
gateway/platforms/slack.py:93: [unresolved-import] unresolved-import: Cannot resolve imported module `slack_sdk.web.async_client`
tests/tools/test_stage2_hook_build_tree_chown.py:29: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
gateway/run.py:1770: [invalid-return-type] invalid-return-type: Return type does not match returned value: expected `str`, found `Literal["cli", "telegram", "discord", "whatsapp", "whatsapp_cloud", ... omitted 18 literals] | set[Unknown]`
gateway/platforms/slack.py:1906: [invalid-method-override] invalid-method-override: Invalid override of method `send_video`: Definition is incompatible with `BasePlatformAdapter.send_video`
gateway/slash_commands.py:1739: [invalid-argument-type] invalid-argument-type: Argument to function `_home_target_env_var` is incorrect: Expected `str`, found `Literal["local", "telegram", "discord", "whatsapp", "whatsapp_cloud", ... omitted 18 literals] | set[Unknown]`
gateway/platforms/dingtalk.py:1441: [unresolved-attribute] unresolved-attribute: Attribute `data` is not defined on `None` in union `Unknown | None`
gateway/platforms/wecom_callback.py:341: [unresolved-attribute] unresolved-attribute: Attribute `fromstring` is not defined on `None` in union `Unknown | None`
gateway/platforms/dingtalk.py:257: [unresolved-attribute] unresolved-attribute: Attribute `AsyncClient` is not defined on `None` in union `Unknown | None`
gateway/platforms/matrix.py:2761: [unresolved-import] unresolved-import: Cannot resolve imported module `mautrix.crypto.attachments`
gateway/channel_directory.py:87: [invalid-assignment] invalid-assignment: Invalid subscript assignment with key of type `Literal["local", "telegram", "discord", "whatsapp", "whatsapp_cloud", ... omitted 18 literals] | set[Unknown]` and value of type `list[dict[str, str]]` on object of type `dict[str, list[dict[str, str]]]`
gateway/platforms/email.py:475: [invalid-argument-type] invalid-argument-type: Argument to bound method `IMAP4.uid` is incorrect: Expected `str`, found `None`
gateway/platforms/telegram_network.py:18: [unresolved-import] unresolved-import: Cannot resolve imported module `httpx`
gateway/platforms/wecom.py:1430: [invalid-method-override] invalid-method-override: Invalid override of method `send_image_file`: Definition is incompatible with `BasePlatformAdapter.send_image_file`
gateway/platforms/matrix.py:1139: [unresolved-import] unresolved-import: Cannot resolve imported module `mautrix.api`
gateway/platforms/sms.py:293: [unresolved-import] unresolved-import: Cannot resolve imported module `aiohttp`
gateway/platforms/feishu.py:1368: [unresolved-import] unresolved-import: Cannot resolve imported module `lark_oapi.core`
gateway/platforms/dingtalk.py:1279: [unresolved-attribute] unresolved-attribute: Attribute `RobotReplyEmotionHeaders` is not defined on `None` in union `Unknown | None`
tools/send_message_tool.py:1517: [unresolved-import] unresolved-import: Cannot resolve imported module `markdown`
... and 241 more
Unchanged: 5404 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
PR Summary by QodoAdd Keenable optional skill and MCP catalog entry WalkthroughsDescription• Register Keenable as an approved remote HTTP MCP (free-tier, no-auth) catalog entry. • Add an optional keenable CLI skill for web search and page-to-markdown fetch workflows. • Document rate-limit/auth tradeoffs and when to prefer native web_search/web_extract. Diagramgraph TD
agent["Hermes Agent"] --> skill["optional-skills/research/keenable-cli/SKILL.md"] --> cli["keenable CLI"] --> rest["Keenable REST API"]
agent --> catalog["optional-mcps/keenable/manifest.yaml"] --> mcp["Hermes MCP client"] --> remote["api.keenable.ai/mcp"]
High-Level AssessmentThe following are alternative approaches to this PR: 1. Add API-key header injection for HTTP MCP catalog entries
2. Ship only the MCP catalog entry (omit CLI skill)
3. Rely solely on native `web_search`/`web_extract` toolset (no Keenable integration)
Recommendation: The PR’s dual-surface approach (unauthenticated MCP entry + optional CLI skill for authenticated/high-rate usage) is the best fit given current Hermes limitations around HTTP catalog auth header injection. If Keenable adoption grows, consider the first alternative as a follow-up to support File ChangesEnhancement (1)
Other (1)
|
| From source: `cargo install --git https://github.com/keenableai/keenable-cli`. | ||
| Update with `brew upgrade keenable-cli` or by re-running the installer. | ||
|
|
||
| **Auth is optional.** The free tier needs nothing. To raise rate limits, either run `keenable login` (device-code flow — prints a link + code, works headless) or pass `--api-key keen_***` on any command. `KEENABLE_API_KEY` from `~/.hermes/.env` is picked up as the key for REST calls. |
There was a problem hiding this comment.
1. Hardcoded ~/.hermes/.env path 📘 Rule violation ≡ Correctness
SKILL.md hardcodes ~/.hermes/.env, which is incorrect under non-default profiles and conflicts with profile-isolation expectations. This can mislead users into placing secrets in the wrong location when HERMES_HOME is set.
Agent Prompt
## Issue description
The skill documentation hardcodes `~/.hermes/.env`, which is not profile-aware and can be wrong when users set `HERMES_HOME`.
## Issue Context
Hermes supports profile isolation via `HERMES_HOME`, so user-facing path guidance should not assume `~/.hermes`.
## Fix Focus Areas
- optional-skills/research/keenable-cli/SKILL.md[63-63]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| --- | ||
| name: keenable-cli | ||
| description: Search the web and fetch pages as markdown via Keenable. | ||
| version: 1.0.0 | ||
| author: Ilya Gusev (Keenable) | ||
| license: MIT | ||
| platforms: [linux, macos, windows] | ||
| required_environment_variables: | ||
| - name: KEENABLE_API_KEY | ||
| prompt: Keenable API key (optional) | ||
| help: Create one at https://keenable.ai/signup. Skippable — the free tier works without a key; a key raises rate limits. | ||
| required_for: higher rate limits | ||
| metadata: | ||
| hermes: | ||
| tags: [Research, Web, Search, Fetch, Markdown, CLI] | ||
| related_skills: [parallel-cli, searxng-search, duckduckgo-search] | ||
| --- |
There was a problem hiding this comment.
2. Missing keenable skill test 📘 Rule violation ☼ Reliability
This PR adds a new optional skill but does not add the required tests/skills/test_<skill>_skill.py coverage. That increases the risk of format/packaging regressions slipping into main.
Agent Prompt
## Issue description
A new skill was added without the required test coverage under `tests/skills/`.
## Issue Context
The compliance checklist requires a `tests/skills/test_<skill>_skill.py` test for each new skill to validate the skill package structure and key invariants without performing live network calls.
## Fix Focus Areas
- optional-skills/research/keenable-cli/SKILL.md[1-112]
- tests/skills/test_keenable_cli_skill.py[1-200]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
|
7 similar comments
|
|
|
|
|
|
|
|
Phase 1 of the pluggable cron-scheduler refactor (Axis B — the trigger).
No call-site changes; this phase only makes the abstraction exist + tested
in isolation.
Task 1.1: cron/scheduler_provider.py — the EXPERIMENTAL CronScheduler ABC.
Required surface is name + start; is_available()/stop() carry safe defaults.
is_available has a no-network invariant. Docstring marks it experimental
until the Chronos provider (Phase 4) validates the shape.
Task 1.2: InProcessCronScheduler wraps the historical 60s ticker loop, calling
cron.scheduler.tick(sync=False) exactly as the raw ticker does. Uses
stop_event.wait(interval) for responsive stop (both raw tickers already do).
Tests: ABC-is-abstract, default-is_available, the InProcess loop drives tick
and stops, stop() no-op, and test_abc_growth_stays_additive (the forward-compat
guard: required abstractmethods must stay exactly {name, start}, so the three
Phase-4 hooks land as NON-abstract additions).
tick() internals in cron/scheduler.py are byte-unchanged (only new file added).
Phase 0 characterization tests still green. Full tests/cron/: 445 passed.
|
🚨 CRITICAL Supply Chain Risk DetectedThis PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging. 🚨 CRITICAL: Install-hook file added or modifiedThese files can execute code during package installation or interpreter startup. Files: Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting. |
|
🚨 CRITICAL Supply Chain Risk DetectedThis PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging. 🚨 CRITICAL: Install-hook file added or modifiedThese files can execute code during package installation or interpreter startup. Files: Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting. |
|
🚨 CRITICAL Supply Chain Risk DetectedThis PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging. 🚨 CRITICAL: Install-hook file added or modifiedThese files can execute code during package installation or interpreter startup. Files: Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting. |
|
🚨 CRITICAL Supply Chain Risk DetectedThis PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging. 🚨 CRITICAL: Install-hook file added or modifiedThese files can execute code during package installation or interpreter startup. Files: Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting. |
|
🚨 CRITICAL Supply Chain Risk DetectedThis PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging. 🚨 CRITICAL: Install-hook file added or modifiedThese files can execute code during package installation or interpreter startup. Files: Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting. |
|
🚨 CRITICAL Supply Chain Risk DetectedThis PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging. 🚨 CRITICAL: Install-hook file added or modifiedThese files can execute code during package installation or interpreter startup. Files: Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting. |
NousResearch#66373 swapped GITHUB_TOKEN -> AUTOFIX_BOT_PAT across the workflows and NousResearch#66577 restored the `|| github.token` fork fallback for detect-changes and the label gates -- but it missed the ci-timings "Collect timings and generate report" step, which still passes a bare AUTOFIX_BOT_PAT. On fork PRs that PAT is empty, so timings_report.py hard-fails at expect_env("GITHUB_TOKEN") before it can reach its own "degraded run must never redden the PR" soft-fail path. Every fork PR gets a red run from this advisory job (e.g. NousResearch#66573). - ci.yml: apply the same `secrets.AUTOFIX_BOT_PAT || github.token` fallback to the timings step. github.token has `actions: read`, enough to read the run's job/step durations on forks. - timings_report.py: treat a missing/empty GITHUB_TOKEN as a degraded run (TimingsUnavailable) instead of a hard ValueError, so this whole class of failure can never redden a PR again even if a future workflow drops the token. Still writes no JSON, so no empty baseline is ever cached.
…cus-open fix(desktop): stop tooltips re-opening when a menu/dialog restores focus to its trigger
…s-fork-token fix(ci): make timings report fork-safe (missed by NousResearch#66577)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…gate_task returns results
run_agent._dispatch_delegate_task forces background=True for every top-level
delegation, and async_delivery_supported() returns True for any session that
never binds the capability. On runners that cannot receive a completion after
their turn ends, that combination silently discards every subagent result: the
model gets a dispatch handle, ends its turn, and reports 'waiting for results'.
Two such runners never bind the capability:
* hermes -z (one-shot) prints one final response and exits. It bypasses cli.py,
so nothing drains process_registry.completion_queue (only the interactive
process_loop and the gateway watchers do).
* cron run_job clears the HERMES_SESSION_* routing keys, so a completion event
carries session_key="" — _enrich_async_delegation_routing cannot resolve it
and _inject_watch_notification drops it ("no routing metadata"). By then
run_job has already shipped the job's final response via _deliver_result;
there is no turn left to re-enter. Worse, get_current_session_key() can fall
back to the ambient os.environ HERMES_SESSION_KEY, so a cron subagent's output
can be routed into an unrelated user chat rather than merely dropped.
Add declare_stateless_channel() and bind it in both runners, routing
delegate_task to its existing inline/synchronous path — the same fallback the
stateless HTTP adapter already relies on, and the fix suggested in NousResearch#63142. The
helper binds only the capability: set_session_vars() would also latch
_session_context_engaged, which a pure single-process one-shot must not trigger.
Also correct two agent-facing strings that hardcoded 'stateless HTTP API' as the
only channel without async delivery (delegate_tool, terminal_tool); they now name
the actual condition.
Repro (before): hermes -z 'Use delegate_task to spawn a subagent that replies
BANANA. Report its reply.' -> "Waiting for the subagent's response...", exit 0,
no BANANA. After: BANANA is returned in-turn.
Fixes NousResearch#53027
Fixes NousResearch#63142
A plain /usr/bin/tini → /init symlink forwarded tini's -g into s6-overlay's rc.init as the container CMD, causing boot loops after image updates that preserve old entrypoints (NousResearch#66679).
Unit-test flag stripping without Docker, and assert the image shim rejects the rc.init '-g: not found' restart loop from NousResearch#66679.
`_prepare_inbound_message_text` (async) called `_decide_image_input_mode` inline for every inbound image. That decision is synchronous and does blocking network I/O on the way to a capability answer: - `agent.models_dev.fetch_models_dev` — an HTTP GET to models.dev (15s timeout) whenever the 1-hour in-memory cache is cold or models.dev is slow. - `agent.model_metadata.query_ollama_supports_vision` — HTTP probes (`detect_local_server_type` + `/api/show`) against a local Ollama server when the active provider fronts one. Running that inline blocks the gateway event loop for up to the request timeout — so a single user attaching an image freezes EVERY session on that gateway (no other messages processed, no heartbeats) until the fetch/probe returns or times out. This is the same off-the-loop class as the cron-fire verifier and the async_is_safe_url work. Wrap the call in `asyncio.to_thread` so the blocking capability lookup runs on a worker thread and the loop stays responsive. The decision result and routing are unchanged. Test: a gateway image-routing runtime test asserts the capability lookup runs off the main (event-loop) thread; it runs on the main thread before the fix.
… tools to leaf children A leaf subagent is meant to be denied delegate_task, execute_code, memory, clarify, cronjob, and send_message. _strip_blocked_tools() only drops a toolset when EVERY tool in it is blocked, so mixed platform bundles (hermes-cli, hermes-telegram, and every other gateway bundle) survived stripping and re-exposed the blocked tools after composite expansion. A leaf child spawned from any gateway platform could recursively delegate, run code, and write memory. Pass exact one-tool deny toolsets into the child's disabled_toolsets so model_tools subtracts the blocked names AFTER composite expansion, and the restriction survives later registry/MCP refreshes. Orchestrators regain only delegate_task. Salvaged from NousResearch#66036 by Mason Tanguay (@DictatorBacon); scoped to the authority fix + its regressions (docs/interrupt changes dropped). Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
…, /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 NousResearch#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 (NousResearch#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 (NousResearch#9) * fix(billing): thread idempotency key through the TUI step-up replay (NousResearch#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 (NousResearch#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 NousResearch#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.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Tool results scraped from the web/social platforms can carry unpaired UTF-16 surrogates (e.g. half of a mathematical-bold character pair). _sha256() did a strict utf-8 encode, which raises UnicodeEncodeError on that input and took down the whole conversation loop — the hash only needs deterministic bytes, not valid UTF-8, so encode with surrogatepass instead.
Grant ALL APPLICATION PACKAGES RX on the unpacked app and stick a boot marker so fatal Chromium sandbox deaths relaunch with --no-sandbox (NousResearch#38216).
…sandbox loss Follow-up to the salvaged NousResearch#66803 (@HexLab98): - Two-strike boot marker: a single mid-boot abort (task-manager kill, power loss) no longer disables the sandbox — only a second consecutive abort, or a signature-confirmed GPU/renderer STATUS_BREAKPOINT death, engages --no-sandbox. - Version-scoped stickiness: the fallback marker records the app version and re-probes the sandbox once after an update (new Electron or installer ACL repair may have fixed the host) instead of degrading forever. A failed re-probe returns straight to fallback. - Launch-time icacls repair now runs only when the marker shows a prior aborted boot (icacls /T recurses the whole install tree — healthy launches skip it; the installer grants the ACE at install time), and targets the install dir only. The userData grant is dropped: granting S-1-15-2-2 RX on userData would expose Hermes sessions/config to every AppContainer app on the machine. - Renderer crash-loop recovery (same class as NousResearch#56726, credit @Sahil-SS9 in PR NousResearch#57414): a Windows renderer crash loop bearing the breakpoint exit code gets the same one-shot --no-sandbox relaunch instead of a dead window; unrelated crash loops keep the sandbox. - Manual --no-sandbox launches are honored but never made sticky. Tests: 15/15 windows-sandbox-fallback vitest; full desktop electron suite 432 passed / 1 skipped.
Windows Notepad and PowerShell 5.1 Set-Content -Encoding UTF8 write a
leading UTF-8 BOM. json.load under encoding=utf-8 raises
JSONDecodeError("Unexpected UTF-8 BOM"), and load_jobs wraps that as
RuntimeError("Cron database corrupted and unrepairable"), taking down
cron CRUD/scheduler for a hand-edited jobs.json.
Read with utf-8-sig on all four independent jobs.json readers
(load_jobs primary + strict=False repair, dump _cron_summary, status
Scheduled Jobs). Write path stays plain utf-8 so the next save_jobs
heals a BOM'd file. Matches the env-class dialect (NousResearch#65123).
Tests: BOM load (crash repro), bomless regression, empty store,
BOM+bare-list auto-repair, BOM+control-char strict=False arm, dump and
status CLI readers.
Follow-up to the salvaged NousResearch#66609 (4 primary readers) and NousResearch#41604 (context files): two more jobs.json readers rejected a BOM'd file — - hermes_cli/backup.py _count_cron_jobs: a BOM made the count None, silently disabling the post-update cron-loss auto-restore safety net - agent/curator_backup.py _backup_cron_jobs_into: BOM broke the job count (spurious parse_warning) and propagated the BOM into snapshots Both now read utf-8-sig; curator snapshots are written BOM-free so rollback restores a file load_jobs can read. AUTHOR_MAP entry added for deacon-botdoctor. Tests: BOM'd-live-file auto-restore + BOM'd snapshot count/BOM-free copy.
On Windows (and some Linux setups), an application like VS Code's js-debug can hold 127.0.0.1:9222 while a Chromium browser launched with --remote-debugging-port=9222 silently binds [::1]:9222 only. The IPv4-only probe then (a) missed the live browser entirely and (b) hung against the squatter — which accepts TCP but never answers the /json/version HTTP probe — repeatedly, driving the whole connect past the desktop GUI's RPC deadline: 'error: request timed out: browser.manage'. Fix, applied to both the gateway browser.manage RPC and the CLI /browser connect path via shared helpers in browser_connect.py: - discover_local_cdp_url(): probe BOTH loopbacks (127.0.0.1 first, then [::1]) and adopt whichever actually speaks CDP. - local_port_in_use() + find_free_debug_port(): when neither loopback speaks CDP but the port is held by another application, report the squatter explicitly and launch the debug browser on a nearby free port instead of fighting a bind conflict on 9222. - Bound the gateway's post-launch wait to a 10s deadline (was up to 20 unbounded probe cycles) so connect always answers inside the client RPC timeout. - _wait_for_browser_debug_ready_or_exit() also probes dual-stack so a successful launch pushed onto [::1] is classified 'ready'. Verified on a live Windows repro (VS Code holding 127.0.0.1:9222, Chrome 148 on [::1]:9222): connect now resolves http://[::1]:9222 in ~4.5s instead of timing out.
The dual-stack discovery change made the default-local /browser connect path call discover_local_cdp_url instead of is_browser_debug_ready, so the old is_browser_debug_ready patch no longer short-circuited the probe. On the CI runner nothing listens on 9222, so the test fell through to a REAL chromium launch (which dies headless: 'The platform failed to initialize') and no context note was queued. Patch the new discovery helper at the mixin's import site instead.
Use the direct POSIX parent relationship instead of process creation time and pid_exists checks. Remove the dead create-time argument chain while preserving process-group cleanup and signal forwarding.\n\nRefs NousResearch#62505
Remove process creation time and pid_exists from the slash worker parent-death predicate. The worker remains attached while its original PPID matches and keeps the existing in-flight grace behavior.\n\nRefs NousResearch#62505
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Native web-search provider moved out to a standalone pip plugin (hermes-keenable-web) per the standalone-plugin policy. This keeps only the two separable, in-tree parts: the optional MCP catalog entry and the research skill.
1084011 to
1f7668d
Compare
What does this PR do?
Integrates Keenable (low-latency web search + page-to-markdown fetch for agents) across the three extension surfaces Hermes supports, so it works however a user reaches for it:
plugins/web/keenable/) — Keenable becomes a first-class backend for the built-inweb_search/web_extracttools, selectable viaweb.backend: keenable(or per-capabilitysearch_backend/extract_backend). Modeled on the xAI/iFlow provider pattern (feat(web): add xAI Web Search provider plugin NousResearch/hermes-agent#29042). BYOK — requiresKEENABLE_API_KEY, no keyless tier (per the keyless-Parallel revert revert(web): remove keyless Parallel search fallback NousResearch/hermes-agent#46350).optional-skills/research/keenable-cli/) — wraps thekeenableCLI for explicit, terminal-native use; sibling toparallel-cli/searxng-search.optional-mcps/keenable/) — registers Keenable's remote HTTP MCP (api.keenable.ai/mcp), installable viahermes mcp install official/keenable.The REST contract is taken from Keenable's published OpenAPI spec (
docs.keenable.ai/api-reference): search isGET /v1/search?query=&count=, fetch isGET /v1/fetch?url=, both authenticated withX-API-Key. Requests also sendX-Keenable-Title: Hermesfor client attribution.Related Issue
N/A — tracks the family of vendor web-provider requests (cf. NousResearch#42646 iFlow, NousResearch#41161 AnySearch, NousResearch#32600 Oxylabs).
Type of Change
Changes Made
Native provider
plugins/web/keenable/{plugin.yaml,__init__.py,provider.py}—KeenableWebSearchProvider(search + extract), BYOK,KEENABLE_API_URLoverride.tools/web_tools.py— registerkeenablein both configured-backend sets, the auto-detect candidate,_is_backend_available, and the tool-metadata env list.hermes_cli/config.py—KEENABLE_API_KEYinOPTIONAL_ENV_VARS+ the two API-key display/allowlist lists.agent/web_search_registry.py— added to the legacy fallback preference order.Skill + MCP
optional-skills/research/keenable-cli/SKILL.md— CLI skill (HARDLINE-compliant, 56-char description).optional-mcps/keenable/manifest.yaml— remote HTTP MCP catalog entry.Packaging / attribution
pyproject.toml—data-filestarget so the manifest ships in the wheel (test_packaging_metadataenforces one per entry).scripts/release.py—AUTHOR_MAPentry for the contributor email.Existing tests updated (no new test files added)
tests/tools/conftest.py— register Keenable in the shared provider fixture.tests/plugins/web/test_web_search_provider_plugins.py— provider-set/capability assertions now includekeenable.How to Test
Checklist
Code
pytest tests/ -qrun — not run locally (dev deps/httpx unavailable in my env); relying on CI. Provider logic verified via injected-httpx checks; all edited files byte-compile.tests/tools/test_web_providers_keenable.py; happy to add on request.Documentation & Housekeeping
cli-config.yaml.example— N/A (no new config keys beyond the standardweb.backendvalue)CONTRIBUTING.md/AGENTS.md— N/Aplatforms: [linux, macos, windows]; MCP is a remote URLweb_search/web_extractschemas)For New Skills
optional-skills/httpx; skill wraps the externalkeenableCLIhermes --toolsets skills -q "..."run — not yet performedKeyless web provider + live smoke test
The native
plugins/web/keenable/provider supports keyless use: with noKEENABLE_API_KEYit calls Keenable's/publicendpoints and omitsX-API-Key. It stays opt-in (key-gatedis_available(), excluded from no-config auto-detect) so it never becomes a silent default — the posture NousResearch#46350 reverted. A key raises rate limits.Live keyless smoke test passed against
api.keenable.ai:GET /v1/search/public(ranked results) andGET /v1/fetch/public(page markdown). It also caught a spec/live mismatch —/v1/searchrejects acountparam — now fixed by applyinglimitclient-side. KeyedX-API-Keypath verified via injected-httpx.