feat(desktop): local spawn API — start app-owned chats from the CLI, streamed live - #298
Conversation
Delegated work started outside the app shows up in the sidebar but cannot stream: the session belongs to whichever process created it. This adds a loopback control channel so a CLI on this machine can ask the RUNNING app to start the chat instead, making it app-owned from birth and identical to a typed one. The channel deliberately does NOT live on the Python backend. The gateway routes streaming events by the transport stored on the session (tui_gateway/server.py `write_json`), and `prompt.submit` re-pins that transport to whichever client calls it. An HTTP caller has no websocket, so a backend-side spawn route would create a session whose deltas go to stdio — the row would appear via the session-store watcher and then sit there, silent. Handing the request to the renderer means the session is created and submitted on the renderer's own gateway socket, so streaming, transcript, tool cards, titles and sidebar are all the existing typed-chat path with no second streaming implementation to keep in sync. Two things this had to get right, both found by measuring against the app: - Spawns are serialized, and each waits for the new-chat draft to settle before submitting. `startFreshSession` navigates, and that navigation lands asynchronously; submitting in the same tick captured the previous route token, so the submit saw the route move under it, treated it as the user navigating away, and aborted — after the caller had already been told 202. Measured: two spawns produced one session, the first vanishing with freshDraftReady=false and ok=false. - Model/provider/profile travel as per-session overrides rather than being written into the composer selection. `setCurrentModel` persists, so steering one spawn through it would silently change the model for every chat the user starts afterwards. Security posture: the listener binds 127.0.0.1 on an ephemeral port and is never reachable off-box; requests carry a token minted fresh each launch and published in a 0600 file under the user's own HERMES_HOME, so "can read the token" is already "is this user"; the file is removed on quit and a stale one only yields connection-refused. Non-loopback peers are rejected regardless. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One obvious command for handing work to the desktop app:
hermes desktop spawn "audit the auth flow" \
-m deepseek-v4-flash-0731-ds4 --provider ai-router
It resolves the running app's port and token from
`<HERMES_HOME>/desktop/control.json` itself, so there is nothing to configure
and no token to copy around. Every override is optional — omitted keys are
left out of the request entirely, so a bare `spawn "..."` runs on whatever the
app is already set to, exactly like typing into it.
Uses only stdlib `urllib.request` (dependency-pinning policy) and fails loud
with a distinct, actionable message per failure: no control file (app is not
running), unparseable file, connection refused (stale file), 401 (stale token,
restart the app), 503 (no window), and any other non-202.
The model above is an example in the help text, never a default.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI triage: the one red check is pre-existing on
|
…ody will answer (#306) `hermes desktop spawn` starts a chat the app owns (#298), but the chat it starts is an ordinary interactive one. That is the wrong shape for a script that walked away. On 2026-08-01 a spawned session was given an underspecified brief, correctly stopped to ask "where should I deliver this?", and correctly waited — forever. Right behaviour with a person present; a hang without one. `--delegated` says nobody is watching, in two layers, because the first is advice and the second is the guarantee. Layer one is a contract prepended to the prompt: do not ask; where the brief leaves a choice open take the safest reversible option and note it; where the brief does not say where the result goes, put it in this chat rather than writing files or messaging anyone; list your assumptions; end DONE or BLOCKER. It goes in the user message, not the system prompt — that has to stay byte-stable for the life of a conversation or per-conversation caching is lost, and skill slash commands already inject this way for the same reason. It also means the operator can read exactly what the run was told. Layer two is a deadline. If the agent raises a clarify prompt anyway, a delegated session answers it after `--delegated-timeout` (default 120s), notes that in the transcript, and carries on. The answer is deliberately neither the empty string that Skip sends — indistinguishable to the agent from a person declining to say, so it learns nothing and may ask again — nor one of the offered choices, since at that layer "keep it" and "delete it" are both just strings and picking one is a guess with consequences. It hands the decision back with the rule to apply, which is what the CLI, the gateway and one-shot mode already do when nobody answers. Scoped to clarify. Approvals already fail closed on the backend after `approvals.timeout` — denied, on the grounds that silence is not consent — so they cannot park a session, and must not be auto-allowed here. Two smaller things this needed: - `submitText` resolves to a bare boolean, so a spawn never learned which chat it started and had nowhere to hang the deadline. `onSessionCreated` reports the id, and only after the existing drift check has confirmed the session is really ours. - The clarify watcher reads a reference-stable projection of the session states rather than the states atom itself, which republishes on every streamed token and would re-arm the effect at delta rates. Sidebar rows for delegated chats carry a small badge, so a run that answered its own question is not mistaken for one a person sat through. Verified in the dev-sandbox harness (isolated HERMES_HOME) three ways, all on a local model: - Delegated, underspecified brief: no card raised at all. Delivered inline, with an Assumptions line, ending DONE. The contract was honoured. - Delegated, brief that explicitly ordered a clarify call: card raised, and `tool clarify completed (20.12s)` against `--delegated-timeout 20`. The tool result carries the standing instruction verbatim; the agent then chose "In this chat", said why, and finished DONE. Note that run's four choices included no Skip — picking an offered one would have meant a file or an email. - Control, same forcing brief without the flag: still parked on the card two minutes later. The original failure, reproduced. Co-authored-by: Omar Baradei <omar@kostudios.io> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… pick (#318) PR #298 promised that `hermes desktop spawn -m <model>` would steer one session without touching the composer's persisted selection, because `setCurrentModel` writes localStorage and would otherwise re-aim every chat the user starts afterwards. Two paths broke that promise, and the test meant to lock it was passing vacuously. The backend echoes the spawn's override back as `info.model` on the session.create response, and stamps `model` on every session.info after that. Both echoes were being fed into the composer's sticky selection: `applyRuntimeInfo` wrote it once at create, and `syncRuntimeMetadataToView` rewrote it on every heartbeat for the session's whole life. The second one is the irony — use-message-stream/gateway-event.ts explicitly refuses to call setCurrentModel on the direct path, then routed the same value around itself through the state cache. Runtime metadata now lands in a separate, unpersisted mirror ($activeSessionModel/$activeSessionProvider). The primary chat surface displays $primaryModel — the open session's model, falling back to the composer's pick on a fresh draft — which is the same shape a session tile already computes from its own $sessionStates slice. Only a user pick or the profile-default seed writes $currentModel, so desktopSessionCreateParams can never see a spawned override. The existing lock stubbed session.create as `{session_id, stored_session_id}` with no `info` key, so `applyRuntimeInfo(undefined)` returned early and the assertion never reached the leak. The stub now echoes `info` the way the backend does, and a new case follows the guarantee end to end: spawn with an override, then start the next chat and assert it is created on the user's own model. Verified live in a sandboxed HERMES_HOME. On this build a spawn with `-m glm-5.2-2bit-pool` runs on glm and the next chat is still created on deepseek-v4-flash-0731-ds4; on a build without the fix that next chat came up on glm-5.2-2bit-pool. Co-authored-by: Omar Baradei <omar@kostudios.io> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
What does this PR do?
#284 made externally-created sessions appear in the sidebar. They still cannot stream, because the session belongs to whichever process created it — a headless
hermes -z …run is a separate OS process with no transport to this app's gateway.This closes that gap from the other side: a local CLI asks the running app to start the chat, so the session is app-owned from birth and behaves exactly like a typed one.
hermes desktop spawn "audit the auth flow" -m deepseek-v4-flash-0731-ds4 --provider ai-routerRelated Issue
No tracking issue — follow-up to #284.
Type of Change
Changes Made
feat(desktop): let a local CLI start a chat the app owns and streamsapps/desktop/electron/spawn-control.ts(new) — loopback HTTP control server.POST /spawnwith{prompt, model?, provider?, profile?, toolsets?}, token-authenticated, publishing its port+token to<HERMES_HOME>/desktop/control.json.apps/desktop/electron/main.ts— starts it onwhenReady, forwards to the renderer, stops onbefore-quit, and parks a spawn that races boot until the renderer signals ready. A control server that fails to start logs and degrades; it never fails boot.apps/desktop/electron/preload.ts,src/global.d.ts—onSpawnSession/signalSpawnReadybridge.apps/desktop/src/app/contrib/hooks/use-spawn-bridge.ts(new) — runs the spawn through the renderer's own new-chat + submit path.apps/desktop/src/app/session/session-overrides.ts(new) + threading throughuse-prompt-actions/use-session-actions— per-session model/provider/profile.feat(cli): add hermes desktop spawn—hermes_cli/desktop_spawn.py(new) plus the nestedspawnverb on the existingdesktop/guiparser. Stdliburllib.requestonly.Why this is not a route on the Python backend
This was the design question, and the answer is forced by how streaming works.
tui_gateway/server.pywrite_jsonroutes event frames by the transport stored on the session, andprompt.submitre-pins that transport to whichever client calls it:An HTTP caller has no websocket, so
current_transport()isNoneand a backend-sidePOST /api/sessions/spawnwould create a session whose deltas go to stdio. The row would appear in the sidebar (via #284's watcher) and then sit there in silence — the exact failure this PR exists to fix. Routing through the renderer means the session is created and submitted on the renderer's own gateway socket, so streaming, transcript, tool cards, titles and sidebar are all the existing typed-chat path, with no second streaming implementation to keep in sync.This is also why the alternative — a read-only "spectate" mode for foreign sessions — was set aside: it runs into session ownership (
sessions.pending_owner), whereas app-owned sessions have no ownership question at all.Two bugs found by measuring against the real app
Spawns must be serialized, and must wait for the draft to settle.
startFreshSessionnavigates to the new-chat route and that navigation lands asynchronously. Submitting in the same tick captured the previous route token, so the in-flight submit saw the route move under it, treated it as the user navigating away, and aborted — after the caller had already been told 202. Measured: two spawns produced one session.Instrumented trace of the failure (temporary logging, removed before commit):
Overrides must not go through the composer stores.
setCurrentModelpersists to storage, so steering one spawned session through it would silently change the model for every chat the user starts afterwards. They travel as per-session overrides instead, asserted by a test that the persisted selection is unchanged.Security posture
127.0.0.1on an ephemeral port — never reachable off-box. Non-loopback peers are rejected regardless of bind.HERMES_HOME, so "can read the token" is already "is this user". Removed on quit; a stale file only ever yields connection-refused.POST /spawnis routable.How to Test
Verified in an isolated sandbox (
scripts/dev-sandbox.sh); the packaged app was never touched.Checklist
Code
Documentation & Housekeeping
--help; rationale lives in the module headers and commit bodiescli-config.yaml.example— N/A (no config keys; the control file is runtime state, not user config)CONTRIBUTING.md/AGENTS.md— N/Apath.joinare portable;HERMES_HOMEresolution (incl. Windows%LOCALAPPDATA%\hermes) goes through the existing constant on both sides. The 0600 file mode is a POSIX permission — on Windows it is a no-op, where the protection is instead that the file sits in the user's own profile directory. Worth a reviewer's eye if Windows is a target.Screenshots / Logs
Streaming during generation, not after. App booted against an empty store; spawn issued from outside; temporary
message.deltainstrumentation (removed before commit):1.611 s of incremental deltas before completion — 20 deltas for a "count to 20" prompt, one per number.
The session is app-owned, which is the whole point. Note
source=desktop, notcli:Two concurrent spawns, after the fix — both run, each with its own session and its own stream:
The real CLI verb against the live server (not a mock):
Test results
npm run typecheck(all three projects) — clean.scripts/run_tests.sh tests/hermes_cli/test_desktop_spawn.py test_gui_command.py test_subcommands_batch.py— 108 passed, 0 failed.spawn-control.test.ts(auth, loopback binding, 0600 file mode, validation, 503/500 paths), 14 inuse-spawn-bridge.test.tsx, 5 added touse-session-actions.test.tsx, 12 intest_desktop_spawn.py, 3 added totest_gui_command.py.Known-red CI, unrelated to this PR
Check uv.lockandDesktop E2Efail on staleuv.lockonmain—uv lock --checkexits 1 on pristineorigin/mainwith no local changes, and the E2E dies atuv sync --locked. Fix is in flight onfix/uv-lock-check-red-on-every-pr; this branch will go green once that lands.