d9ca819cc4902fe0a90d1c30d89350ec889dcfbf
1059 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d9ca819cc4 |
Inspired by Amp: pick the workspace a dashboard chat starts in
A fresh dashboard /chat always spawned the TUI in the dashboard process's launch directory, so from a phone or any browser there was no way to aim a new session at a specific repository. Amp's runners now serve many directories and the web composer offers a picker of the runner's projects and discovered git checkouts; this ports that mechanism onto the surface Hermes already has: the dashboard is the phone/web front, the host's projects.db + repo-discovery cache are the served directories. - GET /api/chat/workspaces: the profile's projects (with folders) and discovered repos (session-derived + scanned), default_cwd, home; ?scan=1 rescans desktop.repo_scan_roots on the host, so headless installs (no Desktop to populate the cache) discover repos too. - /api/pty?cwd=<dir>: validated (existing directory, fail-closed 400 via the PTY error path) and forwarded to the TUI child as HERMES_CWD (self-spawned gateway cwd) + HERMES_TUI_CWD (explicit cwd on session.create for the dashboard's in-memory gateway, whose own cwd is the launch dir). Resumed sessions ignore it. - ui-tui: session.create carries cwd when HERMES_TUI_CWD is set, so /new inside a dashboard chat stays in the picked workspace too. - web: workspace selector in the Chat rail above "New chat" (projects, repos by recency, Other path…, rescan), remembered per profile in localStorage; 17 locales. - docs: web-dashboard.md rail + REST sections. Live E2E: real `hermes dashboard` under a scratch HOME with two git repos under desktop.repo_scan_roots -> /api/chat/workspaces?scan=1 lists both; /api/pty?cwd=repo-a -> TUI status bar shows ~/code/repo-a; /api/pty?cwd=<missing> -> "Working directory does not exist" + close. |
||
|
|
70f5dc5f46 |
feat(connectors): the backend API for the desktop Connectors page; connect an app without a chat session (#115191)
* feat(connectors): the backend serves a connector's tool list, cached for 24 hours
The Connectors page opens one app and shows every tool it has. The backend
had no way to read that list.
- `tools/connectors/portal/`: a client for the portal's tool-list route and a
JSON cache under the Hermes home, one file per portal origin and connector.
An entry is fresh for 24 hours. After that the read revalidates with the
stored ETag: 304 keeps the list, 404 deletes the entry, an upstream failure
serves the stored list marked stale, and a 401 never serves the cache.
- `connectors.tools {slug, refresh}`: account-level, routed by `profile`, no
chat session. Errors carry a fixed `reason` from one closed set on the rail.
- Every connector model that is not operation state moves into
`tui_gateway/contracts/connectors.py`. Handlers that no chat session owns
live in `tui_gateway/methods_connectors_account.py`.
The wire model is tolerant: an unknown facet reads as unclassified and one odd
tool never blanks a connector.
* feat(connectors): catalog, accounts and member tool rules by RPC
The Connectors page needs the app catalog, the connected account of one app,
a way to disconnect it, and the member's own on/off rules. None had an RPC.
- `connectors.catalog`: name, description, category and logo of each app.
- `connectors.accounts`, `connectors.accounts.remove`: read the accounts at
the tool gateway and remove one by id.
- `connectors.policy.get`: the rule layers that apply to the member, widest
first. The body is a union on `mode`, so a reader can name who turned a
tool off.
- `connectors.policy.set`: one change, a union on `type` (the tools of one
connector, or one connector on or off), with the revision the user saw. A
stale revision answers `POLICY_CONFLICT`. The backend composes the upstream
write in one pure function, so no renderer learns the upstream rules.
- Bundled MCP manifests can name their hosted twin with `connector:`, so the
page can show one card per app.
* feat(connectors): connect an app without a chat session
Every connector RPC took a `session_id`, and a connect that did not come from
the model's tool call minted a link with no watcher. The Connectors page has
no chat session, and its card must flip to connected by itself.
- `connectors.list`, `connectors.connect`, `connectors.operation.status`,
`connectors.operation.wake` and `connection.respond` take `owner`, a union
on `type`: `session` (today's behaviour and authorization) or `account`
(routed by `profile`, authorized by the live transport like `mcp.*`).
`session_id` is gone from these params; every desktop caller sends `owner`.
- An account connect runs the same operation lifecycle on a background
thread, under the profile's scope, so the watcher reads the account and
settles the operation. A second connect for an app that is already
connecting returns the open operation and mints nothing.
- `connection.update` carries `owner`. An account operation has no session to
address, so its updates go out on the session-less broadcast path.
* feat(mcp-catalog): eighteen more bundled entries name their hosted connector
A bundled MCP entry and a hosted connector for the same app are one card
on the Connectors page only when the manifest names its hosted twin.
Linear and Notion had the field. These entries get it too: airtable,
asana, attio, calendly, dropbox, figma, railway, supabase, todoist,
betterstack, canva, cloudflare, datadog, intercom, neon, sentry, stripe
and vercel. Atlassian maps to two hosted connectors and Prisma Postgres
is not clearly the same app, so both stay without one.
* refactor(connectors): the account handlers share one gate, one params model and one write table
The six account-level handlers each repeated the availability gate, the
auth catch and the catch-all reply. One decorator now owns that, and each
handler validates its params with its contract model instead of a ladder
of isinstance checks. The five connection RPCs share one guard for the
unexpected-failure reply.
The four write composers for the member rules were the same function
with a different list key and polarity. They are one table now.
The owner union lives in contracts/common.py, so the params side and the
event side stop declaring it twice and the import cycle is gone.
An account operation start carries one event and a flag, so the wait for
the sign-in link blocks instead of polling every 50 ms. run_operation
loses its two account-only parameters; drive_operation is the second
entry point.
Tests: four deleted (they exercised pydantic or the mock), three merged
into tables, two added (a client that still sends the old top-level
session_id is refused; all six account RPCs run off the server loop).
The shared reply helper and the HTTP and managed-client fakes move to
one place each. Comments are one line or gone.
* fix(connectors): a missing tool-list route reads as "unavailable", not "connector gone"
The tool-list read treated every 404 as the portal's "this connector is
not in the catalog" answer. It deleted the cache entry and answered
CONNECTOR_NOT_FOUND, so a page would offer to remove an app that is
connected and works. A portal that does not serve the route yet answers
a bare 404 for every app.
Only the portal's own {"error": "connector_not_found"} means the
connector is gone. Any other 404 is now a tool-list outage: the cached
list is served as stale, or the RPC answers TOOLS_UNAVAILABLE.
* fix(connectors): a connect from the page returns to the app after sign-in
The sign-in link carries a return target only when the session's surface
is the desktop. A chat session binds that surface. An account-owned call
has no chat session, so nothing bound it: the link was minted without a
return target and the browser ended on the portal's done page instead of
coming back to Hermes.
Every account-owned call now runs with the process's own surface bound,
next to its profile scope. The operation thread copies that context, so
the first link and every reissued link carry the return target and the
operation id.
* test(connectors): defer the new connector RPC coverage
The tests for the new account RPCs, the portal client, the tool-list cache
and the rule composer leave this PR and come back in one later change, after
the API is settled. The same was done for #111008.
Kept: the edits that existing tests need because the five connection RPCs
now take `owner` instead of `session_id`, and the rename of the managed
client seam.
Removed: six new test files, their two fakes and the gateway conftest, and
the new cases in test_mcp_catalog.py, test_connectors_gateway_client.py,
gateway-rpc.test.ts and notifications.test.ts. Reverting this commit restores
all of them.
* fix(cli): the connection panel hands the tool thread back at once
The classic CLI's connection callback waited on a queue for the user's first
decision. The operation's watcher starts only after the callback returns, and
the watcher is what polls a hosted account, runs the 300-second deadline and
sees Ctrl+C.
For a hosted connector the panel opens on the sign-in link, where the only
key that filled the queue was Cancel. The account was never polled: the user
signed in, the panel never changed, and Esc reported the app as skipped.
Ctrl+C set the interrupt flag but left the thread parked on the queue, so the
turn never ended.
The callback now opens the panel and returns, as the gateway's callback does
for the desktop and the Ink TUI. The panel's actions already reach the
operation through apply_answer on the UI thread, so the queue is removed. An
install with a form still waits for Connect, because the backend starts no
work for a pending row. Ctrl+C now settles the operation as `interrupt`, and
open rows become `not_connected`.
Checked on the e2e rig with the fake tool gateway: hosted connect completes on
the third status read; Ctrl+C ends the turn and the polling stops; an MCP
install with a plain and a secret field still saves config and both values.
* fix(connectors): "run it again" lives in the library, so the classic CLI can use it
Making a new sign-in link for a failed or expired hosted connector was
implemented only in the JSON-RPC layer (`_reissue`). The classic CLI does not
go through JSON-RPC: its Connect button on a failed row called apply_answer,
which does nothing for a hosted operation because it has no MCP runner. The
panel showed "Waiting…" until the deadline.
`tools.connectors.run.reissue(operation, names)` now holds the checks and the
per-kind action, and returns a refusal reason or None. The gateway maps each
reason to the same JSON-RPC error as before. The CLI calls it for a hosted
row; a refusal is shown on the row. MCP rows keep their path, because Connect
on a failed MCP row re-sends the form values.
Checked on the e2e rig: a scripted failed sign-in, then Connect: a second mint
with `reinitiate: true`, a new link with a new connection id, then connected.
* feat(connectors): the account list and disconnect go through the portal
`connectors.accounts` and `connectors.accounts.remove` called the tool
gateway. They now call the portal's account-management routes
(`GET /api/v1/connectors/accounts`, `DELETE /api/v1/connectors/accounts/{id}`),
which apply the organisation membership checks and write the disconnect audit
row. There is no fallback to the gateway when the portal is unavailable, and a
removal is never retried.
The read of ONE account stays on the gateway (`GET v1/connectors/accounts/{id}`):
the portal has no such route, and the operation watcher polls it once per second.
`ConnectorClient.list_accounts` and `delete_account` are removed. The removed
account's reply model carries `connector`, which both services send.
* fix(connectors): the account RPCs answer what the portal really sends
Checked against the portal source and against the staging and production
services.
- Errors are read from the upstream error code, not the HTTP status. A rule
write answered 409 for a stale revision and for a user with no organisation;
both read as "the policy changed". `org_required` is now `ORG_REQUIRED` and
403 `no_access` is `ORG_ACCESS_DENIED` on every account RPC; only a rejected
sign-in is `NEEDS_NOUS_AUTH`. `connectors.list` and `connectors.connect` with
the account owner map these too.
- `connectors.policy.get` and `connectors.policy.set` carry `effective`: the
portal's own result for this user, with its stamp and without provider or
subject ids. Nothing is recomputed locally.
- A rule write needs the revision the user saw: `expected_revision` is required
and must be a revision string; a bad one is refused before any HTTP call.
- A tool row carries `no_auth`; a list without the upstream flag is an invalid
answer, not `false`.
- `connectors.accounts.remove` returns the app of the removed account. An
invalid id is `INVALID_PARAMS`.
- The tool-list cache is per signed-in member (a hash of the token's `sub`),
so two Nous accounts on one profile do not share entries.
- A malformed slug is a local error, not a 404 from a server nobody called.
Live, staging: no revision and a malformed revision refused locally; a good
revision wrote one disabled Gmail tool and returned it in `effective`; the
same revision again answered `POLICY_CONFLICT`; the list row showed the tool;
the restore brought the member rules back to the start. Live, staging and
production, read-only: all 60 tool lists (5483 tools) parse.
* fix(connectors): the operation RPCs match their contract; a settled card cannot start a new link
Found by two adversarial reviews of the RPC layer and its types.
- `connectors.connect` from a chat session with no open operation is refused
(`UNKNOWN_OPERATION`). It used to call `manage_connections` through the tool
registry with no card: it made a link nobody watched, returned a reply
without the required `settled` field, and named an operation that was never
registered. There is one way into an operation: the agent's call, or the
account owner's `connectors.connect`. "Run it again" inside an open
operation is unchanged.
- `connection.update` for a session is routed by session key AND profile; two
profiles with the same key no longer cross-deliver a sign-in link. The event
payload gets the same redaction as the RPC replies.
- `connection.respond` runs on the long-handler pool: an approval can start MCP
OAuth discovery, which blocked every RPC of the gateway while it ran.
- `connectors.list` rows are a closed snake_case model: `connector`, `enabled`,
`connected`, `connection_status`, `status_reason`, `gateway_disabled_tools`.
The last one is display data: the gateway enforces the rules, the backend
only passes the list on. The phantom `name` and `description` are gone, and
the desktop uses the generated types instead of hand-written copies.
- `tools_listing` (model-only data) no longer rides on `connectors.operation.status`.
- `unavailable` is removed from the target states and settle reasons: nothing
produces it. The contract generator now fails when a contract enum and its
domain enum differ.
- `ConnectorErrorReason` is part of the generated TypeScript and OpenRPC.
- The desktop sends `connection.respond` on the socket that holds the session,
as wake and reissue already did.
- Contract violations are logged every time, at error level.
- An account connect whose prepare step is slow returns the live operation
instead of an error while the operation keeps running.
- The MCP-manifest `connector` field leaves this PR (it moves to a later one
on top of the catalog-reader change). `hermes_cli/mcp_catalog.py` and
`optional-mcps/` are untouched by this PR again.
anti-slop: no net-new findings (15 touched files).
* fix(connectors): the model gets no sign-in link wherever a card exists; side agents cannot connect
The flag that tells the model "a connection card exists" was the session
platform (`== "desktop"`). The Ink TUI and the classic CLI also draw a card,
so there a connector call on an unconnected app handed the model the raw
`connect_url` and told it to pass the link to the user.
- The agent turn now declares how a link can reach the user
(`tools/connectors/turn.py`): CARD when the agent was built with a
connection callback, SIDE for a subagent or a background turn, LINK for a
headless run (`-q`, cron, ACP, api_server, messaging). It is set once per
tool batch in the agent loop and read by the connector dispatch path, which
never sees the agent. The session platform decides return-to-app only.
- CARD: the result carries `connect_card_available` and our hint, never the
link and never the gateway's own hint.
- SIDE: subagents (`delegate_tool`), gateway background turns and the classic
CLI `/bg` are built with `side_agent=True`. They hold no `manage_connections`
tool on any path that derives the tool list, and a connector call on an
unconnected app gets no link, only "report this to the main agent".
- LINK is unchanged.
- The hosted path with no card builds a detached operation, as the MCP path
does, so no `connection.update` is emitted for an operation no client asked
for. Names and docstrings that said "off desktop" now say "no card".
- A settled card is dead on the desktop: `reissueConnectionTarget` and
`respondToConnectionRequest` share one guard and send nothing for a settled
or unknown operation.
- The model-facing settled result no longer carries `connection_id`; the model
repeated it to the user.
Shown on the real clients with a real model (rig, fake tool gateway): Ink TUI
and classic CLI get `connect_card_available` and no link, the model opens the
card, the account connects, the retried call succeeds; `-q` still gets the
link; a subagent and a background turn have no `manage_connections` and get
the no-link hint; on the desktop a card settled with Continue has no enabled
control and sends no RPC.
* feat(tools): every call made through tool_search + tool_call shows a real label on all three clients
A bridged call showed as a generic `tool_call` row in the Ink TUI and as
`⚡ tool_call` in the classic CLI, because the display looked the name up in
the tool registry and bridged names are made at run time. The desktop labelled
only batches that were all hosted connector calls, by parsing names itself.
- `tools/tool_labels.py` is the one place that turns a bridged call into a
label: kind, app, action, emoji and text. Hosted: `connectors__gmail__GMAIL_SEND_EMAIL`
→ "Gmail · send email". MCP: "Linear · list issues". A local deferred tool
keeps its own emoji, verb and primary-argument preview. A batch gets exactly
one label per entry, always; an entry with no name gets a generic label.
- Classic CLI: one row per inner call; the duration on the last row; the
failure text on the row of the call that failed. With friendly labels off
it prints what it printed before.
- Gateway: tool start, progress and complete events and stored transcript rows
carry a typed `labels` field. It does not depend on the classic CLI's
display setting. Clients no longer parse tool names.
- Ink TUI: rows from the labels; the verbose trail keeps Args and Result.
- Desktop: `ConnectorExecution` renders hosted, MCP and mixed turns from the
labels, one row per call. The labels reach the row under a key no tool
argument can use. The connect card it drew under a failed tool result is
gone: after `CONNECTION_REQUIRED` the one way in is the agent's own
`manage_connections` call.
- `tool_search` and `tool_describe` rows read "Searching tools · <query>" and
"Reading tool details · N tools".
Shown on the real desktop (video and screenshots), the Ink TUI and the classic
CLI with the rig: hosted rows, MCP rows, a two-entry batch, a failed entry, a
`CONNECTION_REQUIRED` row with no card under it, labels after a reload, and the
desktop rows with the classic CLI setting off.
* fix(connectors): the model can tell "hosted tools unavailable" from "no such tool"; manage_connections routes MCP names correctly
- A failed hosted search or describe used to return nothing, by design, so the
model saw only local tools and told the user that a connected app was
missing. The local results are unchanged; when the hosted leg failed, the
`tool_search` and `tool_describe` results carry
`connectors: {status: "unavailable", reason: "unreachable" | "sign_in_expired"}`
and one hint line. A rejected token is `sign_in_expired`; an entitlement
refusal or a shut gate adds nothing. `tool_describe` no longer lists those
names under `not_found` next to "search again".
- NS-932. The description now says which side a name belongs to: a bare name
is a hosted connector account; `mcp: true` only when the user asks for an MCP
server, a local server or an install, or when the name exists only in the
catalog; connect and reconnect are hosted verbs, install, enable and
authorize are MCP verbs. It names the three clients that draw a card.
- A misrouted target is refused with the call that works. Only when the
gateway does not know the connector (confirmed on that failure path) and the
name is a catalog entry does the target fail with "X is a local MCP server.
Call manage_connections with action install ...". It is a per-target
outcome: other targets of the same call keep their links and their card. A
vendor failure on a name both sides know stays an ordinary failed row. The
MCP side mirrors it, and never for an entry that is only not installed.
- "Do not re-ask after a skip or a timeout" no longer stops the model when the
USER asks for that app again; the description and the settled-result notes
say so. A builder saw the model refuse a direct user request.
Shown on the Ink TUI and the classic CLI with a real model: a dead gateway and
a 401; "connect fxmail" goes hosted; "install the fx-noauth MCP server" goes
MCP; "connect fx-noauth" reaches the MCP install card in one corrective round
with no hosted mint; a two-target call where one is misrouted still connects
the other with exactly one mint.
* fix(tui): the connection card answers every key, shows what is happening, and is dead once settled
Reproduced on the real Ink TUI with the rig, then fixed:
- The keyboard was dead during the sign-in wait: the card kept a `submitting`
flag that the normal OAuth path never cleared, and Esc went through the same
guard. The in-flight state now belongs to the answered row and clears when
that row moves, when any later frame of the operation arrives, or after
five seconds. Esc skips the row in every phase; Ctrl+C interrupts the turn
(the input handler had no branch for this overlay); Shift+arrows scroll the
transcript and the card ignores them; arrow keys no longer move the text
cursor and the field focus at once.
- The card was lost at turn idle: the overlay flag was cleared while the
operation stayed in the store, and a resume dropped the pending card. The
flag survives idle, a resume shows the pending card again, a session switch
clears it.
- States with no branch: `not_connected` and a row with no link fell into the
credential form; `expired` vanished with no note. The title and the row text
now name the action (connect, reconnect, install, enable, authorize); a
failed or expired row with no fields offers Try again / Skip; a failed row
WITH fields reopens the form over the typed draft, with the failure above it.
- A settled card is dead: at settle the overlay closes and one transcript line
per app states the outcome. A settled or dismissed operation id is
remembered, so no replay or resume can reopen its card. Esc in the last
"Finishing…" moment hides the card and still writes the outcome lines.
- A failed `connection.respond` and a browser that did not open are shown on
the card in one sentence.
Also: `tui_gateway/connector_payload.py` redacted the BOOLEAN `secret` flag of
a credential field to the string "[REDACTED]". On the desktop every credential
field therefore rendered as a password and lost its prefilled default. A
boolean is no longer redacted.
* chore(connectors): remove the comments and docstrings this branch added
Deletions only. Kept: tool directives (`# noqa`, `// eslint-disable`, ...),
`// SAFETY:` lines, and the docstrings of the contract models under
`tui_gateway/contracts/`, which become the descriptions in the generated
OpenRPC and TypeScript.
Checked that no code changed: every Python file has the same AST as before
once docstrings and `pass` are ignored (62 files), and every TypeScript file
prints the same with comments stripped by the TypeScript printer (32 files).
The generated contract files are unchanged.
* fix(connectors): a card restored after a reload answers again; every account RPC names auth and org failures
Found by the end-to-end runs on the pushed head.
- Desktop: after a window reload, Continue on the restored card sent nothing.
The answer looked up the backend that holds the session with the runtime
session id, the lookup wants the stored id, and a failed lookup returned
silently. When the lookup gives no owner the answer now goes out on the
window's active socket, which is what main does.
- `connectors.policy.get` answered `POLICY_UNAVAILABLE` for a rejected sign-in,
a refused scope, a non-member and a missing organisation alike: the handler
runs with the gateway's globals and did not import the reason enum, so its
own error mapping raised. `connectors.accounts.remove` caught auth failures
in its generic branch. `org_required` was mapped on `policy.set` only. All
six account RPCs now answer `NEEDS_NOUS_AUTH`, `FORBIDDEN_SCOPE`,
`ORG_ACCESS_DENIED` and `ORG_REQUIRED` for those four upstream answers.
|
||
|
|
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. |