070c7cf00a14cffafad20c46d6c6e17c0191817e
1057 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c7c2df1a53 |
fmt(js): npm run fix on merge (#118435)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
bc655bfb40 |
fmt(js): npm run fix on merge (#118250)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
afc3b7c6f3 |
feat(connectors): one backend-owned connection operation, with a setup card on Desktop, TUI and CLI (#111008)
* feat(connectors): the desktop connects apps through one backend-owned operation Re-based onto main after #109517, #110368, #110574 and #110843 landed as squash merges ( |
||
|
|
1d30aa5571 |
feat(tui): Processes block in the live-work dock and /agents overlay
The Ink dock and /agents overlay only merged subagent.* events with subagent.list. They now poll process.list for the owning session on the same cadence and paint a Processes block under the agents (⚙ command · elapsed · last output; exit verdict for 60 s after exit). The dock budget is split so neither block hides the other; processes alone still surface the dock. Glyphs live in lib/processGlyph.ts so panel and overlay match. |
||
|
|
2c78b9b39e |
feat(process_registry): stamp exited_at and expose it on process.list
The live-work docks retire a finished background process ~60 s after it ends; the registry only knew started_at, so the age of an exit was not observable. _move_to_finished is the single choke point every exit path (reader loop, reconcile, kill) passes through, so the stamp lives there. completion_reason rides along so a killed process can read "killed" instead of "exit -15". |
||
|
|
1cf8a9fb41 |
fix(tui): count streamed frames as heartbeat liveness
The TUI's JsonRpcRequestChannel took the 'response' liveness default, so only a ping-ack or a response to a pending call refreshed the deadline: a socket streaming a delta every second through a 134s turn was declared dead at the 45s deadline and force-closed, splitting sessions that went on to complete server-side (#115251). Pass heartbeatLiveness: 'any-inbound' on the TUI channel — the same contract the desktop/web client already uses — so any inbound frame counts as life. A silent drop still trips the deadline, so true dead-transport detection is unchanged; only the false-kill class goes. The channel's 'response' mode keeps its semantics for callers that explicitly choose it; the shared wiring test pins the TUI construction site so the option cannot silently regress. Fixes #115251 |
||
|
|
d6a69ea5a3 |
fix(tui): treat every ctrl chord as a binding name, never typed text
Widen #115382 from bare C0 bytes to every ctrl-modified keypress: a kitty CSI u / xterm modifyOtherKeys Ctrl+L (`ESC [ 108 ; 5 u`) reaches parseKey with ctrl set and name `l` exactly like the 0x0c redraw byte, so the composer typed an `l` for it too. `isControlChord` is ctrl && !isPasted; bracketed pastes stay text. Test trimmed to two invariants (chord is bindable and refused by the insert gate; typed text and pastes still land). |
||
|
|
addf694901 |
fix(tui): a ctrl chord's control byte is not typed text (stray l after resume)
The dashboard asks a reused PTY's TUI for a full redraw on every re-attach by writing the force-redraw byte Ctrl+L (0x0c, `hermes_cli/pty_session.py` TUI_FORCE_REDRAW) into its stdin (`web_routers/chat_ws.py`: `session.attach(ws, force_redraw=not _created)`). parse-keypress names a C0 control byte after the letter it encodes (0x0c -> 'l') and `parseKey` hands that name to `InputEvent.input` so bindings can match ctrl+<letter> on the raw byte; the composer's insert gate read the name as typed text, so the redraw byte landed in the input box as a solitary `l` that prefixed the next message (#115284 — also on tab switch and window restore, both of which re-attach). Flag the event instead of touching `input` (clearing it would break the composer's own ctrl+a/e/u/k/w/z/y branches and the global ctrl+c/x/o/t pass-through): `InputEvent.isControlByteChord` is true only for a single-byte C0 sequence with ctrl set, so kitty CSI u / xterm modifyOtherKeys chords and bracketed pastes are unaffected. The composer skips both insert sites for it, and still flushes a pending key-burst because a chord is not typing. |
||
|
|
b787fb9128 |
fix(tui): Ctrl+D exits from an empty composer on macOS too
The exit binding matched isAction(key, ch, "d"), which on macOS means Cmd+D: Ghostty consumes Cmd+D for split panes and literal Ctrl+D never matched, so the TUI stayed open. Ctrl+D is the terminal EOF convention, not a Cmd shortcut, so it now also routes through the existing isMacActionFallback seam (target union gains "d"), and on every platform it exits only when the composer holds no text, buffered lines or attachments, matching the classic CLI. Slim redo of #116454. Co-authored-by: Mohamad Kanso <91088196+MohamadKanso@users.noreply.github.com> |
||
|
|
6abbc02228 | fix(tui): no gateway respawn after graceful-exit kill (#114987) | ||
|
|
171a1777b5 |
fix(desktop,tui): reasoning pill and effort rows say ultra sends max on this route
The Desktop pill, the catalog row meta and the effort radio row rendered a clamped pick as a plain "Ultra", and the TUI status bar as "ultra" — presenting a Hermes-internal step as a wire level the route does not have, while the CLI's `/reasoning` shows "ultra (sends max on this route)" (#115876). Both surfaces now read `session.info.reasoning_effort_wire`: - Desktop pill / catalog row meta: compact "Ultra→Max", tooltip and aria-label "Effort: Ultra (sends Max on this route)" (new `modelOptions.sendsOnRoute` string in every locale, mirroring the CLI wording). - Effort radio row for the selected clamped level: "Ultra (sends Max on this route)". - TUI status bar: "ultra→max". - Unknown wire ('' — not stamped yet, or an optimistic pick) or a verbatim level makes no claim, so nothing changes on routes that send the level as is. `setCurrentReasoningEffort` and the tile optimistic write clear the wire so a stale clamp never pairs with a new pick until the gateway re-stamps. Docs: one sentence in the Desktop guide. Part of #61634. |
||
|
|
6f6ed01355 |
fix: WAF 403s stop reading as key rejections; anthropic_messages routes send custom_providers extra_headers
Two gaps for custom providers behind a WAF/CDN:
- `build_anthropic_client` never consulted `custom_providers[].extra_headers`,
so a relay in `anthropic_messages` mode that rejects the SDK User-Agent kept
403ing even with `extra_headers: {User-Agent: ...}` configured, while the
OpenAI-wire clients already applied it. The lookup now lives in
`_new_sdk_client`, the one constructor every builder path goes through
(init, /model switch, rebuild, auxiliary), keyed by the caller's raw route
because entries are keyed by the `/v1` form the normalizer strips.
Salvaged direction of #46002 (@wait4xx). Fixes #24293, #9721.
- `_status_403` classified every non-billing 403 as `auth`, so a WAF's plain
"Your request was blocked." or a Cloudflare browser challenge printed "Your
API key was rejected" and could rotate a healthy credential. A 403 carrying
established block/challenge markers is now `upstream_blocked`: no rotation,
no retry, fallback allowed, WAF/User-Agent guidance on every surface (CLI
loop, chat copy, cli chat error copy, TUI gateway + Ink TUI copy). Generic
403 and all 401 keep the auth verdict. Salvaged direction of #70567
(@ooiuuii) and #53114 (@AgenticSpark). Fixes #53099, #70566.
|
||
|
|
5ae7ec8623 |
fix(setup): a provider configured after boot unblocks the dashboard chat without a restart
The serve process's free-tier boot record (`free_tier_bootstrap._record`) is
built once; when the boot inventory found nothing (mint failed or the tier is
off) it says `provider_configured: false` for the process lifetime and
`setup.status` answers from it, so the Ink chat (dashboard /chat, `hermes
--tui`) parks every new session on "Setup Required" no matter what the user
configures afterwards — the Models page, a picker key save, or `hermes setup`
from a shell all land on disk and change nothing in the running process.
Reconcile the record on read instead of re-minting on write: `reconcile_record`
re-runs the cheap inventory (`_inventory_other_providers`, the resolver ladder
with the free-tier rung hidden) when a `False` record's config files
(`config.yaml` / `.env` / `auth.json`) moved since the inventory that built it,
replaces the record's inventory half and broadcasts `setup.ready`. The mint
verdict is kept as is: only the boot bootstrap and its retries mint.
`wait_for_record` (what `setup.status` reads) reconciles, which also covers
writes this process never saw (`/setup`'s `hermes setup` handoff, `hermes model`
in docker exec, a hand edit); the two in-process write paths from #114708
(`/api/model/set`, `model.save_key`) call it for the immediate broadcast.
Dropped from #114708: `retry_bootstrap_mint(force=True)` on the write paths — a
forced portal mint (network, cooldown bypassed) inside an HTTP write; the record
only needed its inventory refreshed. Not taken from #108773: OR-ing the record
with `_has_any_provider_configured()` — that first-run guard counts host-wide
credentials (other host-wide agent-CLI logins) and answers True on a blank machine (verified
live on this host), which would open the gate with nothing configured.
The 4001 hint for a sessionless `config.set model` named "Settings -> Models",
a Desktop-only surface; the Ink chat has no Settings and the dashboard has a
Models page. One string now names /setup, the Models page and Settings ->
Models. The TUI's "Setup Required" panel no longer advertises `/model` as the
in-place fix (sessionless picks are refused by design,
|
||
|
|
9b0ca895b3 |
fix(tui,desktop): send session_id on commands.catalog, complete.slash and skills.reload
The clients called all three RPCs with `{}`, so the server's session-less
fallback (`_completion_cwd({})`) read the process TERMINAL_CWD, which
`gateway/run.py` rewrites to $HOME at import in the default-config case: the
catalog, popup and reload still missed the session's project skills.
- ui-tui: `useCompletion` adds `session_id` to `complete.slash`;
`/reload-skills` sends it on `skills.reload` and the follow-up
`commands.catalog`; the gateway-ready catalog fetch sends it when a session
already exists (reconnect). Before the first session the gateway binds the
same workspace it seeds a new session with.
- Desktop: `useSlashCompletions` takes `sessionId`, sends it on both RPCs and
keys the completion cache per session so two chats in two repos don't
serve each other's catalog.
|
||
|
|
f7e70e954e |
test(ui-tui): the no-heartbeat check expects the capability advertisement
Every gateway.ready now puts one client.capabilities frame on the wire, so "no frames" is no longer the invariant; "no gateway.ping" is. |
||
|
|
dc8fe4def8 |
refactor(shared): a crashed server-request handler reports through an owner hook, not console.error
The deliverRequest catch branch wrote to a module-level console.error sink (the only direct console.* in apps/shared/src) and fired onUnhandledRequest, whose contract is "nobody handled it, already answered -32601" — so the TUI logged a -32603 crash as "unhandled server request". Add onRequestHandlerError(error, request) to JsonRpcRequestChannelOptions beside onHeartbeatFailure, call it from the catch after answering -32603, and drop the console sink. Wire both owners: HermesGateway (desktop, via a GatewayClientOptions passthrough) logs to console.error like its dial-failure sink; ui-tui gatewayClient pushes a [protocol] log line. Collapse the two normalisation arms into the existing `error instanceof Error ? … : new Error(String(error))` idiom and restore the early `return true` instead of the handled flag + break — nothing runs after the loop but the -32601 fallthrough. Test: the crash case now asserts onRequestHandlerError fires once for the -32603 request and onUnhandledRequest only for the -32601 one. |
||
|
|
9583c8c45a | fix(tui): preserve inflight synthetic display metadata | ||
|
|
04ded3b145 |
fix: cover the geocoding 'location not found' branch in the weather test
Review noted the error-phase test only exercised the HTTP-503 path; the
branch where geocoding returns `{results: []}` was untested. Extend the
existing error test with a second stub so both failure routes are
asserted without adding a test case.
|
||
|
|
b5821a578d | fix(tui): move weather widget to Open-Meteo | ||
|
|
abdb402701 |
fix(mcp): carry the lazy status across the TUI wire, tests and docs
Follow-up to the ported status fix: - `tui_gateway/contracts/tools_mcp_plugins.py::McpRuntimeStatus` is a closed wire enum; `mcp.servers.status` would raise `ContractViolation` on the new `lazy` value. Declare it and regenerate the TS/OpenRPC contract files. - `ui-tui` session panel: an unknown status fell through to the red `failed` branch; render `lazy` with its cached tool count (inline branch, no component extraction). - Two invariant tests, both red on origin/main: the real discovery path yields `status: lazy` with the cached tool count and a summary without `failed` (eager control stays `configured`, live control stays `connected`); a lazy-only run neither warns nor re-arms the startup retry, while a configured-only run still does. - Document the per-server `lazy` key (undocumented until now) in `cli-config.yaml.example`, the MCP config reference and the MCP guide. |
||
|
|
5a4c3b32d0 |
fix(tui): track the durable session id in ui state instead of on the replaceable info object
Agent-less producers (session.activate of a lazily-resumed session via _fallback_session_info, session.info events from agent-less cwd switches) replace state.info with payloads that omit stored_session_id, so the exit handler's recovery target went stale/null after such a switch. Keep the durable id in a dedicated ui.storedSid field written at every sid transition (create/resume/activate), read it from there in the exit handler, and when a session.info payload omits it, carry the tracked id forward onto info. |
||
|
|
4ccbc2936d |
test(tui): events keep flowing and backoff grows across gateway reconnects
Invariant for #111594: after drain() on mount, every later transport generation still emits gateway.ready live, and the reconnect delay grows across consecutive failures instead of restarting at the base delay. |
||
|
|
583dbb534c |
fix(tui): preserve sessions across gateway reconnects
An attached (dashboard-embedded) Ink TUI whose WebSocket dropped never recovered even though the backend stayed alive, and a spawned gateway that crashed resumed the wrong session. - GatewayClient no longer resets `subscribed` on each transport generation: the renderer drain()s once on mount, so every post-reconnect event (gateway.ready included) stayed buffered forever. - clearReconnect() keeps the attempt counter; it is reset on gateway.ready (and kill()), so backoff actually grows across failed reconnects. - useMainApp's exit handler no longer calls start() for an attached socket closure — GatewayClient owns that reconnect; it only respawns a still-owned child, and plans the resume with the durable stored_session_id (what session.resume takes) instead of the process-local runtime sid. - session.create's stored_session_id is carried into ui state / the active session file so recovery and the exit epilogue target the durable id. - The recovery target is cleared only after resumeById resolves into a live sid, so a second disconnect during setup/history loading keeps it. - Stale-socket identity guards on 'open'/'message'. Salvaged from #111599 (@gustavosmendes) with trims: kept the client-side backoff reconnect for spawned children too (the "keeps trying to reconnect in the background" copy depends on it), kept the RPC-triggered reconnect during backoff, kept the spawn-mode "reply in progress was lost" wording (true for a dead child) and added attached-mode copy in userMessages.ts, dropped the config_warning contract regen and the SessionCreateResponse re-export (local type gains stored_session_id instead). Fixes #111594 |
||
|
|
81805a97ef |
fix(tui): drop a pending key-burst flush when an external value replaces the draft
The composer hands key bursts to the parent on a 16ms timer. When history navigation, a slash completion or a submit clears/replaces the draft while such a flush is still armed, the [value] effect reset the local buffer correctly but left the timer running, so 16ms later the stale burst was handed to the parent and overwrote the external value (second hole in #111934). Disarm the pending flush in the external branch of the [value] effect: once the parent has replaced the draft, a burst typed against the old draft can never be the newer value. Second invariant test covers it alongside the stale own-echo case. |
||
|
|
c9fe50f39d |
fix(tui): keep stale own-echo flushes from rewinding composer keystrokes
A deferred key-burst flush can still be in flight when the parent's re-render lands: the echoed value is the one we emitted, older than vRef because the user typed past it. The [value] effect treated any non-equal incoming value as an external assignment and rewound local state — the cursor jumped backward and freshly typed letters were overwritten (#111934). Track the last value handed to onChange; an echo matching it stays on the own-change path, and the pending flush for the newer local value converges the parent on its next timer. |
||
|
|
66878996dd |
fix(ux): plain-language, actionable user-facing messages (desktop-tui)
Squashed integration of the user-facing message audit for this surface set. Full per-finding receipts: /tmp/ux-audit/lanes/*-receipt.md (campaign artifacts). |
||
|
|
b67441309c |
fix(contracts): SessionLiveInfo model/tools/skills stay optional — lazy and mirror paths emit session.info without them
The strict suite showed 41 emit sites sending {model} or {} alone; the TUI
banner coerces the missing maps instead of the contract lying about them.
|
||
|
|
f6306d1920 |
feat(contracts): TypeScript consumes the generated contract; hand-typed wire shapes deleted
apps/shared/src/gateway-events.ts is now a thin layer over gateway-contract.generated.ts (client-local synthetic events + the GatewayEvent envelope); gateway-events.json, its two rendezvous tests and the duplicated BillingBlock / SessionInfo / ProjectInfo hand copies are gone. Desktop, TUI, web and shared typecheck against the generated RpcMethods / ServerRequestMap / BackendGatewayEventMap. What tsc found once the types were honest: three phantom fields the backend never sent (tool.start.todos, error.reason, voice.transcript.voice_stopped) - the TUI todo tests were driving the list through the phantom and are retargeted to tool.complete, where the wire actually carries it; nullable fields (`None` on the wire) were typed as plain optionals in eight places and now coerce at the boundary; SessionResumeResult had a stale generic. Contract fixes from the consumer pass: TranscriptMessage is the gateway projection (text/row_id/context/args), not the stored row; SkinPayload matches HermesSkin (empty-string defaults, never null); SessionLiveInfo model/tools/skills are required (always emitted); BillingBlock.billing_url is required-nullable (dataclass asdict). tui_gateway/AGENTS.md documents the declare -> regenerate -> tsc loop. |
||
|
|
9f7f2f28c0 |
feat(gateway): server→client JSON-RPC requests replace the *.request/*.respond event pairs (#110521)
The gateway asked the user questions (approval, clarify, sudo, secret,
vault, MCP setup, the desktop read/act bridges) by emitting a
`<x>.request` EVENT carrying a hand-minted request_id, blocking the
agent thread on a module dict keyed by that id, and exposing a paired
`<x>.respond` METHOD per kind — thirteen pairs, four registries
(`_pending`, `_answers`, `_batch_clarify`, `_EXPIRING_REQUESTS`) and a
per-kind reconnect snapshot (`pending_clarify` / `pending_approval`)
that only two of the thirteen kinds ever got. JSON-RPC already has the
primitive: the server sends a request frame with an id and the client
answers with a response frame bearing the same id.
`tui_gateway/server_requests.py` owns the one mechanism:
send() block the agent thread until the response frame
(`srq-<n>` ids; ints belong to the client)
send_async() fire-and-callback variant (bot relay)
cancel*() withdraw with ONE `request.cancel {id, method, reason}`
event (timeout / interrupt / process exit /
answered elsewhere) instead of per-kind *.expire
open_requests() the still-open frames, replayed by session.resume,
session.activate and session.events.since so a
reconnecting client re-renders every kind, not two
clarify.lock stays a real client→server RPC (locks one batch
answer early); locked answers merge into the final
set even when the closing response carries only the
tail the user answered last
A client that does not implement a method answers -32601 and the agent
fails fast (the old fixed-timeout "unavailable" probes for tour/preview
still work — a wire error IS an answer). Approval: the queue entry's
settle hook withdraws the request when `/approve` from another surface,
a timeout or an interrupt resolves it first, so no window keeps a dead
card. Compute-host children own their waits; the parent mirrors their
open frames for replay and relays `clarify.lock` + response frames.
Clients: `JsonRpcRequestChannel` gains `onRequest` (unhandled → -32601,
dedup by id) and `JsonRpcGatewayClient` re-delivers `open_requests`
from the replay result. Desktop gets `gateway-event/server-requests.ts`
(one handler per method, replacing the request branches of
`input-requests.ts` / `desktop-bridge.ts`) and a `store/server-requests`
registry so every answer site calls `respondToServerRequest(id, result)`
synchronously; the TUI gets `createServerRequestHandler.ts` +
`serverRequestStore.ts`. `gateway-events.json` now pins both halves
(events + server request methods); the two contract tests check both.
Live (real stdio gateway, real `clarify_callback` on the agent thread):
before, `clarify.request` event + `clarify.respond` RPC, batch final
answers lost ('' returned); after, `{"id":"srq-…","method":"clarify"}`
frame, `session.events.since.open_requests` replays it, response frame
`{"answer":"yes"}` reaches the agent, batch lock + final response
merge to `{"q0":"1","q1":"free text"}`.
|
||
|
|
ebe8cda8ea |
feat(tui_gateway): real JSON-RPC server→client requests replace the *.request / *.respond notification pair
The backend never sent a JSON-RPC request; when it needed an answer from the
renderer it hand-correlated a `*.request` notification with a later `*.respond`
method through four module-level dicts, a timeout thread and 13 derived
`*.expire` names, plus a separate reconnect snapshot per prompt kind. That is a
second request/response layer built on a protocol that already has one.
`tui_gateway/server_requests.py` sends `{id: "srq-…", method, params}` and
blocks on the response frame with that id (string ids never collide with the
clients' integer ids). One `request.cancel {id, method, reason}` notification
withdraws a request on timeout / interrupt / session close. `open_requests` on
`session.resume` / `session.activate` / `session.events.since` re-delivers
unanswered requests after a reconnect; the shared TypeScript channel does that
itself before the caller sees the result. Batch clarify keeps its per-question
locks as a normal `clarify.lock` RPC (the last lock resolves the request).
Approvals stay queue-backed (`tools.approval` owns the timeout, `/approve all`,
coalescing): the request resolves the queue entry and the entry's own
resolution withdraws the request through `register_gateway_settle`.
Deleted: `_block`, `_respond`, `_pending`, `_answers`,
`_pending_prompt_payloads`, `_batch_clarify`, `_EXPIRING_REQUESTS`, the
`*.respond` methods, every `*.request` / `*.expire` event, `pending_clarify`.
Compute-host (turn isolation) mirrors the child's open request and relays the
response frame / lock to it. Desktop, TUI and shared clients register
`onRequest` handlers where they used to switch on `*.request` events; answers
are response frames over the socket the request arrived on, so #91684's
owner-routing class cannot recur for prompts.
|
||
|
|
2c0bec33f9 |
feat(model-pickers): reasoning effort selection on every model picker
The Desktop composer got a reasoning-effort pill this morning; every other place a
model is picked still left the effort to a separate command (`/reasoning`) or a
hand edit of config.yaml. `hermes model` had one effort step for Copilot only, and
its auxiliary-model menu had none at all even though every aux block already reads
`auxiliary.<task>.reasoning_effort`.
One request now carries a model pick AND its effort on every surface:
- `hermes_cli/model_switch.py`: the single `/model` parser accepts `--reasoning
<level>` (validated against `parse_reasoning_effort`; unknown level ->
`MODEL_SWITCH_ERR_BAD_REASONING`; Unicode-dash normalized like the other flags).
`ModelSwitchRequest.reasoning_effort` rides with the pick.
- Classic CLI (`cli_model_switch_mixin`, `cli_tui_mixin`): `/model X --reasoning
high` applies the effort AFTER the agent swap (`switch_model` re-resolves
`reasoning_config` from config.yaml, so an earlier write is clobbered) with the
pick's scope (session; config on `--global`; `--once` snapshots and restores it).
The `/model` picker gains a third stage, "Reasoning effort for <model>", built
from `VALID_REASONING_EFFORTS` + none + "Keep current effort"; hidden when the
inventory capability map says the route has no reasoning control.
- TUI gateway (`tui_gateway/model_switch.py`, serves Ink TUI + Desktop):
`config.set model "X --reasoning high"` applies after the swap; session pin
(`create_reasoning_override`) by default, `agent.reasoning_effort` on --global,
one-turn restore carries `reasoning_config`; re-emits `session_info` so the
status bar shows the new effort.
- Ink TUI `ModelPicker`: step 3/3 (same rows, same capability gate) emitting
`<model> --provider <slug> --reasoning <level> <scope>`; the new-session draft
label strips the flag like `--provider`.
- Messaging gateway `/model`: `--reasoning` goes through the existing
`_apply_reasoning_selection` (the `/reasoning` applier) with the pick's scope.
- `hermes model`: one shared post-pick effort step for the MAIN model (replaces
the Copilot-only inline prompt; Copilot keeps its per-model level set via
`github_model_reasoning_efforts`, other routes get the ladder, catalog
`supports_reasoning=False` skips it) plus a "Reasoning effort for the current
model..." row. The auxiliary menu's provider->model and custom-endpoint flows end
with the same step (+ "Provider default"), stored as
`auxiliary.<task>.reasoning_effort` / `delegation.reasoning_effort`, shown in
the task list ("openrouter · model · high"), cleared by "Reset all to auto";
tasks whose block omits the key by design (MoA slots, memory_query_rewrite) skip
it.
Live (temp HERMES_HOME, stub key, no model call):
- `hermes model` -> aux -> Vision -> OpenRouter -> model: before ends at
"Vision: openrouter · <m>", no key written; after adds "Select reasoning effort"
and saves `reasoning_effort: high`.
- `hermes model` -> DeepSeek -> model: before no effort step; after the step
writes `agent.reasoning_effort: xhigh`.
- tui_gateway stdio: `config.set model "... --reasoning high --session"` before
errors "Model names cannot contain spaces"; after switches and `config.get
reasoning` returns high; bad level -> the canonical error text.
- classic CLI `process_command`: before the same spaces error; after "Reasoning
effort: high" under the switch summary, `--global` writes config.
- `hermes --tui` PTY: /model -> step 1/3 -> 2/3 -> 3/3 -> high; transcript
"reasoning: high", status bar "fable 5.1 high".
|
||
|
|
f1d5c99fe5 |
feat: background-process completions paint a compact title, not the raw notification wall
Subagent completions already got this: the model receives the full
`[ASYNC DELEGATION …]` text while the CLI/TUI/Desktop paint a one-line
"Subagent Task Completed: <goal>" event. Background-process completions
(`terminal(background=True, notify=True)`) still echoed the entire
`[IMPORTANT: Background process proc_… completed normally (exit code 0).
Command: … Output: …]` block as if the user had typed it.
Generalise the delegation mechanism: `TimelineNotification` (formerly
`SubagentNotification`) carries `display_kind` + `display_text`;
`ProcessNotificationBatch` renders a `process_complete` one with a
`process_completion_display_text` title ("Background Process Finished:
<cmd>", "Background Process Failed (exit 1): <cmd>", "N Background
Processes Finished"). The TUI gateway stamps the same kind/metadata on
the synthesized turn and emits the title on `status.update`; Ink and
Desktop project `process_complete` rows as timeline events (Desktop keeps
the raw output behind the existing expandable async-result row). Model
content is byte-identical to before.
|
||
|
|
61304d5923 |
fmt(js): npm run fix on merge (#110132)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
988d471479 | style(ts): sort imports/exports the way perfectionist wants after the rebase | ||
|
|
c764d6d354 |
fix(shared): ensureContrast keeps the desktop's 0.2-step ladder; TUI chain opts into 0.05
The shared ensureContrast shipped the TUI's fine 0.05×20 ladder, which changed --dt-primary-solid for 7 of 15 desktop presets (nous #3b6acb → #3f70d8, cyberpunk #00661a → #008021, slate #505457 → #6f7377) while the PR body said no preset VALUE changed. The ladder is now the desktop's original algorithm exactly — pole by luminance < 0.5, accumulating 0.2 steps up to 1.0001, re-mixed from the source colour — with `step` as a parameter. The only pre-refactor TUI caller (ColorChain.ensureContrast) passes 0.05, so the terminal palette is byte-identical too. Test: apps/desktop context.test.tsx iterates every builtin preset × mode, paints it through ThemeProvider and asserts --dt-primary-solid equals the value a reference copy of the old desktop algorithm computes. Sabotage (default step 0.05): 11/30 rows fail. Docs: the SDK table now lists contrastRatio as `number | null` under sRGB measures, not OKLCH. |
||
|
|
057c2c85fc |
refactor(slash): delete the dead web slash re-implementation; one slash parser + command.dispatch narrowing in @hermes/shared
web/src/lib/slashExec.ts and web/src/components/SlashPopover.tsx had zero
importers since the React composer was replaced by the PTY-embedded TUI
(
|
||
|
|
35022e02ed |
refactor(themes): one sRGB color-math module in @hermes/shared; measured readableOn + fine ensureContrast ladder on both surfaces
ui-tui/src/lib/color.ts called itself "the twin of the desktop app's src/themes/color.ts" and the two had already drifted: the desktop measured readableOn but used a coarse 0.2x5 ensureContrast ladder and returned 0 for unparseable luminance; the TUI had the fine 0.05x20 ladder and null-for-garbage but a luminance>0.5 threshold readableOn. Both now import the primitives from apps/shared/src/color.ts (`@hermes/shared/color`, also exported from the root index); each surface keeps only what is specific to it. No palette / preset / skin VALUE changes anywhere — only math. Sites (path::symbol → canonical): apps/desktop/src/themes/color.ts::hexToRgb → @hermes/shared/color::parseColor (deleted) apps/desktop/src/themes/color.ts::rgbToHex → @hermes/shared/color::toHex (deleted) apps/desktop/src/themes/color.ts::mix → @hermes/shared/color::mix apps/desktop/src/themes/color.ts::relativeLuminance → @hermes/shared/color::relativeLuminance apps/desktop/src/themes/color.ts::contrastRatio → @hermes/shared/color::contrastRatio apps/desktop/src/themes/color.ts::readableOn → @hermes/shared/color::readableOn (desktop wrapper readableInk pins ['#161616','#ffffff']) apps/desktop/src/themes/color.ts::ensureContrast → @hermes/shared/color::ensureContrast ui-tui/src/lib/color.ts::{Rgb,parseColor,toHex,mix,relativeLuminance,contrastRatio,readableOn,ensureContrast,lighten,darken} → @hermes/shared/color (same names) Stays desktop-only (apps/desktop/src/themes/color.ts): luminance, normalizeHex, readableInk, OKLCH set (hexToOklch, oklchToHex, oklchToSrgb255, maxChroma, hueDelta, harmonize, mixOklab, withHue, ensureContrastOklch). Stays TUI-only (ui-tui/src/lib/color.ts): liftForContrast, grayOf, desaturate, toHsl, fromHsl, retone, boostSaturation, color()/ColorChain. Importers repointed (17): apps/desktop/src/{sdk/index.ts, themes/context.tsx, themes/retint.ts, themes/retint.test.ts, themes/skin.ts, themes/vscode.ts, themes/vscode.test.ts}; ui-tui/src/{theme.ts, sdk/index.ts, sdk/apps/weather.tsx, app/createGatewayEventHandler.ts, components/agentsPanel.tsx, components/branding.tsx, components/loaders.tsx, components/overlayPrimitives.tsx, lib/color.ts, lib/color.test.ts}. Wiring: apps/shared/package.json exports './color'; apps/shared/src/index.ts re-exports; apps/desktop/tsconfig.json paths + vite.config.ts alias for '@hermes/shared/color' (ui-tui resolves the subpath via the workspace package exports, like './billing'). Behavior change (1): relativeLuminance / contrastRatio return null for unparseable input on the desktop too (previously 0, which made garbage measure like pure black). Desktop SDK export `contrastRatio` therefore widens to `number | null`. Only ensureContrastOklch relied on the number: it now treats null as "already passing / can't measure" and returns the input unchanged. Every other desktop caller passes 6-digit hex. Behavior change (2): readableOn MEASURES both candidate inks and returns the one with the higher contrast ratio (desktop semantics; the threshold version got mid-lightness accents wrong: white on #4f9e5e is 3.29:1 vs near-black 5.50:1). Signature is readableOn(bg, inks = ['#000000', '#ffffff']); the desktop passes its own pair via `readableInk` so desktop output is byte-identical. The TUI switches from the luminance>0.5 threshold to measurement: over the 185 distinct hexes in ui-tui/src/theme.ts (DARK/LIGHT seeds + built palettes) and hermes_cli/skin_engine.py, 56 flip from '#ffffff' to '#000000' — all mid-lightness accents (L 0.18–0.49, e.g. #cd7f32, #4caf50, #ef5350, #ffa726, #4dabf7) where black measures 4.6–10.8:1 against white's 1.9–4.5:1. Note the TUI never called readableOn directly; it only reaches ensureContrast's pole choice (below), and ensureContrast is only reachable via the color() chain and the theme.ts re-export (no production caller today). Behavior change (3): ensureContrast steps 0.05 x 20 from the ORIGINAL color toward the measured readableOn pole (TUI semantics). The desktop previously stepped 0.2 x 5 toward a threshold-chosen pole, so desktop-derived accents that needed a lift (skin/VS Code imports whose accent fails 4.5:1 on the sidebar, and --dt-primary-solid) may now land up to 0.15 closer to their original hue — they stop at the first passing rung. Palette VALUES are unchanged; only synthesized colors move. Also: parseColor accepts #rgb shorthand where desktop hexToRgb rejected it — strictly more permissive; the only desktop path fed raw user hex is normalizeHex, which already expands shorthand itself. Tests: apps/shared/src/color.test.ts (moved TUI parse/mix/contrast cases + two invariants): - "readableOn(%s) returns the ink with the higher measured contrast" — computes contrastRatio for each candidate in the test and asserts the returned ink is the max (a contract, not a hardcoded hex) over #4f9e5e (both ink pairs), #cba6f7, #ffffff, #101014. Sabotage: reverted readableOn to the luminance threshold → 3 red (#4f9e5e x2, #cba6f7); restored → green. - "ensureContrast(%s on %s) clears %s" — 5 failing pairs end ≥ min; plus "leaves passing and unparseable colors byte-identical". Sabotage: truncated the ladder to 3 rungs → 5 red; restored → green. ui-tui/src/lib/color.test.ts keeps only the color() chain case. Validation: apps/shared: npx tsc -p . --noEmit (0) && npx vitest run → 3 files, 30 tests passed; npm run lint clean apps/desktop: npx tsc -p . --noEmit (0); npx vitest run --project ui → 798/800 files, 7563/7572 tests; the 9 failures (src/app/messaging/index.test.tsx x8 12s-timeouts, src/lib/markdown-blocks.test.ts property fuzz 36s) are load-induced flakes under the full parallel run: both files pass in isolation on this branch (16/16) and on origin/main; neither imports color math. npm run lint 0 errors ui-tui: npm run build:ink; npx tsc -p . --noEmit (0) && npx vitest run → 168 files, 1764 tests passed; npm run lint 0 errors git diff --check clean; no new gitignored .d.ts. Handoff: desktop vs web preset palettes diverge for the four shared ids (web presets carry a 3-slot palette {background, midground, foreground(alpha 0)} + warmGlow, not the desktop's 24-slot set, so only the comparable slots are listed; web `foreground` is #ffffff alpha 0 on all four — a glow/overlay slot, not text ink). Design call for Teknium; nothing changed here. preset slot desktop web cyberpunk background #000a00 #040608 cyberpunk accent #00ff41 (primary/ring/mid) #9bffcf (midground) cyberpunk foreground #00ff41 #ffffff (alpha 0) ember background #160800 #1a0a06 ember accent #d97316 (ring/midground) #ffd8b0 (midground = desktop fg/primary) ember foreground #ffd8b0 #ffffff (alpha 0) midnight background #08081c #0a0a1f midnight accent #8b80e8 (ring/midground) #d4c8ff (midground) midnight foreground #ddd6ff #ffffff (alpha 0) mono background #0e0e0e #0e0e0e (match) mono accent #9a9a9a (ring/midground) #eaeaea (midground = desktop fg/primary) mono foreground #eaeaea #ffffff (alpha 0) |
||
|
|
a3d259019b |
refactor(ts): one stripAnsi in @hermes/shared (TUI's OSC/DCS/partial-CSI coverage); desktop adopts it
Three TS surfaces each carried their own ANSI stripper with different
coverage. The TUI's (OSC, DCS/SOS/PM/APC strings, complete and truncated
CSI, multi-byte non-CSI ESC sequences, stray ESC, C0 controls) is now the
single implementation at apps/shared/src/ansi.ts, exported from the root
index and the new `@hermes/shared/ansi` subpath (ui-tui has no DOM lib, so
it imports the subpath like it does for billing/skin).
Sites (path::symbol → canonical):
ui-tui/src/lib/text.ts::stripAnsi, sanitizeAnsiForRender, hasAnsi
→ moved to apps/shared/src/ansi.ts (text.ts now imports stripAnsi
from '@hermes/shared/ansi' for its own trail helpers)
ui-tui: 13 importers repointed from '../lib/text.js' to
'@hermes/shared/ansi' (createGatewayEventHandler.ts,
components/messageLine.tsx, 11 __tests__ files)
apps/desktop/src/lib/ansi.ts::stripAnsi (2 regexes) → deleted;
parseAnsi/ansiColorClass/hasAnsiCodes stay (styled-segment parser)
apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
→ imports stripAnsi from '@hermes/shared/ansi'
apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts
private SGR-only stripAnsi → deleted; imports the shared one
Tests: the TUI 'ANSI sanitizers' cases move from
ui-tui/src/__tests__/text.test.ts to apps/shared/src/ansi.test.ts, plus
one invariant: an OSC-8 hyperlink + DCS string + SGR + partial CSI tail
strips to exactly the visible text with no ESC/BEL left.
Behavior change: desktop chat system messages (use-prompt-actions) and
inline-diff chrome (stripInlineDiffChrome) now also lose OSC hyperlink
payloads, DCS strings, truncated CSI tails and C0 control bytes that the
weaker regexes let through. TUI behavior is unchanged.
|
||
|
|
172b2a722b |
refactor(ts): one compactNumber and one reasoning-effort value set in @hermes/shared
Three hand-rolled compact-number formatters and two mirrored copies of the
reasoning-effort value set collapse into apps/shared/src/format.ts and
apps/shared/src/reasoning-effort.ts, exported from the package root and as
the subpaths `@hermes/shared/format` / `@hermes/shared/reasoning-effort`
(the TUI compiles with lib ES2023 and imports subpaths only). Surfaces keep
their own label maps and UI helpers. No re-export shims remain.
Convention for compactNumber (desktop's implementation, moved verbatim):
lowercase 'k', uppercase 'M', promotion-guarded thresholds (>= 999.5 -> k,
>= 999_950 -> M) so rounding can never print "1000k", trailing ".0"
stripped, non-finite / <= 0 -> "0".
Sites (path::symbol -> canonical):
apps/desktop/src/lib/format.ts::compactNumber -> apps/shared/src/format.ts::compactNumber (moved; file deleted)
web/src/lib/format.ts::formatTokenCount -> deleted
ui-tui/src/lib/text.ts::fmtK -> deleted (text.ts's own callers use compactNumber)
apps/desktop/src/app/agents/index.tsx -> @hermes/shared
apps/desktop/src/app/chat/sidebar/chrome.tsx -> @hermes/shared
apps/desktop/src/app/chat/sidebar/session-row.tsx -> @hermes/shared
apps/desktop/src/app/command-center/index.tsx -> @hermes/shared
apps/desktop/src/app/shell/context-usage-panel.tsx -> @hermes/shared
apps/desktop/src/app/shell/titlebar-controls.tsx -> @hermes/shared
apps/desktop/src/app/skills/index.tsx -> @hermes/shared
apps/desktop/src/app/skills/mcp-tab.tsx -> @hermes/shared
apps/desktop/src/components/ui/tab-dropdown.tsx -> @hermes/shared
apps/desktop/src/lib/statusbar.tsx -> @hermes/shared
apps/desktop/src/sdk/index.ts::compactNumber -> re-exported from @hermes/shared (plugin SDK surface unchanged)
apps/desktop/src/plugins/kanban/{board,drawer}.tsx -> unchanged (import via @hermes/plugin-sdk)
web/src/components/ModelInfoCard.tsx::formatTokenCount -> @hermes/shared::compactNumber
web/src/pages/ModelsPage.tsx::formatTokenCount -> @hermes/shared::compactNumber
ui-tui/src/components/appChrome.tsx::fmtK -> @hermes/shared/format::compactNumber
ui-tui/src/components/thinking.tsx::fmtK -> @hermes/shared/format::compactNumber
ui-tui/src/app/slash/commands/session.ts::fmtK -> @hermes/shared/format::compactNumber
ui-tui/src/__tests__/text.test.ts::fmtK suite -> apps/shared/src/format.test.ts (table incl. promotion guard)
apps/desktop/src/lib/reasoning-effort.ts::REASONING_EFFORTS/REASONING_EFFORT_VALUES/
DEFAULT_REASONING_EFFORT/ReasoningEffort/isReasoningEffort -> apps/shared/src/reasoning-effort.ts
(SHORT_LABELS, reasoningEffortLabel, isThinkingEnabled, resolveReasoningEffort stay local)
apps/desktop/src/app/settings/constants.ts -> @hermes/shared
apps/desktop/src/app/settings/model-settings.tsx -> @hermes/shared
apps/desktop/src/app/shell/model-catalog-menu.tsx -> @hermes/shared (+ local reasoningEffortLabel)
apps/desktop/src/app/shell/model-edit-submenu.tsx -> @hermes/shared (+ local UI helpers)
apps/desktop/src/app/shell/model-menu-panel.tsx -> @hermes/shared
apps/desktop/src/lib/model-status-label.ts -> @hermes/shared (+ local reasoningEffortLabel)
apps/desktop/src/sdk/index.ts -> value set re-exported from @hermes/shared; label helper stays from '@/lib/reasoning-effort'
apps/desktop/src/lib/reasoning-effort.test.ts -> value-set + isReasoningEffort cases moved to apps/shared/src/reasoning-effort.test.ts
web/src/lib/reasoning-effort.ts::EFFORT_OPTIONS -> labels mapped over shared REASONING_EFFORT_VALUES (same order: none, then 7 levels)
web/src/lib/reasoning-effort.ts::VALID_EFFORTS -> Set(REASONING_EFFORT_VALUES); normalizeEffort falls back to DEFAULT_REASONING_EFFORT
Semantics kept: web `none` is selectable; desktop `none` resolves to ''
(thinking off); desktop isReasoningEffort still trims + lowercases.
Behavior change:
- web: token counts on the Models page and ModelInfoCard now print a
lowercase 'k' and are promotion-guarded: 128_000 "128K" -> "128k",
999_999 "1000.0K" -> "1M", 1_500 "1.5K" -> "1.5k". 'M' is unchanged.
- TUI: fmtK used Intl compact notation; compactNumber differs only in
suffix case and the guard: 1_000_000 "1m" -> "1M", and billions no
longer get a 'b' suffix (1_000_000_000 "1b" -> "1000M"). Sub-million
values are identical ("999", "1k", "1.5k"). Non-positive values now
print "0" instead of "-1k".
- desktop: none (its formatter moved verbatim).
Tests: apps/shared/src/format.test.ts::"compactNumber" (table incl.
999_999 -> "1M", 999_949 -> "999.9k"; fails when the promotion guard is
removed) and apps/shared/src/reasoning-effort.test.ts::"reasoning-effort"
(no duplicate values, `none` is the only non-level, default is a member;
fails on a duplicated level or a `none`-accepting isReasoningEffort).
|
||
|
|
a2ae8f229d |
refactor(ts): one fuzzy + model-search-text helper in @hermes/shared; desktop picker ranks with fuzzyRank
Three byte-identical (modulo prettier and a "keep in sync" header comment)
copies of model-search-text.ts and two of fuzzy.ts collapse into one copy
each under apps/shared/src, exported from the package root and as the
subpaths `@hermes/shared/fuzzy` / `@hermes/shared/model-search-text` (the
TUI compiles with lib ES2023 and imports subpaths, never the DOM-typed
root). The vitest suites move with the code; no re-export shims remain.
Sites (path::symbol -> canonical):
ui-tui/src/lib/fuzzy.ts::fuzzyScore/fuzzyScoreMulti/fuzzyRank -> apps/shared/src/fuzzy.ts (moved)
web/src/lib/fuzzy.ts::fuzzyScore/fuzzyScoreMulti/fuzzyRank -> deleted
ui-tui/src/lib/model-search-text.ts::modelSearchText -> apps/shared/src/model-search-text.ts (moved)
web/src/lib/model-search-text.ts::modelSearchText -> deleted
apps/desktop/src/lib/model-search-text.ts::modelSearchText -> deleted
ui-tui/src/lib/fuzzy.test.ts -> apps/shared/src/fuzzy.test.ts (moved)
ui-tui/src/lib/model-search-text.test.ts -> apps/shared/src/model-search-text.test.ts (moved)
ui-tui/src/components/modelPicker.tsx::fuzzyRank, modelSearchText -> @hermes/shared/fuzzy, @hermes/shared/model-search-text
web/src/components/ModelPickerDialog.tsx::fuzzyRank, modelSearchText -> @hermes/shared
web/src/lib/model-picker-filter.ts::fuzzyScoreMulti -> @hermes/shared
apps/desktop/src/components/model-picker.tsx::modelSearchText -> @hermes/shared (+ fuzzyRank, see below)
The header comment now names only the cross-language twin
(hermes_cli/model_search.py) as the thing to keep in sync.
Behavior change (desktop only): the desktop model picker used to filter
model rows with `foldIncludes` substring matching and keep the curated
order; it now ranks them with the same `fuzzyRank(models, query,
modelSearchText)` the web and TUI pickers use. What a user sees
differently while typing a query:
- subsequence queries match: "g4o" now finds "gpt-4o" (previously only
a literal substring such as "gpt-4" or "4o" matched);
- the best match floats to the top instead of rows staying in curated
order (exact > prefix > word-boundary > contiguous > scattered);
- a query that matches the provider name/slug still shows that
provider's full curated list in order, exactly as before;
- an empty query still shows the curated list verbatim.
The in-row highlight is unchanged (substring emphasis via HighlightMatches),
so a fuzzy-only hit renders without emphasis rather than mis-highlighting.
Tests: apps/desktop/src/components/model-picker.test.tsx::"orders model
rows exactly as the shared fuzzyRank does" asserts the rendered row order
equals the shared fuzzyRank order for the same inputs (fails on both the
old substring filter and a reversed ranking).
|
||
|
|
c1e0fd83f9 |
fix(shared): GatewayEventMap drops phantom keys and types child_session_id
Re-verified against the tui_gateway emitters:
- SubagentEventPayload.cost_usd / .iteration: not in
tool_progress.py::_SUBAGENT_FIELDS, never emitted → removed; the TUI's
turnController no longer copies them (its SubagentProgress keeps the
fields for spawn-history persistence).
- SubagentEventPayload.child_session_id: emitted (in _SUBAGENT_FIELDS, read
by agent_callbacks.py::_mirror_subagent_to_child) but untyped → added.
- ToolCompletePayload.error: _on_tool_complete never sets it → removed;
the TUI's completeTool drops its dead `error` parameter and renders the
trail line as non-error (which is what it always did on the wire).
- ToolStartPayload.todos: not on the wire either, but the TUI handler and
its fixtures exercise recordTodos from tool.start; kept with a comment
saying so rather than churning the handler.
- MessageCompletePayload.failure_reason: prompt_turn.py passes
result.get("failure_reason") through → `string | null`.
|
||
|
|
6b406f1c89 |
refactor(ts): ui-tui rides apps/shared's JSON-RPC request channel; one pending map, one heartbeat, typed RPC errors
Two independent JSON-RPC client cores existed for one backend: apps/shared's
JsonRpcGatewayClient (desktop, web) and ui-tui/src/gatewayClient.ts, which
re-implemented request ids, the pending map with timeouts, response->error
mapping, event decoding and the gateway.ping heartbeat (~200 LOC, drifted).
Split the transport-agnostic half out of the shared client into
JsonRpcRequestChannel (apps/shared/src/json-rpc-channel.ts): the owner binds a
JsonRpcTransport { send(text) } per connection generation and feeds inbound
text through handleFrame(). JsonRpcGatewayClient keeps only the WebSocket
lifecycle, seq replay and the typed event hub on top of it; the Ink TUI keeps
only its two transports (spawned child stdio, attached socket) and its
mount-order event buffering, and delegates everything else.
Behavior change:
- TUI RPC errors now carry the JSON-RPC `code` / `data` (JsonRpcGatewayError)
instead of a bare Error(message); the TUI's timeout text is now the shared
"request timed out after Ns: <method>" (was "timeout: <method>", matched by
no caller) and callers may pass a per-call timeout.
- TUI heartbeat liveness counts any inbound frame (shared semantics) rather
than tracking one in-flight ping id; the interval/deadline are unchanged
and pings no longer carry the unread `last_activity_ms` param.
- Desktop isMissingRpcMethod reads the -32601 code first and only regexes the
message for code-less (IPC-flattened) errors, so a tool result that merely
mentions "unknown method" no longer reads as a capability verdict.
- Shared connect() now settles on a `close` during the handshake (auth-gate
4401/4403) instead of waiting out the 15s connect timeout, and
invalidate()/close() drop the socket generation before calling close() so a
synchronous close event cannot run the closed-path twice.
|
||
|
|
36773e0d78 |
refactor(ts): one GatewayEventMap in apps/shared typed from tui_gateway emitters; drop never-emitted tool.progress
Three TypeScript clients each declared their own copy of the tui_gateway wire
types and had drifted apart: apps/shared had a partial GatewayEventName union
with a `(string & {})` escape hatch, ui-tui/gatewayTypes.ts a 150-line
discriminated union, and apps/desktop an `RpcEvent<T>` that was field-for-field
the shared GatewayEvent with `type: string`. None matched the emitter:
message.complete lacked warning/status/error/recoverable/error_surface,
tool.start/tool.complete lacked args/result, SessionResumeResponse lacked
session_key/messages_omitted/hydrating/auto_continue/todo_state, three
different ModelOptionProvider shapes disagreed on fields, and all three unions
handled a `tool.progress` event that no Python emitter has ever produced.
Now:
* `apps/shared/src/gateway-events.ts` is the single home: payload interfaces
typed from the Python emitters (file::symbol cited per interface),
`BackendGatewayEventMap` (89 backend names) + `ClientLocalGatewayEventMap`
(5 TUI-synthetic transport events, clearly marked, excluded from the
contract) merged into `GatewayEventMap`; `GatewayEvent<K>` is discriminated
on `type` with `seq` typed. RPC shapes shared by 2+ surfaces live beside it
(ModelOptionProvider = union of every field hermes_cli/inventory.py sets,
incl. pricing_pending/free_tier_pending; SessionResumeResponse<Info>;
SessionListItem with resolved_id; Usage).
* `JsonRpcGatewayClient.on<K>` is keyed by event name; the gateway.ready
heartbeat/replay_epoch and per-frame `seq` reads are typed instead of cast.
* ui-tui and apps/desktop import the shared names; their local duplicates are
deleted (no re-export shims — importers are repointed; the desktop plugin
SDK barrel keeps its public `RpcEvent` name as an alias of GatewayEvent).
web/src repoints ModelOptionProvider/ModelOptionsResponse.
* `tool.progress` handling is removed from the TUI handler/turnController,
desktop event sets/tools handler, shared union, tests, and two docs
(`grep '"tool.progress"' tui_gateway/` = 0 hits; the `display.tool_progress`
config mode is unrelated and untouched).
* `message.complete.warning` (history-commit note from
prompt_turn.py::_complete_turn_payload) is typed and surfaced on both
surfaces through their existing notice paths (TUI pushActivity 'warn',
desktop notify kind 'warning').
Contract: `apps/shared/src/gateway-events.json` is the sorted list of
backend-emitted names. `tests/tui_gateway/test_gateway_event_contract.py`
collects names from the Python emitter side (emit-helper literals, the
`.request → .expire` table, change-watcher table, child delta mirror,
subagent relay, desktop_ui tool emitters, gateway.ready/setup.ready/
browser-controller frames) and asserts emitted == JSON in both directions.
`apps/shared/src/gateway-events.test.ts` asserts BACKEND_EVENT_NAMES (which
the map type is `satisfies`-checked against) == JSON. Sabotage-verified: a
fake JSON name fails both tests; a fake TS name fails tsc + vitest; a fake
Python `_emit("...")` fails pytest.
|
||
|
|
70d0f556d7 |
fix(branding): use the Caduceus ☤ (U+2624), not the Rod of Asclepius ⚕ (U+2625)
Every inline glyph — CLI banner/status bar/response labels/goodbye, setup and doctor boxes, gateway update prompts, WhatsApp reply prefix, TUI theme, locale strings and the docs — used ⚕, the staff of Asclepius (medicine). Hermes carries the Caduceus ☤. The ASCII-art logo was already correct. Mechanical swap across 60 files (no logic change); both glyphs are East-Asian-width Neutral so no layout shifts. Skins that set their own `response_label` / `goodbye` are unaffected. Direction from PR #7064 (@bixycler), the earliest of #7064 / #9611 / #15574, redone against current main. Fixes #9565 |
||
|
|
6f8b8e77dd |
fmt(js): npm run fix on merge (#107545)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |
||
|
|
b51da65258 |
fix: adapt execute_code cell authority to the widened prompt-callback table
_callback_api() now yields (getter, setter) pairs for every per-thread prompt (approval, sudo, vault unlock); the kernel cell captured and restored the old fixed 4-tuple. Iterate the table so a cell carries every callback and a future addition needs no change here. Test recorder unpacks the new shape. Also: perfectionist import order in ui-tui interfaces.ts (CI lint). |
||
|
|
ac33da3c73 |
fix(tui): hide the composer while the password-manager unlock card is open
Live Ink TUI repro: with the unlock card mounted, keystrokes reached BOTH the masked prompt and the still-focused composer, so the master password echoed in clear text in the composer row and was queued as a message. `$isBlocked` (which unmounts the composer for approval/sudo/secret cards) did not list the new overlay; the pet's awaiting-input predicate had the same gap. After the fix the raw PTY stream no longer contains the typed password. Also tightens the classic-CLI panel copy to fit an 80-column box. |
||
|
|
92e0de0ac4 |
feat(vault): Desktop, TUI and CLI surfaces for password-manager unlock
Desktop - Settings → Credential Vault gains a "Password managers" section: per-manager toggle (disabled with a hint when the CLI isn't installed), Locked/Unlocked pill, Unlock (masked master-password dialog → vault.unlock) and Lock. Items from a manager show a source badge instead of a delete button. - Mid-turn vault.unlock.request renders a masked card in the chat (same contract as the secret/sudo cards: dismiss = keep locked, late answers tolerated, blocks the composer, badges background sessions). - i18n parity en/ar/ja/zh/zh-hant. Ink TUI (hermes --tui): vault.unlock.request/expire overlay via MaskedPrompt; Esc keeps the manager locked. CLI: `hermes vault sources [--enable|--disable NAME]`; `hermes vault list` shows the source column and names enabled-but-locked managers. Docs: credential-vault.md covers managers, per-session unlock, and the headless (cron/webhook/API/-q) no-prompt posture. |
||
|
|
ae43fd6df6 |
fix(tui): bare URLs render verbatim instead of a derived site label (#106843)
The markdown renderer resolved every link to a label — an authored one when present, otherwise the fetched HTML <title>, otherwise a slug derived from the last path segment. For a bare URL that meant the target never reached the screen: `Connect link: https://connect.example.com/link/lk_...` rendered as `Connect link: <site name>`, so the connect-link handoff could not be read, copied or retyped from the TUI. A bare URL is now its own text. Authored markdown labels still win, because `Link` emits the OSC 8 hyperlink unconditionally and the renderer records it per cell, so a label never strands its target. Title resolution no longer drives any render path. |
||
|
|
d4d4ecfae0 |
fmt(js): npm run fix on merge (#105739)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> |