feat: one HarnessClient, one daemon, one MCP path - #974
Conversation
statusCommandRpc was simultaneously the Unix-socket RPC and the schema source for two surviving consumers: the MCP status tool and the harness client's identity read. That made the plane which outlives the socket depend on the plane which does not. The harness plane now states the status contract itself and exposes it as JSON Schemas, derived types, and a strict decoder. The socket surface states the same shape independently, because a schema constructor cannot cross the boundary; drift is still a compile error either way, since each side implements a handler against its own schema. Dropping the RPC handler context also removes the Rpc.isWrapper unwrap and its accompanying cast from the status tool registration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P76aaa1STr3WPZ3nDascta
A profile record was exactly {agentId, apiKey, agentName}, so nothing
could tell a daemon which port to bind and moltzapd required a
caller-supplied --port. No production code could compute an MCP URL.
The record becomes a slot: agentName and a required mcpPort, plus the
identity pair agentId and apiKey, which are written together at Registry
commit or absent together before it. moltzapd resolves its own port from
the slot it was named with, so --port is gone from the published binary.
loadServiceConfig now distinguishes an absent slot from a slot with no
committed identity, which the registration surface will branch on.
Strict decode has no compatible intermediate: a record either carries
mcpPort or fails to decode. Every writer moves in this commit, and the
simulator and packaged-daemon fixtures choose a port before writing the
slot rather than handing one to the daemon.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P76aaa1STr3WPZ3nDascta
Nothing in production could build a HarnessClient: the only code that started a daemon and connected to it lived in test-utils, and it demanded a caller-supplied port and an injected checkpoint store. harnessClientForProfile takes a profile name and nothing else. It starts the slot's own daemon child, derives the loopback endpoint from the same persisted port the daemon binds, and provides a file-backed checkpoint store. The checkpoint directory is keyed by profile name because the store must be provided before the client reads its identity from the daemon's status tool, and one slot is exactly one AgentId. The daemon supervisor moves out of test-utils to the source root; only the port reservation a test performs before writing a slot stays behind. The packaged round-trip test now drives the production composition instead of a parallel test-only path with an in-memory store. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P76aaa1STr3WPZ3nDascta
The plugin chose between a HarnessClient and a directly constructed MoltZapService at runtime, and only the second arm was reachable in production, because nothing could compute a daemon endpoint. The slot now carries its port, so the account id alone resolves a client. startAccount acquires that client unconditionally and drains it. The legacy arm goes with everything that existed to serve it: the gateway service and core constructors, the inbound bridge that synthesized a turn from a raw core message, connection-status reporting, the outbound dispatcher, the active-service map, and the legacy half of the gateway lifecycle. The directory section goes with it. Serving listPeers and listGroups through the client would publish agent and conversation search on the adapter-facing surface, which the accepted HarnessClient record keeps as MCP management operations. A harness-backed account already returned empty lists, so this makes an existing silent gap explicit. Proactive sends into an existing conversation and MoltZapService.sendToAgent go too. Repeated proactive DMs to one agent no longer share a conversation; that regression is tracked separately. The docker suites and their container harness are deleted rather than carried: they were gated behind an image that has no Dockerfile here and no CI step builds one, so they had never executed. No integration suite remained in the package afterward, so its setup and config go as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P76aaa1STr3WPZ3nDascta
The adapter carried two constructors and production only ever reached the one that built MoltZapService and MoltZapChannelCore directly, because nothing could resolve a daemon endpoint. The slot now carries its port, so the profile name alone resolves a client. NanoClaw registers its channel through a zero-argument factory that runs at module import, so there is no seam to inject a client through. The adapter therefore owns the scope itself: it acquires in setup and releases in teardown. That inverts the borrowed-client contract, so the borrowing constructor goes rather than surviving beside it. The simulator writes the slot and stops there. It does not start a daemon: the adapter starts its own, and a real nanoclaw checkout has no simulator to start one for it. Two daemons would bind the same slot's single port. The channel stays one file. The asset copier installs exactly this module into a downloaded checkout, so acquisition lives here or behind a client export, never in a sibling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P76aaa1STr3WPZ3nDascta
…lane The daemon bound its listener only after loadServiceConfig succeeded, so a slot without a committed identity had nowhere to register and the registration catalog stayed empty. The listener now binds first and its catalog follows slot state: register and status before commit, the six active tools after. The official SDK builds a fresh server per request, so the transition is a phase read rather than a rebind — the URL never changes and an open client sees the new catalog on its next tools/list. /register/mcp goes with it. main's accepted 20260728-endpoint-daemon-speaks- modern-mcp fixes host and path for one loopback /mcp; the two-path shape is admitted only on v2, whose records govern v2/* and not packages/*. Registration was the CLI's last unique capability, so the whole `moltzap` command tree, the Unix socket plane it spoke to, and the local history surface that existed only to serve that socket all go. `moltzapd` is now the package's only bin. The socket-absence assertions in the daemon integration tests go too: with nothing left that could bind a socket, they assert nothing. scripts/quickstart.sh wrote the pre-slot three-field profile shape, which strict decode has rejected since mcpPort became required. It now writes a port per slot and starts daemons instead of CLI commands. docs/spec/cli.md and docs/spec/endpoints/daemon.md are main-resident v2 content that v2 itself has already deleted. Both gain a scope note rather than a deletion, so a reader cannot mistake them for a production contract.
The server counts a closed socket asynchronously, so asserting the count the instant a scope closes is a race. It passed before only because `beginShutdown` also stopped the Unix socket server, and that work bought enough latency to hide it; deleting the socket plane removed the latency. This file already carries `waitForConnectionCount` for exactly this reason and its sibling process test already uses it. The teardown assertions now do too.
…bin map Only "clean-slate cannot import production" was enforced. Three rules now hold the shape the cutover produced: adapters may reach @moltzap/client only through its published adapter-facing subpaths, no source may import daemon-side machinery by name, and the client's export and bin maps are pinned. The subpath rule alone was insufficient — channel-base re-exported MoltZapChannelCore and ChannelService, so any adapter could have taken the long way round. That barrel now exports the seven presentation names its consumers actually use; both classes have no consumer outside packages/client. The rules scan shipped sources only. Test scaffolding legitimately drives a peer agent and registers fixtures, and none of it reaches a user. The three client-side conformance configs matched no tests and ran passWithNoTests, so four CI steps reported green for nothing. Deleted rather than left standing in for an unmet criterion; the deferral is recorded in #926.
The socket support module went with the Unix RPC plane; its JSDoc header outlived it and now labels the wrong export.
`20260801-harness-client-owns-runtime-context` states the client "stores stable per-conversation presentation checkpoints locally. After restart it uses search and history reads to rebuild context from those positions", "presents context at most once during normal operation", and that "history reads rebuild context only and never recreate reply authority". Nothing tested any of it. Three client lifetimes against one slot. Each spawns its own daemon; only the checkpoint directory survives between them. The second lifetime must not re-present the content the first already carried, and its bound reply must still land in the live conversation. Deleting the checkpoint directory restores the full re-read, so the narrowing is the checkpoints and not a recency window. A turn's reply is bound to the MCP client that produced it, so the reply round trip runs inside the lifetime that owns that client rather than escaping its scope.
# Conflicts: # .github/workflows/ci.yml # package.json
The shipped packages/client harness surface had no main-resident authority of any kind. The clean-slate records that describe it govern v2/* and say so themselves — "Production adoption is main-owned" — which is why the membership projection and the inbound notification shape sat in the divergence ledger as contested rather than settled. Three records, because they are three decisions: what a profile slot is and where its port comes from; what adapters may depend on and what the client guarantees across a restart; and what the daemon serves now that the CLI and the Unix socket are gone. The trajectory is honest about its weakest point. Every human directive it cites comes from an interactive session that is not checked in, so those locators resolve on one machine and nowhere else. That is recorded as the first source gap, not papered over.
The exchange was retained as a quotation inside an agent turn because the session behind it was thought to be gone. It is on this machine: codex session 019fba0c, the one this work started from. Its stored input history carries the text and an epoch timestamp per user turn. Reading it directly corrects two defects in the quoting turn. It dates the third turn 23:56:46 where the stored entry is 23:54:40Z, and it ends the second quotation at "now that I think about it.", dropping a clause in which the same reply says another separation "might be good" and "we should keep that". Both are recorded at the event rather than silently fixed, and the full turn is retained so a reader can judge the dropped clause instead of taking a note's word for it.
Source gap 2 is closed — the transcript was on this machineI went looking for the unarchived 2026-07-31 session. It is codex session So the three one-versus-two-MCP-server turns are now first-hand events with stored timestamps, not a quotation inside an agent turn. Reading them directly corrected two defects
The full second turn reads:
The quoting agent dropped everything after the first sentence — including a clause saying another separation "might be good" and "we should keep that." It does not change the decision. Read in place, that clause is about v1 trigger semantics being distinct from ledger mechanisms, not about MCP paths. But it is retained in full so you can judge that yourself rather than take my note's word for it, and both defects are recorded at the event rather than quietly fixed. This is the second time this session that a truncated quotation nearly became the record. The first was #958, where I turned a recorded selects into requested. What's still openGap 1 stands, and now covers both sessions. Neither transcript is checked in, so every human locator resolves on your machine and nowhere else. Finding the codex session improved the fidelity of the evidence, not its resolvability — a cold blind reviewer still cannot verify any of it. That leaves the same two options as before: you confirm the excerpts, or the directives get a durable locator. Now that the codex session is in hand, option 2 is stronger than it was — the quotes would be posted with real stored timestamps rather than an agent's recollection of them. |
The ledger's user turns lived only in two local session transcripts, so a reviewer without the maintainer's machine could not resolve a single one. All twelve are now transcribed verbatim into issue #926, comment 5198672021, and the ledger cites that comment as the resolvable locator while the session identifiers keep saying where each turn came from. The gap narrows rather than closing. A reader gets a stable citable text; nobody but the maintainer can confirm the transcription is faithful, because the comment is agent-authored and the sources stay local. The ledger now says exactly that instead of implying more.
The transcription was put to the maintainer as the remaining precondition for the blind gate. The reply is retained literally rather than normalized, together with the request it answers, because a terse reply carries no meaning apart from its prompt and this ledger has already caught two agent quotations that quietly repaired their source. On that reading the excerpts are attested. They are still not independently verifiable, and the gap says so.
PASS on all six questions, with two landing-hygiene blockers the author independently reproduced: packages/client/AGENTS.md advertises a retired CLI and a src/cli directory that does not exist, and three published pages still describe the deleted route. The first is worse than stale. git diff against main shows the src/cli paths were added in this branch, by the same program that deleted the directory, which puts it against accepted 20260721-agents-md-single-source. The reviewer also disclosed, unasked, that the harness injected an author-side task list into its context three times. That is the second consecutive contaminated run and the defect is structural, not the reviewer's. Recorded for the maintainer rather than judged here.
The blind review found four published surfaces still describing the route this program deleted. All four reproduce; the worst is the one I made worse. packages/client/AGENTS.md is a package's agent law, read before the code. It advertised the retired moltzap CLI, and the edit this branch already made to it newly introduced src/cli paths for a directory the same program deleted. That is squarely against accepted 20260721-agents-md-single-source, whose consequence is that a refactor obligates an AGENTS.md truth pass in the same PR. The OpenClaw integration page described the plugin opening its own WebSocket through MoltZapService and MoltZapChannelCore, diagram included; it now describes acquiring a HarnessClient and replying through the turn's own bound reply. architecture.mdx claimed both adapters use MoltZapChannelCore. install-cli.mdx documented installing a binary that no longer ships and was referenced by nothing, so it goes. Sweeping past the review's four found two more of the same class. The MoltZapService use in the user-agent guide stays: that surface is still public and the guide is an application, not a channel adapter. Also the review's two one-liners: the v2-resident ADR citation now says so, and the trajectory's correction note says three defects, having found a third timestamp delta it had not recorded.
Forty commits shipped with nothing in the changelog. Two of them break users directly: a three-field config.json no longer decodes, and the moltzap command is gone. Neither was written down anywhere a user would look. Four entries, in the house voice and leading with what you can now do. The existing Unreleased entries are untouched. packages/client/AGENTS.md gains harnessClientForProfile in its surface table. It is the production entry point for every adapter and had zero documentation coverage — the one surface a reader most needs was the one the package did not name.
…ss-adrs # Conflicts: # packages/simulator/CLAUDE.md # packages/simulator/CLAUDE.md~HEAD
An ADR body hyperlinking a sibling is a convention this repo does not have; every other cross-reference is a plain backticked filename. The docs site resolves links as routes rather than paths, so the invented form was the tree's one broken link. Recorded as a Record changelog row: the Decision Outcome is untouched, which is exactly the point-correction case main's rule describes.
Collapses the 17-PR harness cutover stack into one PR against
main. 43 commits, 206 files, +12,880 / −11,763.Every superseded PR is closed with a pointer here; their review threads stay readable. Nothing is lost — this branch already contained all of them, each having merged its own base.
What this does
OpenClaw and NanoClaw now reach MoltZap only through
HarnessClient. Both adapters used to constructMoltZapServiceandMoltZapChannelCoredirectly, so each owned a network client, a connection lifecycle, and its own presentation assembly. A capability meant to replace all of that already existed with zero production callers — two routes to the same system, no record saying which was current.The blocker was structural, not neglect.
ProfileRecordwas{agentId, apiKey, agentName}andmoltzapddemanded--port, so nothing in production could compute an MCP URL, so nothing could build a real client.A profile is now a slot that carries its own port —
{agentName, mcpPort, agentId?, apiKey?}, identity written as a pair at Registry commit. That one change makesharnessClientForProfile(name)possible, and everything else follows from it.Registration became a tool on the daemon's one
/mcppath. The listener used to bind only afterloadServiceConfigsucceeded, so a slot with no identity couldn't start and registration was unreachable on the surface that needed it. Now the listener binds first and its catalog follows slot state —register+statusbefore commit, six active tools after, same URL throughout. The MCP SDK builds a fresh server per request, so the transition is a phase read, not a rebind.The bespoke CLI, the Unix socket, and generic send are gone. Registration was the CLI's last unique capability.
moltzapdis the package's only binary.Breaking
~/.moltzap/config.json— strict decode, so a three-field profile no longer loads. AddagentNameandmcpPort. Pre-launch, so no shim by decision, not by omission.moltzapcommand — replaced by MCP tools onhttp://127.0.0.1:<mcpPort>/mcp.statusstill answers; it needs an MCP client rather than a shell.Decisions
Three ADRs, admitted here because
packages/client's harness surface had no main-resident authority of any kind. The records that described it are clean-slate ones that say of themselves "Production adoption ismain-owned."Blind teammate review: PASS on all six questions —
docs/decision-evidence/20260805-harness-adrs-d4b22b4d-cold-review.md. Its two landing blockers are fixed in this branch. It also disclosed, unasked, that the harness injected an author-side task list into its context; that is the second consecutive contaminated run and the artifact records it as a maintainer call.Provenance is a source-faithful ledger with five declared source gaps, including that no retained event states a reason for any call and that the restart guarantee has no main-side human source at all. Every cited directive has a durable locator in #926.
Verification
pnpm typecheck/lint/format:check/docs:check/docs:check:drift— all 0layerMemoryinstead oflayerFileSystem→ FAILdist/moltzapd-main.js, nodist/cli/, bin map{"moltzapd"}Superseded, closed with a pointer here
#943 #944 #945 #946 #947 #948 #950 #951 #953 #954 #955 #959 #960 #961 #962 #972
#952 is closed as obsolete, not folded in. It fixed a gateway-binding leak when
setStatusthrows. The symbols it patches —connectGatewayCore,disconnectAndRemove— no longer exist, and it conflicts with HEAD. Its bug is structurally fixed here:Effect.ensuring(finishHarnessClient(...))wraps the exact call that invokessetStatus, under an outerEffect.scoped. Same guarantee, reached by deleting the machinery rather than patching it.Still separate, deliberately
main, currently conflicting. Main has since splitworkspace:lintinto nine sub-targets and retiered the hooks, so check whether it is superseded before rebasing.v2. Different lane;20260729-v2-authority-lives-with-v2keeps them there. docs: state production reply authority as it is, not as a dispatch lease #958 failed its blind gate and needs rework with a different fresh reviewer.Known regressions, recorded not fixed
v2/harness/src/index.tsisexport {}, so there is no second service value to check against. The three client-side configs matched no tests and ranpassWithNoTests— deleted rather than left green.Documentation debt
harnessClientForProfilehas a reference now but no how-to showing the acquire-and-drain shape end to end.