Skip to content

feat(desktop): local spawn API — start app-owned chats from the CLI, streamed live - #298

Merged
OmarB97 merged 2 commits into
mainfrom
work/local-spawn-api
Aug 2, 2026
Merged

feat(desktop): local spawn API — start app-owned chats from the CLI, streamed live#298
OmarB97 merged 2 commits into
mainfrom
work/local-spawn-api

Conversation

@OmarB97

@OmarB97 OmarB97 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

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-router

Related Issue

No tracking issue — follow-up to #284.

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)

Changes Made

feat(desktop): let a local CLI start a chat the app owns and streams

  • apps/desktop/electron/spawn-control.ts (new) — loopback HTTP control server. POST /spawn with {prompt, model?, provider?, profile?, toolsets?}, token-authenticated, publishing its port+token to <HERMES_HOME>/desktop/control.json.
  • apps/desktop/electron/main.ts — starts it on whenReady, forwards to the renderer, stops on before-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.tsonSpawnSession / signalSpawnReady bridge.
  • 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 through use-prompt-actions / use-session-actions — per-session model/provider/profile.

feat(cli): add hermes desktop spawnhermes_cli/desktop_spawn.py (new) plus the nested spawn verb on the existing desktop/gui parser. Stdlib urllib.request only.

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.py write_json routes event frames by the transport stored on the session, and prompt.submit re-pins that transport to whichever client calls it:

# Re-bind to the current client transport for this request.
if (t := current_transport()) is not None:
    session["transport"] = t

An HTTP caller has no websocket, so current_transport() is None and a backend-side POST /api/sessions/spawn would 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. startFreshSession navigates 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):

step start Count from 1 to  busy=false freshReady=false   05:55:28.653Z
step done  Count from 1 to  ok=false                      05:55:28.782Z   <- silently dropped
step start List the first 1 busy=true  freshReady=true    05:55:28.782Z
step done  List the first 1 ok=true                       05:55:28.937Z

Overrides must not go through the composer stores. setCurrentModel persists 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

  • Binds 127.0.0.1 on an ephemeral port — never reachable off-box. Non-loopback peers are rejected regardless of bind.
  • Token minted fresh each app launch, never persisted across runs, compared in constant time.
  • Published in a 0600 file under the user's own HERMES_HOME, so "can read the token" is already "is this user". Removed on quit; a stale file only ever yields connection-refused.
  • Body capped at 128 KB; only POST /spawn is routable.

How to Test

Verified in an isolated sandbox (scripts/dev-sandbox.sh); the packaged app was never touched.

cd apps/desktop
HERMES_DESKTOP_HERMES_ROOT="$HOME/.hermes/hermes-agent" \
HERMES_DESKTOP_PYTHON="$HOME/.hermes/hermes-agent/venv/bin/python" \
  ../../scripts/dev-sandbox.sh --persistent -- npm run dev

# then, from another shell:
HERMES_HOME=<sandbox>/hermes-home hermes desktop spawn "Reply with exactly READY" \
  -m deepseek-v4-flash-0731-ds4 --provider ai-router

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this feature
  • Tests pass — see below
  • I've added tests for my changes
  • I've tested on my platform: macOS 15 (Darwin 25.6.0), Apple Silicon

Documentation & Housekeeping

  • Documentation — the CLI verb is self-documenting via --help; rationale lives in the module headers and commit bodies
  • cli-config.yaml.example — N/A (no config keys; the control file is runtime state, not user config)
  • CONTRIBUTING.md / AGENTS.md — N/A
  • Cross-platform impact considered: loopback HTTP and path.join are portable; HERMES_HOME resolution (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.
  • Tool descriptions/schemas — N/A

Screenshots / Logs

Streaming during generation, not after. App booted against an empty store; spawn issued from outside; temporary message.delta instrumentation (removed before commit):

spawn POST                       -> HTTP 202
[stream] delta   2026-08-02T05:32:27.385Z  session 724af760   <- first token
[stream] delta   ... 20 deltas at ~84ms intervals ...
[stream] delta   2026-08-02T05:32:28.996Z
[stream] COMPLETE 2026-08-02T05:32:29.062Z

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, not cli:

20260801_223144_eac6ed  msgs=2  src=desktop  model=deepseek-v4-flash-0731-ds4
agent.log: conversation turn: session=20260801_223144_eac6ed
           model=deepseek-v4-flash-0731-ds4 provider=custom platform=desktop

Two concurrent spawns, after the fix — both run, each with its own session and its own stream:

session 53aea355: 30 deltas  first=05:58:52.303Z  complete=05:58:55.084Z   ("count to 30")
session b1cf1018: 10 deltas  first=06:00:14.700Z  complete=06:00:15.493Z   ("first 10 primes")

The real CLI verb against the live server (not a mock):

$ hermes desktop spawn "Reply with exactly READY" -m deepseek-v4-flash-0731-ds4 --provider ai-router
✓ Sent prompt to the Hermes desktop app.

agent.log: conversation turn: session=20260801_230229_0cf9e4
           model=deepseek-v4-flash-0731-ds4 'Reply with exactly READY'

Test results

  • npm run typecheck (all three projects) — clean.
  • Desktop suite: 275 files, 2354 passed, 2 skipped.
  • Python: scripts/run_tests.sh tests/hermes_cli/test_desktop_spawn.py test_gui_command.py test_subcommands_batch.py108 passed, 0 failed.
  • New tests: 14 in spawn-control.test.ts (auth, loopback binding, 0600 file mode, validation, 503/500 paths), 14 in use-spawn-bridge.test.tsx, 5 added to use-session-actions.test.tsx, 12 in test_desktop_spawn.py, 3 added to test_gui_command.py.
  • Guard tests were mutation-checked: removing the serialization reintroduces the concurrent-spawn drop; restoring the un-settled submit reintroduces the silent abort.

Known-red CI, unrelated to this PR

Check uv.lock and Desktop E2E fail on stale uv.lock on mainuv lock --check exits 1 on pristine origin/main with no local changes, and the E2E dies at uv sync --locked. Fix is in flight on fix/uv-lock-check-red-on-every-pr; this branch will go green once that lands.

Omar Baradei and others added 2 commits August 1, 2026 23:03
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>
@OmarB97

OmarB97 commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

CI triage: the one red check is pre-existing on main

Python tests / Run tests slice 6/8 fails on two tests in tests/test_tui_gateway_server.py:

  • test_prompt_submit_golden_transcript_matches_flag_off_and_on
  • test_session_activate_returns_inflight_stream_before_completion

This branch touches no tui_gateway code — git diff --name-only origin/main...HEAD matches nothing under tui_gateway/ or that test file. Its Python changes are confined to hermes_cli/desktop_spawn.py, hermes_cli/main.py, hermes_cli/subcommands/gui.py and tests/hermes_cli/.

Reproduced on pristine origin/main (2e08411e8c, no local changes):

FAILED tests/test_tui_gateway_server.py::test_prompt_submit_golden_transcript_matches_flag_off_and_on
FAILED tests/test_tui_gateway_server.py::test_session_activate_returns_inflight_stream_before_completion
2 failed in 5.27s

Identical failures on this branch — same two tests, same errors. The underlying defect looks like a test-side signature drift rather than a product bug:

[gateway-crash] thread Thread-4 (run) raised TypeError:
  test_session_activate_returns_inflight_stream_before_completion.<locals>.<lambda>()
  takes 1 positional argument but 2 were given

i.e. the test's fake stream callback is being invoked with two arguments, so the turn thread dies and the started.wait(2) assertion times out. Worth a separate fix; filed as a follow-up.

Everything else is green, including Typecheck & Test (apps/desktop), Desktop E2E, uv.lock, ruff, and the other seven Python slices.

@OmarB97
OmarB97 merged commit 309ddd4 into main Aug 2, 2026
40 of 42 checks passed
OmarB97 added a commit that referenced this pull request Aug 2, 2026
…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>
OmarB97 added a commit that referenced this pull request Aug 2, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant