Repository navigation
design phase 8 client
Status: planned — written before implementation (2026-09). PRD:
prd-phase-8-client.md(decisions locked there are not re-litigated here). Absorbs Phase 5, 3.6, 3.7 and Phase 6's stdio bridge entry.
A control-plane / data-plane split. The server + web UI are the control plane;
a single client program (hnx, growing the existing CLI package) on each
enrolled machine is the data plane:
CONTROL PLANE DATA PLANE (per machine)
┌───────────────────────────────┐ WSS (Socket.IO) ┌─────────────────────────────┐
browser ──►│ Fastify │ /api/* REST │◄──────────────────►│ hnx daemon (on-demand) │
(durable │ │ /mcp outlet │ daemon /ctl │ • heartbeat / jobs │
mutations)│ │ socket.io /ctl │ browser /app │ • inventory scans │
browser ◄─►│ │ socket.io /app │ (both bidirect.) │ • chat sessions: semantic │
(live UI + │ │ Machine/Job/… │ │ ↔ agent-protocol adapter │
chat + │ │ mcp-runtime │ │ (ACP today; chat:* evts) │
control) │ │ │ ├─────────────────────────────┤
└───────────────────────────────┘ │ hnx mcp serve (per process) │
▲ platform tools + │ ← stdio → agent tools │
└─ non-distributable creds │ → distributable upstreams │
pooled & exposed at /mcp │ (creds in memory only) │
│ + platform /mcp (PAT) │
└─────────────────────────────┘
Layering rules are unchanged: core stays pure (new domain types + ports),
zod schemas in shared, sockets/routes in server, client code in cli.
| Package | Change |
|---|---|
packages/cli |
Grows into the client: src/daemon/ (socket client, /ctl + /acp handlers), src/mcp-shim/ (C2), target scanners (C3). Keeps the 3.3 install pipeline unchanged as the job executor (C4). |
acp-bridge |
Deleted in C1 — its concerns move into hnx daemon. |
mcp-runtime |
New in C2: McpRegistry extracted from server/src/mcp/registry.ts, made transport-agnostic (injectable configProvider + secretResolver instead of UnitOfWork + encryption key). Used by BOTH the server's /mcp and the client shim. |
packages/server |
src/plugins/realtime.ts (socket.io attach + per-namespace auth), src/modules/machines.ts / agents.ts / jobs.ts / client-config.ts. |
packages/shared |
src/realtime.ts — zod schemas for every envelope/event (single source for server, daemon, web). |
packages/core |
Domain: Machine, AgentInstance, Job, AcSession + repository ports. |
apps/web |
socket.io-client + Machines / machine-detail / jobs / chat pages. |
New deps: socket.io (server), socket.io-client (cli, web). The server also
serves Socket.IO over the same HTTP port (path: /socket.io default) so TLS and
PUBLIC_BASE_URL are reused as-is.
New domain entities (packages/core/src/domain/); scope model follows the
existing convention (personal = owner-only; admin sees everything, and
owner-only mutation questions do not arise — machines are always personal).
Machine {
id, ownerId,
name, // user-assigned
hostname, os, arch, // reported at hello (informational)
daemonVersion, capabilities: string[], // e.g. ['mcp-shim','deploy','acp']
remoteChatEnabled: boolean, // default FALSE — gates /acp (C5)
enrollmentPatId, // the machine PAT bound to this machine
enrolledAt, lastSeenAt,
} // online/offline is DERIVED from socket presence, never stored as truth
AgentInstance {
id, machineId, ownerId,
target: AgentTarget, // 'claude-code' | 'codex' | 'hermes' | ...
name, // e.g. "claude @ ~/work/proj"
profileId?, // applied profile, if any (reported by daemon)
directory?, // working directory / install location
status: 'ready' | 'unknown',
createdAt,
}
Job {
id, machineId, ownerId,
type: 'deploy' | 'import' | 'scan',
status: 'queued' | 'dispatched' | 'running' | 'succeeded' | 'failed' | 'cancelled',
payload: unknown, // zod-validated per type
result?: unknown, error?: string,
createdAt, updatedAt,
}
AcSession { id, agentInstanceId, ownerId, openedAt, closedAt?, closeReason? }Inventory snapshots: MachineRepository.saveInventory(machineId, target, snapshot)
keeps only the latest per (machine, target) (JSON blob + collectedAt) —
no history table in v1.
Storage: extend UnitOfWork with machines, agentInstances, jobs,
acSessions ports; implement in BOTH drivers + register in factory.ts
(architecture rule #3). SQLite migration 0006 adds machines,
agent_instances, jobs, ac_sessions, machine_inventory.
PAT extension (reuses Phase 1 + 3.5 precedent): a machine kind PAT carries
scopes: ['machine-ctl'] and is linked to its Machine row via
enrollmentPatId. The REST auth hook rejects machine-scoped tokens (blast
radius = realtime only), exactly as it rejects marketplace tokens.
| Client | Namespace | Handshake auth
|
Verified by |
|---|---|---|---|
| daemon | /ctl |
{ token: <machine PAT>, machineId } |
PAT valid + kind machine + machineId matches the PAT's machine + not revoked |
| browser | /app |
{ token: <JWT or PAT> } |
existing bearer logic (same channel set as REST); per-event ownership checks |
One bidirectional namespace per client role. The daemon connects /ctl
(management + routed interactive traffic); the browser connects /app (UI
push + chat + agent control). The platform bridges the two — a browser never
talks to a daemon directly, and neither side speaks the other's event
vocabulary.
Protocol version rides the machine:hello ack (proto: 1); breaking changes
bump namespace names (/v2/ctl), never silently mutate events.
-
Namespace = client role (auth profile + policy attach point); event
domain = traffic class. Two namespaces:
/ctl(daemon-only, machine PAT) and/app(browser-only, JWT/PAT) — both fully bidirectional. Interactive traffic classes are event domains riding the same connection (chat:*,agent:*, futurefile:*/terminal:*), each gated by its own handler checks (e.g.chat:*requiresmachine.remoteChatEnabled+ owner), so a new traffic class never needs a new namespace or a reconnect. -
Event names are namespaced by domain prefix,
<domain>:<verb>—machine:hello,job:dispatch,chat:message.send. Only whitelisted handlers are registered; anything else gets no handler (and thus no ack → sender timeout). Every registered handler validates its payload with the shared zod schema; malformed → ack errorproto:invalid. -
Rooms are the only addressing mechanism. Server→daemon: room
machine:<machineId>. Server→browsers: roomuser:<userId>(UI push) andchan:<sessionId>(all viewers of one interactive channel — chat today, terminal/file later). No broadcast-to-all ever. -
Envelope addressing + identity binding. Every payload carries the ids it
concerns (
machineId/jobId/sessionId), and the server re-verifies them against the socket's identity: a daemon may only report its own machine's jobs and channels; a browser may only send into sessions/channels it owns.
Multiple channels per agent. One AgentInstance may have N concurrent
conversation channels (two browser conversations, a second viewer on the same
one, …). Each channel = one AcSession = one sessionId = one daemon-side
agent subprocess = one chan:<sessionId> room. Cross-talk is impossible by
construction (every interactive event carries sessionId; rooms scope
delivery). MCP serving is NOT a channel on this bus — shims are independent
per-process stdio servers and never touch the daemon or the socket (MCP and
chat share only the PAT and server URL).
/ctl (daemon ↔ server):
| Event | Dir | Payload | Ack |
|---|---|---|---|
machine:hello |
C → S | { daemonVersion, os, arch, hostname, capabilities } |
{ proto, machineId } — also updates Machine metadata; on success the server replays queued jobs as job:dispatch
|
job:dispatch |
S → C |
{ job: Job } (full record incl. payload) |
ack = daemon accepted (delivery, not completion) |
job:progress |
C → S | { jobId, phase, message?, percent? } |
— |
job:result |
C → S |
{ jobId, ok, error?, data? } (terminal) |
ack = recorded |
inventory:report |
C → S |
{ target, snapshot } (C3) |
ack = stored |
config:invalidate |
S → C |
{ profileId? } (advisory cache push, optional) |
— |
/app (browser ↔ platform, fully bidirectional; browsers join
user:<userId> on connect — admins additionally join admins for fleet-wide
presence; UI-push domains + interactive domains):
| Event | Dir | Payload | Notes |
|---|---|---|---|
machine:status |
S → B | { machineId, online, lastSeenAt, daemonVersion? } |
on daemon connect/disconnect (online = socket presence) |
job:update |
S → B | { job: Job } |
on dispatch / progress (coalesced ~1s) / terminal |
agent:update |
S → B | { machineId, agent: AgentInstance } |
on registration/changes (C4) |
agent:state |
S → B | { agentInstanceId, sessionId?, state } |
model / thinking level / permission mode etc. (C5) |
agent:control.apply |
B → S | { target: {agentInstanceId?, sessionId?}, control: {key, value} } |
ack; routed to the daemon — model, thinkingLevel, permissionMode, … are keys, not events |
chat:session.open |
B → S | { agentInstanceId } |
ack { sessionId }; checks machine online + remoteChatEnabled + owner |
chat:session.close |
B → S | { sessionId, reason? } |
terminal |
chat:message.send |
B → S | { sessionId, content } |
ack = accepted; routed to the daemon, which adapts it to the agent protocol |
chat:event |
S → B | { sessionId, event } |
fan-out to chan:<sessionId>; the semantic agent stream the UI renders (below) |
chat:event.event is a semantic stream — message.delta, tool.update,
permission.request, done, … — defined in shared/realtime.ts and stable
across agent protocols. A raw variant carries protocol frames verbatim for
advanced UI needs without the platform interpreting them.
/ctl interactive routing (extends the /ctl catalog above; S→C carries what
browsers initiated on /app, C→S carries the adapted stream):
| Event | Dir | Payload | Notes |
|---|---|---|---|
chat:session.start |
S → C | { sessionId, agentInstanceId } |
ack = subprocess spawned |
chat:session.ready |
C → S | { sessionId, agentInfo, state } |
platform joins the opening browsers into chan:<sid>
|
chat:message.send |
S → C | { sessionId, content } |
daemon adapts to the agent protocol (ACP session/prompt today) |
chat:event |
C → S | { sessionId, event } |
semantic stream + raw escape hatch; routed to chan:<sid>
|
chat:session.close |
S → C | { sessionId, reason? } |
daemon kills the subprocess; room torn down |
agent:control.apply |
S → C | { sessionId?, agentInstanceId?, control } |
applied via ACP method where supported, else by session restart |
agent:state |
C → S | { agentInstanceId, sessionId?, state } |
after control changes / on state change |
The daemon is the protocol-adaptation edge. The browser speaks
platform-semantic events (chat:*, agent:control.*); the daemon owns the
mapping to each agent's protocol (ACP today — the C5 per-target adapter matrix
defines what each target supports). The platform stays router + policy + audit
and never learns agent protocols, so protocol churn is confined to the edge:
ACP evolution reaches the UI through the raw variant or new semantic event
versions, never through new platform routing.
Durable mutations (enroll/revoke machines, create jobs, profiles, resources)
stay on REST — one auth/validation/audit surface. Sockets carry everything
session-scoped and streaming: UI push, chat, agent control, and (later) file
management and terminal — all over /app on the browser side. The browser
never connects to a daemon — the platform is the sole routing point
between /app and machine:<id> rooms, which is also what keeps ownership
checks and session audit centralized. Browser JWT expiry mid-connection: the
server drops the socket on auth failure, and the web client re-reads its token
on every Socket.IO reconnect attempt (fresh JWT after re-login) — a token
refresh needs no extra protocol support in v1.
The channel pattern — session + chan:<sid> room + identity-checked envelope
- per-domain gating — is the substrate for interactive features after C5; none of them need new namespaces or reconnects:
-
Channel-based file management (
file:*): request/response browsing plus change events, machine-scoped, own gating flag. -
Web terminal (
terminal:*): session-stream semantics like chat; the highest-risk extension — needs its own per-machine enable + audit, same posture as remote chat but stricter. -
More agent-control keys:
agent:control.applyis an open key/value surface — new keys (model, thinking level, permission mode, …) are schema additions inshared/realtime.ts, not protocol changes.
-
Job delivery is push + replay: dispatches carry an ack timeout; un-acked
jobs stay
queuedand are replayed after the next successfulmachine:hello(jobs are idempotent byjobId; the 3.3 pipeline's plans are idempotent overwrites). No lost-dispatch window. -
ACP has no resume in v1: on daemon disconnect the server marks all its
sessions closed (
reason: 'connection-lost') and notifies viewers. - Socket.IO built-in ping/pong is the heartbeat; presence = socket in
machine:<id>room;lastSeenAtupdated on every packet. Machine status is honest by construction (no daemon ⇒ offline). -
maxHttpBufferSizeset explicitly (default proposal: 8 MiB) — bounds ACP payloads; configurable via env. - All event schemas live in
packages/shared/src/realtime.ts; server, daemon, and web import the same definitions.
The "client MCP proxy" is not a server — it is a set of per-session aggregator processes:
agent tool ──stdio──► hnx mcp serve --profile <id> [--server <url>] (spawned by the tool)
├── dials distributable upstreams directly (${cred:NAME} resolved in memory)
└── dials platform /mcp?profile=<id> (PAT) (server-side tools + locked creds)
- Spawned by the agent tool per session; dies with it; upstream connections release naturally. No TCP listener, no port allocation, no daemon dependency, no "daemon down ⇒ MCP dead" failure mode.
- One stdio entry per profile in every target's config (
.mcp.json,config.toml, Hermesmcp_servers) — stdio is every target's greatest common denominator, which is what deletes the proxy/direct split and makes stdio-only targets (Codex) first-class. - Install paths bake the absolute path of
hnxinto the stdio entry (GUI-launched tools lack PATH). Known edge: the 3.5 emitter's zips are machine-independent and cannot bake a path — that flow requireshnxon PATH (documented; the HTTP/PAT emitter mode remains the no-hnxfallback). - Config is fetched at shim start ⇒ changes apply on the next agent session (consistent with how tools snapshot MCP tool lists today).
- Accepted trade-off: concurrently running tools each dial upstreams independently (N tools = N upstream connections). Fine on single-user dev machines; a "share connections with a running daemon if present" hybrid broker is a future option, not v1.
Per profile entry referencing an McpServer:
-
McpServer.dialSite(new field):'auto' | 'client' | 'server', default'auto'. -
auto→ client iff the server's transport config references no credential, or every referenced credential is distributable; else server. (Credentialgainsdistributable: boolean— forcedfalsefor global scope unless an admin opts in; personal scope is implicitly distributable.) -
'server'override exists because reachability is not only a credential question — e.g. upstreams only the platform's VPC can reach must be dialed server-side even with distributable creds. - Server-side pooling condition (2.2's
mode === 'proxy') becomes "resolves to server". The platform/mcp?profile=<id>outlet exposes exactly the server-dialed set (+ future platform-hosted tools); the shim merges its locally-dialed set with the outlet's namespaced tools into one stdio view. Each upstream is dialed by exactly one side — no duplication.
GET /api/client/mcp-config?profile=<id> (machine PAT or user PAT; profile
visibility rules apply) →
{ "profileId": "…",
"platform": { "baseUrl": "https://…", "profileId": "…" }, // shim dials /mcp if any entry is server-dialed
"servers": [ { "id": "…", "name": "…", "transport": { …resolved… } } ] }transport carries resolved values (placeholders substituted) for
client-dialed servers only — i.e. the response contains plaintext secrets only
for credentials whose distributability explicitly permits it, over TLS,
authenticated. Server-dialed servers are listed by name but return no
transport (the shim needs nothing to use them — they arrive via the outlet).
Unit tests assert non-distributable plaintext never appears in any response.
-
McpServer.modeis dropped (SQLite migration foldsproxy→auto,direct→client);409 STDIO_REQUIRES_DIRECTand install-time${cred:NAME}resolution disappear — the 3.3 adapters emit one stdio entry per profile instead. - The server
/mcp, registry pooling, and the 2.4 connect surface keep working throughout; their role narrows to the outlet. The 2.4 status surface becomes per-machine (shims report their locally-dialed liveness with inventory/scan jobs; the server-side pool reports server-dialed rows). - Emitter:
emitMode: 'client' | 'server'(default flips toclientonce C2 ships;'server'keeps today's HTTP +${HN_PAT_*}output as the no-hnxfallback).
Job lifecycle: queued → dispatched → running → succeeded | failed | cancelled
(persisted; dispatched entries older than the ack timeout revert to
queued). A deploy job's payload { target, profileId, name, directory? }
reuses the unchanged 3.3 pipeline (resolver → adapter → plan → apply →
ledger) inside the daemon; job:progress maps to plan/apply phases. On
success the daemon reports the created AgentInstance in job:result.data,
and the server registers it. hnx install remains as the manual local path —
same code, different trigger.
- Daemon scanners per target read the same ground-truth locations the 3.3
adapters write (CC
~/.claudeplugins/skills/agents/mcpServers; Codex~/.codex; Hermes plugin dirs) and report a normalized snapshot:{ target, agents: [{ name, directory, profileApplied?, items: [{ kind, name, source-ish descriptor }] }] }viainventory:report(triggered by ascanjob or daemon start). - The server stores the latest snapshot and computes diffs against a
profile (
GET /api/machines/:id/inventory/diff?profile=<id>): profile entries missing on the machine / machine artifacts missing from the profile / both. Profile truth lives server-side — diff logic is pure and testable. -
Import = an
importjob carrying a selected subset of the snapshot: the daemon uploads artifact bodies; the server createsResources (reusing the 4.2 backend and 3.5's{resourceId, kind}entry arm) and bundles them into a new profile. No new permission surface — ordinary resource/profile rules.
Routing: browser (/app chat:* + agent:control.*) ↔ server ↔ daemon
(/ctl routed chat:* to machine:<id>) ↔ local agent subprocess (ACP over
stdio), with the daemon adapting semantic events ↔ ACP frames in both
directions. Prerequisite research: a per-target ACP adapter matrix (which
agents speak ACP natively, which need a wrapper process, e.g. community ACP
adapters; which control keys each target supports) — C5 starts with that
research doc. Gating: machine.remoteChatEnabled (default off) + owner-only +
per-session AcSession rows retained as audit. The daemon's session manager
owns subprocess lifecycle (spawn on chat:session.start, kill on
close/disconnect, cap concurrent sessions per machine). AcSession is
chat-shaped in v1; when terminal/file channels land it generalizes with a
kind discriminator.
-
Enrollment is explicit consent (
hnx enrollis interactive; the response token is shown once, 3.5-reveal-dialog style) and revocable per machine (deleting a machine revokes its PAT ⇒ realtime access dies immediately). -
Machine PAT blast radius: REST API rejects
machine-scoped tokens; a leaked machine token reaches only the realtime channel of its own machine. - ACP is a remote-code-execution surface by design. Remote chat off by default, owner-only, session audit rows, concurrent-session cap, message size cap. These are load-bearing, not decorations.
- Secrets: resolved upstream secrets exist only in shim process memory; the sole at-rest client secret is hnx's own token file (0600; OS keychain later). Non-distributable credentials never appear in any client-visible response — asserted by tests.
- Server-side request-log masking extends to socket frames (tokens/URLs in payloads never logged raw).
-
Machines page: list with honest online/offline via
/appmachine:statuspush (online →bg-okper the 2.4 mesh precedent; never faked), daemon version, capabilities, remote-chat toggle (confirm-first), revoke. - Machine detail: agent instances, latest inventory per target, profile diff view, "import to platform" flow.
-
Create agent: target + profile + machine wizard → deploy job with live
progress via
/appjob:updatepush. - Jobs: per-machine job list with status/progress.
-
Chat (C5): session list + conversation view; permission prompts render
from ACP method names. All new routes register in
navItems(). - Signal system rules apply unchanged (
--signalstays reserved for liveness).
Each phase lands independently verifiable; build order follows the dependency chain, and each phase's docs update (this file + roadmap) happens with it.
C1 — daemon + machine registration.
core (Machine, ports) → shared (PAT kind, realtime.ts v0: hello/dispatch
shapes) → server (realtime.ts plugin with /ctl + /app, modules/machines.ts,
repos + migration, REST-hook machine-token rejection) → cli (hnx enroll,
hnx daemon, socket client) → web (socket.io-client /app feed + Machines
page). Delete acp-bridge.
Verify: schema unit tests, presence tests, smoke enroll → online → revoke →
offline.
C2 — client MCP serving.
core/shared (Credential.distributable, McpServer.dialSite, mode removal,
migration) → mcp-runtime extraction (server first, shim second) → server
(client-config.ts module + secret-leak tests, outlet narrowing, emitter
emitMode) → cli (hnx mcp serve + stdio entry emission in install
adapters; Codex adapter lands here as the trivial case) → web (config UI for
the two new fields; per-machine status view). Verify: routing-derivation unit
tests, shim E2E vs fixture upstreams, emitter zip snapshot tests.
C3 — inventory + diff + import. shared (snapshot schema) → server (inventory storage + diff endpoint + import job handler) → cli (scanners) → web (machine detail + import wizard). Verify: diff unit tests (pure), report→diff→import smoke.
C4 — remote deploy. ✅ shipped — see design-phase-8-c4.md.
server (job dispatch/replay, AgentInstance registration) → cli (job executor
wrapping the 3.3 pipeline) → web (create-agent wizard + jobs view). Verify:
dispatch→progress→result smoke incl. offline-queue replay.
C5 — ACP chat. ✅ shipped — see design-phase-8-c5.md (the plan as
implemented; agent:control.* was deferred with rationale there).
research (adapter matrix) → shared (chat:* / agent:control.* schemas) →
server (/app↔/ctl routing + gating + session persistence) → cli (session
manager, subprocess spawn, semantic↔ACP adapter) → web (chat UI). Verify:
session open → message → event round-trip smoke against a fixture agent;
gating tests (remote chat off ⇒ refused).
C6 — orchestration. Not designed. C1–C5 deliver its substrate: machines (placement), AgentInstances (addressable units), AcSessions (invocation + observation channels).
-
pnpm -r typecheck+ per-package builds after each phase (composite references needdistin dependency order: core → shared → server/cli/web). - Vitest:
shared/realtimeschemas, routing derivation, diff logic, auth middleware, secret-leak assertions. Existing 2.2/2.4/3.3 tests must stay green through the C2 migration (the outlet keeps the old/mcpcontract). -
scripts/smoke.mjsgrows one block per phase (see plan above).
- Session resume; offline/keychain config cache; hybrid connection broker; multi-user machines; machine-to-machine features; orchestration design; platform-hosted tools themselves (outlet only); content scanning.
- C5: exact per-target ACP process wiring (native vs wrapper adapters) — resolved by the C5 research doc before implementation.
- C2: emitter default flip timing (
server→client) — decide at C2 ship based onhnxinstall base; the flag makes either default cheap. - Whether
config:invalidate(advisory push) earns its complexity once shims are the only config consumers (they re-fetch per session anyway) — likely dropped in implementation.