Skip to content

feat: one HarnessClient, one daemon, one MCP path - #974

Open
chughtapan wants to merge 43 commits into
mainfrom
docs/main-owned-harness-adrs
Open

feat: one HarnessClient, one daemon, one MCP path#974
chughtapan wants to merge 43 commits into
mainfrom
docs/main-owned-harness-adrs

Conversation

@chughtapan

@chughtapan chughtapan commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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 construct MoltZapService and MoltZapChannelCore directly, 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. ProfileRecord was {agentId, apiKey, agentName} and moltzapd demanded --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 makes harnessClientForProfile(name) possible, and everything else follows from it.

Registration became a tool on the daemon's one /mcp path. The listener used to bind only after loadServiceConfig succeeded, 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 + status before 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. moltzapd is the package's only binary.

Breaking

  • ~/.moltzap/config.json — strict decode, so a three-field profile no longer loads. Add agentName and mcpPort. Pre-launch, so no shim by decision, not by omission.
  • The moltzap command — replaced by MCP tools on http://127.0.0.1:<mcpPort>/mcp. status still 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 is main-owned."

Blind teammate review: PASS on all six questionsdocs/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 0
  • Architecture gates non-vacuous under five probes, including delete every adapter source → must FAIL
  • Restart proven under two probes — ignore prior checkpoints → FAIL; layerMemory instead of layerFileSystem → FAIL
  • Simulator integration 3/3; the OpenClaw case constructs the plugin with no injected client
  • Packed tarball ships dist/moltzapd-main.js, no dist/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 setStatus throws. 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 invokes setStatus, under an outer Effect.scoped. Same guarantee, reached by deleting the machinery rather than patching it.

Still separate, deliberately

Known regressions, recorded not fixed

Documentation debt

  • harnessClientForProfile has a reference now but no how-to showing the acquire-and-drain shape end to end.
  • The restart guarantee has no reference page; at most once and reply authority is never recreated live only in an ADR.

chughtapan and others added 30 commits August 3, 2026 22:24
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.
@chughtapan

Copy link
Copy Markdown
Owner Author

Source gap 2 is closed — the transcript was on this machine

I went looking for the unarchived 2026-07-31 session. It is codex session 019fba0c-9f1e-7911-9496-45b305a00cb5 — the same session this entire line of work started from, the one named in the first message of the current session. ~/.codex/history.jsonl carries the text and an epoch timestamp for every user turn in it; 133 for that 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

Earlier agent quotation (6dcea6f6) Stored entry
dates the third turn 23:56:46 2026-07-31T23:54:40Z
second quote ends at "…now that I think about it." truncated

The full second turn reads:

i think it's fine to actualy have one MCP server instead of two now that I think about it. The other separation might be good that v1 has clean things for when to trigger, that is separate from the actual ledger mechanisms and the open group v1 and all that. we should keep that.

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 open

Gap 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.
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