e2da92f0ae3bcb607700fee35ceca08352d52c8a
224 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
00adf69d92 |
feat(stt): live transcription while you speak on CLI, TUI and Desktop (stt.streaming)
Voice input was record-then-upload on every surface: nothing showed while the user talked, and the transcript arrived only after the whole take uploaded. With `stt.streaming: true` the openai, xai and elevenlabs providers now stream microphone PCM to their realtime endpoints while the user speaks. Partial text renders live and the final transcript is ready at end of speech. - tools/transcription_streaming.py: one session API (push_audio / end_audio / finalize / cancel, an on_partial callback, a worker thread that owns the socket) with three wires, all verified live: the OpenAI Realtime `type: transcription` session (gpt-live-transcribe, 16->24 kHz resample since the endpoint refuses < 24 kHz), xAI wss /v1/stt with interim results, and ElevenLabs Scribe v2 realtime. Plugin providers opt in through TranscriptionProvider.streaming_capable / open_stream_session. - tools/voice_mode.py: AudioRecorder taps its capture callback into the session and parks it under the WAV path. transcribe_recording uses the live transcript and falls back to the file on any failure or empty result, so CLI push-to-talk, the continuous VAD loop and the TUI gateway gain streaming with no call-site changes. - Classic CLI and Ink TUI show partial text in the input placeholder while recording (new `voice.partial` gateway event, contract regenerated). Desktop dictation streams PCM to the new /api/audio/transcribe-stream socket (provider keys stay on the host, and the host resamples) and shows partial text in the dictation pill. The recorded blob stays the fallback. The plugin streaming contract (streaming_capable / open_stream_session / TranscriptionStreamSession) and the transcribe-stream socket shape come from #103173. Co-authored-by: Vinicius <timelord1025@gmail.com> |
||
|
|
bf81f4b2c9 |
fix(tour): built-in tour no longer opens over a chat the user switched to (review)
Re-check the active chat after the local-model wait and answer 'Tours only run in the session the user is looking at.' when it changed. Move TourPreset to tools/tour_presets.py so importing the wire contracts no longer registers gui_tour. |
||
|
|
890976c224 |
feat(tour): gui_tour start with no steps runs the app's built-in tour
A general look around the desktop app is now one tool call. Before, the model ran a targets scan and wrote its own steps from raw DOM labels: two round trips, English-only copy. - gui_tour action='start' without steps runs the built-in tour on surface='app' (refused on 'preview'). preset 'quick' shows four stops: chats list, composer, new session, model picker. 'full' (the default) adds what the interface mode puts on screen. A stop that is not on screen is skipped. The model stop adds a line when this computer can run a local model (the local-setup tip's fit check, capped at 2 s). - TourPreset is defined once, in tools/tour_tool.py. The wire contract types tour.preset with it, so the renderer uses the generated TourPreset type. Contracts regenerated. - The stop copy lives in i18n (appTour, en_app_tour.ts); other locales fall back to English until the translations PR. - engine.ts: one shared teardown for every driver exit, which now tells the host. The app host clears a reactive $tourActive flag, so the usage-stats consent dialog waits until no tour is on screen. A closed one-off highlight also clears it. - en.ts and types.ts are over the size cap, so the existing handoffTour copy and type move unchanged into the same siblings to offset appTour. test_start_validates_its_steps: a bare start is no longer an error; it is the built-in tour, and start on surface='preview' without steps is. |
||
|
|
901da1cb6a |
fix(catalog): sessions off the desktop no longer see manage_catalog through tool_search (review)
An agent stores its session platform's gated toolsets in disabled_toolsets at load, so the tool_search listing, the bridge's search, MCP refreshes and delegate children drop manage_catalog too. InstallPhase moves to the leaf hermes_cli/plugin_install_phase.py; the gateway contracts no longer import the installer. The per-home enable lock moves from the plugins_cmd facade to plugins_cmd_install._enable_placed. Reusing an installed catalog tree reads the install record through catalog_install_record, so older top-level catalog_name records match. A failed row no longer redacts the Advanced choices (profile name, commit); only credentials are redacted. The profiles.py role comment no longer says a role grants the setup toolset. |
||
|
|
c2b13dc3d8 |
feat(desktop): every desktop chat can install catalog plugins; tools gated by session platform
Toolsets that exist only for one kind of session now say so on their spec:
`platforms=frozenset({"desktop"})`. `TOOLSET_SESSION_PLATFORMS` is derived
from the specs that carry the key, `all` / `*` / no selection skip those
toolsets, and `toolsets.agent_tool_drops(agent)` removes their tools from any
agent whose own session platform is not listed. `agent_init._load_tools` and
`tools/mcp_tool_agent.py` (MCP refresh and prefix restore) call that one
helper. `tool_search._DIRECT_SURFACE_TOOLSETS` is derived from
`CLIENT_SURFACE_TOOLSETS` and `TOOLSET_SESSION_PLATFORMS`.
This replaces the profile-role gate: `profile_role_toolsets`, the role code in
`_with_session_toolsets` and the explicit-pin branch, the strip step in
`_select_tool_names`, and main's `setup` toolset (whose only tool moves out)
are deleted. `manage_catalog` moves into a new `catalog` toolset, which the
desktop client folds into every desktop chat like `project` and `desktop_ui`.
It is deferred behind tool_search by default (`config_defaults` defer list).
The shared-metrics schemas' toolset enum follows (`setup` out, `catalog` in).
The install card works outside the setup chat (#129650, with review fixes):
- Parallel installs: `_set_plugin_enabled` holds a per-home lock around the
read-then-admit section, so rows enabled at once no longer refuse each other.
- A catalog plugin already on disk and not enabled is enabled in place, so Try
again after a refused enable can finish.
- A failed catalog row counts as resolved (`contract.resolves`), so it no longer
holds the turn to the 300 s deadline. The snapshot carries `resolved`, and the
card reads it instead of a hand copy of the backend's rule.
- Each card draws its own operation by op id, because models reuse call ids.
- The row carries the approved non-secret choices (`approved`: force, enable,
ref) and the chosen profile, so Try again after the card settled repeats them.
- Install phases are ids (`InstallPhase`, wire `CatalogInstallPhase`) and the
installed-row notes are fields (`enabled`, `missing_env`, `server_errors`,
`already_installed`, `requires_hermes`); the card words them in English
strings (translations follow later). Failure reasons stay raw text, and
`detail` keeps the English notes for the model.
- Installs and search target the calling chat's own home, carried as a path;
the row's profile name is only a label, with no `'default'` fallback in
Python or TypeScript.
- `dashboard_install_plugin` is split into `_resolve_source`, `_place_tree` and
`_enable_placed`.
Desktop English strings for the card move to `en_catalog_install.ts` /
`types_catalog_install.ts`, which offsets the growth of `en.ts` and `types.ts`.
|
||
|
|
90ff655c81 |
feat(free-tier): browser challenge primitive for the anonymous token exchange
NAS may answer POST /api/anonymous/token with 428 challenge_required and a portal URL. Hermes implements the one `browser-v1` primitive: open the URL (hidden where it can), poll /api/anonymous/challenge/status, exchange again. Everything the page does (BotID, in-browser proof of work, an interactive Turnstile fallback, its copy) belongs to the portal and changes without a Hermes release. The mint stays authoritative throughout. Python (hermes_cli/anon_challenge.py) - the exchange reports surface (desktop / cli / gateway), version and the browser-v1 capability; a UA of the documented shape goes on every call - the wait runs outside the auth-store and shared-store locks, under a per-profile work lock; only a caller someone is waiting on waits — the keepalive tick and a status paint announce the challenge and return - 403 signin_required / access_denied and any 428 this version has no primitive for become a terminal sign-in error, scoped to the token exchange; sign-up and the promotion endpoints keep their previous verdicts - status-poll 429 / 5xx / transport are blips; a 4xx is a verdict (the credential is gone); a second 428 starts a fail-fast cooldown so a boot burst of token readers does not each re-mint and reopen a window - the pending challenge is published as the free_tier.challenge event and on free_tier.status for a client that connects later; the desktop reports how each window ended (free_tier.challenge_result) so the waiter re-mints promptly, and an attempt counter lets a fresh foreground attempt reopen a window the user closed without a status replay reopening it - terminal: print the link, open a graphical browser once per ticket; a gateway process never opens one; control characters never reach the tty - challenge URLs are only ever opened on the portal origin the exchange hit Desktop (apps/desktop/electron/challenge-window.ts) - the page loads in a hidden, sandboxed window of its own partition, pinned to the portal origin (will-navigate, will-redirect, off-origin commits end the run); a phase is read only from a main-frame portal URL fragment - #interactive reveals the window (never for an optional challenge), #done and #failed close it; an HTTP error commit, a load failure and a crashed renderer are retryable, a closed window is not; timers are bounded - the renderer relays the event and the status read to one window per attempt and reports the outcome once; a backend without the RPC is fine |
||
|
|
f640a6274f |
fix: Desktop backend update names owed work on partial and receipt-proven runs
The backend-apply store consulted the receipt's owed steps only on exit 0. A partial run (record_user_action turns a committed success into partial / exit 1, e.g. a stash whose restore conflicted) fell through to "Backend update failed." and a receipt-proven partial after the restart to "did not come back online", dropping the exact recovery instruction; and the success branch read followups from whatever receipt the status route attached, which is the latest receipt when this action has none. Add @hermes/shared updateDebt(receipt, actionId): the debt of a committed (success|partial) run, only when the receipt's action_id is the id the start request returned. The store composes it on success and failure: follow-ups keep the rerun remedy, a user action shows the producer's instruction verbatim (a rerun does not restore a parked stash). The existing owed-success spec's receipt gains the matching action_id the real route emits. |
||
|
|
b5025017d5 |
feat(telemetry): ask once more where a "No thanks" may never have been seen
Before the type-ahead fix, an Enter pressed while `hermes chat` started could save "No thanks" before the offer was on screen, and every surface then treated the profile as answered. Every answer now also writes `telemetry.shared_metrics.offer_version`. An "off" answer without it reads as `reask`: undecided, so the CLI, Desktop strip and dashboard banner offer it once more with a one-line reason. Any response settles it, including Esc in the terminal and the banner close, which keep "No thanks", so nobody is asked a third time. Opt-in answers were always deliberate picks and are never re-asked. |
||
|
|
1dfef400a7 |
fix(shared): a hidden Desktop window no longer drops its own healthy gateway socket
The gateway.ping heartbeat failed the transport when the newest inbound frame was older than the 45 s deadline. A hidden or minimized Chromium window runs the 15 s heartbeat interval once a minute (intensive wake-up throttling), so at every tick the previous ping's pong is ~60 s old even though the backend answered it within milliseconds. Every idle hidden window closed a healthy socket, redialed, and the renderer recorded hermes.desktop.friction kind=backend_disconnect detail=network for it. Judge silence only when the newest ping we sent is still unanswered (lastPingAt > lastLivenessAt). A dead transport still fails one tick after its first unanswered ping, throttled or not; an answered one is never dropped for timer spacing. This also covers the suspended-process shape (OS sleep, WebView freezer, SIGSTOP): the pre-suspend ping was answered, so the first tick after resume pings instead of failing. |
||
|
|
d0e40fff3c | feat(usage): report credential-pool usage per account | ||
|
|
38d19b92e8 |
feat(desktop): a progress chip warns before a subscription hits its usage wall
The provider heading's status slot now shows a fuel gauge for the
tightest usage window: hidden while there's room, "17% left · resets
3:00 PM" at 20% or less (amber from 10%), with a bar along its bottom
edge for what's left, then the existing "Limited until" once the login
is out. Hovering lists every window (5-hour, weekly, per-family).
model.options attaches `usage: { windows }` from a per-profile cache in
agent/account_usage_cache.py and refreshes it in the background, so the
picker never waits on a usage API. Every fetch_account_usage call lands
in that cache, including the one session.usage runs after each turn, so
the chip is at most a turn or two minutes behind. Covers the built-in
fetchers (Anthropic, Codex, OpenRouter) and any provider plugin's usage
hook.
Both pickers now render one ProviderStatusChip instead of separate
limit badges.
|
||
|
|
8ab6d64043 |
feat(desktop): a rate-limited provider says why and until when in the model pickers
model.options now attaches `limit` to a row whose credential pool is rate-limited: `account` (every live entry benched credential-wide, e.g. a Claude Pro/Max 5-hour window) with the pool's own reset time, or `models` (only those models are cooling down, each until its own time). Read from a new read-only CredentialPool.limit_state; entitlement benches aren't reset windows and stay out. Desktop renders it in both pickers: an amber "Limited until 4:30 PM" badge on the provider heading with its models dimmed but still selectable, or a "resets 4:30 PM" badge on just the cooled-down row so its siblings read as the way to keep working. A limit whose reset has passed drops on its own. |
||
|
|
f33d817404 |
fix(agent): name a final that reuses an already-delivered response
When the model answers beside a housekeeping tool call (memory, todo_list, skill_manage, session_search) and the follow-up is empty, the turn ends on that pre-tool answer (fallback_prior_turn_content, since #10472). The final then repeats text the client already streamed before the tool, but nothing on the wire says so: message.complete looks like a new response. Record the reuse where it happens (the housekeeping fallback and the budget-exhaustion reuse of a previewed verification candidate), report response_reused only when the final still equals that text (a footer or explanation appended later drops it), and carry it on message.complete. Never inferred from equal text: "Done." / tool / "Done." are two responses. |
||
|
|
3778021382 |
feat(desktop): exact subscription speed tiers (Standard / Fast / Ultrafast)
Ultrafast collapsed into ordinary Fast across the stack: the catalog only reported `fast`, presets and drafts stored a boolean, session.create took only `fast`, and Settings had a single Fast switch, so an Ultrafast default read as Fast and a new chat could lose it. - Inventory reports `ultrafast` per model from the same route-aware resolver that builds the request; Daybreak aliases offer no accelerated speed (their priority request is ignored). - session.create accepts an exact `service_tier` that wins over the legacy `fast` flag; unknown tiers are rejected. - Desktop keeps the exact tier through presets, draft creation, session options and labels; an unsupported remembered tier falls back to Standard, and failed edits reconcile to the confirmed state. - Settings → Model → Main model offers one Speed choice (Standard / Fast / Ultrafast) on models that support Ultrafast; others keep the Fast switch. Split out of #129251 (which keeps Daybreak). (cherry picked from commit 4e332fb850c989ef9f34bd70aaca174042beecc6) |
||
|
|
f1c79255fe |
feat(plugins): requires.gpu gates a plugin server on the host's GPU vendor
A portable plugin can now declare `requires: {gpu: nvidia}` for an MCP
server. On a host without an NVIDIA GPU, Hermes then refuses the
install, hides the server's tools, and the catalog card says "needs an
NVIDIA GPU". The check is the existing
`hermes_platform.host.facts.gpu_class()` read.
The requirement is independent of `app:`. The RTX Remix Toolkit is the
first consumer: it is a package the user unpacks anywhere, so there is
no install location to check, but it needs an NVIDIA GPU.
An unreadable GPU (`unknown`) passes, so a failed registry or sysfs
read does not lock out a machine that has the GPU. Only `nvidia` is
accepted: gpu_class() reports the highest-priority vendor, which
answers the question exactly for NVIDIA and not for AMD or Intel.
A registered server on such a host reports the new `unsupported_gpu`
state, carried through the gateway contract and the Plugins tab, with
a sentence that names the GPU instead of "Install <app>".
|
||
|
|
370b3a91c8 |
fix(tui_gateway): type cron last_delivery_unverified as a list of targets
The scheduler persists last_delivery_unverified as a list of unconfirmed platform:chat_id[:thread_id] targets and cron.manage passes it through, but CronJobRow typed it bool | None, so contract validation rejected any job with an unconfirmed delivery. Regenerate the TS/OpenRPC contract. |
||
|
|
c55a6e3d9f |
feat(desktop): identity-attribution traces for multi-connection bot rosters
Change-only desktop.log / agent.log lines that name which writer moved a bot identity when two connections share a profile name: - [gateway-route] per (connection, profile): via=primary|secondary and the window primary; `active` on every active-scope change (key, socket connection, profile) and `published` on every published-connection change, so a socket/label disagreement shows up side by side. - [bot-roster] each Bots-pane snapshot: the install that ANSWERED profiles.list vs the install expected for activeConnectionId; MISROUTED when they differ. - [bot-relay] rows per connection with the answering install; MISROUTED when two connections answer from the same install. - [bot-meta] every $botMeta title change per scoped key. - backend: bot_relay.roster.sync and ui_meta['hermes-bots'].title writes. profiles.list now echoes install_id (same value as /api/status) so a client can prove which machine answered a routed list. Additive field; older clients ignore it. Plugins reach the tracer through host.traceIdentityChange?.() so wholesale SDK mocks stay valid. |
||
|
|
83097ba008 |
fix(desktop): keep the hermes://skill/install deep link through the catalog revert
The plugin deep link (hermes://plugin/install?catalog=) and the skill deep link are separate features: one per kind. Reverting #119381's card catalog should not leave skills without a link, so this restores the skill route on its own: - Desktop: skill-deeplink-install store, the skill route in deeplink-routes, its use-desktop-integrations wiring, and the confirm-store onConfirm/details/busy/done phases its dialog uses (restored from main with their tests). Its nine strings move from the deleted catalog.* namespace to skillDeepLink.* (en + the six locales that translated them). - Website: Skills Hub "Install in Hermes" button, plus the source-qualified installIdentifier from extract-skills.py so the link, the embedded picker and the CLI command all name the exact skill (apps/shared/src/catalog-install.ts keeps only the skill helpers). - Docs: skills.md "Install from the website" section, desktop.md pointer. The card catalog UI stays reverted. |
||
|
|
6cb7a64e59 |
revert: Skills and Plugins card catalog browser, third landing (reverts #119381)
Reverts all seven commits of #119381 (59c54c78..476ff4dd), which re-landed the native card-based Skills/Plugins catalog browser after it was reverted twice (#113037 reverting #112669, #115787 reverting #113128). Capabilities -> Skills and Plugins return to the list/detail tabs with the embedded catalog picker they had before #119381. Removed with it: - Desktop catalog/ module (browser, cards, detail dialog, filters, reels/masonry, install switch) and skill deep-link install (hermes://skill/install); skills/embedded-hub-picker.tsx comes back. - apps/shared catalog-browse.ts and catalog-install.ts. - The `catalog` i18n namespace in every locale (+ regenerated locales/_keys.desktop.json); the two i18n pack tests and the keys test now exercise connectorsPage.searchPlaceholder instead. - Website /skills "Open in Hermes Desktop" skill links and the extract-skills install identifiers; docs return to the pre-#119381 text. - Later fixes that only patched the reverted catalog code go with it: |
||
|
|
5654863ccd |
fix(desktop,tui): /browser use [off] works outside the CLI
The Desktop and TUI subcommand pickers offer /browser use (it is in the registry's subcommands), but both handlers only knew connect, disconnect and status, so picking it printed the usage line. browser.manage gains a use action (enabled: bool) that writes the session profile's browser.backend through the same set_browser_use_mode helper the CLI now calls, and status/use report browser_use. The handler is profile scoped, so a worker session writes its own profile. A live agent keeps its tools (prompt cache), so Desktop and TUI say the switch applies to new chats instead of swapping tools mid-conversation. use and status also run on remote backends; connect/disconnect stay local-only. |
||
|
|
eb049af69c |
Revert "feat(desktop): copy and Bot Mode group turns follow no-deadline prompts"
This reverts commit
|
||
|
|
666b9f04c4 |
fix(shared): never dispatch a truncated reconnect replay tail
session.events.since reports truncated=true when the ring evicted events between the client's watermark and its oldest retained seq. The client ignored the flag and dispatched the retained tail as a gap-free replay, painting responses missing their prefix, tool state or terminal event. On truncation, skip the window up to the server's head without dispatching it. The session's replay barrier still resolves true, so the reconnect backstop's history read (#94779) resyncs from the stored transcript; parked live frames newer than the head still dispatch. Covers the client half of #100122; the server-side session-eviction seq reset is #124587. |
||
|
|
c579be967f |
fix(tui_gateway,desktop): every branch create accepts the idempotency key, and a branch_stored hit keeps its messages off the wire
The retry notification added in this PR re-invokes forkBranch with the same key, but forkBranch stamps idempotency_key into the params of EVERY branch flavor — including session.branch_stored, whose params contract is extra=forbid and did not declare the key. The desktop's right-click branch (that spec's exact path) answered 4000 'idempotency_key: Extra inputs are not permitted', no child row was created, and e2e/core/lineage-sidebar.spec.ts failed at 'the branch created a session row' (0 rows, 60s poll). - SessionBranchStoredParams: accept idempotency_key (optional; an older client that omits it is unchanged). - session.branch / session.branch_whole: accept idempotency_key and dedupe via the same _idempotency_keys registry as session.create, so a lost-response retry of a LIVE branch also returns the SAME child instead of a duplicate (the desktop retry covers those flavors too, but the key never reached the backend). - _build_branch_agent records parent_session_id/branch_title on the child runtime so the idempotent hit answers the same parent/title as the fresh reply, without a DB round-trip. - The session.create idempotent-hit early return now honors copy_parent_history: a retried session.branch_stored answers messages_omitted=true and never puts the copied transcript on the wire (its result contract forbids 'messages', and re-serializing the parent transcript through the renderer is what branch_stored exists to avoid). - Regenerated gateway-contract artifacts; two use-session-actions tests updated to expect the (opaque) key in branch params. Red on main: tests/tui_gateway/test_protocol.py:: test_session_branch_stored_accepts_idempotency_key fails with the exact 4000 the e2e saw. Green with the fix, plus test_session_branch_idempotency_key_dedupes_retry (server suite). e2e lineage-sidebar passes locally on macOS (14-19s; CI PR run failed at 1.2m). |
||
|
|
4d8c5922eb |
fix: address sweeper review on #65411 — notifyError path + idempotent retry
Two issues from the hermes-sweeper review: 1. Notification bypassed readableError normalization. The catch block called notify() with raw err.message, skipping wrapper stripping, detail extraction, and the >180-char long-message fallback. Switch to notifyError(err, fallback, action) so the retry action rides the established normalization path. notifyError gains an optional third action parameter (backward compatible). 2. Retry was unsafe against a lost response. tui_gateway/server.py session.create generates a fresh UUID per call; if the first create committed but its response was lost in transit, the user's retry would spawn a duplicate child session. Add optional idempotency_key support: the server dedupes on (key, live session), returning the original sid for a retry, falling through to a fresh create if the original session was closed in between. forkBranch generates a stable key per logical branch attempt and passes it through the retry action so both attempts share it. Tests: - tests/tui_gateway/test_protocol.py: 3 new tests cover dedupe, no-key backward compat, and key expiry when the session is closed. Full protocol file: 86/86 passed. - use-session-actions.test.tsx: 2 new tests cover key reuse across retry and readableError normalization on the failure notification. Full file: 21/21 passed. tsc --noEmit: 0 errors. - scripts/check-windows-footguns.py on changed files: clean. |
||
|
|
0ea07255e0 |
feat(desktop): copy and Bot Mode group turns follow no-deadline prompts
A Bot Mode group turn blocked on the user's clarify/approval answer pushes its runaway hard cap out instead of expiring the card under the room; the offline toast and the Settings description stop promising an approval timeout; the approval.respond deadline comments describe what it bounds now. |
||
|
|
036c577c86 |
fix(desktop): surface staged skill-write review on the desktop and TUI
With skills.write_approval on, every skill write is staged to the file-backed pending store, but no non-CLI surface could review it: the desktop resolver marked the whole /skills family as the sidebar's (desktop="settings") and discarded the subcommand token, and tui_gateway command.dispatch had no route for /skills or /memory — a failed slash worker turned both into the routing refusal, so staged writes accumulated silently (#98330, #123315, #118812). - hermes_cli/commands.py: new CommandDef.desktop_subcommands field; /skills declares its write-approval review slice (pending/approve/reject/diff/approval) and drops desktop="settings". command_desktop_meta serializes the allowlist (empty tuple = deny all); gateway contract regenerated. - apps/desktop: the resolver threads the typed arg — desktopSubcommandAllowlist, an exec gate (desktopSubcommandUnavailableMessage) that renders an actionable refusal for hub mutations instead of forwarding them, completion filtering so the popover never suggests a dead end, and the isDesktopSlashCommand(name, arg) threading at the runExec gate. An explicit /skills spec keeps a current Desktop able to review staged writes even against an older backend catalog. - tui_gateway command.dispatch: _cmd_skills answers only the registry-declared review slice under _session_home_scope (an unlisted subcommand keeps the 4018 routing refusal); _cmd_memory routes the shared handler with the live agent's store or a fresh on-disk one and a profile-scoped config setter. Gate-off still answers while staged writes exist (gateway parity, never stranded). - tools/write_approval: _staged()'s review hint is surface-aware — slash-capable surfaces (CLI/TUI/desktop/chat gateway) keep "<command> pending"; headless contexts (cron, kanban, api_server, webhook delivery) get the pending directory path instead of a command nobody can deliver. Batch skill writes also rendered "(batch on ...)" for /skills diff — skill_pending_diff had no action='batch' case; ops now diff in order against what earlier ops of the same batch left (finn763's #123861). Supersedes #98794 (registry-driven desktop_subcommands + resolver allowlist), #105773, #118821 (command.dispatch handlers + arg threading), #123861 (batch diff renderer). #107799 is superseded by the same subcommand-aware resolution. Co-authored-by: foma-agent <foma@bokonon.ai> Co-authored-by: Jason Havenaar <35441875+jasonHav@users.noreply.github.com> Co-authored-by: finn763 <165816600+finn763@users.noreply.github.com> Co-authored-by: chelsealong <chelsealong@126.com> |
||
|
|
fe5b728bd9 |
fix(bots): size live_message_count with SessionDB's own display projection
The count left out DISPLAY_VISIBLE_SQL, so model-only rows from micro-compaction were counted while the reader painted none, and the history wait this count exists to skip still ran. SessionDB.display_message_count now counts the same set _display_rows_from_conn paints, through _read_one instead of reaching into db._lock/_conn, and one _live_count_field serves all three listing paths. The field is declared on SessionListRow, ProfileSessionPreview and ProfileCanonicalSession (extra=forbid) and the desktop derives it from the generated contract instead of hand-typing it twice. |
||
|
|
591e3a7ce1 |
fix(tui_gateway): broadcast approval.cancelled when reap/teardown drop pending prompts
The WS-orphan reap interrupts the turn (deny-resolving the pending approval queue) and teardown pops it via unregister_gateway_notify — both silently. A reconnecting client's parked prompt card then answers resolved: 0 (or a bare 4001 on approval.pending), so the prompt looked lost rather than cancelled and the user had no signal the command would never run (#106678). - session_lifecycle: _announce_cancelled_gateway_approvals broadcasts the dropped entries (session ids, reason, request ids/count) before the deny-resolve drains the queue, from both _interrupt_session_turn and _teardown_session. Fail-open: a missing key, empty queue, or broadcast failure never blocks the deny-resolve or teardown. - _interrupt_session_turn gains orphan= so the reaper labels its cancels ws_orphan_reap explicitly (never request_id prefix sniffing). - contracts: ApprovalCancelledPayload event model + regenerated TS/OpenRPC. - desktop: gateway-event lifecycle consumes approval.cancelled and clears the parked prompt cards by request id (mismatch is a no-op, so a newer prompt survives); the turn's BLOCKED tool result is the in-transcript signal, same as the timeout path. Supersedes #106693 (KoNit-K) and #126455 (Finn763); design credit to both. Co-authored-by: KoNit-K <konit.block@protonmail.com> Fixes #106678 |
||
|
|
5eea87882a |
Clarify: one question shape and one result shape on every surface (skip, cancel, timeout and undelivered are told apart) (#127760)
* refactor(desktop): split clarify-tool.tsx into a clarify/ folder
Pure moves, no behaviour change. Parsing, the question-card core
(shell, choice rows, question block), the delivery watchdog, and each
pending/settled card get their own file so the core can be reused.
* feat(clarify): one questions[] shape with per-question status and one outcome
The clarify tool now takes only questions=[{question, choices?, multi_select?}].
A wrong shape is a tool error that names the right one, so a model that
sends the old top-level question/choices corrects itself on the next call.
Every result has the same shape on every surface: each response carries
status (answered, skipped, unanswered) with user_response null unless
answered, and the result carries one outcome (submitted, cancelled,
timed_out, undelivered) plus an optional surface-written notice. The
timeout and cancel sentences that used to pose as the user's answer are
gone, and the compressor reads status instead of matching their prefixes.
The callback contract is callback(questions) -> {answers, outcome, notice?}
(None = skipped, missing qid = unanswered). tui_gateway settles a batch the
same way for the last lock, a cancel, an interrupt and the deadline, and
clarify.lock keeps a null answer as a skip. A multi-select answer that is
not a JSON array counts as one typed ("Other") answer. The tool-row preview
reads the first question.
* feat(clarify): every surface asks through the one question card
CLI: the batch panel is the only panel; Enter on an empty field skips a
question, Ctrl+C cancels and keeps the locked answers, the deadline returns
timed_out. -q and -z return undelivered with a no-user notice.
Ink TUI: the single-question prompt is gone; the card supports multi-select
(Space or a digit toggles, Enter locks, typed Other text joins the picks),
an empty submit skips, Esc cancels, and "Batch" names are dropped.
Desktop: the single-question card is deleted and its keyboard handling moved
into the one card (arrows, letters and digits, auto-advance, Enter picks then
confirms). Confirm enables at one answer; blank questions lock null; Skip and
a composer message cancel; the settled card shows No answer for unanswered
questions. The bots room card follows the same contract.
Messaging: one card per question with a "Reply skip to skip" line in every
locale; timeouts and delivery failures map to timed_out and undelivered.
* fix(clarify): cancelled for a released messaging card, labels win over the skip word
A prose reply to a messaging choice card, /new and session end released
the wait with "", which read as timed_out. They now resolve with a
CANCELLED marker and the tool gets outcome cancelled.
A typed reply that matches a choice label ("Skip") now resolves to that
choice; the skip word applies only when no choice matches.
The bots room card sends the picked labels as they are; the tool already
strips the recommendation label. Unused OUTCOMES and a new docstring and
comment are removed.
* fix(tui): re-editing a multi-select answer keeps its picks
The Ink card stored a multi-select answer as display text ("A, B"), so
revisiting the question put the whole string into Other and re-locked it
as one item. The answers map now keeps the raw JSON array, the card
restores the picks on revisit, and only the display lines format it.
Comments that earlier edits reworded are restored to their original
words, minus the phrases the change made wrong. The compute-host clarify
lock accepts None for a skipped question.
* test(clarify): align three checks with the one-answer confirm and the regenerated keys
The desktop Cmd+Enter test now expects a send with one answer (the blank
question locks null), the compressor test expects the single-answer summary
the code gives, and locales/_keys.desktop.json is regenerated for the
removed and added clarify strings.
* test(tui): the one-question card header is singular
|
||
|
|
3a439b69ad | Free tier: separate 'account exists' from 'runs this chat', and stop the mint from writing active_provider (#127486) | ||
|
|
e33f1a0faf |
fix(desktop): refuse window-owned pane requests at once when no window shows the session
preview.read / preview.act / terminal.read / window.read / tour are answered only by the Desktop window that shows the requesting session (#114981). When no attached window showed it, every window stayed silent and the agent waited out the full 30-45s bridge deadline, then got the generic "No preview tab is open, or the read timed out." A window not hosting the session now declines with JSON-RPC error 4404 instead of staying silent. The gateway counts a decline as that client's vote, not as the answer: the request stays open for the owner window and settles only once every attached answering client declined, with a distinct refusal ("No Hermes Desktop window is showing this chat ... Ask the user to open this chat in the Desktop app, then retry."). A bystander window therefore still cannot beat the owner (#113348). The backend advertises the counting via client.capabilities (declines_not_shown); the shared channel only sends a decline to a backend that did, so a new app against an older backend keeps today's silence. Fixes #119333 |
||
|
|
9bcbe7b5df |
feat(i18n): pluggable, layered language packs across core, Desktop and TUI (#126296)
* feat(i18n): layered catalogs — plugin packs and user overlay over bundled locales
* feat(tui): i18n layer — en catalog, nanostore runtime, RPC pack loader, _keys.tui.json emitter
ui-tui/src/i18n/: en.ts (facade over topical siblings under en/), types.ts
(Translations + dotted TranslationKey derived from en), runtime.ts ($locale/
$catalog atoms, translateFrom active→en→key, pack merge with string→fn
wrapping for {0}/{1} placeholders), loader.ts (display.language →
i18n.catalog {lang, surface:'tui'}, English when the method is missing),
useT()/useLocale() hooks, t() for non-React code. useConfigSync feeds the
loader from the existing config.get full hydration. `npm run i18n:keys`
writes locales/_keys.tui.json (sorted flat key list) and runs before build.
* feat(plugins): provides_locales manifest field, ctx.register_locale/register_locale_dir, manifest-only language packs
* chore(tui): split en catalog siblings by lane (slash sibling)
* feat(plugins): validate language packs — parse, text-only, key-subset WARN against en / _keys exports
* feat(tui_gateway): i18n.languages / i18n.catalog RPC + regenerated contracts
* feat(config): display.language accepts any supported_languages() id, refuses unknown ids with the list
* docs(i18n): language packs user guide, pluggable display.language, plugin developer section, AGENTS notes
* feat(plugins): report language-pack layers in the mid-run activation summary
* feat(tui): wire status bar, composer placeholders, hotkey help and approval/clarify/confirm prompts through i18n
StatusRule maps compared state values (ready/running…/summoning) to catalog
text at render via displayStatus(); hotkeys()/placeholder() resolve lazily so
a pack that arrives after boot applies. Catalog grows to 81 keys.
* feat(desktop): pluggable app locales — registry, host.i18n.registerAppLocale, backend packs, keys emitter
- Locale widens to string (BundledLocale keeps the union); TRANSLATIONS stays
the bundled record and every consumer resolves through the registry.
- src/i18n/registry.ts: registerAppLocale(id, {endonym, rtl, translations})
layers partial packs (nested or flat dotted) over bundled/en via
mergeTranslations; a string over a function-valued en entry becomes a
positional {0}/{1} formatter; $appLocaleVersion bumps so translators
re-render; per-source disposers + replaceAppLocaleSource for atomic swaps.
- Backend packs: i18n.languages + i18n.catalog {surface:'desktop'} feed the
registry as source 'backend' (method-not-found is silent); re-synced on
socket open, display.language change and profile switch. A saved pack-only
language is promoted once its pack registers.
- SDK: host.i18n.registerAppLocale / languageOptions; ctx.i18n.registerAppLocale
tracked for unload. Docs in the desktop plugin SDK guide + skill reference.
- Language switcher lists bundled ∪ registered ∪ backend, endonym-only; RTL
from the registry (applyDocumentLocale takes rtl).
- npm run i18n:keys emits locales/_keys.desktop.json (wired into build).
* i18n(cli): route /topup + /subscription copy through t() (cli.billing.*, cli.subscription.*)
Module-level copy tables and modal choice tuples in cli_billing_mixin.py froze
English at import, before display.language was known. They are now key tables /
builder functions evaluated at call time; every user-facing line in the /usage
balance block, /subscription and the five /topup screens reads the catalog.
Choice VALUES stay English identifiers. Fragment-assembled status lines
(Plan: … → cancels · $x left · renews …) become full templates.
* i18n(gateway): exec-approval card contract + base/run/run_busy/run_inbound replies through t()
- base_exec_approval: EA_* English constants stay; add ea_header_text()/ea_reason_label_text()/
ea_smart_deny_line_text()/ea_default_reason_text()/ea_action_labels()/approval_timed_out_notice()
accessors; deadline + timed-out notice resolve via gateway.exec_approval.*
- BasePlatformAdapter._EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/_EA_ACTION_LABELS become
properties (adapters still shadow them with markup class attrs)
- run.py: provider error replies table holds catalog keys; _CONTEXT_OVERFLOW_REPLY -> _context_overflow_reply()
- run_busy/run_inbound: typed approval + slash-confirm matchers accept English ∪ approval.inputs.* (t())
- locales/en.yaml: gateway.exec_approval/busy/errors/... namespaces
* i18n(cli): wire modal, loops, agent-setup mixins through t() (cli.* keys)
* i18n(platforms): route Slack, Matrix and Feishu user-facing text through t()
Exec-approval markup overrides (_EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/
_EA_ACTION_LABELS) become per-call properties over the shared
gateway.exec_approval.* contract keys, so Slack's 3000-char section budget
measures the resolved template. Slack _APPROVAL_DECISIONS/_CONFIRM_DECISIONS,
Feishu _APPROVAL_LABEL_MAP and Matrix _EA_LEGEND/_EA_TYPED_HINT turn into
key tables resolved at click time; the Matrix typed hints become whole
sentences per offered tier instead of spliced fragments. Slack button labels
are cut to 75 chars and select placeholders to 150 after translation; the
model-facing clarify fallback answer ('choice N') stays English while the
card copy localizes.
locales/en.yaml gains the gateway.exec_approval.* contract keys plus the
platform.shared.* / platform.slack.* / platform.matrix.* / platform.feishu.*
namespaces (and the keys for the other adapters wired in follow-up commits).
* i18n(gateway): run_turn / run_turn_runner / approval-settle copy through t()
- status hints, proxy errors, background task notices, progress heartbeats, session info lines
- tool progress chrome (tool_head/tool_pending/tool_preview/tool_verbose) shared by base.format_tool_event
- run_turn_runner:1406 Chinese clarify placeholder -> gateway.clarify.native_stream_placeholder (zh text kept in zh.yaml)
- _UNEXPECTED_SILENCE_REPLY/_CLARIFY_EXPIRED_NOTICE -> accessor functions
* i18n(platforms): route Google Chat and Teams user-facing text through t()
Google Chat clarify card, typing placeholder, orphan-card labels and the whole
/setup-files reply set (module constants become platform.google_chat.setup_files.*
keys resolved at reply time). The attachment-fallback notice that shipped
hardcoded in Spanish is keyed with an English en value; es.yaml carries the
original Spanish text for those four keys.
Teams approval card header/reason use the gateway.exec_approval.* contract,
_APPROVAL_LABELS becomes a key table resolved at click time, and the meeting
summary writer resolves its section headings/fallbacks per render.
* i18n(platforms): route LINE, WeCom, email, DingTalk, IRC and Home Assistant text through t()
LINE default copy constants become catalog keys resolved in __init__ (the
LINE_*_TEXT / extra.* operator overrides still win); the busy-ack bypass
matcher keys on the leading emoji marker only, so it keeps firing once the
gateway busy heads are localized. WeCom media size/format notices that shipped
hardcoded in Chinese are keyed with English en values and zh.yaml carries the
original Chinese text. DingTalk emotion bubbles resolve per send.
* i18n(cli): route /model switch output and -q status lines through t() (cli.model.*, cli.single_query.*)
Switch-summary labels shared with the gateway reuse gateway.model.* keys
(provider/context/max-output/capabilities/prompt-caching); CLI-only variants
(glyph or no-backtick forms) live under cli.model.*. The hand-padded /model usage
block becomes a (form, description-key) table padded at render time so the
command syntax stays fixed while descriptions translate. -q 'Error:' reuses
gateway.model.error_prefix.
* i18n(cli): route TUI panel/hint/placeholder copy through t() (cli.tui.*)
_APPROVAL_CHOICE_LABELS and _TUI_MODAL_HINTS become key tables resolved at
render time; vault/sudo panel bodies are one catalog value per panel split on
newline; inline plurals use <key>_one/<key>_other. Adds the cli.* namespace
(shared/tui/voice/render/subagents/dock) to locales/en.yaml.
* i18n(cli): voice/wake-word CLI copy through t() (cli.voice.*)
RuntimeError texts raised in _voice_start_recording are human copy (callers
print {e}) and are keyed; the Termux requirement-check match stays English.
Wake state ids stay internal, only their labels localize.
* i18n(cli): live-work dock, subagent monitor and render copy through t()
cli.subagents.* / cli.dock.* / cli.render.*; count fragments pluralize via
_one/_other keys, verdict table holds keys resolved at paint time so width
clipping measures the translated text.
* i18n(gateway): unauthorized/pairing, voice, topics, shutdown, startup, notifications, kanban pings through t()
* i18n(cli): move tips + composer placeholders into the catalog (tips.tNNN / tips.placeholder.pNN)
get_random_tip()/get_random_composer_placeholder() pick a key from the English catalog
(the parity baseline, probed once per process) and resolve it through t() for the active
language, so language packs translate tips like any other string. Also lands the cli.*
en.yaml namespace consumed by the CLI info/help/error-copy wiring in the next commit.
* i18n(cli): wire chat-turn + session mixins through t(); kanban log trimmer matches t() output
* test(cli): assert TUI/dock/voice copy via t(key); prove labels resolve at render time
Pinned-English assertions in the approval-UI, live-work dock and voice tests now
go through the catalog. New test swaps the catalog after import and checks the
approval panel + hint row follow it (the reason _APPROVAL_CHOICE_LABELS and
_TUI_MODAL_HINTS became key tables).
* i18n(cli): route CLI info/help/error copy through t() (cli.* namespace)
cli_info_mixin: /help consumes CommandDef.describe() (added to commands.py: slash.<name>.description
with fallback to .description), section titles/skill/quick-command headers, /tools, /toolsets,
/usage labels, /context, /whoami, /insights, /gateway status, tool-progress labels, bang-shell
denials, MCP config-watch + /reload-mcp confirm/reload lines, /reload-skills, and the session-store
warning all read the catalog at call time (module-level label tables became functions so the
active language is honoured after startup). cli.py: worktree cleanup, tirith warning, show_config
(labels re-padded at print time), quick/plugin/skill slash-command errors, ambiguous-command hint,
stdin error, gateway start, profile warning. cli_chat_error_copy / cli_unknown_command /
cli_output / cli_init_mixin: chat error panel copy, did-you-mean lines, n-more / yes-no prompt
(localized affirmative initial alongside 'y'), unknown-toolsets warning.
* i18n(w1a): wire agent display/explainers/approval + slash registry/help through t()
- hermes_cli/commands.py: CommandDef.describe() resolves slash.<name>.description
at call time; category labels via slash.category.*; help/alias/usage suffixes
via slash.shared.*; gateway_help_lines and commands_platforms/slash_exec use them.
- agent/display.py: display.verb.* resolved at call time via get_tool_verb();
bridge/spinner/thinking-verb/cute-row/failure/preview/diff text via display.*.
- agent/turn_explainers.py: exit-reason / persistence-cause tables become call-time
lookups (explainer.exit.*, explainer.persistence.*, explainer.file_mutation.*).
- agent/background_review.py, session_activity.py, context_breakdown.py,
status_output.py: review summaries, iteration progress, context notices.
- tools/approval.py, approval_context.py: approval.summary.*, approval.noun.*,
approval.window.* pluralized keys.
- gateway/slash_commands*.py: remaining raw strings (busy, whoami, platform,
bundles, memory, skills, approvals, set_home, diff, update, debug, profile,
heartbeat, refine, review, subgoal, loop, retry, compress codex path, save,
sessions, model guard/errors, agents rows, topup, login). HISTORY_UNREADABLE
keeps its English constant; callers use history_unreadable() ->
gateway.shared.history_unreadable.
- locales/en.yaml: new approval/display/explainer/slash blocks + gateway leftovers.
* i18n(telegram): route adapter chat copy through t()
Approval card (header/reason/smart-deny as HTML-escaped properties), inline
button labels, callback toasts (cut at Telegram's 200-char cap), model/choice
pickers, clarify/update/slash-confirm prompts, gmail-triage labels and the
inbound-media failure notice now come from the catalog. _UNAUTHORIZED is a
lazy _unauthorized() so the import no longer binds a language. The command
menu carries a language+payload fingerprint (forum scopes re-register on
change) and BotCommand descriptions are cut at 256.
Adds gateway.exec_approval.* (WAVE2 contract), platform.telegram.*,
platform.discord.* and the slash.*.description keys the Discord table shares
with the CLI registry to locales/en.yaml.
* i18n(gateway/platforms): whatsapp_cloud, yuanbao, weixin, signal, api_server copy through t()
- whatsapp_cloud: clarify list/buttons, approve/deny + slash-confirm labels via platform.whatsapp.* (t()-then-truncate at 20/24/72 caps); _EA_HEADER becomes a property wrapping ea_header_text()
- yuanbao: SLOW_RESPONSE_MESSAGE -> slow_response_message() (platform.yuanbao.slow_response_notice; zh keeps the original text); cron-wrapper markers centralized as module constants for strip_cron_wrapper
- api_server: PROVIDER_AUTH_FAILED_LABEL/PROVIDER_RATE_LIMITED_LABEL stay English for run.py matchers; user_text() renders via t()
- signal/_format_wait, weixin voice caption, openai_routes transformed notice
- run_turn: second _UNEXPECTED_SILENCE_REPLY consumer -> accessor
* i18n(discord): route adapter chat copy through t()
Native slash-command table becomes _NATIVE_SLASH_COMMAND_SPECS holding catalog
keys; _native_slash_commands() resolves descriptions, parameter descriptions
and Choice names for the active language, each cut at Discord's 100-char cap,
and the app-command sync fingerprint now includes get_language() so a
display.language change re-syncs. Exec-approval card (gateway.exec_approval.*
contract), slash-confirm / clarify / update views, model+choice pickers,
thread creation, forum titles, voice acks, the response-truncation notice,
the unauthorized-slash security alert and the media upload-size notices all
read from platform.discord.*. Decorator-declared button labels are relabelled
in __init__ (80-char cap); embed titles cut at 256, select placeholders at
150, option label/description at 100. _UNAUTHORIZED is a lazy _unauthorized().
* i18n(cli): wire status-bar, stream, terminal mixins + terminal_input through t(); rename kwargs that shadow t(key)
* i18n: wire hermes_cli/cli_commands_mixin.py slash-command copy through t()
- 431 new leaves under cli.commands.<cmd>.* in locales/en.yaml; 12 rows reuse
existing gateway.* keys (rollback, diff, resume, branch, btw, model, reasoning)
via a _gt() helper so CLI and gateway replies stay identical.
- Module-level English tables (_BUSY_MODE_*, _REASONING_TOGGLES, _HATCH_PROGRESS,
_DIFF_LABELS, _LOCAL_ENGINE_LINES) become call-time catalog lookups keyed by id.
- Verb tables (Enabling/Disabling, Paused/Resumed/Triggered, planned/done,
Updating/Generating) are one full template per variant; plurals use
<key>_one/<key>_other via _tn(); hand-padded column labels (/snapshot list)
translate the value and re-pad at the call site.
- Multi-line usage blocks are single catalog values split with _lines().
- Model-facing system notes and DB-stored reasons stay English (EXCLUDED).
* tests: assert /handoff, /worktree, /login CLI copy via t(key) instead of pinned English
* test(i18n): pin Telegram/Discord adapter catalog wiring
Lazy unauthorized notice, exec-approval contract keys, HTML escaping before
Telegram <b> wrapping, 200-char toast / 256-char BotCommand caps, Discord
100-char app-command text and 80-char button caps, and language-bearing
command-menu fingerprints on both platforms.
* i18n: reconcile cli.shared on/off vs enabled/disabled after lane merge
* i18n: describe() in TUI-gateway slash listings; localize TUI exit resume hint
* i18n(tr): translate bundled catalog + tui pack
* i18n(ja): translate bundled catalog + tui pack
* i18n(ko): translate bundled catalog + tui pack
* i18n(zh): translate bundled catalog + tui pack
* i18n(fr): translate bundled catalog + tui pack
* i18n(af): translate bundled catalog + tui pack
* i18n(uk): translate bundled catalog + tui pack
* i18n(ar): translate bundled catalog + tui pack
* i18n(pt): translate bundled catalog + tui pack
* i18n(it): translate bundled catalog + tui pack
* i18n(es): translate bundled catalog + tui pack
* i18n(zh-hant): translate bundled catalog + tui pack
* i18n(ru): translate bundled catalog + tui pack
* i18n(hu): translate bundled catalog + tui pack
* i18n(hu): translate pre-existing English-valued leftovers (kanban wake, /context, /status, fast labels)
* i18n(de): translate bundled catalog + tui pack
* i18n(ga): translate bundled catalog + tui pack
* test(i18n): fixture matches _normalize_lang(lang, home) signature
* i18n(tui): scaffold userMessages/slashCmd en siblings
* i18n(tui): wire secure prompts + content tables
* feat(tui): i18n — wire billing, subscription, connection-setup and journey overlays
Adds en siblings billing.ts / subscription.ts / connection.ts (namespaces
billing, subscription, connection, journey) and routes every user-facing
literal in billingOverlay, subscriptionOverlay, connectionSetupOverlay and
journey through useT()/messages(). Module-level label tables became lazy
(scopeStillDeniedResult(), verbOf(T, action)); auto-reload rows dispatch on
stable ids instead of label text. Regenerates locales/_keys.tui.json.
* i18n(tui): wire slash ops/wake replies
* i18n(tui): wire pickers (modelPicker, activeSessionSwitcher, petPicker)
* i18n(tui): wire slash core/debug/setup replies
* i18n(tui): wire hubs (agents overlay/panel/controls, skills, plugins)
* i18n(tui): wire slash session/topup/subscription replies
* i18n(tui): wire chat bits (branding, thinking, messageLine, loaders, todo, queued, banner, entry)
* i18n(tui): register t3 siblings (pickers, hubs, secure, content, chatBits) and regenerate keys
* i18n(tui): wire userMessages copy through the userMessages namespace
* i18n(tui): lazy-copy test for userMessages, regenerate _keys.tui.json
* i18n(tui): wire session/gateway/lib text through the TUI catalog (lane t2)
Adds en siblings session.ts, gatewayMsg.ts, libText.ts (namespaces session,
gatewayMsg, libText) and routes user-facing literals in app/{useMainApp,
useSessionLifecycle,useInputHandlers,turnController,createServerRequestHandler,
setupHandoff,createGatewayEventHandler}.ts, gatewayClient displayed reasons,
lib/*, domain/*, hooks/* through t()/messages(). Status-bar state values that
code compares against, backend-matched strings, log lines, model-bound text,
and machine 'error:' prefixes stay literal. Regenerates locales/_keys.tui.json
(232 keys).
* i18n: serve bundled locales/<lang>.tui.yaml under overlay/packs; TUI pack parity test; regen _keys.tui.json (1250)
* i18n: translate pre-existing English stubs in bundled locales (424 leaves, 14 locales)
* tui: i18n-export-en script (English templates for pack translators)
* docs(i18n): bundled TUI packs are the bottom layer of the tui surface
* i18n(ru): translate TUI pack
* i18n(ar): translate TUI pack
* i18n(es): translate TUI pack
* i18n(pt): translate TUI pack
* i18n(ko): translate TUI pack
* i18n(de): translate TUI pack
* i18n(ja): translate TUI pack
* i18n(fr): translate TUI pack
* i18n(tr): translate TUI pack
* i18n(it): translate TUI pack
* i18n(zh): translate TUI pack
* i18n(zh-hant): translate TUI pack
* i18n(hu): translate TUI pack
* i18n(uk): translate TUI pack
1,169 missing keys translated; 81 pre-existing kept byte-identical. Parity OK missing=0 extra=0 placeholder_mismatch=0 empty=0.
Deliberately identical to en: chatBits.branding.mcpSummary ({0} MCP), chatBits.thinking.agentsHint ((/agents)), session.main.voiceStt (◉ STT), session.main.voiceTtsSuffix ( [tts]), slashCmd.core.help.tuiSection (TUI), slashCmd.core.history.hermesTag (Hermes #{0}), slashCmd.debug.heapdump.heapPath (heapdump: {0}), slashCmd.debug.mem.rss (rss), subscription.stepUp.title (Remote Spending — product feature name, as in core catalog), content.faces.* (glyph-only kaomoji).
* i18n(ga): translate TUI pack
* i18n(af): translate TUI pack
* plugin_guard: locale catalogs in language packs step down the agent-config family
A translated status line such as "Updating AGENTS.md" in locales/<lang>.yaml is UI text the loader
reads as a string leaf; it cannot edit a file. The bundled en.yaml itself tripped agent_config_mod
at critical, making any faithful language pack uninstallable. Injection shapes keep full severity.
* plugin_validate_locales: read key exports with utf-8-sig (Windows footgun lint)
* i18n(relay): route relay adapter prompt copy through t(); drop dead import-bound approval header
Adds platform.relay.* (5 keys) to en and all 16 bundled locales, reusing the sibling platform
translations for the confirm buttons and the Other option.
* ci: fix TUI import order, MDX table pipe, main's overflow-warning wording in all locales; fresh-install fixture carries the i18n kernel
- ui-tui/src/i18n/en.ts: perfectionist/sort-imports (slash before slashCmd)
- docs plugins/index.md: escape the | inside the provides_locales table cell (MDX parsed <id> as JSX)
- display.notice.uncompressed_context_overflow: adopt main's wording (names compression.enabled: false
and /compact) in en + 16 locales; the guardrail test pins that phrase
- tests/scripts/test_fresh_source_install.py: the installer tail now resolves CLI text through
agent.i18n, so the fixture tree carries the i18n kernel + en.yaml (not the agent runtime)
* docs(desktop-plugin-sdk): double-backtick the template-literal example (MDX evaluated ${n})
* test(e2e): display.language is validated against the live language set; exclude it from the arbitrary-string set property
* commands: keep the localized COMMANDS/COMMANDS_BY_CATEGORY module __getattr__ after the compat block removal
* build: never write locales/_keys.*.json from the desktop/TUI builds; regenerate the committed desktop key export
The desktop build regenerated locales/_keys.desktop.json in the checkout, so a
hermes update that rebuilt the app left the tree dirty (Desktop update E2E:
'M locales/_keys.desktop.json'). The key exports are committed artifacts pinned
to en.ts by apps/desktop/scripts/i18n-keys.test.mjs and ui-tui i18n:keys:check;
builds read them, never write them. Regenerated after main's new desktop strings.
* test: unbreak two main-red timing tests the PR merge-ref inherits
- test_local_runtime racing fake publishes the modern state record (legacy pid-only
records are rejected since 65ff3ad353; main has been red on this test since)
- test_run_progress_topics ManyProgressLinesAgent waits for the first bubble instead of
a fixed 0.35s, which a loaded CI runner does not always meet
* chore(i18n): regenerate desktop key catalog for main's new strings (model pricing, copy changelog)
* test(e2e): torture-chamber fd monitor confirms a deleted sidecar is still held before calling it a leak
SQLite's WAL last-close unlinks -shm before closing its descriptor (unixShmUnmap, then
unixShmPurge), so a healthy close shows a (deleted) -shm for microseconds; the 20ms poll
occasionally caught that window on the short-lived opener and failed the episode.
* chore(i18n): regenerate desktop key catalog for main's telemetry/consent strings
* chore(i18n): regenerate desktop key catalog after main sync
---------
Co-authored-by: Teknium <teknium@nousresearch.com>
|
||
|
|
6c143c238f |
feat(metrics): hermes.desktop.* contract, schema and gateway RPCs
Desktop love/hate telemetry needs a backend half: six counters (feature_use, action_use, mode_use, friction, dislike, onboarding) with closed dimension sets, the v3 JSON schema entries, the public doc rows, and profile-scoped gateway RPCs the app reports through. Every value the app sends is collapsed server-side onto the closed set (unknown -> other); feature use latches per area per UTC day, friction and dislike are capped per day, the daily report (action counts + mode time + bot count) latches per day and answers recorded=false on a store failure so the app keeps the day for a retry. For setting changes the app sends only the config key: the backend reads the saved value and compares it to DEFAULT_CONFIG itself, so values never cross the wire; keys outside DEFAULT_CONFIG read other. All helpers check enabled() before doing any work. |
||
|
|
88eb76f02e |
fix(metrics): startup latency counts once per client launch, never after an in-place exec
- m9: shared_metrics.startup_latency had no server-side latch, so any repeat call added a row. The TUI and Desktop now send an opaque per-launch id (TUI: one per process; Desktop: minted when the once-per-launch main-process claim succeeds) and the backend claims (surface, launch_id) once per process: a reconnect re-sending the same launch counts once, a new Desktop launch against a long-lived backend still counts. Legacy clients without an id count once per backend process and surface. The id is only a local latch key (bounded set, length-capped), never recorded. Only a usable measurement spends the claim. Contract + apps/shared regenerated. - m10: relaunch() execs in place, keeping the PID, so `sessions browse` -> resume counted the picker time as CLI startup. relaunch() stamps its PID in HERMES_RELAUNCHED_PID right before exec; process-start surfaces skip when it matches, while children (other PIDs) inheriting the env still count. - m8 (psutil half): record_process_ready checks the opt-in gate before reading the process start time (psutil) or starting a thread. The claim is taken first so a later opt-in never records a stale "startup" mid-session. Test changes to existing assertions: the TUI/Desktop param assertions now expect launch_id (a new wire param); the RPC surface test sends distinct launch ids so its three calls stay three launches under the new latch. |
||
|
|
d9fd8587be |
feat(desktop): report packaged self-update outcome via shared_metrics.update_run
Desktop's packaged updaters (electron-updater, App Installer, Store) never run `hermes update`, so the receipt cannot count them. Main persists a pending record before apply (the installer may quit the app before apply returns), decides a hand-off on the next launch (version changed => success), and the renderer sends it once the gateway is open; the file is removed only after the RPC resolved. Checkout hand-offs are excluded: their receipt already counts them as kind=desktop. The backend buckets raw words into bounded dims. |
||
|
|
2b3e1c3995 |
feat(metrics): startup latency and install version-lag/hardware fields
Two product questions shared metrics could not answer: how long each surface
takes from launch to usable (so startup regressions show per release), and how
current, on which channel and on what class of machine installs run.
hermes.startup.latency {surface, latency_bucket} records once per process start:
cli (process creation -> first rendered prompt, or -q dispatch; Kanban workers
excluded), gateway_boot (-> GatewayRunner.start done), serve_boot (-> hermes
serve listening), and tui / desktop_attach reported by the clients through the
new shared_metrics.startup_latency RPC. The clients declare their surface
because a Desktop on a URL/cloud backend has no HERMES_DESKTOP there; env
detection is the fallback for older clients. In-process surfaces measure from
psutil's process create time, the earliest timestamp available, and hand the
runtime start to a daemon thread under the caller's context so no event loop
waits on it. Everything goes through _emit, so disabled profiles record nothing.
The install snapshot gains release_channel, version_age_bucket, behind_bucket,
ram_bucket, gpu_class and local_model_provider_used. All are read offline:
the installed commit's own date, the channel record / packaged channel / checkout
branch (never the remote URL or branch name), and the update check's existing
cache for this exact revision (never a network call). Rows counted before these
fields existed stay valid as a legacy field set.
|
||
|
|
2efa4f3caa |
feat(telemetry): per-model tool-call quality, friction and context-peak counters
Three shared-metrics counters that answer "which models misbehave, frustrate
users, or run out of room", all attributed to catalog provider/model names
(custom endpoints and loopback servers collapse to custom) and all behind the
existing enabled() gate.
hermes.model_tool_quality.count {provider, model, call_role, issue}
Counts every tool call a model emits where the agent validates it, clean calls
as issue=none so the rates have a denominator: invalid_json, unknown_tool,
schema_mismatch (missing required keys / non-object), empty_arguments (only for
tools with required params), repaired (Hermes fixed the name or the streamed
argument JSON and ran the call). Stream assembly marks args it repaired and the
chat transport carries the marker onto the normalized ToolCall, because
normalization otherwise erases it.
hermes.model_friction.count {provider, model, signal}
retry / undo / interrupt / quick_abandon / switch_away, blamed on the model that
produced the turn: the relay session remembers its last primary route, so a
/retry after a /model switch still counts against the retried model. Counted
where the action executes, once: CLI handlers (skipped on the TUI slash worker's
shadow CLI), tui_gateway command.dispatch retry/undo and session.undo (Ink
/retry now sends intent=retry, so it counts as a retry, not an undo), gateway
/retry and /undo (multiplexed runners bind the owning profile home), and every
/model surface via record_model_switch(from_model=...). Interrupts and quick
abandonment (session closed within 60s of a failed turn) come from the runtime's
turn close, for attended entrypoints only; a turn still running when the
session closes is neither.
hermes.context_peak.count {provider, model, peak_fill_bucket, window_bucket, limit_hit}
One row per closed top-level session: the fullest primary context it reached
(post_api_request now carries the compressor's context_length) and whether a
call was rejected as too large (context_overflow / payload_too_large, the
rejections Hermes answers with a forced compression). A session whose every
call overflowed still reports, with unknown buckets.
|
||
|
|
b73225cc1c |
feat(telemetry): add shared_metrics.slash_command RPC; gateway stops counting slash.exec
The gateway counted slash commands at its client boundary (successful
slash.exec / command.dispatch), so every command the Desktop or Ink TUI
handles locally (/new, /branch, /skin, /resume, overlays, pickers) never
reached shared metrics. Counting moves to the clients' dispatcher entry:
the new fire-and-forget RPC records {command, execution_surface} for one
user-typed command (surface desktop|tui from the same client detection),
scoped to the session's profile, always answering {ok: true}.
_count_slash_command is removed from rpc_dispatch so each typed command
lands exactly once, from the client. Contracts regenerated.
|
||
|
|
b685eb90c0 |
feat: shared_metrics.status/set RPCs for Desktop consent
Desktop had no way to read or change the shared-metrics opt-ins. The new
profile-scoped RPCs read/write telemetry.shared_metrics.{enabled,send} in the
focused profile's config.yaml through the one config writer, keep the setup
wizard's invariant (send is forced off unless collection is on) and reconcile
the consent windows on every change via the wizard's own helper. first_run
answers also record the desktop setup-completed metric (lazy import until the
events module lands).
|
||
|
|
28365a5bec |
fix(tui_gateway): session.set_hidden requires an explicit hidden flag (#122190, salvage #122568)
Trim the salvaged handler/test prose to the salvage bar and make the contract match the handler: SessionSetHiddenParams.hidden is required (no default=True), so the generated TS/OpenRPC types stop advertising an optional flag whose absence used to hide the whole compression lineage. Regenerated apps/shared/src/gateway-contract.*. |
||
|
|
399d956903 |
feat(bot_desktop): Bot Screen, computer_use and the browser run inside the terminal backend (#121169)
* feat(docker): publish nousresearch/hermes-sandbox:desktop for terminal backends
The terminal backends (docker, modal, daytona, singularity) all default to
nikolaik/python-nodejs:python3.11-nodejs20, a bare Python+Node base. For Bot
Screen, computer_use and the browser to run INSIDE that sandbox instead of on
the gateway host, the sandbox image needs the display stack.
docker/sandbox-desktop.Dockerfile is that base plus:
- the everyday tools it lacked (jq, ripgrep, fd, tmux, less, nano, vim,
zip, rsync, tree, procps, htop, sudo for the base's uid-1000 `pn`)
- the exact package set the Hermes -desktop image installs (TigerVNC,
Xfce components, dbus, xauth, fonts)
- Playwright's headed Chromium (same build as the -desktop image)
- cua-driver 0.28.2 from its pinned release tarball
No Hermes inside; the default user stays root like the base so nothing
changes for people who just switch docker_image. Desktop processes run as
`pn`. 4.27 GB on amd64.
docker.yml gains a `sandbox` variant with its own cache scope and repository
(nousresearch/hermes-sandbox:desktop, :main-desktop, :<release>-desktop);
the docker-integration suite is skipped for it (no Hermes to test) and
docker/sandbox-desktop-smoke.sh runs instead: as `pn`, every launcher and
cua-driver binary resolves, the real launcher.sh publishes :20, the RFB
socket completes the 3.8 handshake relayed over `docker exec -i` stdio, and
a headed Chromium maps a window on that display. hadolint lints the new
Dockerfile in docker-lint.yml.
* feat(docker): sandbox desktop base on python3.13-nodejs26
Matches the Hermes image (Python 3.13 / Node 26) and the top of requires-python;
the default docker_image tag it inherited was Python 3.11 / Node 20. Same pn
uid 1000, Debian 13; smoke (launcher, RFB relay, headed Chromium) passes.
* feat(docker): bake agent-browser into hermes-sandbox:desktop
The browser tools drive the agent-browser CLI; when the browser follows the
terminal backend that CLI has to exist inside the sandbox. Pinned to the same
^0.26.0 range the gateway resolves, --ignore-scripts like the gateway's npx path.
* feat(bot_desktop): the screen, computer_use and the browser follow the terminal backend
A user who sandboxes `terminal` (docker/ssh/singularity) had the agent's
screen, cua-driver and Chromium running on the gateway HOST beside that
sandbox: Bot Screen gave a headless host a display, the Xfce panel carries
xfce4-terminal, and `computer_use` could open a shell outside the boundary
the sandbox exists for.
Now the desktop lives where the terminal lives:
- tools/environments/streams.py: one primitive per spawn-per-call backend, the
local argv prefix that runs its remainder inside the sandbox with stdio open
(`docker exec -i`, `ssh`, `apptainer exec`). SDK backends (modal, daytona,
vercel) have none and report so.
- tools/bot_desktop/sandbox_host.py: launcher.sh runs inside the sandbox as
the image's `pn`; the pane's RFB bytes ride a 12-line python relay over that
prefix; `cua-driver mcp` is the prefix + the sandbox image's own driver.
- tools/bot_desktop/placement.py + `bot_desktop.placement` (auto|terminal|
gateway). `auto` follows the backend; a sandbox that cannot host a screen
REFUSES with the opt-in named instead of silently using the host.
- runtime.start/stop/status/published_env branch on placement; the pane,
lease, epoch fencing and CLI are unchanged.
- cua_backend: the MCP invocation is the sandbox one when the screen is
there; the host driver's runtime contract is irrelevant then; check_fn is
true under a terminal placement without a host binary.
- browser_tool_session: agent-browser invocations are wrapped in the prefix
with the daemon, socket dir and profile inside the sandbox; screenshots are
fetched back so MEDIA: paths keep working; recycle closes the sandbox
daemon.
- web_routers/display.py: the bridge pumps a relay's stdio when the screen is
in a sandbox, a unix socket otherwise.
Live on docker with nousresearch/hermes-sandbox:desktop: start/observe/RFB
handshake through the dashboard bridge, human takeover fences the agent
(HumanHasControl) and keystrokes reach the sandbox Xvnc, handback restores,
three start/stop rounds leave zero desktop processes; computer_use capture
and list_windows see only the sandbox's Xfce; browser_navigate/snapshot/
vision run with Chromium and agent-browser inside the container and zero
host processes on the bot profile; modal + auto refuses naming the opt-in.
* feat(desktop): Screen pane shows where a sandbox-placed screen runs; Install is host-only
DesktopStatus gains placement ('gateway' | 'terminal:<backend>'). A sandbox
image lacking the stack is a blocker naming hermes-sandbox:desktop, shown in
place of Start; install_command stays None there because the pane's Install
button runs the package manager on the gateway host, the wrong machine, and
display.install refuses for the same reason. The pane header carries
'Screen runs inside the docker sandbox, with the terminal' (4 locales).
* fix(bot_desktop): "is the screen in the sandbox" is a disk check on hot paths, never a config read
Every browser command and CUA spawn asked in_sandbox(), which resolves placement by
loading config, which initializes HERMES_HOME. Under a test's fake home that raised
HomeInitializationError from _run_browser_command; on a real host it read config per
click. Hot paths now ask sandbox_screen_running(): the start marker on disk, written
only by a sandbox start. Policy (in_sandbox) stays for start/install, where config is
the question. display.observe gates on "an RFB endpoint exists" for either placement.
* test(moa): late-accounting sink test asserts the wedged slot's row, not sink order
Under CI load the poll loop can see the interrupt before collecting the fast slot, so the
fast slot also arrives late and first; the test then failed on sink_calls[0]. The
contract is that the wedged slot's real usage reaches the sink.
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
* fix(bot_desktop): a sandbox that died under a live screen fails loudly, never falls to the host
sandbox_screen_running() drops a start marker whose terminal environment is no longer
registered (stale after a process restart). When the environment object outlives its
container, the browser's sandbox wrap now checks the published DISPLAY and raises
"the screen inside the terminal backend's sandbox is gone; start it again" instead of
KeyError('AGENT_BROWSER_PROFILE'). Live: fresh sandbox navigate ok; docker rm -f the
container; next navigate returns that error; zero host Chromium either way.
* feat(terminal): nousresearch/hermes-sandbox:desktop is the default container sandbox
Every container backend (docker, modal, daytona, singularity) now defaults to the
sandbox image with the desktop stack, so Bot Screen, computer_use and the browser
run inside the sandbox for everyone who never chose an image; Python 3.13 / Node 26
match the Hermes image. One constant (DEFAULT_SANDBOX_IMAGE) replaces six copies of
the old literal. Migration 47 moves saved configs still holding the OLD default and
never touches an image the user pinned. Docker reuse recreates a container built
from another image, or the flip would silently never take effect for anyone with a
persisted container (live: old container removed, new one on 3.13 / Node 26 with
Xvnc, cua-driver, agent-browser present).
* chore: retrigger CI (zero-job dispatch failure)
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
* chore(config): template stamps v47 and shows the new default sandbox image
The template is what install.sh / docker / doctor --fix seed; a stamp behind
DEFAULT_CONFIG makes every fresh install migrate on first run.
* feat(sandbox): the default image change is a decision, not a surprise
A persisted Docker sandbox on another image is kept when docker_image is unset;
only a written docker_image (an explicit pin) recreates it. The pin verdict
travels as TERMINAL_DOCKER_IMAGE_PINNED through both terminal bridges (process
env and per-profile scope) and the container-config allowlist.
Approval surfaces, all through hermes_cli.sandbox_image_switch: the interactive
CLI asks once at startup (y = pin the new image, n = pin the current one, Enter =
ask later); the Screen pane shows the same choice with Switch / Keep buttons via
display.switchSandboxImage; `hermes config set terminal.docker_image …` is the
same answer from any shell. Gateways and cron never decide: they keep the sandbox
and log the notice.
Migration 47 now unsets a saved image equal to the OLD default instead of
rewriting it to the new one — that value was the template copied, not a pin, and
rewriting it would have made the runtime recreate existing sandboxes unasked.
Modal restores its snapshot and Daytona reuses its labeled sandbox regardless of
the configured image, so existing sandboxes there were already untouched.
* fix(config): both plain defaults that preceded the desktop sandbox image are template copies
main pinned nikolaik/python-nodejs:python3.14-nodejs22 (
|
||
|
|
3fe934aabf |
fix(desktop): scope the new-chat slash palette to the rail-selected profile (#124651, salvage #124845)
Root cause: a draft has no session_id, so use-slash-completions sent commands.catalog /
complete.slash with empty params and tui_gateway's _session_home_scope (session-only
profile_home) scanned the LAUNCH profile's skills; CompleteSlashParams also rejected a
`profile` key outright (extra=forbid -> 4000).
Fix: the hook sends the tile's routed profile when there is no session (cache keyed by
session-or-profile); _session_home_scope resolves a session-less `profile` through
_profile_home; `profile` added to CompleteSlashParams and SkillsReloadParams (same
session_id-or-empty shape in skills.reload) and the TS contract regenerated.
Red-on-base: tests/tui_gateway/test_protocol.py::test_sessionless_slash_palette_follows_profile_param
fails on origin/main (complete.slash -> 4000 "profile: Extra inputs are not permitted");
use-slash-completions.test.tsx "scopes a sessionless catalog and typed lookup to the routed
profile" fails on origin/main (commands.catalog called with {} not { profile }). Both green on head.
|
||
|
|
80ee8830db | fix(tui_gateway): declare known_issues on the plugins.manage result contract; drop issue number from test filename | ||
|
|
88039d73b3 |
feat(tui_gateway): inline_images=false switch on session.resume history reads
_history_dict_text has always had both image renderings, but _coerce_message_text hard-coded image_urls=True at both call sites, so the reference-form branch was unreachable from the API: a remote client re-read the sum of every attachment the conversation ever carried (26.30 MiB on the measured session.resume, paid again on every reconnect) with no way to ask for less. session.resume now accepts inline_images (default true) and threads it through every path that carries messages — cold/lazy/eager resume, live reattach via _live_session_payload — routing to the existing [image] branch when false. The REST twin lands in the follow-up commit. Fixes https://github.com/NousResearch/hermes-agent/issues/116511 |
||
|
|
d3f9ba3cd5 |
feat(tui_gateway): add session.archive RPC for Desktop archive parity
Desktop archives sessions via PATCH /api/sessions/{id} -> set_session_archived,
but the TUI gateway had no equivalent JSON-RPC method, so TUI clients had to
keep every session visible or delete it permanently. Open PR #47184 added this
against the pre-split server.py layout and resolves only live runtime sessions;
this lands the method on current main next to session.set_hidden with the same
two-tier resolution: live runtime id first (unpersisted drafts defer via
pending_archived, applied by _ensure_session_db_row like pending_hidden), then
a stored id/key resolved through the profile db, covering the historical rows
the session.list picker shows. Contract declared in tui_gateway/contracts and
rendered into the generated TS/OpenRPC catalog.
Fixes https://github.com/NousResearch/hermes-agent/issues/47168
|
||
|
|
0f15d80a02 | fix(contracts): regenerate gateway contract for hermes_not_connected enum | ||
|
|
1da0c50c98 |
fix(shared): normalize invalid ensureContrast step values before the ladder loop
ensureContrast's accumulating ladder loop trusted its `step` argument: step=0 never advanced (1,000,001 iterations, still unbounded), negative steps walked away from the pole forever, NaN skipped the loop body entirely so the original below-threshold color was returned silently, and Infinity/above-1 steps jumped straight to a full blend. Normalize instead: a finite step in (0.001, 1] is used as-is (clamped to 1 above that), everything else falls back to the default 0.2 ladder. The desktop 0.2 and TUI 0.05 float sequences are byte-identical. Fixes #109955 Salvaged from #109975 by kokhlo |
||
|
|
42d70d29ac |
fix(gateway): broadcast projects.changed when projects.db moves
projects.db is written by the CLI (hermes projects create), other Desktop windows, and workspace-settings flows — processes that never touch this gateway's transports, exactly like state.db. The change watcher had no projects.db signature, so those writes left the Desktop's project list and folder tree stale until an unrelated refresh (#53046, Add a projects.changed watch (2s interval, newest mtime across projects.db + WAL for the watcher home and every served sibling profile, mirroring the sessions.changed contract), register the event in the gateway contract (generated TS/OpenRPC refreshed), and route it through the desktop lifecycle handler into a $projectsChangeTick that refreshes both the projects list and the sidebar tree. Fixes #53046 Fixes #56757 Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com> |
||
|
|
da4a1ffd08 |
fix(desktop+gateway): surface continuation kind on compression-chain tips (#121148)
The branch-render guard landed earlier (
|
||
|
|
a30bd337e5 | fix(tui-gateway): report recently failed async delegations in subagent.list |