* 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 (e0ef0eb9c3,d105376b21,ee2f5629b8,1ab32b212b): the branch's history no longer shared a base with main, so this is the PR's exact delta against +3762/-1470, identical to the branch tip 05d7f2d3d5. The fifteen commits it carried, in order: --- feat(connectors): the wire follows the connector contract (six-state status, required toolkit metadata, connectionId on mint) Hermes types exactly what the contract page writes: /tmp/magic/CONTRACT-TOOLKIT-METADATA.resolved.md (carriers: portal PR2 `sid/connection-api` for the enum and account rows, the portal contract branch on top of it for the list metadata). No optional-for-compat fields, no fallback branch, no seven-state word left in the tree. A gateway that does not speak this contract fails validation loudly. Wire (tools/connectors/gateway/wire.py): `ConnectionStatus` is the six values, `pending` covering the vendor's INITIALIZING and INITIATED; `ConnectorListItem` requires title, description, https iconUrl and authKind, and carries activeConnectionId when the session binds an account; `ConnectorListResponse` types a page whole with its total; `ConnectorAccount` and `ConnectorAccountsResponse` type the account routes; a mint result names its connectionId on `initiated` and never on `failed`; CONNECTION_REQUIRED carries connectUrl and connectionId together or not at all; `account` exists on the execute call and is never sent while multi-account is off. Client: `list_connectors(search, connected)` refuses a search under three characters before any request and parses every page whole; `list_accounts` and `account_status` read the account routes, 404 `connection_not_found` is None and 429 raises `RateLimited(retry_after)`. Watcher: the target keeps the account id the mint named and the toolkit's title and icon from one list read before the card is emitted; `_status_for` is the one seam between the watcher and the gateway's status source (the list walk today; the per-account route replaces that body only). `pending` and a missing status move nothing; revoked and inactive are failed. Desktop: `ConnectorRow` is the strict list item; `ConnectorRowSeed` is what a tool call's args can say; `connection.request`/`update` targets carry `connection_id`, `title`, `icon_url`; the mark ladder is glyph, vendor icon as a plain image, favicon, monogram. Tests run red first: wire rejects the retired words and the missing metadata; client search minimum, execute omits account, account_status 404/429; managed targets carry the metadata and the account id, a pending row moves nothing; the logo ladder order; the store passes the fields through. --- feat(connectors): onboarding connect-first rides the connection operation The guided first build had its own connector surface: a 2 s connectors.list poll loop, an auto-open of every minted link, a hidden "[setup] links opened" note that painted as a user bubble, CONNECT FIRST rows in the transcript, and a "Start with N apps connected" composer pill that sent the go signal early. Delete all of it. The build session's first manage_connections connect now shows the same card as any chat, and the settled tool result is the go signal. Deleted: store/first-build-connectors.ts, assistant-ui/first-build-connectors.tsx (nothing rendered it any more), lib/first-build-start.ts, onboarding-chat/start.tsx, the first-build session markers in handoff-receipt.ts, the connectionRows / latestConnectorPart helpers they were the last readers of, and the five strings only they used. The runbook (setup-profile.ts::connectFirstRunbook) now describes the operation's real contract: one connect call with every picked slug, one card with a row per app, blocks until settled, a per-app result of connected / skipped / not_connected. No wait, no "Start with", no "already active" branch (the result never carried it), and no offer to re-mint: Try again and Continue live on the card, and a Continue means the user moved on. Tests (red first): the runbook names one connect call and its per-app result and never the retired model actions; the no-account rule survives an empty pick. --- feat(connectors): the registry is keyed by profile, every watch read is bounded by the deadline, connectionId is optional, connect and execute carry returnTo and op Registry (P1-14): tools/connectors/live.py keys an operation by (profile home, session key). Two multiplexed profiles can carry the same timestamp-based session key; the tool thread opens under its turn's profile override and the RPC side names the session record's profile home, the default profile resolving to the process home on both sides. Watch reads (P1-8 residual): list_connectors takes a per-page timeout and the watcher bounds each read by the operation's remaining deadline (floor one second), so a stalled gateway page cannot hold the operation past its deadline. Contract follow-up from the portal handoff (/tmp/magic/HANDOFF-HERMES-PR3-PORTAL-CONTRACT.md): connectionId on a mint result and on CONNECTION_REQUIRED is plain Optional (a no-auth toolkit answers active with none; a failed mint answers with neither link nor id); a target without one has nothing to watch. Connect and execute requests carry returnTo (hermes-desktop | hermes-desktop-dev | portal) and op, so the vendor's done page can send the browser back to the app; the call sites and the deep-link handler follow in the next commit. Tests, red first: two profiles share a session key without seeing each other; each watch read shrinks with the deadline; the client honours a per-page timeout; the optional id; the two request fields. The fakes' list_connectors accept the timeout keyword; two wrapper spies that did not were the cause of a 300 s hang under the per-file harness. --- feat(connectors): an MCP target runs on the shared connection operation manage_connections ran MCP targets as a renderer errand: the desktop card ran the catalog lookup, the install and the OAuth flow, then told the backend what had happened, and every surface without a card got `unavailable` plus two terminal commands. That left the outcome in the renderer's word, and left the model unable to connect an MCP server anywhere but the desktop. The backend now owns the work, the way it already owns a managed connector. `prepare` starts the OAuth flow in process and points the browser at the backend's own callback route; an install that still needs credentials waits pending and publishes their names as `required_env`; `observe` reads the flow or the install worker on every tick. The worker itself moved out of tui_gateway/mcp_oauth_sessions.py into tools/connectors/mcp_oauth.py, and that module now calls it, so the Capabilities tab keeps its RPC session table. The card may only say approved, skipped or continue: a claim of any other state moves nothing. With that, `renderer_flow` and the `unavailable` state have no producer left and are gone from the contract. Off the desktop there is no card, so the action runs at once and the result carries the authorization URL for the user to open. Try again reaches the same work: `_reissue` dispatches on `target.kind`, so an MCP row re-runs its own install, enable or OAuth instead of minting a managed gateway link. (cherry picked from commit fa0438c4121807604e7c983ba42b901314a1305b) --- feat(connectors): the MCP card projects the operation instead of running the flow The MCP setup card used to own the work: it called the catalog, ran installMcpCatalogEntry, polled the action, drove the OAuth window, and then told the backend what had happened through connection.respond. That made the renderer a second authority on target state, so an install that finished after the window closed, or a card that never mounted, left the operation with a state nobody could correct. PR3 moves that work to the backend, so the card has one job left: show the operation and send the user's consent. McpSetupPending now renders request.targets the way ConnectorOffer renders them: one row per target, one verb by (action, state), Continue below the shell. Install and Enable send {status: 'approved'} and nothing else; an authorize row opens the link the backend minted, with no OAuth RPC of its own; a running install holds its verb with the busy mark; a failed row sends connectors.connect {reconnect: true} on the open operation, the same call the managed card makes. The owner lookup and that call are now shared helpers on connector-tool.tsx rather than a second copy here. ConnectionTargetOutcome loses connected / initiated / failed: those were the renderer reporting state, which it no longer may do. ConnectionActor loses renderer_flow for the same reason. ConnectionOperationTarget gains required_env, the credentials an MCP install is still waiting for; the card renders a field per entry under the pending row and holds Install until every required one has text, so the values travel with the approval instead of through a separate catalog call. (cherry picked from commit 2e42ad19f63e41f274a87199d07eb99a7c995cbe) --- fix(connectors): the toolkit metadata leaves the wire; the vendor logo is derived from the slug Sid cancelled portal PR3 (the toolkit metadata on the list item) on 2026-09-14, so the fields the contract commit typed as required come off the wire: no title, description, iconUrl, authKind or activeConnectionId on the list item, no total on the page, no search or connected query, no title or icon_url on a target snapshot. The list item is exactly what portal #1220 emits. The desktop derives the vendor logo from the toolkit slug instead (`connectorIconUrl`, https://logos.composio.dev/api/{slug}; the gateway slug is the vendor slug, checked for every lead-order pick), rendered as a plain image because the host sends no CORS header. The title stays `connectorTitle(slug)`. The mark ladder is unchanged: glyph, derived vendor icon, favicon, monogram. Everything else in the contract stands: six-state status, optional connectionId on mint results and on CONNECTION_REQUIRED, returnTo and op, the account routes. The contract commit's message still names the metadata; this commit is the correction. --- fix(connectors): an MCP target survives the card's Continue, and the model is not sent to an action that refuses it The review of the MCP backend found eight defects; each one is a test first. - The off-desktop note told the model to confirm with action 'status', which refuses MCP names outright. It now says the authorization finishes in the background and the tools arrive on the next turn. - The card's existence is a property of the session. A missing callback no longer routes a desktop session down the off-desktop path, where the model would be handed a live link. - A second failure of a backend attempt was published with actor 'user'. ``refresh`` takes the actor, so the frame says who produced the text. - Continue on the RPC thread can settle the operation between any read of a target's state and the transition that follows it. A settled operation has a frozen result, so the lost move is dropped; an IllegalTransition no longer escapes into the tool result. The same rule covers a worker whose outcome arrives late and a Try again that arrives after Continue. - Try again on an install carried no credentials, so the second install ran with an empty env. The approved map is kept on the runner (never on the target: target fields are serialised to the model) and reused. - An approval that does not cover a required credential no longer reaches the worker, where ``install_entry``'s prompt would block on stdin forever; the row waits for the card's fields instead. - ``enable`` writes ``mcp_servers`` under the scope and lock the dashboard's toggle route uses, so the two read-modify-write paths in one process cannot drop each other's write. - Several authorize targets start their flows together and share one wait; one wait per target kept the card empty for minutes. - The off-desktop operation is in no session's registry, so it now emits no connection.update. Try again is also refused for a target the transition table cannot move back to 'initiated' (an MCP target has no move out of 'expired') and for an operation that settled during the call. The contract test that froze the Actor enum is replaced by two behaviour tests: no actor but the backend watcher can connect an MCP target, and the card's word never moves one. (cherry picked from commit 081c16412b48667758a77a7e7c29eaf2be6a93dd) --- fix(connectors): the watcher reads one account route per target, and every frame carries its seq The watch loop walked the whole toolkit list once per tick to learn whether one target had connected. That read costs a vendor call per page, cannot tell one account from another, and forced `awaiting_new_attempt`: after a forced reconnect the list still reported the OLD account `active`, so the row had to be disbelieved until it read as something else once. The gateway now serves `GET /v1/connectors/accounts/{connectionId}`, so each pending target reads its own account: the id the mint named, one read per target per tick at 1 Hz (the route's bucket is 180/min per principal), each bounded by the operation's remaining deadline. A 429 parks that one target until its Retry-After passes and leaves the others reading. A 404 is "not yet" until the deadline. A target the mint gave no account for has nothing to read, so it is not read. A forced reconnect watches the new account, which is why `awaiting_new_attempt` and its two tests are gone. `mint` and `run_remote` now name where the browser should come back to (`returnTo`, plus the operation id on a mint), so the vendor's done page can hand the user back to the desktop app that asked instead of stranding them on a web page. Only the desktop registers that URL scheme, so no other surface sends either field. `connection.update` frames were built by re-reading the operation after the lock was released, so a second writer could give an older frame a newer state and the renderer could not tell which frame was last. Every write now advances a monotonic `seq` and takes its snapshot under the same lock, and the emitter sends that snapshot; a renderer that keeps the highest seq per operation can drop a frame that arrives out of order. `connectors.operation.wake` lets the desktop's deep-link return ask for a read now instead of at the next tick; it only shortens the wait and trusts nothing else in the link. (cherry picked from commit f5423cf72302b92b26c043495184ad6426fa2b36) --- fix(connectors): the desktop card follows the operation, and says so out loud The MCP card was a second implementation of the connector card with the review's defects: it painted for any request on the session, kept its controls after the operation settled, offered an approve verb while the backend was still minting an authorize link, and re-enabled Install when the RPC returned rather than when the state frame moved the row. A second click in that window sent the consent twice. The renderer now reads the operation's `seq`: an update or a status frame whose sequence is not greater than the one already applied changes nothing, and a resume snapshot neither revives a settled card nor puts back a row a newer frame has moved. Without it the transport's ordering decided what the user saw. `hermes://connections/done?op=…` brings the user back from the browser to the session that opened the operation and wakes its watcher, so the row moves at once instead of at the watcher's next tick. Only the op id is used; the link's status moves no row. Each row's mark and cue are one polite live region, so a row that flips is heard and not only seen, focus follows the row the backend moved while the card holds it, and the waiting mark stops spinning under reduced motion. (cherry picked from commit 7c30d252556d7d496ddb354b50bcecc41bceef27) --- fix(connectors): the renderer types seq as the wire carries it The watcher commit made `seq` a required field on every operation frame. The desktop store still declared its own optional `seq` so it would compile against a backend that predates the field; that backend no longer exists on this branch, so the hedge is dead code and the fixtures were short one field. The store now reads `seq` from the shared types, and the fixtures count the way the backend does. The repeat-frame test asserted the whole request keeps its reference. With a real rising `seq` the request must change; the invariant the test guards is that the target row keeps its identity so open credential inputs do not remount. --- fix(connectors): a URL that arrives after the shared wait still lands on its row The shared prepare wait failed every row still pending when it ran out, while that row's own thread was still waiting on the provider. When the URL arrived a moment later the thread's move raised inside the daemon thread, the link was lost, and Try again started a second flow. The wait now bounds only how long prepare blocks; a row still pending afterwards is left to its own thread, which is the only writer of that row and ends with the URL or the flow's own failure. The comment on the approved credentials said every target field is serialised to the model; it is not (the snapshot names its keys). The reason they live on the runner is that they are secrets and the runner's life is exactly the operation's. --- fix(connectors): the review findings the operation must survive before the fold The MCP prepare threads and the install worker started with an empty context, so a named-profile turn's home override never reached them: the flow resolved the process home's `mcp_servers` and stored the token there. Each thread now runs in a copy of the calling thread's context. `connection.respond` had the same gap on the RPC thread: an approval ran the enable, which writes config.yaml, with no profile bound, so the flag landed in the launch home. The handler binds the session record's profile the way `_connector_rpc` does; `config_write_scope(None)` keeps that override, so the enable needs no change. The watcher raised out of the tool when a per-target Skip landed while that target's read was in flight: the operation stayed open with `live` closed, and every later answer got 4004. A read for a row that is no longer live is dropped at debug; only a refusal on a live row is still a contract violation. The same skip from the card raced a row the backend had just connected and aborted the rest of the answer; a skip for a resolved row is ignored and every entry, then the settle check, still runs. A read was bounded by the whole remaining deadline, so a hung gateway held the first read for 300 s and Continue could not return the tool; a read now waits ten seconds at most. A 429 parked only the target that read, but the budget is the principal's, so the next target's read in the same tick spent it again: every live target waits out the one Retry-After. The MCP surface rule read the platform alone, so a desktop call without the callback (registry dispatch from execute_code) opened an operation nobody rendered and blocked for the deadline; it now uses the managed rule, surface and callback. A write after settlement advanced `seq` while emitting the frozen frame, so the resume snapshot named a seq no frame carried; the counter stops at the settle frame. A mint that reports `initiated` with no account id logs that the watcher cannot read the row. (cherry picked from commit 587b228016bf8ee2b022e7fa58c2d9f6057809e9) --- fix(connectors): the desktop card holds a verb until the backend answers, and never takes the keyboard from a credential field The review of the desktop card found five defects; each one is a test first. - A resume snapshot was refused whenever the cache held a settled operation, whichever operation it was, so a session that opened a second operation after settling the first never got its card back from a resume. And the refused snapshot handed the caller the settled cache as "the request", so the session was flagged as needing input behind a summary with no controls. Only the same operation can refuse the snapshot now, and a refused one is no pending card. - The focus handoff picked the row's first button, which after pending -> initiated is the disabled working verb; the focus call was a no-op and the keyboard landed on the document body. It picks the first control that can take focus, else the row. It also moved focus out of a credential field the user was typing in whenever another row moved; it leaves an editable alone. The "focus Continue once every row resolved" branch was dead (Continue unmounts the moment nothing is unresolved), so it and its ref plumbing are gone. - The done link navigated to a settled operation's session and rejected when the wake RPC did (4004 once the operation left the live registry). A settled request is ignored, and a refused wake is nothing: the wake only shortens the wait, the watcher still ticks. - Install spun forever when the store refused to send the consent (the operation gone or settled under the card): `respondToConnectionRequest` resolves false in that case and the verb was only released in `catch`. - After a partial approval (a required credential missing) the backend answers with a same-state frame whose detail names what is missing; nothing released the verb because it was held until the row's state moved. The row now remembers the seq the click saw and holds the verb only until a frame past it arrives, which is the backend's word on the click whether or not the row moved. (cherry picked from commit 4c534d6e2af979778d9a2423cf97d717edeaa1a9) --- fix(connectors): a skip that loses the race to the watcher is ignored, not raised The skip guard read the row's state and then moved it; the watcher can connect the row between the two, and the refused move aborted the rest of the card's answer. The refusal itself is now the witness: a move refused for a row that is resolved, or on a settled operation, is the same nothing-to-do as a row resolved earlier. Two recording fakes in the managed tests kept their lists on the class; they now start per instance so a lifted fake cannot share reads between tests. --- fix(connectors): a resume that lands behind a newer live frame still reports the pending card The refused-snapshot branch answered "no pending card" for both reasons it can refuse: the operation settled, or a newer frame already moved a row. Only the first is no card. For the second the live card is still open and blocking the turn, so the caller must keep the session flagged as waiting on it. * test(connectors): defer new connection coverage until implementation settles Remove PR-added test cases and their unused helpers while retaining existing tests adapted to the changed connection contract. The three PR-only renderer test files are removed for now. Focused behavioral coverage will be added as the final implementation step before verification. Existing main coverage is not being removed wholesale, and this does not declare the feature merge-ready. * fix(connectors): commit MCP authorization at initialize, save setup values after success The OAuth probe treated one exception as one outcome: any failure after the browser step restored the token snapshot and manager entry, so a server that accepted the token but failed tools/list discarded a completed consent. Now the probe reports whether initialize succeeded (details["initialized"], read from the claimed MCPServerTask). Failure before that point rolls back as before. Failure after it saves the server config, keeps the tokens, and reports tools unavailable through flow.discovery_error; the card can retry discovery without repeating consent. Catalog install wrote the submitted values to .env before install_entry and the probe ran. The values now live in the secret scope for the duration of the install (get_env_value reads through get_secret, so install_entry finds them without a prompt), and .env is written only after the probe returns tools. A failed probe removes the server block and writes nothing. _probe_tool_names returns None on a failed probe instead of an empty list, so failure and a valid empty listing are distinct. Failure text is redacted before it reaches target detail: every value the card submitted for that target is replaced by exact match, then the pattern redactor runs. required_env now carries the manifest's secret and default flags; Target carries the manifest's post_install text as instructions. The wire contract gains secret, default, instructions and discovery_error; generated TS and OpenRPC regenerated. * fix(connectors): one OAuth callback receiver picker for the connection card The card's authorize target built its redirect from the dashboard web server and raised when none was bound in the process, so a standalone hermes --tui session could never authorize an MCP server. The receiver is now chosen in one place (choose_callback_receiver): a pinned pre-registered client keeps the SDK's own listener on the registered port; a client-advertised loopback URI is used as-is and its callback arrives through the mcp.servers.oauth.callback relay; otherwise the backend binds a one-shot loopback listener and feeds it into the flow. The dashboard route stays with the dashboard web page, which cannot bind a port. tui_gateway/mcp_oauth_sessions.py had a second copy of the loopback listener and a _worker that referenced _probe_with_rollback, set_hermes_home_override, reset_hermes_home_override and Path without importing them, so every RPC-started flow raised NameError. Both are deleted; start_flow spawns run_worker directly and uses the same receiver picker. Flow registration is shared (register_flow / finish_flow) so a card-started flow with a client URI is reachable by the relay. Under an SSH session with no client listener the attempt's detail carries the existing paste-the-redirect instructions. Nothing on the card path opens a browser. * feat(desktop): setup-form modal for MCP connection cards The connection card rendered an MCP server's setup fields inline: every field as a password input, no default value, no instructions. A URL such as the n8n MCP server URL was typed blind, and the manifest's setup text never reached the user. Two components carry the form now. SetupFieldList renders the ordered fields the backend declares (a plain field as text prefilled with the manifest default, a secret field masked and empty). SetupFormDialog composes it with a one-line title, the manifest instructions, an inline error for a failed attempt, and Cancel / Connect. The row's Install action opens the dialog when the target has fields; Connect sends {status: approved, env}; Cancel sends {status: skipped}. A failed attempt keeps the dialog open with the draft intact. The draft lives in the dialog component only; nothing reaches the store or the resume snapshot. Once the backend publishes the authorization URL the dialog shows it as text with an Open in browser button. Nothing opens a browser on a state change: the Try again path on both cards used to open the re-minted link at once; it now waits for the row's update frame and the user's click. The store types gain the wire's secret, default, instructions and discoveryError fields and normalise them; a connected target with discoveryError renders as authorized with tools unavailable. * feat(tui): connection card in the Ink TUI The Ink TUI had no client for the connection operation: connection.request, connection.update, connection.respond and the pending_connection resume snapshot were unhandled, so a manage_connections call in a TUI session could only print a link through the model. connectionOperationStore.ts holds the backend snapshot: a request opens only for a new operation id, an update applies only to the live operation with a higher seq, a settled update freezes the id so a late request frame cannot reopen the card. The gateway event handler feeds it; session resume hydrates it from pending_connection. connectionSetupOverlay.tsx renders one callout in the prompt zone: a one-line title, the manifest instructions, every field (plain rows prefilled with the default, secret rows masked), then a Connect / Cancel selector. Connect sends the draft through connection.respond; Cancel skips the active target. When the backend publishes the authorization URL the callout shows it as text under "Press Enter to open in browser"; Enter is the only thing that opens it. A failed attempt unlocks the fields with the draft kept. An accepted secret renders as "Set" and is never echoed. A target authorized without tools shows that state and Continue. The overlay joins the existing input-owner set in overlayStore so typing and other prompts are blocked while it is up. * feat(cli): connection panel in the classic CLI The classic hermes CLI passed no connection_callback, so a manage_connections call could only print an authorization link through the model and could not take a setup value at all. The CLI now renders the connection operation as a prompt_toolkit panel, on the same queue mechanism as the clarify panel: the agent thread's callback opens the panel, blocks until the first decision, then returns so the operation's watch loop runs; every later state reaches the panel through ConnectionOperation.on_change (installed only when no gateway hook is set, restored on close). Panel: one-line title, the manifest instructions, one row per field (plain rows prefilled with the default, secret rows masked in the input buffer and rendered as "Set" once accepted), then Connect / Cancel. Connect sends the draft through apply_answer; Esc skips the active target; Ctrl-C sets the tool-thread interrupt so the operation settles as interrupted. Once the backend publishes the authorization URL the panel shows it with the target detail and "Press Enter to open in browser"; Enter is the only thing that opens it. A failed attempt unlocks the fields with the draft kept; an authorized target without tools offers Retry discovery / Continue. Up/Down move between rows, Left/Right toggle the action, and the panel joins the blocking-overlay guards so chat input, history and voice stay out while it is up. The single-query (headless) mode passes no callback, as it does for clarify. * feat(connectors): register a connected MCP server and report its tools in the result After a successful install or authorization the target carried the probe's tool names and the model was told the tools "become available on your next turn". MCP tool schemas are deferrable by construction, so nothing about them lives in the sent tool array; a server registered in the scoped registry is callable through tool_describe/tool_call in the same turn. The operation now registers the server (register_mcp_servers under the owner's home scope) once authorization is committed, records the registered names on the target, and the settled result carries a tools_listing block in the deferred-catalog format plus a note that the tools are callable now. A registration failure keeps the target connected with tools: [] and a sanitized discovery_error. agent.tools and the system prompt are untouched; the between-turns refresh updates the catalog block as before. The card gate no longer asks for the desktop platform. Every surface that renders the card attaches a connection callback (Desktop, the Ink TUI, the classic CLI); registry dispatch and messaging sessions attach none and keep the link result. Pre-commit rollback in probe_with_rollback used restore(only_if_absent=True), which skips the rollback when a token file exists. On a first-ever authorization the only file is the one this attempt wrote, so a token the resource rejected was kept. Live E2E (controlled provider answering 403 to the issued token) showed the row fail and the token survive; the rollback now restores the snapshot outright, and the same run shows the token file removed. * fix(connectors): install an OAuth catalog entry through the card's own flow The card's install ran install_entry and then a plain probe. For an OAuth entry that probe has no card flow around it: in the desktop backend it failed at once ("non-interactive environment and no cached tokens"), and in the classic CLI it saw a TTY, opened a browser by itself and drew install_entry's curses tool checklist over the panel. Since a probe failure is now an error, the failed install was rolled back with _remove_mcp_server, and authorize refuses a server that is not configured. A clean home had no path to a connected OAuth entry, which is 55 of the 65 catalog entries. Found by the live three-surface run. An install now builds the entry's configuration in memory (card_install_config: no prompts, no probe, no checklist; a prior tool selection or the manifest's curated filter applies). An OAuth entry starts the same flow authorize uses with that configuration and the setup values in the attempt's secret scope, so the row reaches the URL step, and the configuration and the setup values are saved together when initialize accepts the token. Every other entry is probed in memory and saved after the server answers. A failure writes nothing, so a failed reinstall keeps the previous configuration, and the failed row asks for its fields again so the card can reopen the form over the draft it kept. * fix(agent): make a server connected in this turn callable in this turn tool_describe and tool_call resolve names inside the agent's toolset selection, which is fixed when the agent is built. A server that manage_connections had just registered was therefore "not found" for the rest of the turn whose result calls its tools available, and stayed out of the next turn's catalog too. The live Desktop run showed it: the result listed mcp__fx_oauth_fields__echo and the tool_describe that followed answered not_found. The executor now adds the MCP servers the call connected to the selection. Only the selection changes; agent.tools does not, so the sent tool schema bytes stay the same. A selection of None (every toolset) and the no_mcp sentinel are left alone. * fix(cli): reopen the connection form when a required field is still empty When the backend refuses an answer because a required field is missing it keeps the row pending and names the fields. The panel mapped that frame to its waiting phase, which draws the detail line and nothing else, so the user saw "waiting for FX_API_KEY" with no fields and no buttons until the 300 s deadline. The panel now returns to the form on the first missing field, over the draft it kept. * test(connectors): the no-card path is the one with no callback attached The card gate is "a connection callback is attached" since the classic CLI got its own panel. This test still attached one under platform "cli" and expected no card, so it opened a real operation, waited out the 300 s deadline and failed. It now drives the path that has no card: no callback. * fix(connectors): order a cancel against the commit and stop deleting a working grant Found by the live runs on Desktop, the Ink TUI and the classic CLI. A user's skip did not stop the OAuth attempt. With the worker parked in the token request, the row settled "skipped" and the token was written 33 s later. A skip now cancels the attempt, and one lock orders that cancel against the commit: the attempt is either canceled with the earlier tokens restored, or committed and kept. When the commit won, the skipped row says the authorization was kept. An interrupted turn cancels its attempts the same way. Every attempt began by deleting the saved tokens, so retrying discovery for an authorized server demanded consent again, and a cancel in between left no grant at all. The card's flow now connects with the saved tokens first, with no browser step; only when they do not work does it replace them. The RPC session surface keeps the old behavior because its caller waits for an authorization URL. A second Connect from the form a failed row reopened was dropped without a frame, which left the Desktop dialog with Connect loading and Cancel disabled until the deadline. An approval on a failed or expired row is now a retry with the new values. The reported tool names came from the registration call, which returns nothing for a server the process already holds. A retry after a failed listing therefore said "no tools" while the server had them. The names are read from the registry, and a parked server is woken first. An authorization that commits after its card closed by deadline was never registered, so the next turn still could not reach it. The runner keeps such attempts, and the between-turns refresh adopts the ones that were approved. An error with no message reached the user as a class name ("CancelledError"). The tool description still said a server's tools arrive on the next turn. * fix(desktop): let an OAuth install open its link, and keep the form's draft An install of an OAuth catalog entry with no setup fields reached the URL step and gave the user nothing to click: an initiated install was always drawn as a disabled spinner, and the only other place the link is shown is the setup dialog, which opens for entries with fields. That is most of the catalog. An initiated row that carries a link now offers Open, whatever the action. The setup dialog reset its draft whenever the field list changed identity. The backend sends a fresh list with every frame and an empty one while an attempt runs, so a failed Connect erased what the user had typed. Fields now only fill in what the draft lacks, and closing the dialog drops the draft. The settled summary dropped the "tools unavailable" fact, and the live row offered a Try again for that state which the gateway refuses for a connected target. The summary keeps the fact and the row offers no dead control. * fix(tui): keep the typed draft when a Connect fails The overlay reset its draft whenever required_env changed identity, and every backend snapshot delivers a freshly parsed array. A failed Connect therefore came back as a form with the default region and an empty secret. The draft now resets per target only, and a snapshot's fields fill in what the draft lacks. * fix(cli): show the URL step and start an install that has no fields The panel applied the user's answer and then set its phase to "waiting". The backend applies the answer on the same thread and its change hook had already set the next phase, so the URL step of an OAuth install and the reopened form for a missing field were both overwritten, and the card sat on "Waiting…" until the deadline. The waiting phase is now set before the answer is applied. A pending install or enable with no fields opened in the waiting phase, which sends no approval, so the flow never started. It now opens on Connect/Cancel. A target that already carries its link opens on the URL step. Connect on a failed row re-ran the attempt without the values now in the draft; it sends them. The authorized-without-tools phase offered a "Retry discovery" that a connected target cannot run inside the same operation; it offers Continue. * fix(connectors): a newer OAuth attempt replaces the older one for the same server A card that closed by deadline leaves its worker waiting on the browser for up to 300 s, and a tampered callback leaves one waiting too. A retry or a new operation for the same server then ran beside it: both wrote the same token files, and the older one's rollback could write over the newer attempt's grant. The newest attempt per home and server is recorded. Starting one cancels the older attempt, takes over its pre-attempt snapshot so a later failure still restores the state from before either, and the older attempt's rollback leaves the files alone. * fix(desktop): label an install's link control "Open in browser" The control that hands an install's authorization link to the browser reused the action's verb, so the row showed "Install" before the click and "Install" again at the link step, told apart only by the cue. It now reads "Open in browser", the label the setup dialog already uses for the same act. Authorize keeps its verb. * docs(mcp): describe the setup card on the desktop, the terminal UI and the CLI The MCP guide said the chat install exists only in the desktop app and that the CLI relays commands. All three surfaces now show the same card: fields, Connect or Cancel, an authorization link the user opens, one save when the server has accepted the token, and tools the agent can call in the same turn. The tools reference gains the result fields (tools, tools_listing, discovery_error) and the no-card behavior of an OAuth install. * fix(cli): keep the layout hook callable without a connection widget The CLI panel commit added connection_widget to _build_tui_layout_children as a required keyword. That method is the documented override point for wrappers, and five existing tests (extension hooks, prompt stash, subagent dock) call it without the new argument, so CI failed with a TypeError. The argument is now optional, like the other widgets added after the hook was published; a missing widget is left out of the layout. The settled tool result also carried the target's setup instructions. Those are the card's text for the user, and a catalog entry's notes can predate this flow ("restart your session so the tools are loaded"), which contradicts a result that says the tools are callable now. The model-facing result drops them; the card payloads keep them. This restores test_connector_local_batches. * fix(connectors): wait for an in-progress registration before reporting a server's tools Found with the real Vercel MCP server. Saving the configuration wakes the config watcher, which starts its own connect for the new server. The operation's registration call then skips the server as "already connecting" and returns at once, so the card settled "connected" with no tools, and the 214 tools were registered three seconds later. With no names in the result the model searched, found the hosted connector of the same vendor and asked the user to connect that instead. The registered names are now read once the registration has finished: while another task is connecting the server the read waits (30 s at most), a parked server is woken once, and a server that finished registering with no tools is still a valid empty list.
866 lines
46 KiB
Python
866 lines
46 KiB
Python
"""Agent construction + session-resume display for ``HermesCLI``: credential resolution,
|
|
per-turn agent config, first-use build, resume preload + recap. ``cli.py`` helpers are
|
|
imported lazily inside each method (import cycle)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
|
|
from rich.markup import escape as _escape
|
|
|
|
from utils import base_url_host_matches
|
|
|
|
|
|
def _single_query_clarify_callback(question: str, choices=None, multi_select=False) -> str:
|
|
"""Headless clarify answer for ``hermes chat -q``.
|
|
|
|
A -q turn never builds the prompt_toolkit app, so the interactive clarify modal
|
|
can never be painted or answered — the CLI callback would poll until
|
|
``agent.clarify_timeout`` while the caller sees a silent hang. Mirror the oneshot
|
|
path and answer immediately instead.
|
|
|
|
The oneshot path answers immediately via ``_oneshot_clarify_callback``; single-query turns need the same
|
|
headless behavior (#94943).
|
|
"""
|
|
prefix = f"[single-query mode: no user available to answer {question!r}. "
|
|
if choices:
|
|
what = "subset" if multi_select else "option"
|
|
return f"{prefix}Pick the best {what} from {choices} using your own judgment and continue.]"
|
|
return f"{prefix}Make the most reasonable assumption you can and continue.]"
|
|
|
|
|
|
def _current_runtime(cli) -> dict:
|
|
"""Snapshot the CLI's resolved provider routing as an AIAgent runtime dict.
|
|
|
|
getattr guards stay: tests build minimal shells lacking these attributes."""
|
|
return {
|
|
"api_key": cli.api_key,
|
|
"base_url": cli.base_url,
|
|
"provider": cli.provider,
|
|
"requested_provider": getattr(cli, "requested_provider", cli.provider),
|
|
"api_mode": cli.api_mode,
|
|
"command": cli.acp_command,
|
|
"args": list(cli.acp_args or []),
|
|
"credential_pool": getattr(cli, "_credential_pool", None)}
|
|
|
|
|
|
def _route_signature(model, runtime: dict) -> tuple:
|
|
"""Hashable identity of (model, routing) used to detect when the agent must be rebuilt."""
|
|
return (
|
|
model, runtime.get("provider"), runtime.get("requested_provider"), runtime.get("base_url"),
|
|
runtime.get("api_mode"), runtime.get("command"), tuple(runtime.get("args") or ()))
|
|
|
|
|
|
def _cooldown_cause(entry) -> str:
|
|
"""Why a benched (exhausted) row is cooling down, from what the pool recorded: a rate-limit or
|
|
quota response, a failed token refresh, or another HTTP failure."""
|
|
reason = (entry.last_error_reason or "").lower()
|
|
if entry.last_error_code in (402, 429) or any(k in reason for k in ("rate", "quota", "insufficient")):
|
|
return "after a rate-limit or quota response"
|
|
if entry.last_error_code is None or "refresh" in reason:
|
|
return "after a failed token refresh"
|
|
return f"after an HTTP {entry.last_error_code} response"
|
|
|
|
|
|
def _credential_pool_notice(provider: str) -> tuple:
|
|
"""``(cooling, lines)`` on why *provider*'s pool has nothing selectable right now, for the
|
|
startup notice. *cooling* is True when the first line is a live cooldown with its remaining
|
|
time; a dead (quarantined) sign-in adds a line naming the re-login."""
|
|
import time
|
|
from agent.credential_pool import STATUS_DEAD, STATUS_EXHAUSTED, load_pool
|
|
try:
|
|
pool = load_pool(provider)
|
|
if not pool.has_credentials() or pool.has_available():
|
|
return False, []
|
|
next_at = pool.next_available_at()
|
|
entries = pool.entries()
|
|
except Exception:
|
|
return False, []
|
|
lines = []
|
|
if next_at is not None:
|
|
minutes = max(1, int((next_at - time.time() + 59) // 60))
|
|
benched = [e for e in entries if e.last_status == STATUS_EXHAUSTED]
|
|
cause = _cooldown_cause(benched[0]) if benched else "after a failed request"
|
|
lines.append(f"The {provider} credential is cooling down {cause}; "
|
|
f"it re-enters rotation in about {minutes}m.")
|
|
dead = [e for e in entries if e.last_status == STATUS_DEAD]
|
|
if dead:
|
|
reason = dead[0].last_error_message or dead[0].last_error_reason or "sign-in lost"
|
|
lines.append(f"The {provider} sign-in was lost ({reason}); run `hermes auth add {provider}` "
|
|
"to sign in again.")
|
|
return next_at is not None, lines
|
|
|
|
|
|
def _keyless_custom_base(base_url) -> bool:
|
|
"""Custom/local endpoints (llama.cpp, ollama, vLLM) often need no auth; only a
|
|
non-OpenRouter base_url qualifies."""
|
|
return bool(
|
|
isinstance(base_url, str)
|
|
and base_url
|
|
and not base_url_host_matches(base_url, "openrouter.ai"))
|
|
|
|
|
|
def _compression_descendant(session_db, session_id):
|
|
"""If ``session_id`` is the (empty) head of a compression chain, return the
|
|
descendant that actually holds the messages; else None. Fails open on DB errors."""
|
|
try:
|
|
resolved_id = session_db.resolve_resume_session_id(session_id)
|
|
except Exception:
|
|
return None
|
|
return resolved_id if resolved_id and resolved_id != session_id else None
|
|
|
|
|
|
def _user_display_text(content) -> str:
|
|
"""Recap text for a user row; multimodal lists become text parts + ``[image]`` markers."""
|
|
if isinstance(content, list):
|
|
return " ".join(
|
|
part.get("text", "") if part.get("type") == "text" else "[image]"
|
|
for part in content
|
|
if isinstance(part, dict) and part.get("type") in ("text", "image_url"))
|
|
return "" if content is None else str(content)
|
|
|
|
|
|
def _tool_calls_summary(tool_calls) -> str:
|
|
"""``[N tool call(s): name, ...]`` with up to 4 distinct names."""
|
|
names = []
|
|
for tc in tool_calls:
|
|
fn = tc.get("function", {})
|
|
name = fn.get("name", "unknown") if isinstance(fn, dict) else "unknown"
|
|
if name not in names:
|
|
names.append(name)
|
|
names_str = ", ".join(names[:4]) + (", ..." if len(names) > 4 else "")
|
|
noun = "call" if len(tool_calls) == 1 else "calls"
|
|
return f"[{len(tool_calls)} tool {noun}: {names_str}]"
|
|
|
|
|
|
# display_kind -> recap event line; ``hidden`` rows are skipped before this lookup.
|
|
_RESUME_EVENT_TEXT = {
|
|
"model_switch": "model changed",
|
|
"async_delegation_complete": "background delegation completed",
|
|
"process_complete": "background process finished",
|
|
"auto_continue": "resumed interrupted turn"}
|
|
|
|
def _collect_resume_entries(display_history, disp: dict, clean_assistant):
|
|
"""Displayable ``(role, text)`` recap entries from stored history, truncated per the
|
|
``display.resume_*`` config; system and tool-result rows are skipped. Returns
|
|
``(entries, index of last assistant entry, its un-truncated text)``.
|
|
|
|
Stored history is untrusted for display: text is sanitized so replay can't clear the
|
|
screen, retitle the window or restyle the panel. Pure-reasoning assistant rows with no
|
|
visible output are skipped, as are tool-call-only rows when ``resume_skip_tool_only``.
|
|
"""
|
|
from tools.ansi_strip import sanitize_display_text as _sanitize_display_text
|
|
max_user_len = int(disp.get("resume_max_user_chars", 300))
|
|
max_asst_len = int(disp.get("resume_max_assistant_chars", 200))
|
|
max_asst_lines = int(disp.get("resume_max_assistant_lines", 3))
|
|
skip_tool_only = disp.get("resume_skip_tool_only", True)
|
|
entries: list = []
|
|
last_asst_idx = None
|
|
last_asst_full = None
|
|
for msg in display_history:
|
|
role = msg.get("role", "")
|
|
display_kind = msg.get("display_kind")
|
|
content = msg.get("content")
|
|
tool_calls = msg.get("tool_calls") or []
|
|
if display_kind == "hidden":
|
|
continue
|
|
if display_kind in _RESUME_EVENT_TEXT:
|
|
metadata = msg.get("display_metadata") or {}
|
|
label = metadata.get("display_text") if display_kind in ("async_delegation_complete", "process_complete") else None
|
|
entries.append(("event", _sanitize_display_text(label or _RESUME_EVENT_TEXT[display_kind])))
|
|
continue
|
|
if role == "user":
|
|
text = _sanitize_display_text(_user_display_text(content))
|
|
if len(text) > max_user_len:
|
|
text = text[:max_user_len] + "..."
|
|
entries.append(("user", text))
|
|
elif role == "assistant":
|
|
text = clean_assistant("" if content is None else str(content))
|
|
parts, full_parts = [], []
|
|
if text:
|
|
full_parts.append(text)
|
|
lines = text.splitlines()
|
|
if len(lines) > max_asst_lines:
|
|
text = "\n".join(lines[:max_asst_lines]) + " ..."
|
|
if len(text) > max_asst_len:
|
|
text = text[:max_asst_len] + "..."
|
|
parts.append(text)
|
|
if tool_calls:
|
|
parts.append(_tool_calls_summary(tool_calls))
|
|
full_parts.append(parts[-1])
|
|
if not text and (skip_tool_only or not tool_calls):
|
|
continue
|
|
entries.append(("assistant", " ".join(parts)))
|
|
last_asst_idx = len(entries) - 1
|
|
last_asst_full = " ".join(full_parts)
|
|
return entries, last_asst_idx, last_asst_full
|
|
|
|
|
|
# (skin key, fallback) for recap panel colors: body text, session label, border, assistant label.
|
|
_RESUME_SKIN_COLORS = (
|
|
("banner_text", "#FFF8DC"), ("session_label", "#DAA520"), ("session_border", "#8B8682"),
|
|
("ui_ok", "#8FBC8F"))
|
|
|
|
|
|
def _resume_panel_colors() -> tuple:
|
|
"""Active-skin colors for ``_RESUME_SKIN_COLORS`` (fallbacks when no skin loads)."""
|
|
try:
|
|
from hermes_cli.skin_engine import get_active_skin
|
|
_skin = get_active_skin()
|
|
return tuple(_skin.get_color(key, default) for key, default in _RESUME_SKIN_COLORS)
|
|
except Exception:
|
|
return tuple(default for _, default in _RESUME_SKIN_COLORS)
|
|
|
|
|
|
def _retire_agent(cli) -> None:
|
|
"""Drop ``cli.agent`` so the next turn rebuilds it, releasing its LLM clients first: the Codex
|
|
app-server child (and MCP descendants) belongs to the instance, so ``self.agent = None`` alone
|
|
orphans it for the CLI process lifetime (#72548). Session tool state is kept (soft release)."""
|
|
agent = cli.agent
|
|
if agent is not None and hasattr(agent, "release_clients"):
|
|
agent.release_clients()
|
|
cli.agent = None
|
|
|
|
|
|
class CLIAgentSetupMixin:
|
|
"""Agent construction + session-resume display methods for ``HermesCLI``."""
|
|
|
|
def _ensure_runtime_credentials(self) -> bool:
|
|
"""Re-resolve provider credentials before agent use so key rotation / token
|
|
refresh are picked up without restarting the CLI. False on auth failure."""
|
|
from cli import ChatConsole, logger
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider, format_runtime_provider_error
|
|
_primary_exc = None
|
|
runtime = None
|
|
_model_at_entry = self.model
|
|
self._credentials_rate_limited = False
|
|
try:
|
|
# target_model: the ladder's model-keyed rungs (Zen/Go api_mode, Copilot/Nous
|
|
# api_mode) must see the model this CLI will actually send, not config's `default`,
|
|
# or `hermes -m mimo-v2.5 --provider opencode-go` resolves an api_mode/base_url the
|
|
# sent model cannot use (#112600).
|
|
runtime = resolve_runtime_provider(
|
|
requested=self.requested_provider, explicit_api_key=self._explicit_api_key,
|
|
explicit_base_url=self._explicit_base_url, target_model=self.model or None)
|
|
except Exception as exc:
|
|
_primary_exc = exc
|
|
if _primary_exc is not None:
|
|
runtime = self._resolve_fallback_runtime(_primary_exc)
|
|
if runtime is not None:
|
|
_primary_exc = None
|
|
if runtime is None:
|
|
from hermes_cli.auth import is_rate_limited_auth_error
|
|
self._credentials_rate_limited = bool(_primary_exc) and is_rate_limited_auth_error(_primary_exc)
|
|
message = format_runtime_provider_error(_primary_exc) if _primary_exc else "Provider resolution failed."
|
|
if getattr(self, "tool_progress_mode", "full") == "off":
|
|
print(message, file=sys.stderr) # quiet/stream-json: stdout is machine-readable
|
|
else:
|
|
ChatConsole().print(f"[bold red]{message}[/]")
|
|
return False
|
|
api_key = runtime.get("api_key")
|
|
base_url = runtime.get("base_url")
|
|
resolved_provider = runtime.get("provider", "openrouter")
|
|
if resolved_provider != "nous":
|
|
# An explicit provider carries inference. The free-tier identity (for connectors) was
|
|
# created by the boot bootstrap before this point, never here; this prints the one-time
|
|
# "free tier is here" notice the first time an identity is seen beside an own key.
|
|
self._maybe_print_free_tier_available_notice()
|
|
resolved_routing = (
|
|
resolved_provider, runtime.get("api_mode", self.api_mode), runtime.get("command"),
|
|
list(runtime.get("args") or []))
|
|
# A callable api_key is a bearer-token provider (Azure Entra ID): the OpenAI SDK
|
|
# invokes it per request, so skip string validation / placeholder substitution.
|
|
if not callable(api_key) and not (isinstance(api_key, str) and api_key):
|
|
if _keyless_custom_base(base_url):
|
|
# Placeholder key so the SDK doesn't reject the keyless local endpoint.
|
|
api_key = "no-key-required"
|
|
logger.debug(
|
|
"No API key for custom endpoint %s (source=%s), "
|
|
"using placeholder — local servers typically ignore auth",
|
|
base_url, runtime.get("source", ""))
|
|
else:
|
|
_prov = (resolved_provider or self.requested_provider or "").strip()
|
|
if _prov and _prov != "auto":
|
|
print(f"\n⚠️ No API key found for provider '{_prov}'.")
|
|
else:
|
|
print("\n⚠️ No inference provider is configured.")
|
|
print(" Run 'hermes model' to choose a provider, or "
|
|
"'hermes setup' for first-time setup.")
|
|
return False
|
|
if not isinstance(base_url, str) or not base_url:
|
|
print("\n⚠️ Provider resolver returned an empty base URL. "
|
|
"Check your provider config or run: hermes setup")
|
|
return False
|
|
credentials_changed = api_key != self.api_key or base_url != self.base_url
|
|
routing_changed = resolved_routing != (self.provider, self.api_mode, self.acp_command, self.acp_args)
|
|
self.provider, self.api_mode, self.acp_command, self.acp_args = resolved_routing
|
|
self._credential_pool = runtime.get("credential_pool")
|
|
self._provider_source = runtime.get("source")
|
|
self.api_key = api_key
|
|
self.base_url = base_url
|
|
|
|
# A custom_provider entry's explicit `model` wins when the CLI model is unset or
|
|
# is just the provider slug/display name (`hermes chat --model <provider-name>`
|
|
# would otherwise send the provider name as the model string -> 400).
|
|
runtime_model = runtime.get("model")
|
|
if runtime_model and isinstance(runtime_model, str) and (
|
|
not self.model or self.model == self.provider or self.model == runtime.get("name")):
|
|
self.model = runtime_model
|
|
|
|
# Still empty (e.g. `hermes auth add` without `hermes model`): fall back to the
|
|
# provider's first catalog model so the API doesn't reject an empty model.
|
|
if not self.model and resolved_provider:
|
|
try:
|
|
from hermes_cli.models import get_default_model_for_provider
|
|
_default = get_default_model_for_provider(resolved_provider)
|
|
if _default:
|
|
self.model = _default
|
|
logger.info(
|
|
"No model configured — defaulting to %s for provider %s",
|
|
_default, resolved_provider)
|
|
except Exception:
|
|
pass
|
|
|
|
# Normalize model for the resolved provider (e.g. swap non-Codex models on openai-codex).
|
|
# Fixes #651.
|
|
model_changed = self._normalize_model_for_provider(resolved_provider)
|
|
|
|
# Startup resolved reasoning_config for the launch model; whichever path above moved
|
|
# self.model (auth fallback, custom-entry model, provider default, normalization) leaves a
|
|
# per-model contract the lazily built agent would otherwise miss (an always-thinking model
|
|
# 400s on the primary's effort). Same chokepoint as /model, /new and --resume; an explicit
|
|
# --reasoning is the user's intent for this run and outranks the new model's config.
|
|
if self.model != _model_at_entry and getattr(self, "_explicit_reasoning_config", None) is None:
|
|
from hermes_cli.cli_model_switch_mixin import _resolve_cli_reasoning
|
|
_resolve_cli_reasoning(self)
|
|
logger.info("Model moved to %s: reasoning_config resolved: %s", self.model, self.reasoning_config)
|
|
|
|
# AIAgent/OpenAI client holds auth at init, so rebuild on key/routing/model change.
|
|
if (credentials_changed or routing_changed or model_changed) and self.agent is not None:
|
|
_retire_agent(self)
|
|
self._active_agent_route_signature = None
|
|
return True
|
|
|
|
def _maybe_print_free_tier_available_notice(self) -> None:
|
|
"""One-time notice for installs whose inference is carried by an explicit provider: the free
|
|
tier (inference + connectors) now exists. Printed the first time an identity is present, then
|
|
flagged on that identity so it never repeats. Never blocks or raises."""
|
|
from cli import logger
|
|
try:
|
|
from hermes_cli import anon_auth
|
|
if not anon_auth.guest_notice_pending():
|
|
return
|
|
self._console_print(f"[dim]{anon_auth.FREE_TIER_AVAILABLE_NOTICE}[/]")
|
|
anon_auth.mark_guest_notice_shown()
|
|
except Exception as exc:
|
|
logger.debug("free tier availability notice skipped: %s", exc)
|
|
|
|
def _resolve_fallback_runtime(self, primary_exc):
|
|
"""Primary provider resolution failed: on an AuthError try each fallback entry in
|
|
order and switch the CLI's requested_provider/model to the first that resolves.
|
|
None when the error is not auth-related or no fallback resolves."""
|
|
from cli import _cprint, logger
|
|
from hermes_cli.auth import AuthError, primary_failure_wording
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
if not isinstance(primary_exc, AuthError):
|
|
return None
|
|
_fb_chain = self._fallback_model if isinstance(self._fallback_model, list) else []
|
|
for _fb in _fb_chain:
|
|
_fb_provider = (_fb.get("provider") or "").strip().lower()
|
|
_fb_model = (_fb.get("model") or "").strip()
|
|
if not _fb_provider or not _fb_model:
|
|
continue
|
|
try:
|
|
from hermes_cli.fallback_config import resolve_entry_api_key
|
|
# target_model: the fallback entry names the model that will be sent; without it the
|
|
# ladder keys off config `default` (see _ensure_runtime_credentials, #112600).
|
|
_fb_kwargs = {"requested": _fb_provider, "target_model": _fb_model}
|
|
if _fb.get("base_url"):
|
|
_fb_kwargs["explicit_base_url"] = _fb["base_url"]
|
|
_fb_api_key = resolve_entry_api_key(_fb)
|
|
if _fb_api_key:
|
|
_fb_kwargs["explicit_api_key"] = _fb_api_key
|
|
runtime = resolve_runtime_provider(**_fb_kwargs)
|
|
_why_log, _why = primary_failure_wording(primary_exc) # #117482: quota is not auth
|
|
logger.warning(
|
|
"Primary provider %s (%s). Falling through to fallback: %s/%s",
|
|
_why_log, primary_exc, _fb_provider, _fb_model)
|
|
from gateway.warning_notifications import render_notification
|
|
render_notification(
|
|
lambda: _cprint(f"⚠️ {_why} — switching to fallback: {_fb_provider} / {_fb_model}"),
|
|
platform="cli")
|
|
self.requested_provider = _fb_provider
|
|
self.model = _fb_model
|
|
# reasoning_config follows the swap in _ensure_runtime_credentials (the only caller).
|
|
return runtime
|
|
except Exception:
|
|
continue
|
|
return None
|
|
|
|
def _runtime_credentials_ready(self) -> bool:
|
|
"""Silently probe whether any inference provider can be resolved.
|
|
|
|
Never prints or mutates CLI state, so the interactive first-run path can route a
|
|
keyless install into onboarding before the user types into a chat that can't work.
|
|
|
|
See #62935.
|
|
"""
|
|
return self._probe_runtime_credentials()[0]
|
|
|
|
def _probe_runtime_credentials(self) -> tuple:
|
|
"""``(ready, error)``: *error* is the exception that stopped resolution — raised, or
|
|
swallowed by the "auto" ladder and stamped on a keyless fallback — ``None`` when a provider
|
|
resolved (usable or merely keyless). Never prints or mutates CLI state."""
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
try:
|
|
runtime = resolve_runtime_provider(
|
|
requested=self.requested_provider, explicit_api_key=self._explicit_api_key,
|
|
explicit_base_url=self._explicit_base_url)
|
|
except Exception as exc:
|
|
return False, exc
|
|
if not isinstance(runtime, dict):
|
|
return False, None
|
|
api_key = runtime.get("api_key")
|
|
base_url = runtime.get("base_url")
|
|
if callable(api_key) or (isinstance(api_key, str) and api_key):
|
|
return bool(base_url), None
|
|
return _keyless_custom_base(base_url), runtime.get("auth_error")
|
|
|
|
def _maybe_offer_first_run_setup(self) -> None:
|
|
"""Interactive startup gate: a blank install goes to the provider wizard; a configured
|
|
profile whose credential is benched or signed out gets the reason instead (#113720)."""
|
|
if not sys.stdin.isatty():
|
|
return
|
|
ready, error = self._probe_runtime_credentials()
|
|
if not ready and not self._explain_unusable_credentials(error):
|
|
self._offer_first_run_setup()
|
|
|
|
def _explain_unusable_credentials(self, error) -> bool:
|
|
"""A configured profile whose credential is benched, quarantined or signed out is not a
|
|
blank install: print what is wrong (and the remaining cooldown) instead of the first-run
|
|
wizard, whose "nothing is configured" claim sends operators into a second login that can
|
|
rotate a single-use OAuth grant away from the session that was working (#113720).
|
|
|
|
True when the failure was explained; False when nothing is configured (the wizard's case).
|
|
"""
|
|
from cli import _cprint
|
|
from hermes_cli.auth import format_auth_error
|
|
if error is None or getattr(error, "code", None) == "no_provider_configured":
|
|
return False
|
|
provider = getattr(error, "provider", None) or self.requested_provider
|
|
cooling, lines = _credential_pool_notice(provider) if provider and provider != "auto" else (False, [])
|
|
_cprint("")
|
|
if cooling:
|
|
# A live cooldown is a wait, not a lost login: lead with it and skip the re-auth hint.
|
|
_cprint(f"⚠️ {_escape(lines.pop(0))}")
|
|
_cprint(f" {_escape(str(error))}")
|
|
else:
|
|
_cprint(f"⚠️ {_escape(format_auth_error(error))}")
|
|
for line in lines:
|
|
_cprint(f" {_escape(line)}")
|
|
return True
|
|
|
|
def _offer_first_run_setup(self) -> bool:
|
|
"""Offer the provider picker when no provider is configured at all (interactive
|
|
startup, TTY). Runs the same flow as ``hermes model`` so onboarding has a single
|
|
source of truth. True when a provider was configured."""
|
|
from cli import _cprint, logger
|
|
_cprint("")
|
|
_cprint("☤ No inference provider is configured yet — let's fix that.")
|
|
_cprint(" You'll pick a provider (Nous Portal OAuth is the fastest; "
|
|
"no API key needed) and a model.")
|
|
try:
|
|
answer = input(" Set up a provider now? [Y/n]: ").strip().lower()
|
|
except (KeyboardInterrupt, EOFError):
|
|
print()
|
|
answer = "n"
|
|
if answer in {"n", "no"}:
|
|
_cprint(" Skipped. Run 'hermes model' or 'hermes setup' any time.")
|
|
return False
|
|
try:
|
|
from hermes_cli.main import select_provider_and_model
|
|
select_provider_and_model()
|
|
except (KeyboardInterrupt, EOFError, SystemExit):
|
|
print()
|
|
_cprint(" Setup cancelled. Run 'hermes model' any time.")
|
|
return False
|
|
except Exception as exc:
|
|
logger.debug("first-run provider setup failed: %s", exc)
|
|
_cprint(f" ⚠️ Provider setup failed: {exc}")
|
|
_cprint(" Run 'hermes model' to try again.")
|
|
return False
|
|
|
|
# Re-sync CLI state from what the picker persisted so the next turn uses it without a restart.
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
_model_cfg = (load_config().get("model") or {})
|
|
if isinstance(_model_cfg, dict):
|
|
self.requested_provider = (_model_cfg.get("provider") or "").strip() or self.requested_provider
|
|
_new_model = (_model_cfg.get("default") or _model_cfg.get("model") or "").strip()
|
|
self.model = _new_model or self.model
|
|
# The picker's model has its own per-model reasoning contract (see
|
|
# _resolve_cli_reasoning); an explicit --reasoning stays the user's intent.
|
|
if _new_model and getattr(self, "_explicit_reasoning_config", None) is None:
|
|
from hermes_cli.cli_model_switch_mixin import _resolve_cli_reasoning
|
|
_resolve_cli_reasoning(self)
|
|
except Exception as exc:
|
|
logger.debug("first-run config re-sync failed: %s", exc)
|
|
# Force credential re-resolution + agent rebuild on next use.
|
|
_retire_agent(self)
|
|
self._active_agent_route_signature = None
|
|
if self._runtime_credentials_ready():
|
|
_cprint(" ✓ Provider configured — you're ready to chat.")
|
|
return True
|
|
_cprint(" Provider setup didn't complete. Run 'hermes model' to retry.")
|
|
return False
|
|
|
|
def _resolve_turn_agent_config(self, user_message: str) -> dict:
|
|
"""Effective model/runtime config for one turn — always the session's primary
|
|
provider. With `/fast` on (service_tier == "priority") attach request_overrides;
|
|
auto/cold tiers are applied per request by agent.fast_mode instead."""
|
|
from hermes_cli.models import resolve_fast_mode_overrides
|
|
runtime = _current_runtime(self)
|
|
route = {"model": self.model, "runtime": runtime, "signature": _route_signature(self.model, runtime)}
|
|
overrides = None
|
|
if getattr(self, "service_tier", None) == "priority":
|
|
try:
|
|
overrides = resolve_fast_mode_overrides(
|
|
route["model"], provider=runtime["provider"], base_url=runtime["base_url"])
|
|
except Exception:
|
|
pass
|
|
route["request_overrides"] = overrides
|
|
return route
|
|
|
|
def _follow_compression_chain(self, session_meta, announce):
|
|
"""If the resumed id is an empty compression-chain head, announce and switch to
|
|
the descendant holding the messages; returns the (possibly refreshed) meta."""
|
|
resolved_id = _compression_descendant(self._session_db, self.session_id)
|
|
if resolved_id:
|
|
announce(resolved_id)
|
|
self.session_id = resolved_id
|
|
session_meta = self._session_db.get_session(self.session_id) or session_meta
|
|
return session_meta
|
|
|
|
def _restore_session_state(self, session_meta, *, quiet: bool = False) -> None:
|
|
"""Restore cwd / yolo / model from the resumed session's metadata."""
|
|
self._restore_session_cwd(session_meta, quiet=quiet)
|
|
self._restore_session_yolo(session_meta, quiet=quiet)
|
|
self._restore_session_model(session_meta, quiet=quiet)
|
|
|
|
def _reopen_session(self) -> None:
|
|
"""Clear ended_at so the resumed session is active again (best effort)."""
|
|
try:
|
|
self._session_db.reopen_session(self.session_id)
|
|
except Exception:
|
|
pass
|
|
|
|
def _load_resumed_history_late(self) -> bool:
|
|
"""Late resume path: validate the session and load its history from the DB when
|
|
_preload_resumed_session() (called from run()) did not already populate it.
|
|
False when the resume must abort (missing session / over the safe-resume limit)."""
|
|
from cli import ChatConsole, _DIM, _RST, _accent_hex, _cprint
|
|
session_meta = self._session_db.get_session(self.session_id)
|
|
# Quiet mode (tool_progress_mode == "off") routes resume status lines to
|
|
# stderr so stdout stays machine-readable for `$(hermes chat -Q --resume ...)`.
|
|
# Without this, the resume banner pollutes captured stdout. See #11793.
|
|
_quiet_mode = getattr(self, "tool_progress_mode", "full") == "off"
|
|
|
|
def _say(plain: str, rich: str) -> None:
|
|
if _quiet_mode:
|
|
print(plain, file=sys.stderr)
|
|
else:
|
|
ChatConsole().print(rich)
|
|
if not session_meta:
|
|
hint = "Use a session ID from a previous CLI run (hermes sessions list)."
|
|
if _quiet_mode:
|
|
print(f"Session not found: {self.session_id}", file=sys.stderr)
|
|
print(hint, file=sys.stderr)
|
|
else:
|
|
_cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}")
|
|
_cprint(f"{_DIM}{hint}{_RST}")
|
|
return False
|
|
session_meta = self._follow_compression_chain(
|
|
session_meta,
|
|
lambda rid: ChatConsole().print(
|
|
f"[dim]Session {_escape(self.session_id)} was compressed into "
|
|
f"{_escape(rid)}; resuming the descendant with your "
|
|
f"transcript.[/dim]"))
|
|
if getattr(self, "_resume_history_error", None):
|
|
return False
|
|
# Only the TIP session's rows are loaded here (no ancestors), so use the
|
|
# tip-only count — the full-lineage count would over-reject compressed sessions.
|
|
resume_limit_error = self._resume_history_limit_error(tip_only=True)
|
|
if resume_limit_error:
|
|
self._resume_history_error = resume_limit_error
|
|
_say(
|
|
f"Cannot resume session: {resume_limit_error}",
|
|
f"[bold red]Cannot resume session:[/] {_escape(resume_limit_error)}")
|
|
return False
|
|
restored = self._session_db.get_messages_as_conversation(self.session_id, repair_alternation=True)
|
|
if restored:
|
|
restored = [m for m in restored if m.get("role") != "session_meta"]
|
|
self.conversation_history = restored
|
|
msg_count = len([m for m in restored if m.get("role") == "user"])
|
|
title_part = f" \"{session_meta['title']}\"" if session_meta.get("title") else ""
|
|
counts = f"({msg_count} user message{'s' if msg_count != 1 else ''}, {len(restored)} total messages)"
|
|
_say(
|
|
f"↻ Resumed session {self.session_id}{title_part} {counts}",
|
|
f"[bold {_accent_hex()}]↻ Resumed session[/] [bold]{_escape(self.session_id)}[/]"
|
|
f"[bold {_accent_hex()}]{_escape(title_part)}[/] {counts}")
|
|
self._restore_session_state(session_meta, quiet=_quiet_mode)
|
|
else:
|
|
_say(
|
|
f"Session {self.session_id} found but has no messages. Starting fresh.",
|
|
f"[bold {_accent_hex()}]Session {_escape(self.session_id)} found but has no messages. Starting fresh.[/]",
|
|
)
|
|
self._reopen_session()
|
|
return True
|
|
|
|
def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool:
|
|
"""Build the agent on first use; when resuming, restore history from SQLite.
|
|
Returns True on success."""
|
|
from cli import ChatConsole, _cprint, _prepare_deferred_agent_startup, logger
|
|
from run_agent import AIAgent
|
|
if self.agent is not None:
|
|
return True
|
|
|
|
# Join the background preloaded-skills load (--skills/-s) BEFORE the agent
|
|
# snapshots self.system_prompt below. No-op when nothing was requested.
|
|
self.finalize_preloaded_skills()
|
|
_prepare_deferred_agent_startup()
|
|
self._install_tool_callbacks()
|
|
self._ensure_tirith_security()
|
|
if not self._ensure_runtime_credentials():
|
|
return False
|
|
from hermes_cli.mcp_startup import ensure_mcp_discovery_before_agent_build
|
|
ensure_mcp_discovery_before_agent_build(
|
|
logger=logger, single_query=getattr(self, "_single_query_mode", False))
|
|
if self._session_db is None:
|
|
try:
|
|
from hermes_state_registry import acquire
|
|
self._session_db = acquire()
|
|
except Exception as e:
|
|
logger.warning("SQLite session store not available — session will NOT be indexed: %s", e)
|
|
if (
|
|
self._resumed and self._session_db and not self.conversation_history
|
|
and not self._load_resumed_history_late()):
|
|
return False
|
|
try:
|
|
runtime = runtime_override or _current_runtime(self)
|
|
effective_model = model_override or self.model
|
|
# -q never builds the prompt_toolkit app, so the clarify modal can't be
|
|
# answered — answer headless instead of polling until clarify_timeout.
|
|
single_query_mode = getattr(self, "_single_query_mode", False)
|
|
clarify_callback = (
|
|
# See #94943.
|
|
_single_query_clarify_callback
|
|
if single_query_mode
|
|
else self._clarify_callback)
|
|
connection_callback = None if single_query_mode else self._connection_callback
|
|
self.agent = AIAgent(
|
|
model=effective_model, api_key=runtime.get("api_key"),
|
|
base_url=runtime.get("base_url"), provider=runtime.get("provider"),
|
|
requested_provider=runtime.get("requested_provider"),
|
|
api_mode=runtime.get("api_mode"), acp_command=runtime.get("command"),
|
|
acp_args=runtime.get("args"), credential_pool=runtime.get("credential_pool"),
|
|
max_iterations=self.max_turns,
|
|
run_budget_seconds=getattr(self, "run_budget_seconds", None),
|
|
enabled_toolsets=self.enabled_toolsets, disabled_toolsets=self.disabled_toolsets,
|
|
verbose_logging=self.verbose, quiet_mode=not self.verbose,
|
|
tool_progress_mode=getattr(self, "tool_progress_mode", "all"),
|
|
ephemeral_system_prompt=self.system_prompt if self.system_prompt else None,
|
|
prefill_messages=self.prefill_messages or None,
|
|
reasoning_config=self.reasoning_config, service_tier=self.service_tier,
|
|
request_overrides=request_overrides, providers_allowed=self._providers_only,
|
|
providers_ignored=self._providers_ignore, providers_order=self._providers_order,
|
|
provider_sort=self._provider_sort,
|
|
provider_require_parameters=self._provider_require_params,
|
|
provider_data_collection=self._provider_data_collection,
|
|
openrouter_min_coding_score=self._openrouter_min_coding_score,
|
|
session_id=self.session_id, platform="cli", session_db=self._session_db,
|
|
clarify_callback=clarify_callback, connection_callback=connection_callback,
|
|
reasoning_callback=self._current_reasoning_callback(),
|
|
fallback_model=self._fallback_model, thinking_callback=self._on_thinking,
|
|
checkpoints_enabled=self.checkpoints_enabled,
|
|
checkpoint_max_snapshots=self.checkpoint_max_snapshots,
|
|
checkpoint_max_total_size_mb=self.checkpoint_max_total_size_mb,
|
|
checkpoint_max_file_size_mb=self.checkpoint_max_file_size_mb,
|
|
pass_session_id=self.pass_session_id, skip_context_files=self.ignore_rules,
|
|
skip_memory=self.ignore_rules, tool_progress_callback=self._on_tool_progress,
|
|
tool_start_callback=self._on_tool_start if self._inline_diffs_enabled else None,
|
|
tool_complete_callback=self._on_tool_complete if self._inline_diffs_enabled else None,
|
|
stream_delta_callback=self._stream_delta if self.streaming_enabled else None,
|
|
tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None,
|
|
notice_callback=self._on_notice, notice_clear_callback=self._on_notice_clear,
|
|
reaction_callback=self._on_reaction)
|
|
# Reference for atexit memory-provider shutdown: ``_run_cleanup`` in cli.py
|
|
# reads ``cli._active_agent_ref``, so this MUST write the ``cli`` module's
|
|
# global — a ``global`` statement here would bind this module's namespace.
|
|
# When this code lived in cli.py a bare ``global _active_agent_ref`` worked; after the god-file
|
|
# extraction into this mixin a ``global`` here would bind *this module's* namespace, leaving
|
|
# ``cli._active_agent_ref`` None forever — so memory shutdown never ran on /exit (#49287).
|
|
import cli as _cli
|
|
_cli._active_agent_ref = self.agent
|
|
# Seed the agent's once-per-lifecycle auto_load cache with the bytes the preload
|
|
# thread rendered, so the shared prompt path never re-reads config or skill files.
|
|
_auto_result = getattr(self, "_auto_load_skills_result", None)
|
|
if _auto_result is not None:
|
|
self.agent._auto_load_skills_result = _auto_result
|
|
self.agent._auto_load_skills_resolved = True
|
|
# Route agent status output through prompt_toolkit so ANSI escapes aren't garbled by
|
|
# patch_stdout's StdoutProxy (#2262), holding lines while a response box streams so a
|
|
# subagent/background completion notice never splits the reply mid-paragraph.
|
|
self.agent._print_fn = self._agent_status_print
|
|
# Hydrate credits notices at session OPEN (parity with the TUI) so a depletion
|
|
# warning shows before the first message. Idempotent + fail-open in the helper.
|
|
try:
|
|
from agent.credits_tracker import seed_credits_at_session_start
|
|
seed_credits_at_session_start(self.agent)
|
|
except Exception:
|
|
pass
|
|
self._active_agent_route_signature = _route_signature(effective_model, runtime)
|
|
|
|
# Force-create DB row on /title intent, then apply title.
|
|
if self._pending_title and self._session_db:
|
|
try:
|
|
self.agent._ensure_db_session()
|
|
if self.agent._session_db_created:
|
|
self._session_db.set_session_title(self.session_id, self._pending_title)
|
|
_cprint(f" Session title applied: {self._pending_title}")
|
|
self._pending_title = None
|
|
# else: row creation failed transiently — keep _pending_title for retry
|
|
except Exception as e:
|
|
_cprint(f" Could not apply pending title: {e}")
|
|
# Keep _pending_title so it can be retried after row creation succeeds
|
|
return True
|
|
except Exception as e:
|
|
console = ChatConsole()
|
|
from hermes_cli.cli_chat_error_copy import agent_init_failure_message
|
|
console.print(f"[bold red]{_escape(agent_init_failure_message(e))}[/]")
|
|
from hermes_constants import partial_update_hint
|
|
for line in partial_update_hint(e):
|
|
console.print(line)
|
|
return False
|
|
|
|
def _resume_history_limit_error(self, tip_only: bool = False):
|
|
"""Return a safe-resume error without materializing transcript rows.
|
|
|
|
``tip_only`` matches call sites that load only the tip session's rows — counting
|
|
the full lineage there would over-reject heavily-compressed sessions with a small
|
|
tip. Generic guard failures fail OPEN; only a genuine over-limit result blocks."""
|
|
if not self._session_db:
|
|
return None
|
|
from cli import logger
|
|
from hermes_state import SessionResumeTooLargeError
|
|
try:
|
|
safety_check = getattr(self._session_db, "assert_resume_safe", None)
|
|
if not callable(safety_check):
|
|
return None
|
|
safety_check(self.session_id, **({"tip_only": True} if tip_only else {}))
|
|
except SessionResumeTooLargeError as exc:
|
|
return str(exc)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Resume safety check failed for %s (proceeding without guard): %s",
|
|
self.session_id, exc)
|
|
return None
|
|
|
|
def _preload_resumed_session(self) -> bool:
|
|
"""Load a resumed session's history early (from run(), before the first chat) so
|
|
it can be displayed; ``_init_agent()`` then skips its own DB round-trip. Sets
|
|
``self.conversation_history`` and prints the status line. True if history loaded."""
|
|
from cli import _accent_hex
|
|
if not self._resumed or not self._session_db:
|
|
return False
|
|
session_meta = self._session_db.get_session(self.session_id)
|
|
if not session_meta:
|
|
self._console_print(f"[bold red]Session not found: {self.session_id}[/]")
|
|
self._console_print("[dim]Use a session ID from a previous CLI run (hermes sessions list).[/]")
|
|
return False
|
|
session_meta = self._follow_compression_chain(
|
|
session_meta,
|
|
lambda rid: self._console_print(
|
|
f"[dim]Session {self.session_id} was compressed into "
|
|
f"{rid}; resuming the descendant with your transcript.[/]"))
|
|
resume_limit_error = self._resume_history_limit_error()
|
|
if resume_limit_error:
|
|
self._resume_history_error = resume_limit_error
|
|
self._console_print(f"[bold red]Cannot resume session:[/] {resume_limit_error}")
|
|
return False
|
|
restored, display_history = self._session_db.get_resume_conversations(self.session_id)
|
|
accent_color = _accent_hex()
|
|
if not restored:
|
|
self._console_print(
|
|
f"[{accent_color}]Session {self.session_id} found but has no "
|
|
f"messages. Starting fresh.[/]")
|
|
return False
|
|
restored = [m for m in restored if m.get("role") != "session_meta"]
|
|
self.conversation_history = restored
|
|
self._resume_display_history = [m for m in display_history if m.get("role") != "session_meta"]
|
|
from agent.context_compressor import is_user_originated_turn
|
|
# Count only user-originated turns: legacy compaction handoffs are durable
|
|
# role=user rows without display_kind.
|
|
msg_count = len([m for m in self._resume_display_history if is_user_originated_turn(m)])
|
|
title_part = f' "{session_meta["title"]}"' if session_meta.get("title") else ""
|
|
self._console_print(
|
|
f"[{accent_color}]↻ Resumed session [bold]{self.session_id}[/bold]"
|
|
f"{title_part} "
|
|
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
|
|
f"{len(restored)} total messages)[/]")
|
|
self._restore_session_state(session_meta)
|
|
self._reopen_session()
|
|
return True
|
|
|
|
def _display_resumed_history(self):
|
|
"""Render a dim Rich-panel recap of the previous conversation, capped at the last
|
|
``resume_exchanges`` user/assistant exchanges with a hidden-count indicator."""
|
|
from cli import CLI_CONFIG, _record_output_history_entry, _strip_reasoning_tags, _suspend_output_history
|
|
from tools.ansi_strip import sanitize_display_text as _sanitize_display_text
|
|
display_history = getattr(self, "_resume_display_history", self.conversation_history)
|
|
if not display_history or self.resume_display == "minimal":
|
|
return
|
|
_disp = CLI_CONFIG.get("display", {})
|
|
entries, _last_asst_idx, _last_asst_full = _collect_resume_entries(
|
|
display_history, _disp, lambda t: _sanitize_display_text(_strip_reasoning_tags(t)))
|
|
if not entries:
|
|
return
|
|
skipped = max(0, len(entries) - int(_disp.get("resume_exchanges", 10)) * 2)
|
|
entries = entries[skipped:]
|
|
# Show the last assistant entry in full so the user sees where they left off.
|
|
if _last_asst_idx is not None and _last_asst_full:
|
|
adj_idx = _last_asst_idx - skipped
|
|
if 0 <= adj_idx < len(entries):
|
|
entries[adj_idx] = ("assistant_last", _last_asst_full)
|
|
from rich.panel import Panel
|
|
from rich.text import Text
|
|
_history_text_c, _session_label_c, _session_border_c, _assistant_label_c = (
|
|
_resume_panel_colors())
|
|
|
|
# role -> (label, label style, body style, continuation indent)
|
|
role_styles = {
|
|
"user": (" ● You: ", f"dim bold {_session_label_c}", "dim", " " * 9),
|
|
"assistant": (" ◆ Hermes: ", f"dim bold {_assistant_label_c}", "dim", " " * 12),
|
|
"assistant_last": (" ◆ Hermes: ", f"bold {_assistant_label_c}", "", " " * 12), # full, non-dim
|
|
}
|
|
lines = Text()
|
|
if skipped:
|
|
lines.append(f" ... {skipped} earlier messages ...\n\n", style="dim italic")
|
|
for i, (role, text) in enumerate(entries):
|
|
if role == "event":
|
|
lines.append(f" ◈ {text}\n", style="dim italic")
|
|
else:
|
|
label, label_style, body_style, indent = role_styles[role]
|
|
lines.append(label, style=label_style)
|
|
first, *rest = text.splitlines() or [""] # first line inline, rest indented
|
|
lines.append(first + "\n", style=body_style)
|
|
for ml in rest:
|
|
lines.append(f"{indent}{ml}\n", style=body_style)
|
|
if i < len(entries) - 1:
|
|
lines.append("") # small gap
|
|
panel = Panel(
|
|
lines, title=f"[dim {_session_label_c}]Previous Conversation[/]",
|
|
border_style=f"dim {_session_border_c}", padding=(0, 1), style=_history_text_c)
|
|
_record_output_history_entry(lambda: self._render_resume_history_panel_lines(panel))
|
|
with _suspend_output_history():
|
|
self._console_print(panel)
|