feat: add Crush agent backend (crush server HTTP API) + mobile agent picker - #1703
feat: add Crush agent backend (crush server HTTP API) + mobile agent picker#1703im47cn wants to merge 3 commits into
Conversation
Hermes is an ACP-native CLI agent (hermes acp). Following the
generic-acp-runner principles, it rides the existing ACP infrastructure
with no vendor-specific runner:
- KNOWN_ACP_AGENTS: hermes -> { command: 'hermes', args: ['acp'] }
- factories/hermes.ts: createHermesBackend over AcpBackend with
HermesTransport; auth is managed by the Hermes CLI itself (native
login), so no credentials are resolved or injected
- HermesTransport: DefaultTransport + tool-name patterns for
change_title / save_memory / think / HermesReasoning
- `happy hermes` routes through the generic ACP runner (runAcp) with
session flavor 'hermes'
- Daemon remote-spawn support: agent command mapping and CLI
availability detection (hermes + crush)
- cliAvailability metadata schema: include agy (previously stripped)
and hermes/crush as optional fields for older-daemon compatibility
Verified end-to-end against Hermes Agent v0.20.1: session creation, ACP
handshake, 9 slash commands, 3 permission modes, and 95 models reported
to the mobile app.
Crush exposes a local HTTP+SSE API (`crush server`, /v1 wire protocol)
rather than ACP, so this adds a custom AgentBackend implementation plus
a small generalization of the generic ACP runner:
- CrushServerBackend: spawns `crush server` on a per-session Unix
socket, creates workspace/session, streams SSE events, and maps the
two-layer event envelope to AgentMessages:
- message (assistant parts, cumulative) -> streamed textDelta diffs
- tool_call / tool_result parts -> tool-call / tool-result
- run_complete / agent_event -> turn lifecycle statuses
- permission_request -> permission-request (full request as payload)
- Permission responses are bridged: runAcp surfaces requests to the
mobile app and forwards approvals to POST /permissions/grant with the
{ permission, action } body crush requires
- runAcp generalization: new optional createBackend factory and
externalPermissions flag let non-ACP backends ride the same runner
pipeline (session setup, message queue, SessionEnvelope mapping,
keepalive, abort/kill RPC). ACP-specific session-config switching is
a no-op when the backend omits those methods
- `happy crush` subcommand with session flavor 'crush'
- connect hermes/crush: explain that these agents manage their own auth
- Fixed vs the original prototype: SSE AbortSignal now actually tears
down the stream; typed event parsing (no any-casts); Windows throws
a clear error instead of silently targeting port 0
Verified against crush v0.80.0: workspace/session creation, prompt
delivery, streamed assistant text, run completion, and the health
endpoints were probed live before implementation.
Register hermes and crush as spawnable agents end to end on the mobile side: session draft persistence, agent selection order, picker entries with placeholder badge icons, home dock menu, and the spawn RPC agent union. Machines only offer the agents their cliAvailability metadata reports as installed, so older CLIs are unaffected (new availability fields are optional in the metadata schema).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 55655c040a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| case 'hermes': | ||
| agentCommand = 'hermes'; | ||
| break; | ||
| case 'crush': | ||
| agentCommand = 'crush'; |
There was a problem hiding this comment.
Handle Hermes and Crush in tmux spawns
When the machine is configured to spawn sessions in tmux (TMUX_SESSION_NAME is set and tmux is available), the daemon returns from the tmux branch before reaching this new regular-spawn switch. That earlier branch still maps only gemini/codex/openclaw/agy and falls back to claude, so a mobile request for the newly supported hermes or crush agent silently starts a Claude session instead. Add these new agent keys to the tmux mapping as well as the regular spawn path.
Useful? React with 👍 / 👎.
| if (args[0] === 'hermes') { | ||
| args[0] = 'acp' | ||
| args.splice(1, 0, 'hermes') |
There was a problem hiding this comment.
Consume the Happy-only flag for the Hermes alias
For daemon-spawned Hermes sessions, daemon/run.ts invokes hermes --happy-starting-mode remote --started-by daemon; this rewrite turns that into the generic ACP path, whose parser consumes only --started-by/--verbose, so --happy-starting-mode remote is passed through to the Hermes subprocess as hermes acp --happy-starting-mode remote. That flag is Happy-internal and should be consumed or ignored for the alias, otherwise remote Hermes sessions can fail on an unknown Hermes option.
Useful? React with 👍 / 👎.
| // Emit tool calls / results as they appear in the accumulated parts | ||
| for (const part of message.parts ?? []) { |
There was a problem hiding this comment.
Deduplicate cumulative Crush tool parts
When Crush sends message updates with cumulative parts, this loop re-emits every existing tool_call and tool_result on each update, unlike the text path below that tracks the accumulated suffix. A tool call followed by streamed text can therefore produce repeated tool-call start/end envelopes for the same call id, cluttering or corrupting the transcript; track emitted tool part ids per message before pushing them again.
Useful? React with 👍 / 👎.
| { key: 'hermes', label: 'hermes' }, | ||
| { key: 'crush', label: 'crush' }, |
There was a problem hiding this comment.
Define mode defaults before exposing Hermes and Crush
After these new agent keys become selectable, the mode/default helpers still only recognize claude/codex/gemini/openclaw/agy, so Hermes and Crush fall through to Claude defaults and hardcoded Claude model/permission options in the new-session UI. Selecting either backend can show and persist irrelevant Claude choices such as opus, bypassPermissions, and effort levels; add explicit defaults/options for these agents or suppress those controls until backend metadata is available.
Useful? React with 👍 / 👎.
| args: opts.args, | ||
| mcpServers, | ||
| permissionHandler, | ||
| transportHandler: new DefaultTransport(opts.agentName), |
There was a problem hiding this comment.
Use the Hermes transport for Hermes sessions
The generic ACP path used by happy hermes and happy acp hermes still constructs AcpBackend with DefaultTransport, so the new HermesTransport and its tool-name extraction are never applied in the actual CLI path. When Hermes reports tools as other/unknown with ids such as change_title-..., the mobile permission UI and transcript will show the wrong tool name; route Hermes through createHermesBackend or select hermesTransport here.
Useful? React with 👍 / 👎.
| const gemini = checkCommand('gemini'); | ||
| const agy = checkCommand('agy'); | ||
| const hermes = checkCommand('hermes'); | ||
| const crush = checkCommand('crush'); |
There was a problem hiding this comment.
Do not advertise Crush on Windows
This reports crush as available on Windows whenever the binary exists, but CrushServerBackend.startCrushServer() immediately throws on win32 because it relies on Unix domain sockets. On Windows machines with Crush installed, the app will enable the new Crush agent and every spawned session will fail; keep this availability false on Windows or add a supported Windows transport first.
Useful? React with 👍 / 👎.
| } else if (text && text !== previous) { | ||
| // Unexpected divergence - send the full text | ||
| accumulatedText.set(messageId, text); |
There was a problem hiding this comment.
Avoid emitting fullText messages the runner drops
In the divergence branch, Crush emits model-output with only fullText, but the shared AcpSessionManager maps model output using msg.textDelta ?? '' and returns no envelopes when textDelta is absent. If Crush replaces or rewrites a cumulative assistant message, this intended correction is silently dropped from the transcript; emit a delta the runner can consume or teach the session manager how to handle fullText.
Useful? React with 👍 / 👎.
Summary
Stacked on #1702 (Hermes). Adds Crush as a Happy backend. Crush does not speak ACP — it exposes a local HTTP + SSE API via
crush server— so this PR adds one customAgentBackendimplementation and a small generalization of the generic runner:CLI (
packages/happy-cli)CrushServerBackend: spawnscrush serveron a per-session Unix socket, creates workspace/session, and maps the/v1SSE event envelope toAgentMessages:messageevents carry cumulative text parts → streamed astextDeltadiffstool_call/tool_resultparts →tool-call/tool-resultrun_complete/agent_event→ turn lifecycle statuses (incl. error details)permission_request→permission-request(full request preserved as payload)runAcpgains anexternalPermissionsmode that surfaces pushed permission requests to the mobile app and forwards responses toPOST /permissions/grantwith the{ permission, action }body Crush requiresrunAcpgeneralization: a new optionalcreateBackendfactory lets non-ACP backends ride the exact same runner pipeline (session setup, message queue, SessionEnvelope mapping, keepalive, abort/kill RPC). ACP-specific session-config switching no-ops when the backend omits those methods.happy acpbehavior is unchangedhappy crushsubcommand with session flavorcrush;happy connect hermes|crushexplain that these agents manage their own authMobile (
packages/happy-app)cliAvailabilityreports as installed (new fields optional, older CLIs unaffected)Wire-protocol notes
Implementation was probed live against crush v0.80.0 before writing the mapper: all endpoints live under
/v1, requests require a UUIDclient_id, SSE events use a two-layer envelope ({type, payload:{type, payload}}), assistant message events carry cumulative parts, and run completion is signaled byrun_complete+agent_event(agent_finished).Testing
202 Accepted), streamed assistant text, and run completion all verified liveDesign decisions (vs. a naive port)
AbortSignalis actually wired to the stream teardownanycasts