Skip to content

hyp status: a probe-less client is unattachable, not unattached - #553

Merged
philcunliffe merged 5 commits into
masterfrom
fix/issue-544
Aug 17, 2026
Merged

hyp status: a probe-less client is unattachable, not unattached#553
philcunliffe merged 5 commits into
masterfrom
fix/issue-544

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Correction (triage, issue #781; the description below was rewritten to match
what merges).
This PR was narrowed after review, and the original body
described the wider shape. For the record:

  • What was originally intended: gate threehyp status surfaces on
    descriptor.attachProbe, the third being the client_attach_missing
    diagnostic, so a probe-less client raised no warning at all; grow the anchor
    #status-derives-by-the-same-gate on LLP 0143;
    amend LLP 0139 #repair-must-be-runnable;
    reword README.md's diagnostics table; and use openclaw as the probe-less
    exemplar throughout.
  • What actually ships, and why it is close to the opposite on the third
    surface:
    the client_attach_missing diagnostic is deliberately not
    gated
    and still fires for a probe-less client.
    LLP 0224 #repair-surface
    (Accepted 2026-08-14, after this branch was opened) made that diagnostic the
    standing incomplete-setup surface for Claude Desktop, and stopped the wizard
    re-offering setup on every reconfigure because that surface exists.
    Gating it here would leave a declined or failed Desktop install with zero
    surfaces, which is a product regression and a silent reversal of a settled
    doc. So the gate was narrowed to the two surfaces that report attach state,
    and the exception is recorded rather than argued away.
  • The rule is now its own document. LLP 0143 is not grown; a new
    LLP 0229 states the general rule (status derives attach state by the
    reconciler's own desired() gate) over the manifest rather than over a
    client roster, with #diagnostic-is-out-of-scope recording the exception.
    llp/0139-...md and README.md are reverted to master byte-for-byte,
    since neither the amendment box nor the reworded table is true any more.
  • The exemplar moved from openclaw to claude-desktop. LLP 0169 (merged
    in OpenClaw two-lane capture (LLP 0172/0173): Lane A attach, Lane B sweep, json_path revival #570) gave OpenClaw its attach_probe back, which closed the OpenClaw half
    of hyp status shows a permanent 'attach openclaw [pending]', 'not attached', and an inert client_attach_missing repair for probe-less clients #544 by itself. claude-desktop is now the only probe-less client
    shipping; openclaw appears in the tests as a second over-suppression guard,
    which is the useful role for the one client that has crossed this gate in both
    directions.

Root cause

The attach reconciler and hyp status derived "is this client an attach target?"
by two different rules.

action_attach.desired() skips any client descriptor with no attach_probe
(src/core/config/action_attach.js) because attach-eligibility requires
reverse-capability and only the probe can reverse it. So for a probe-less client
(claude-desktop per LLP 0115 #no-attach-on-join) perform() never runs and
no marker is ever written, on any host, at any time.

Two surfaces of attach state derived against the attach contract without that
gate, and each read that permanent silence as a permanent negative:

  1. buildClientActionsReport built declaredAttach from every enabled client
    descriptor on a joined host, so no-marker plus declared plus hasCentral
    resolved to pending, forever.
  2. The clients row mapped a probe-less descriptor to { attached: false },
    rendering not attached where nothing is attachable.

The doc comment above buildClientActionsReport already stated the intended
invariant ("pending / n/a are derived for declared targets the reconciler
would act on but has not yet"), so this was a missed gate, not a design choice.

The fix

Gate those two on descriptor.attachProbe, the same rule desired() uses, the
way readAttachPolicy / readBackfillPolicy already keep the two sides from
disagreeing about on_join:

  • client actions: a probe-less declared target carries inert: true and
    derives n/a, joining on_join: false and non-joined as the third "the
    reconciler is a no-op for this target" case. Not dropped from the report: a
    vanished row is its own wrong answer.
  • clients row: ClientAttachReport gains a requiredattachable: boolean,
    set from !!descriptor.attachProbe and never from a probe result. The text
    surface prints attach n/a instead of not attached; --json carries
    attachable beside the unchangedattached boolean, so a consumer pinning
    attached does not break and one that wants to distinguish "no marker" from
    "no such thing as a marker" reads the new key.

The gate keys on the descriptor, never on the probe's outcome: a probe that
resolves and finds no marker, and a probe that errors, are both
attachable: true and keep the full not attached / pending /
client_attach_missing trio. An unresolvable probe is a real negative a user can
act on; the absence of a probe is not.

client_attach_missing is untouched and still fires, including for
claude-desktop, whose repair is the runnable hyp claude-desktop install
(LLP 0139 #repair-must-be-runnable stands, and its configure_command lookup is
still reached). LLP 0224 reached that position with its eyes open: it records as a
known limitation that Desktop declares no probe, so the warning keys on the
enabled plugin alone and clears only via the reconciler, never by observing the
plist. The follow-up 0224 names, a plist-reading attach_probe, is the one thing
that should retire this exception, and it retires it by construction: Desktop
becomes attachable and rejoins the rule with no further decision.

A probe-less client is unattachable, not unattached. This is deliberately a
gate, not a new signal: nothing here reports whether a probe-less client is in
fact routing. That stays each adapter's own question, answered by its own command
(Desktop's is hyp claude-desktop verify).

Docs landed in the same commit

  • LLP 0229 (new, Accepted): the rule, stated over the manifest rather than
    over a client roster, since which clients are probe-less changes under us.
    Anchors: #status-derives-by-the-same-gate, #diagnostic-is-out-of-scope,
    #unattachable-not-unattached, #keys-on-the-descriptor-not-the-probe-result,
    #the-rule-outlives-its-clients.
  • LLP 0044 §Status surface: n/a now also covers "a client desired() would
    never name", with the reason pending must be derived by desired()'s own
    rule, plus an Extended-by: forward-ref to LLP 0229 beside the existing
    LLP 0086 entry.
  • docs/ACCEPTANCE.md: three passages in the openclaw_two_lane_capture
    procedure were false in both directions after LLP 0169, and are corrected.
    The Requires bullet no longer says steps 1 and 7 need this PR merged
    (openclaw is probed again, so its row is derived from disk like any other
    probed client); step 1's pass condition is re-attributed to LLP 0169; and the
    If it fails entry now names the check that actually diagnoses it, that the
    warning is probe-derived so the entry is missing from the OPENCLAW_HOME the
    probe reads. The conflicted section itself was taken from master wholesale.
  • Not changed:llp/0139-...md and README.md, both byte-identical to
    master. LLP 0143 is left alone (it is Superseded by LLP 0169 and is referenced
    from LLP 0229 as where this observation was first written down).

Test evidence

New: test/core/status-probeless-client.test.js, 3 tests. A joined host whose
central layer enables @hypaware/claude-desktop (probe-less) plus @hypaware/claude
(json probe) and @hypaware/openclaw (json_path probe, restored by LLP 0169);
the two probed clients are over-suppression guards in every test.

  1. a probe-less client on a joined host renders attach n/a, never a permanent pending (#544)
  2. the gate stops at attach state: client_attach_missing still fires for a probe-less client (#544) - asserts the warning is present and that its
    repair is ['hyp claude-desktop install'], so the exception is pinned in the
    same file as the rule and cannot be closed by accident along with the other
    two surfaces.
  3. a probe-less client row reads attach n/a, not "not attached" (#544) - text
    and --json, including that attached keeps its type and its place on every
    row.

Mutation-checked in both directions at the reviewed head: reverting the gate
(attachable = true, inert = false) and maximal over-suppression
(attachable = false, inert = true) each fail. The diagnostic-pin test passes
under both, which is correct, since the diagnostic is deliberately outside the
gate.

Also updated: test/core/status-client-error.test.js pins attachable: true on
the row whose probe was declared and then failed to resolve, with a comment
saying why an unresolvable probe is not the same as no probe; and three
ClientAttachReport construction sites in test/core/init-configured-entry.test.js
gained the now-required attachable, which is the typecheck enforcement
LLP 0229 promises.

Rendered status on a joined host, at this head

Real bundled catalog, no client settings files:

 clients:
- claude [configured, not attached] [central · locked]
- openclaw [configured, not attached] [central · locked]
- claude-desktop [configured, attach n/a] [central · locked]
client actions:
- attach claude [pending]
- attach claude-desktop [n/a]
- attach openclaw [pending]
diagnostics:
[WARN ] client_attach_missing: '@hypaware/claude' ... run 'hyp attach --client claude'
[WARN ] client_attach_missing: '@hypaware/openclaw' ... run 'hyp attach --client openclaw'
[WARN ] client_attach_missing: '@hypaware/claude-desktop' ... run 'hyp claude-desktop install'

Both probed clients keep their full trio; the probe-less one gets n/a on both
state surfaces and keeps its LLP 0224 prompt.

Suite

  • CI green on this exact head: test and typecheck on Node 22 and 24, plus the
    duplicate-numbers check. Merge state CLEAN.
  • Locally, against an origin/master baseline in a sibling worktree with the
    same node_modules: master 4021 pass / 23 fail / 1 skip, this head 4024 pass /
    23 fail / 1 skip, and the sorted failure sets are identical line for line (2
    report-render* missing marked, 20 parquet/hyparquet skew, 1 duplicate-0223,
    all inherited from the environment and none from this branch).
  • test/core/llp-ref-hygiene.test.js passes 11/11 at this head, so all eight
    @refs repointed to LLP 0229 resolve to live anchors, including the new
    #diagnostic-is-out-of-scope annotation on the ungated diagnostic.

Refs: LLP 0044, LLP 0115, LLP 0139, LLP 0143, LLP 0169, LLP 0224, LLP 0229,
#444, #445, PR #510, PR #570.

Fixes#544

testand others added 2 commits July 31, 2026 22:00
`action_attach.desired()` skips a client descriptor with no `attach_probe`
(attach must be reversible, and only the probe can reverse it), so
`perform()` never runs and no attach marker is ever written for openclaw or
claude-desktop. Three `hyp status` surfaces derived against the attach
contract without that gate, and each turned that permanent silence into a
permanent negative on a joined host:
clients:
- openclaw [configured, not attached] [local]
client actions:
- attach openclaw [pending]
diagnostics:
[WARN ] client_attach_missing: '@hypaware/openclaw' is enabled but
openclaw settings show no HypAware marker - run 'hyp attach --client
openclaw'
None of the three can ever resolve, and the printed repair resolves the
adapter's deliberate LLP 0143 no-op, so running it clears nothing.
Gate all three on `descriptor.attachProbe`, the same rule `desired()` uses:
- the client-actions row derives `n/a` (via a new `inert` flag on the
declared-attach entry) rather than a permanent `pending`;
- `ClientAttachReport` gains a required `attachable`, and the clients row
prints `attach n/a` instead of `not attached`; `--json` carries
`attachable` beside the unchanged `attached` boolean;
- `client_attach_missing` no longer fires for a probe-less client.
Clients that do declare a probe are untouched: claude with no marker still
reports `pending`, `not attached`, and the warning.
LLP 0143 grows #status-derives-by-the-same-gate for the rule; LLP 0044's
status-surface vocabulary and LLP 0139 #repair-must-be-runnable are amended
to match (Desktop's attach-missing warning fired identically before consent,
after a decline, and after a successful install, so withdrawing it loses no
signal; `hyp claude-desktop verify` is the check that can answer it).
Co-Authored-By: Claude <noreply@anthropic.com>
The probe-less gate removed `client_attach_missing` for openclaw, but
docs/ACCEPTANCE.md step 1 of the OpenClaw flow still told the release
tester to expect that warning, so its (correct) absence would read as a
regression. Restate the expectation as the pass condition.
The `@ref LLP 0139#repair-must-be-runnable` gloss claimed the repair
names an adapterless client's `configure_command`; after the gate the
only adapterless client (claude-desktop) never reaches that code, and no
shipped picker row takes the branch. Re-glossed to what the code does,
with LLP 0139's amendment box recording the same.
README's diagnostics table said `client_attach_missing` fires for any
enabled client plugin with no marker; it is now probe-gated.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral review round - PR #553 @ daca753

Verdict: findings (3 actionable, all fixed and pushed). The core change is correct: the gate keys strictly on descriptor.attachProbe, matches desired()'s own rule, and cannot swallow a real negative. What it missed is the paper trail: a release-gate doc and a user-facing doc still describe the behaviour this PR deliberately removes, and one @ref gloss now describes a code path no shipped plugin can reach.

New head after fixes: 03f6d4757dab872a1c73fce19dbf32807cb21d6d.


What I verified against the code (not the prose)

Over-suppression - clean.src/core/daemon/status.js:600 sets attachable = !!descriptor.attachProbe, and src/core/plugin_catalog.js:77 only ever assigns descriptor.attachProbe from a truthy client.attach_probe, so the flag is exactly the negation of action_attach.js:114's if (!descriptor.attachProbe) continue. Nothing about the result of a probe feeds the gate:

  • probed client, no settings file → attachable: true, probe.attached: falsepending / not attached / client_attach_missing all survive (test/core/status-probeless-client.test.js asserts all three for claude in every one of its three cases - the over-suppression guard is real, not decorative).
  • probed client whose probe resolves and finds no marker → identical path; probeClientAttachFromDescriptor returns { attached: false } and the gate never sees it.
  • probed client whose probe errors{ attached: false, error }, still configured && attachable && !attached → warns. test/core/status-client-error.test.js:67 pins attachable: true for exactly that row, with a comment saying why an unresolvable probe is not the same as no probe. Good distinction to have written down.
  • client_attach_stale (status.js:637) is on the else if and requires probe.attached, so it is untouched in both directions.

attachable as a required field - clean. There is exactly one construction site (src/core/daemon/status.js:604-613); grep -rn ClientAttachReport finds no other producer. npm run typecheck is clean, so a missed site would have failed. --json is genuinely non-breaking: renderStatusJson (src/core/commands/status.js:187) still emits attached as a boolean for every row, in place, with attachable added beside it.

Nit, not fixed: the renderers read the required field defensively (c.attachable !== false at status.js:186, c.attachable === false at :367). With a single in-process producer and a required type there is no cross-version report to defend against, and the defensive form would silently degrade a future missing field to the old wrong not attached instead of failing typecheck. Harmless today; flagging so it is a choice rather than an accident.

LLP 0139 / Claude Desktop - the judgement call holds. I checked the claim rather than taking it: hypaware-core/plugins-workspace/claude-desktop/hypaware.plugin.json declares contributes.client with noattach_probe, so probe was hard-coded { attached: false } and the warning fired on configured alone. Per LLP 0139's own Consequences the plugins stay in config after a decline, so configured is true before consent, after a decline, and after a successful install alike. The warning genuinely never varied, so removing it removes no information. And hyp claude-desktop verify is real and substantive, not a hand-wave: hypaware-core/plugins-workspace/claude-desktop/src/verify.js:28checkInstallState reads plist-present, plist-up-to-date and residue-cleared and drives the exit code. Claim accepted.

Residual gap worth a follow-up issue (not blocking, not fixed here): nothing in hyp status now cues a user to run that verify, so a declined Desktop install is silent on the surface people actually look at. checkInstallState's own doc comment already advertises itself as shareable with "any future caller (e.g. a status surface)", so the hook exists. Wiring a plugin-specific check into core status is exactly the kind of special-casing LLP 0143 leaves open as a registry-derived signal, so it belongs in that follow-up, not in this PR.

backfill @hypaware/openclaw [pending] - correct and untouched.inert is only ever set on declaredAttach (status.js:913); the backfill declared map (status.js:866) never carries the key, so decl.inert === true is strictly false for every backfill row. And the target is real: hypaware-core/plugins-workspace/openclaw/src/index.js:152 calls ctx.backfills.register(...) under an @ref LLP 0161#backfill-provider. The PR's claim checks out.

Conventions. No U+2014 anywhere in the diff; no semicolons outside prose; the new test's @import specifier (../../src/core/daemon/types.js) is correctly repo-root-anchored; test/core/llp-ref-hygiene.test.js passes, so #status-derives-by-the-same-gate and #decision both resolve.


Findings

1. docs/ACCEPTANCE.md:302 - release gate still asserts the removed warning. Severity: medium. FIXED.

Step 1 of the OpenClaw acceptance flow read:

Expect hyp status to also carry a client_attach_missing warning for openclaw, telling you to run hyp attach openclaw.

That warning is precisely what this PR deletes. A human running the acceptance gate would find it absent and read the fix as a regression - or, worse, note-and-move-on and leave a stale expectation in a manual release gate that nothing else checks. CLAUDE.md's "living docs" rule wants this in the same commit as the code. Rewritten so the absence is the pass condition, with the clients row ([configured, attach n/a]), the joined-host attach openclaw [n/a] action, the unrelated backfill @hypaware/openclaw [pending] row, and a link to LLP 0143 #status-derives-by-the-same-gate.

2. src/core/daemon/status.js:626 - the @ref LLP 0139 gloss is now false. Severity: low. FIXED.

The gloss read "an adapterless client's attach-missing repair names its configure_command". After this PR the adapterless client can never reach that line. I enumerated every shipped manifest: claude-desktop is the only picker row declaring a configure_command, and it is probe-less, so the configureCommand ? ... : ... truthy branch is unreachable for the entire shipped plugin set. (openclaw is the inverse case - probe-less but doesregisterClient, so "adapterless" and "probe-less" are not synonyms here.) That is the "keep refs honest" rule biting: the annotation asserts the opposite of what the amendment box in LLP 0139 says. Re-glossed to what the code does, and the surrounding comment now states plainly that no shipping plugin takes the branch and why the lookup is kept anyway. LLP 0139's amendment box records the same, so doc and code agree.

3. README.md:381 - diagnostics table overstates when client_attach_missing fires. Severity: low. FIXED.

Read "a client plugin is enabled but its settings file shows no HypAware marker", which is now false for openclaw and claude-desktop (enabled, no marker, no warning). Now "a client plugin with an attach probe is enabled but shows no HypAware marker". Column padding preserved.

Noted, not actionable

  • desired() has a second skip status cannot mirror: if (!clients.getClient(descriptor.name)) continue. A descriptor whose plugin registers no runtime client would still derive a permanent pending. Pre-existing, unreachable in the shipped set (the only adapterless client is also probe-less and now gated), and hyp status deliberately does not activate plugins - so it cannot be closed the same way. Mentioning it so the "status derives by the same gate" rule is not read as fully closed.
  • A pre-LLP-0109 done attach marker for openclaw would still render attach openclaw [done] while its clients row says attach n/a. Marker-derived, out of this gate's path, and arguably the honest reading of both facts.

Checks

  • npm test on daca753: 3273 pass / 0 fail / 1 skipped (the pre-existing zstd guard) - matches the PR's claim exactly.
  • npm test after the fixes: 3273 pass / 0 fail / 1 skipped.
  • npm run typecheck before and after: clean.
  • test/core/llp-ref-hygiene.test.js: 10/10 pass after the re-gloss.

Fixes verified landed (committed tree, daca75303f6d47)

FileBeforeAfter
docs/ACCEPTANCE.md:302Expect `hyp status` to also carry a `client_attach_missing` warning forExpect `hyp status` to carry **no** `client_attach_missing` warning for
src/core/daemon/status.js:626 gloss an adapterless client's attach-missing repair names its configure_command:629 gloss the repair is the client's own picker configure_command when it declares one
README.md:381a client plugin is enabled but its settings file shows no HypAware markera client plugin with an attach probe is enabled but shows no HypAware marker
llp/0139-...decision.md:220(absent)no plugin shipping today takes its first branch

Each row was read back with git show <sha>:<path>, not inferred from a green suite (LLP 0002).

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral review round 2 - PR #553 @ 03f6d47

Verdict: clean. Nothing actionable; nothing pushed; head is unchanged at 03f6d47.

This round reviews the round-1 fixes themselves (daca753..03f6d47: README.md, docs/ACCEPTANCE.md, llp/0139-..., src/core/daemon/status.js). I re-derived the high-risk properties from the code and from a running binary rather than from round 1's write-up.


1. The status.js line in the delta is provably comment-only

Round 1 called it a re-gloss; the diff shows 21 changed lines, so I checked rather than assumed. Every +/- line in git diff daca753 03f6d47 -- src/core/daemon/status.js starts with //, and stripping line + block comments from both blobs and comparing gives byte-identical source (27965 bytes each). No behaviour changed between the two heads. Reviewed as a comment change.

The re-gloss is honest against the code it sits on. @ref LLP 0139#repair-must-be-runnable at src/core/daemon/status.js:629 now reads "the repair is the client's own picker configure_command when it declares one, never a generic attach that would answer unknown client", which is exactly :630-631. I re-verified the unreachability claim independently instead of trusting it: picker rows come only from plugin manifests (src/core/plugin_catalog.js:90-106 reads entry.manifest.contributes.picker; there is no core-side picker table), and grep -rn configure_command --include=*.json over the whole tree returns exactly one hit, hypaware-core/plugins-workspace/claude-desktop/hypaware.plugin.json:30, on a probe-less plugin. The configureCommand truthy branch is therefore dead for the shipped set, and the comment now says so.

2. Over-suppression, re-verified empirically at this head

I drove collectHypAwareStatus + both renderers against a joined host (central seed enabling @hypaware/central, ai-gateway, openclaw, claude, codex) across four homes, with the real bundled catalog and no stubbing:

homeclaude / codex (probed)openclaw / claude-desktop (probe-less)
A no settings filesattachable:true, [configured, not attached], attach [pending], client_attach_missing firedattachable:false, [configured, attach n/a], attach [n/a], no warning
B settings file present, no marker ({"theme":"dark"}, [model_providers.other])identical to A - all three negatives survivesame
C probe errors (corrupt JSON; EISDIR on the TOML)attachable:truewitherror carried to text and --json; not attached, pending, client_attach_missing all still firedsame
D marker presentattachable:true, attached:true, [configured, attached], no warningsame

So a probed client that is genuinely unattached still reports the full trio, including the two cases the brief singled out (probe reads back no marker; probe throws). An unresolvable/erroring probe is not collapsed into the probe-less bucket - attachable keys strictly on !!descriptor.attachProbe (status.js:600), never on the probe result.

I also enumerated contributes.client across every bundled manifest, so the comment's parenthetical is exhaustive rather than illustrative: probe-less = {claude-desktop, openclaw}, probed = {claude, codex}.

Mutation testing (each mutation applied to status.js, targeted tests run, then reverted):

mutationresult
drop attachable from the construction sitenpm run typecheck fails: TS2345 ... Property 'attachable' is missing ... but required in type 'ClientAttachReport' at status.js(604,18)
const attachable = true (revert the gate)2 failures in status-probeless-client.test.js
const attachable = false (maximal over-suppression)2 failures - the over-suppression guards are load-bearing, not decorative
const inert = false1 failure (attach openclaw back to permanent pending)
const inert = true1 failure (attach claude loses its real pending)

Both directions are pinned. client_attach_stale (status.js:638-644) sits on the else if and requires probe.attached, so it is unreachable for a probe-less client and untouched for a probed one.

3. The required attachable field

grep -rn ClientAttachReport finds one production construction site (src/core/daemon/status.js:604) and one test-side one (test/core/status-client-error.test.js:67), both of which set it; the typecheck mutation above proves a missed site cannot land silently.

--json consumers are unbroken: renderStatusJson (src/core/commands/status.js:180-190) still emits attached as a boolean, in place, for every row - confirmed in all four runtime scenarios above, including the probe-less rows where it stays false exactly as before. I checked every reader of report.clients: wizard/index.js:294 (filter(c => c.attached)), wizard/fork.js:269 and commands/status.js:58 (both filter on configured), and hypaware-core/smoke/flows/status_diagnostics.js:298 (pins claude, a probed client). None changes answer.

On round 1's open nit (c.attachable !== false at commands/status.js:186, === false at :363): I checked whether a cross-version report could actually reach the renderers, since that would make the defensive form correct rather than redundant. It cannot - report.clients is built in-process at status.js:586-613; only daemon/sources come from status.json. So the defensive read protects nothing today, but it is consistent in both directions (a missing field degrades to the pre-PR reading in both renderers) and breaks nothing. Concurring with round 1: noted, not actionable, not worth the churn.

4. LLP 0139 amendment, and 0115 / 0143 consistency

The amendment box (llp/0139-desktop-picker-consent.decision.md:212-224) still reads correctly: the rule stands, the lookup stays, the warning stops firing for probe-less clients, and the "fired identically before consent / after decline / after success" reasoning is intact and is what carries the claim that no signal is lost. Its new sentence ("Claude Desktop was the only picker row declaring a configure_command, no plugin shipping today takes its first branch") is the claim I verified independently in section 1 - it holds, and it is correctly scoped to today's shipped set rather than stated as a permanent property.

No contradiction with the neighbours. LLP 0143's Consequences (:120-127) tell the same story from the other side and name hyp claude-desktop verify as the real check; the amendment does not restate anything 0143 denies. LLP 0115 #no-attach-on-join (:98-102) asserts only that Desktop registers no attach_probe and exposes explicit commands instead - it makes no claim about hyp status attach state at all (grepping 0115 for hyp status / attach_missing / pending / attached returns nothing), so there is nothing for the amendment to contradict. llp/0139:46-52 still describes the old repair, but it is Context, in the past tense, narrating what was observed at the time - correct as history.

5. The two other doc fixes, re-checked against a running binary

docs/ACCEPTANCE.md:302-313: every renderable claim matches what I actually printed - the clients row is - openclaw [configured, attach n/a], the action row is attach openclaw [n/a], backfill @hypaware/openclaw [pending] is present beside it, and no client_attach_missing mentions openclaw. client actions: is the real section header (src/core/commands/status.js:471), hyp attach openclaw is a real positional form (core_commands.js:276), and the LLP 0143 anchor link resolves. The other client_attach_missing mention in that file (:152) is in the Codex Desktop flow, a probed client, so it is still correct and was rightly left alone.

README.md:381: accurate now, and the table's column padding is intact ([39][84][74], matching every other body row).

6. House style

No U+2014 anywhere in the diff or in any touched file. The status.js delta is comments only, so the no-semicolon rule is untouched; no @typedef, no inline import('...'), and the new test's @import specifier is root-anchored. test/core/llp-ref-hygiene.test.js: 10/10 pass, so both #status-derives-by-the-same-gate and #repair-must-be-runnable resolve after the re-gloss.


Checks

  • npm test @ 03f6d47: 3273 pass / 0 fail / 1 skipped.
  • npm run typecheck @ 03f6d47: clean.
  • Smokes: status_diagnosticsok, client_attach_idempotentok.
  • client_attach_on_join, claude_attach_detach and cli_bundled_plugins_activated FAIL here - but they fail identically on origin/master in a second clean worktree (client_attach_on_join reproduced 3/3 on master with the same message, "the attach.claude marker timestamp is unchanged"). Pre-existing, unrelated to this PR, and not counted against it. Flagging only so the failures are not mistaken for a regression by whoever runs them next.

Findings

None actionable. Nothing pushed; head remains 03f6d4757dab872a1c73fce19dbf32807cb21d6d. No findings are left open.

Previously adjudicated and deliberately not reopened: the backfill @hypaware/openclaw [pending] row, LLP 0143's open question about a registry-derived attach signal, and the residual that nothing in hyp status cues claude-desktop verify after a declined install. The accepted LLP 0167/0169 RFC that will give OpenClaw a real attach surface reverses this PR's premise for that one client in a future change set; it is not a defect here.

@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 31, 2026
philcunliffe added a commit that referenced this pull request Aug 3, 2026
…p, json_path revival (#570)
* Design: OpenClaw two-lane capture (LLP 0172)
* Plan: OpenClaw two-lane capture executable tasks (LLP 0173)
* openclaw config: validate sweep_cron and quiesce_ms in backfill section
validateBackfillSection now accepts sweep_cron (a 5-field cron
expression, validated with core's shared isCronExpression grammar so a
malformed schedule is rejected the same way a sink's config.schedule
is) and quiesce_ms (non-negative integer) alongside the existing
on_join/window_days keys, added together so the unknown-key rejection
loop recognizes both from the same merge.
Task-Id: T6
* Restore json_path attach-probe format, add sweep field to BackfillContribution
hypaware-plugin-kernel-types.d.ts: PluginAttachProbeManifest.format regains
'json_path' (removed by LLP 0143 after #212's orphaning danger), plus the
new container_path, provider_keys, and cache_glob fields the format needs,
reusing the existing marker_header. The comment at the removal site is
revised, not deleted: it now explains that the runtime support LLP 0173
T2/T3 add closes the gap #212 warned about, so a manifest can only declare
this format once both sides exist.
BackfillContribution gains an optional sweep?: { cron: string } field for
the daemon's periodic sweep (LLP 0173 T9), absent-by-default for every
existing contribution.
Both additions are purely additive: npm run typecheck (tsc --noEmit over
the whole tree, including hypaware-core/plugins-workspace/openclaw) and
npm test pass unchanged, proving no existing consumer's typecheck shifted.
Task-Id: T1
* OpenClaw attach writes the two provider overrides, refusing to merge
LLP 0169 reverses LLP 0152's premise: there is a real, reversible
settings write for OpenClaw again, so the adapter's honest no-op
attach() has nothing left to be honest about.
New hypaware-core/plugins-workspace/openclaw/src/attach.js exports
createOpenclawAttach({homeDir, env, fs}), mirroring the Claude adapter's
attach() shape (same AiGatewayClientAttachContext, same
withSpan('client.attach', ...), same dry-run branch). It reads
openclaw.json through the one core settings-path seam (so $OPENCLAW_HOME
relocation resolves the same file the manifest's probe will), refuses
with {status:'failed', reason} when models.providers.anthropic or
.openai is already there, and otherwise writes both entries whole from
attachCtx.endpoint: bare origin for anthropic, endpoint + '/v1' for
openai, each with the x-hypaware-upstream marker header and the
mandatory empty models array.
The refusal check runs entirely before the single atomicWriteFile, so
there is no partial write to roll back, and it returns rather than
throws, which is the whole mechanism by which a refuse during
attach-on-join warns instead of failing the join. Every other key in
openclaw.json is carried through by reference. Both output modes end
with the 'openclaw gateway restart' instruction, since a --json caller
is as blocked on the restart as a human is.
index.js drops the no-op body, STEERING_PLUGIN_NAME,
ROUTING_OWNED_BY_STEERING_PLUGIN_MESSAGE and the @ref LLP 0143#decision
block, and wires activate() to the new effect. The registered attach()
keeps the kernel's Promise<void> contract, so the outcome object is
dropped there on purpose: both callers already derive success from a
throw plus the one-line JSON the effect writes.
Tests cover the refusal (including that the file is byte-identical
after it, and that it never throws), the exact two-entry shape with the
bare-origin/+v1 asymmetry, key preservation, the restart instruction on
both output modes, dry-run, $OPENCLAW_HOME, and the missing/malformed
config hard failures. The two attach tests in
openclaw-client-registration.test.js are retargeted at the new behavior
so the suite stays green; T10 owns that file's fuller rewrite.
Task-Id: T4
* daemon/status.js: restore json_path attach-probe read branch
Restores the probe.format === 'json_path' read branch removed by LLP
0143 / PR #510, parallel to the existing json/toml branches in
probeClientAttachFromDescriptor: navigate container_path + each
provider_keys entry, read headers[marker_header], and report attached
when it equals the expected provider key for at least one configured
key. Pure read, no ownership/backup concerns.
Task-Id: T3
* openclaw backfill: quiesce window skips recently-modified session files
listSessionFiles(agentsDir) gains an optional quiesceBeforeMs cutoff, and
runOpenclawBackfill() computes it once per run as Date.now() - quiesceMs.
quiesceMs resolves from the plugin's own config.backfill.quiesce_ms,
defaulting to 180000ms (QUERY_FLUSH_DEBOUNCE_MS plus a one-minute margin),
so a run never imports a session file OpenClaw is still mid-write on, or a
settlement pass is still mid-flush against. Composes with the existing
effectiveProviders/partitionByBackend CLI-backend logic (R10) rather than
replacing it.
Task-Id: T8
* Lane B sweep metadata: openclaw backfill provider + narrowed runner ctx
createOpenclawBackfillProvider now populates the contribution's opt-in
sweep field from config.backfill?.sweep_cron, defaulting to every 5
minutes when absent (LLP 0172#lane-b-sweep, R7).
src/core/commands/backfill.js's runBackfillProvider, runProvider, and
resolveOwnersForRun now declare a new BackfillRunnerContext interface
(env, config, storage, backfills, backfillMaterializers) instead of the
full CommandRunContext, a pure structural narrowing so the daemon-side
sweep driver (LLP 0173 T9) can build one without assembling registries
it never uses. Existing hyp backfill CLI-path and onboarding-finale call
sites keep typechecking and passing unchanged.
Task-Id: T7
* json_path detach returns: ownership, backup-not-discard, best-effort cache purge
LLP 0143 pulled the `json_path` branch out of the disk-driven undo because
LLP 0152 left nothing on disk for it to reverse. LLP 0169 reverses that
premise, so the branch comes back - reshaped for the two provider entries
attach now writes, not the single shadow provider of the old design.
`detachJsonPathProviders` judges each `provider_keys` entry on what it points
at, because this format has no HypAware-owned marker to replay: its undo
record IS the entry. Ours (the gateway's own `baseUrl`, in either the bare
origin or `+ /v1` spelling, with `marker_header` naming its own key, in the
shape attach produces) is deleted. Anything else present at our key is backed
up to a `_hypaware_detach_backup.<key>` sibling inside the same container
before the live key goes, following the `prev_malformed` precedent of LLP
0163: never discard a value HypAware did not write. That closes the
json/toml-vs-json_path asymmetry LLP 0163 flagged as worth its own look,
converging on the outcome without the top-level marker key LLP 0163 correctly
ruled out for this client.
The derived caches (`cache_glob`, relative to the client's config home) are
then purged of the same keys, best-effort: they do not self-heal, so a
partial purge beats none, and one unreadable cache file is logged and skipped
rather than failing a detach whose settings half already landed.
An unknown gateway base URL has no safe default here - guessing either way
silently deletes a foreign value or reports a finished detach over a client
still routed at a dead port - so it refuses (`EXPECTED_BASE_URL_UNKNOWN`).
Both callers degrade correctly: `reverse()` keeps the marker, `hyp detach`
prints the reason.
Both real callers thread the base URL: `detachClientViaCore` resolves it
through the same three rungs manual attach already walks (live
`localEndpoint()`, configured `listen`, the daemon's persisted bound port),
every one optional so detach keeps working with the gateway capability
unloaded; `reverse()` passes the `ctx.endpoint` `perform()` attached with.
Task-Id: T2
* OpenClaw manifest: restore json_path attach_probe, retire steering-plugin copy
hypaware.plugin.json gains contributes.client.attach_probe (design 1.4:
json_path format, .openclaw/openclaw.json settings file, models.providers
container, anthropic/openai provider keys, x-hypaware-upstream marker
header, agents/*/agent/models.json cache glob). description and
picker[0].summary drop every @hypaware/openclaw-steering-plugin reference
and state the two capture tiers directly: live gateway capture once
attached, plus a periodic transcript sweep.
Claude's manifest gains the LLP 0167#onboarding line naming the
claude-cli/<model> case OpenClaw's CLI-backend exclusion produces, so a
user knows which picker entry an OpenClaw-routed Claude Code session
belongs to.
projector.js's UPSTREAM_HEADER comment no longer credits the deleted
steering plugin; it now describes the config-override write attach()
itself makes.
Adds a manifest-shape test asserting attach_probe parses to the exact
design-1.4 fields and that description/summary no longer match
/openclaw-steering-plugin/. Updates the one existing assertion this
manifest change makes false (the R7 "no attach_probe" descriptor check)
to the restored json_path shape; the remaining behavioral rewrites of
that test file are T10's scope.
Task-Id: T5
* Delete openclaw-steering-plugin/ (LLP 0172 Section 5, R9)
Removes openclaw-steering-plugin/ in full (src/, test/, package.json,
openclaw.plugin.json, .d.ts files) and test/plugins/openclaw-steering-plugin.test.js:
Lane A's config-override entries make OpenClaw route to the gateway on
its own, so the credential-borrowing runtime auth shim, the live
wire-parity mirror, the steering decision logic, the live warning
ledger, and the gateway endpoint resolver that fed them no longer have
a purpose.
Also drops tsconfig.json's stray "openclaw-steering-plugin" include
entry (line 19), not named in the design's own deletion inventory but
found verifying the deletion against the real tree; leaving it would
have left a dead include path.
docs/ACCEPTANCE.md and test/plugins/openclaw-manifest.test.js still
mention the package name (an onboarding rewrite reserved for T13, and
a T5 regression test asserting the manifest no longer references it,
respectively); neither is in this task's file list.
Task-Id: T11
* openclaw-client-registration.test.js: finish T4/T5's deferred rewrite
T4 and T5 already retargeted the two attach() no-op tests and the
descriptor's attach_probe assertion to keep the suite green while they
landed; this closes the two pieces both left for T10:
- The "honest no-op" detach test's comment still credited the retired
R7 no-attach_probe guard. Since T5 restored the manifest's json_path
attach_probe, the no-op this test actually observes on a fresh temp
HOME is detachClientFromDisk's absent-settings-file guard instead.
Corrected the comment to say so.
- Added a companion case that stages a real openclaw.json via the
actual createOpenclawAttach() effect, then drives the same hyp
detach CLI entry point (buildClientDescriptorMap's real manifest
descriptor -> detachClientFromDisk's json_path branch) and asserts
the ownership-based detachJsonPathProviders (T2) actually fires:
changed:true, the removed baseUrl, and both provider entries gone
from the file while everything else in it is untouched.
Task-Id: T10
* Daemon sweep driver: run sweep-bearing backfill providers on the tick loop
New `src/core/daemon/backfill_sweep.js`. `createBackfillSweepDriver({backfills,
backfillMaterializers, env, config, storage})`'s `tick({now})` walks
`backfills.list()`, skips any contribution with no `sweep` field or a cron that
is not due (`cronMatches`, the sink driver's own due-check), and fires
`runBackfillProvider` per due contribution with a `sweep-<name>-<now>` dev run
id. Runs are fired unblocked: `tick()` resolves once each run has been started,
never once one finishes, so a provider's transcript scan cannot stall the sink
snapshots, the source-detail refresh, or `persist()` later in the same tick. Both
settlements are handled, so a failing run is a logged `backfill.sweep_failed`
record (component `openclaw`, operation `backfill.sweep`, `error_kind`) rather
than an unhandled rejection that would take the daemon process down. A malformed
`sweep.cron` is logged and treated as not due rather than thrown, so one
provider's bad metadata cannot skip the rest of the list.
`runtime.js`'s `runTick()` calls `await sweepDriver.tick({now})` directly after
the existing sink-driver tick, riding the same `DEFAULT_TICK_INTERVAL_MS`
60-second loop: a `*/5 * * * *` schedule only needs a due-check once a minute, so
this opens no second timer to start, drain, and account for at shutdown.
Also repairs the typecheck this task's branch point already failed: T7's
`sweep: { cron: opts.config?.backfill?.sweep_cron ?? ... }` does not compile,
because the plugin's config slice is a `JsonObject` and every step below its root
is a `JsonValue`. Read through a `resolveSweepCron` mirroring the
`resolveQuiesceMs` helper already sitting beside it; behavior is unchanged.
Externally blocked for real capture: until PR #552 (issue #543) merges, the
LLP 0158 reader still reads OpenClaw v3 fields flat, so a sweep projects nothing
from a real transcript. These tests passing is not evidence that it does.
Tests: the due-check fires only sweep-bearing, cron-due contributions and builds
the narrowed `BackfillRunnerContext` from the daemon's own runtime fields; a
rejected run neither throws out of `tick()` nor lands as an unhandled rejection,
and a never-settling run does not block the tick. A separate wiring test boots a
real daemon with a fixture plugin whose contribution opts into a sweep and proves
the tick actually runs it, which no unit test of the driver can show.
Task-Id: T9
* docs/ACCEPTANCE.md: rewrite openclaw_capture for two-lane capture
Drops the steering-plugin link/enable setup and the
before_model_resolve/hooks.allowConversationAccess version-gate language
(the plugin is deleted; Lane A depends on no OpenClaw hook API). Adds a
setup step running `hyp attach --client openclaw` followed by the restart
instruction it prints, a sweep step (detach to strip the live route,
confirm the row is absent, confirm it lands within one sweep interval past
the quiesce window), and a zero-duplicate assertion (a turn both lanes
observe resolves to exactly one row for its part_id, proven against the
daemon's own scheduler rather than a manual `hyp backfill`). Re-confirms
LLP 0167#verify-results items 1, 3, and 4 against the current tree's
attach/detach behavior instead of assuming them. Drops the retired
deferred-provider-family warning-ledger step (LLP 0171 retires R13; no
ledger) and the shadow-provider-id failure mode (Lane A overrides the
existing anthropic/openai entries, it does not register new ids).
States in the section's own Requires that the sweep/dedupe steps need PR
#552 merged (the LLP 0158 reader still parses OpenClaw v3 flat), and the
client_attach status-row re-confirmation needs PR #553 merged (a
now-probed openclaw otherwise falls back to pre-#553 status behavior).
This is a doc; the test is a human's successful run against a real
OpenClaw install, which this change cannot perform. It is specified
against what T2 (detach), T4 (attach), and T5 (manifest) actually
implement in this tree, read directly from
hypaware-core/plugins-workspace/openclaw/src/attach.js,
src/core/config/client_detach_disk.js, and
hypaware-core/plugins-workspace/openclaw/hypaware.plugin.json.
Task-Id: T13
* Add backfill_openclaw_fixture hermetic smoke for Lane B sweep
New backfill_openclaw_fixture.js under hypaware-core/smoke/flows,
mirroring backfill_claude_fixture.js / backfill_codex_fixture.js: writes
a minimal OpenClaw v3 session JSONL in the nested-message-envelope shape
(PR #552's reader) under a temp agents/<id>/sessions/ tree with a
controllable mtime, drives the real createBackfillSweepDriver (T9)
through a cron-due tick, and asserts (a) a file inside the default
180000ms quiesce window is skipped, (b) a file backdated past it is
captured with native message identity, and (c) rerunning the sweep on a
later cron-due tick (forcing a fresh devRunId, so the ai-gateway
materializer's dedupe genuinely re-scans committed partitions) nets zero
new rows for the already-written part_ids.
Driving a real, non-dry-run sweep write for the first time (T9's own
tests only ever exercised a mocked runBackfill seam) surfaced a latent
bug: writeRows/flushDataset read ctx.query, which BackfillRunnerContext
never carried and the daemon's createBackfillSweepDriver(...) call never
supplied, so any real sweep write actually crashed on
"Cannot read properties of undefined (reading 'getDataset')" in both the
smoke and the real daemon path. Threaded query through
BackfillRunnerContext, BackfillSweepDriverOptions,
createBackfillSweepDriver, and the daemon's sweepDriver construction, and
updated LLP 0172's field enumeration and ctx samples (Sections 4.3/4.4)
to match. Extended the existing T9 unit tests
(test/core/daemon-backfill-sweep.test.js) to cover the new required
field and its passthrough.
Task-Id: T12
* openclaw-backfill.test.js: backdate fixture mtimes to close a quiesce-window race (#570)
listSessionFiles compares stat.mtimeMs <= Date.now() even when a test sets
config.backfill.quiesce_ms: 0 to opt out of the quiesce gate for something
unrelated: 0ms only removes the margin, not the comparison. A fixture
written moments earlier could race the provider's own later Date.now() call
across two different clocks and occasionally lose, projecting 0 items
instead of 1. Confirmed non-deterministic: PR #570's commit 0c62a21 produced
both a green and a red `test (24)` CI run from the identical commit.
writeSession now backdates every fixture's mtime by a small, fixed margin
(FIXTURE_MTIME_MARGIN_MS), comfortably clearing the race while staying far
below the real 180000ms default quiesce window, so the tests that rely on
genuine freshness against that default are unaffected. The one test that
writes its session file outside writeSession (the OPENCLAW_HOME relocation
test) gets the same backdate applied directly.
* Review round 1: make an attach refusal observable, and three smaller fixes
Finding 1 (major). The registered `attach()` wrapper discarded the effect's
`OpenclawAttachOutcome`, and both callers infer success from "did it throw",
so a refusal recorded a `done` marker whose endpoint and assets_key matched
forever: the join never retried even after the user cleared the conflicting
`models.providers` entry, while the json_path attach probe kept reporting
`not attached`. `hyp attach --client openclaw` printed the refusal and exited
0. LLP 0172 1.3 is authoritative (it promises the `{status:'failed', reason}`
outcome is recorded and retried), so the wrapper now rethrows a failed
outcome: `perform()`'s catch turns it back into that shape (recorded, warned,
retried, the join's other actions untouched) and `runClientLifecycle`'s catch
makes it exit 1. Rethrowing at the wrapper rather than teaching `perform()` to
parse the adapter payload is what fixes both callers, since the CLI hands the
adapter `ctx.stdout` directly and captures nothing to inspect. LLP 0172 1.3
gains the translation step it left implicit; the test that locked in the
swallow now asserts the retryable failure, plus the reconciler and exit-code
halves.
Finding 2. `clientConfigHome` took the first segment of the settings path's
home-relative form, which is not the config home when `$OPENCLAW_HOME` is
nested inside `$HOME`: the cache glob then matched nothing and the purge
silently no-opped while the settings half reported success. Derive it by
stripping the manifest's own `settings_file` tail instead, the exact inverse
of what `resolveClientSettingsPath` joined on. Regression test uses a
two-segment `OPENCLAW_HOME`.
Finding 3. `listSessionFiles`'s JSDoc claimed the CLI path runs unfiltered.
It does not: `runOpenclawBackfill` computes the quiesce cutoff on every run,
and `run()` is the single entrypoint for the CLI, the onboarding finale, and
the sweep. Name `plan()` as the only unfiltered caller. No behavior change.
Finding 4. The sweep fired a due provider with no record of what was still
running, so a pass outliving its cron interval got a second concurrent run
against the same datasets and mid-flush spool. Add the `maintenanceInFlight`
guard shape, widened to a Set because the driver fires one run per provider,
with a `backfill.sweep_skipped` / `already_running` record and clearing on
both settlements. LLP 0172 4.4 states the re-entrancy rule it had left out.
Co-Authored-By: Claude <noreply@anthropic.com>
* Review round 2: thread the plugin config, make attach idempotent, name the sweep's component
Three review findings, each with a doc edit in the same commit.
1. `sweep_cron` and `quiesce_ms` were validated then discarded. `activate()`
built the backfill contribution without passing `ctx.config`, so both keys
this PR adds to `validateBackfillSection` resolved to the hardcoded
`*/5 * * * *` / 180000ms defaults at runtime, with no diagnostic. The
existing unit tests handed `config` straight to the factory, so the missing
wiring was invisible to them; the new test starts from an activation.
2. `attach()` refused on bare key presence, so it was not idempotent over its
own output. This PR's manifest `attach_probe` is what makes openclaw
eligible for attach-on-join, and `isCurrent()` re-performs attach on an
ephemeral-port rebind (LLP 0086) or an asset-set change (LLP 0107). Every
re-perform then refused: the marker churned to `failed`, `hyp attach
openclaw` exited 1, and `openclaw.json` stayed pinned to the dead port
while the marker-header probe still reported `attached: true`.
The refusal is now ownership-aware, on the self-identifying triple detach
already tests before deleting. `isOwnedProviderEntry`/`ownedBaseUrls` move
out of `client_detach_disk.js` into a shared
`src/core/config/provider_entry_ownership.js` so the two halves cannot
disagree about the same file. Attach passes no base-URL set (on a drift
re-attach its own entry carries the old origin); detach still passes one,
because there the wrong answer deletes a value HypAware never wrote.
Everything that fails the test still refuses, including `null`, a foreign
entry, and a hand-edited one that merely kept the header.
3. The generic sweep driver stamped `component: 'openclaw'` on all five of its
records while logging as `backfill-sweep`. It fires any contribution
carrying a `sweep` field, so a second opt-in would have been misattributed.
`component` now names the emitting module; plugin identity already rides
`hyp_plugin` and `provider`.
Docs updated to match: LLP 0167#attach-detach, LLP 0169's decision bullet and
summary, LLP 0171 R2, LLP 0172 sections 1.2 and 2.2, LLP 0173's implementer
note on the sweep's telemetry pair.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: test <test@test.com>
Co-authored-by: Claude <noreply@anthropic.com>
@philcunliffephilcunliffe added neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human and removed neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) labels Aug 3, 2026
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

What neutral was doing: the resolve-conflict rung on this PR (fix/issue-544 -> master, head 03f6d47). master advanced when PR #570 (the openclaw-two-lane-capture change set) merged at 2026-08-03T16:53:53Z, which turned this PR CONFLICTING. A worker merged origin/master in a scratch worktree, resolved the textual conflict, and then backed off without pushing, because finishing the merge honestly requires a decision only a human should make.

Why it cannot proceed

The mechanical part is done and verified. Exactly one textual conflict appeared, in docs/ACCEPTANCE.md: this branch edited the old openclaw acceptance step, and #570 deleted that procedure outright and rewrote section 8.1 for two-lane capture. Taking master wholesale is correct there, since the branch's text describes the now-deleted openclaw-steering-plugin/ and a probe-less openclaw. Everything else auto-merged, and the merged production code was checked by hand and is correct: this PR's attachable gate in src/core/daemon/status.js sits above #570's restored json_path probe branch without interfering.

The blocker is that this PR adds a new normative section, #status-derives-by-the-same-gate, to LLP 0143 — and #570 marked LLP 0143 Superseded (by LLP 0169) in the same change set. Post-merge that leaves live references pointing into a Superseded document:

  • src/core/daemon/status.js:599, :915
  • src/core/commands/status.js:366
  • test/core/status-probeless-client.test.js:24 (@ref LLP 0143#decision)
  • src/core/daemon/types.d.ts:176, test/core/status-client-error.test.js:66
  • plus cross-refs from llp/0044-...:180 and llp/0139-...:212,232

All seven other Superseded LLPs in this corpus (0080, 0093, 0094, 0145, 0148, 0149, 0152) carry zero live @refs, so there is no precedent to follow, and CLAUDE.md requires refs stay honest with no stale guidance. The section's body is also now substantively false: it argues from openclaw throughout ("a repair (hyp attach --client openclaw) that resolves this document's own deliberate no-op") and defers to a 0143 open question that LLP 0169 has since answered.

Is this PR still needed? Yes, and this was verified rather than assumed. master today fixes only the openclaw half of #544 (LLP 0169 says so itself: "resolving the OpenClaw half of issue #544"). Reproduced against plain master, claude-desktop still shows a permanent, unclearable not attached / attach [pending]; with this PR merged it correctly becomes attach n/a. This PR's code fix is the only thing fixing that remaining half. What was invalidated is its narrative, not its behaviour: openclaw is no longer the probe-less exemplar, claude-desktop now is (llp/0115-claude-desktop-managed-config-attach.decision.md:98#no-attach-on-join).

npm test on the merge result: 3284 pass, 3 fail (1 skipped). All three failures are in this PR's own new test/core/status-probeless-client.test.js, which seeds @hypaware/openclaw as its probe-less exemplar; #570 gave openclaw a probe. Swapping the exemplar to claude-desktop makes all three pass with no other change, confirming the production code is right and only the test premise and docs are stale.

What it needs from you

Where should the "status derives attach state by the reconciler's own desired() gate" decision live, now that LLP 0143 is Superseded?

  • (a) amend LLP 0115 #no-attach-on-join, the live decision covering claude-desktop, now the only probe-less client; or
  • (b) mint a new LLP (0174) stating the general rule, so it survives the next probe-less client.

Once that is chosen, the rest is mechanical and already scoped: rewrite the section around claude-desktop instead of openclaw, repoint the eight references above, take master's ACCEPTANCE.md, swap the test exemplar to claude-desktop (optionally keeping openclaw as a second over-suppression guard, which now usefully protects #570's new probe), and drop openclaw from the probe-less-client lists in the status.js:606 and types.d.ts:171 comments.

Secondary finding, on master's own text and not a merge artifact:docs/ACCEPTANCE.md:271-274 and :456-459 still describe this PR's effect on openclaw ("a probe-less-client attach n/a state is exactly what an unmerged #553 reproduces here"). Now that openclaw has a probe, this PR changes nothing for openclaw, so that passage is incorrect in either direction and is worth correcting by whoever owns section 8.1.

How to unstick: reply with a comment on this PR (or push to the branch). Neutral monitors this thread and will re-engage with your guidance on its next tick.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Decision on where the rule lives: (b) - mint a new decision LLP stating the general rule ("status derives attach state by the reconciler's own desired() gate"), so it survives the next probe-less client rather than reading as claude-desktop-specific.

Notes for the rework:

  • Take the next free LLP number per the repo convention (fetch origin/master and scan all branches first; the 0174 suggested in the triage note is likely claimed by now).
  • Rewrite the section's narrative around claude-desktop (now the only probe-less client, per LLP 0115 #no-attach-on-join) instead of openclaw, and repoint the eight live refs listed in the triage note at the new doc.
  • Take master's ACCEPTANCE.md wholesale for the conflicted section, as already verified correct.
  • Swap the test exemplar to claude-desktop; keeping openclaw as a second over-suppression guard (now protecting OpenClaw two-lane capture (LLP 0172/0173): Lane A attach, Lane B sweep, json_path revival #570's new probe) is welcome.
  • Drop openclaw from the probe-less-client lists in the status.js:606 and types.d.ts:171 comments.
  • The secondary finding (stale docs/ACCEPTANCE.md:271-274 / :456-459 passages describing this PR's effect on openclaw) can be corrected in the same pass since section 8.1 is being touched anyway.

@philcunliffephilcunliffe removed the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Aug 14, 2026
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Re-engaging with your guidance - taking (b), mint a new decision LLP stating the general rule that status derives attach state by the reconciler's own desired() gate, so it outlives claude-desktop as the only probe-less client.

Taking the rework notes too: take the next free LLP number by scanning origin/master and all branches first rather than reusing 0174, rewrite the narrative around claude-desktop per LLP 0115 #no-attach-on-join instead of openclaw, repoint the eight live refs at the new doc, take master's ACCEPTANCE.md wholesale for the conflicted section, and swap the test exemplar to claude-desktop while keeping openclaw as a second over-suppression guard.

The stuck label is removed. Neutral re-runs the rung at the current head on its next tick.

Master advanced past this branch twice. LLP 0143, which carried this
change's normative section, was marked Superseded by LLP 0169 when
OpenClaw got its attach_probe back, so the section's whole narrative
(and its own exemplar) went stale. Per maintainer guidance on #553,
option (b): mint a new decision LLP stating the general rule so it
outlives whichever client happens to be probe-less.
- LLP 0229 states the rule over the manifest, not over a client roster:
every hyp status surface that reports attach *state* derives it by the
same gate action_attach.desired() uses. LLP 0143 is reverted to
master's text and keeps no live refs.
- All eight live refs repointed from LLP 0143 to LLP 0229, plus
Extended-by forward-refs on LLP 0044.
- docs/ACCEPTANCE.md taken wholesale from master for the conflicted
section, then the three passages describing this PR's effect on
openclaw corrected: openclaw is probed again, so this PR changes
nothing for it.
- Test exemplar swapped to claude-desktop, the only probe-less client
shipping today. claude and openclaw are both over-suppression guards;
openclaw now usefully protects the probe LLP 0169 restored.
Scope narrowed against LLP 0224, accepted on master after this branch:
client_attach_missing is deliberately left ungated. LLP 0224
#repair-surface made it the standing incomplete-setup prompt and
stopped the wizard re-offering Desktop setup because it exists, so
gating it would leave a declined install with no surface at all. LLP
0229 #diagnostic-is-out-of-scope records the exception and names LLP
0224's own follow-up (a plist-reading probe) as what retires it. The
LLP 0139 amendment and the README row this PR previously carried are
reverted to master; a new test pins that the prompt still fires.
Refs: LLP 0044, LLP 0115, LLP 0139, LLP 0169, LLP 0224, LLP 0229.
Fixes#544
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Reworked on your guidance, at e9c96db

Took option (b). origin/master is merged in, the rule now lives in its own decision doc, and the branch is green against the master baseline.

One scope change I made on my own judgement, flagged below, because doing the work surfaced a collision your note could not have known about: LLP 0224 was accepted on master after the stuck note was written, and it depends on the third surface this PR was going to remove.


The decision implemented

LLP 0229: status derives attach state by the reconciler's own desired() gate, Type Decision, Status Accepted, Systems CLI / Config / Plugins.

It is written over the manifest rather than over a client roster, which is the whole point of minting it separately: #the-rule-outlives-its-clients says so explicitly, using OpenClaw's own round trip (probe-less under 0143, probed again under 0169) as the evidence that a client-specific carve-out would have rotted a second time. Anchors: #status-derives-by-the-same-gate (the rule), #diagnostic-is-out-of-scope, #unattachable-not-unattached, #keys-on-the-descriptor-not-the-probe-result, #the-rule-outlives-its-clients, plus #context / #decision / #consequences / #open-questions / #references.

LLP 0143 is reverted to master's text byte for byte and now carries zero live refs, which was the blocker. git diff origin/master -- llp/0143-... is empty.

Why 0229 is free

git fetch --prune origin, then every llp/ path on every remote branch (all 48, including every open PR head and llp/tombstones/):

0212 0213 0214 0215 0216 0217 0218 0219 0220 0222 0223 0224 0225 0226 0228

0228 is the highest claimed anywhere, so 0229 is the first free number above it. 0221, 0227 and 0229 are all unclaimed; I took the first above the ceiling rather than backfilling a hole. 0174 is claimed on master, so the triage note's suggestion was indeed stale. My doc adds no second collision: no LLP number is claimed by two documents still reports exactly the inherited LLP 223 pair that #775 fixes, and nothing else.

Every ref repointed

Eight live refs, each verified individually rather than counted:

filelinebeforeafter
src/core/daemon/status.js906@ref LLP 0143#status-derives-by-the-same-gate@ref LLP 0229#status-derives-by-the-same-gate
src/core/daemon/status.js1271@ref LLP 0143#status-derives-by-the-same-gate@ref LLP 0229#status-derives-by-the-same-gate
src/core/commands/status.js366@ref LLP 0143#status-derives-by-the-same-gate@ref LLP 0229#status-derives-by-the-same-gate
src/core/daemon/types.d.ts213prose LLP 0143 #status-derives-by-the-same-gateLLP 0229 #..., and the probe-less list drops openclaw
test/core/status-probeless-client.test.js24@ref LLP 0143#decision@ref LLP 0229#decision
test/core/status-client-error.test.js64prose LLP 0143 #status-derives-by-the-same-gateLLP 0229 #...
llp/0044-...decision.md180link into 0143link into 0229
llp/0139-...decision.md212, 232amendment box + Consequences into 0143withdrawn entirely, see below

Two forward-refs added so the new doc is reachable from what it extends: llp/0044's Extended-by: line now names LLP 0229 beside LLP 0086. (LLP 0139 gets none, since it is no longer amended.)

One new ref: @ref LLP 0229#diagnostic-is-out-of-scope [constrained-by] on the diagnostic that is deliberately not gated, so the exception is annotated rather than silent.

test/core/llp-ref-hygiene.test.js: 10/11 pass. every @ref resolves to a live LLP document and one of its anchors passes, so every anchor above is real. The single failure is no LLP number is claimed by two documents, reproduced identically on plain origin/master (the 0223 pair, #775).

The scope change: client_attach_missing stays

Your note said to repoint the eight refs and rewrite around claude-desktop. Doing that made test/core/status-needs-setup-repair.test.js fail, which is a master test that did not exist when this branch was reviewed. Tracing it:

LLP 0224 #repair-surface (Accepted, 2026-08-14) makes client_attach_missingthe standing incomplete-setup surface for Claude Desktop, and stopped the wizard re-offering setup on every reconfigure because that surface exists: "state already has a standing surface (#repair-surface); re-offering setup on every reconfigure was precisely the annoyance being removed." It test-pinned the diagnostic and its exact repair string for that reason. It also already knows this PR's argument and answers it: it records as a known limitation that Desktop "declares no attach probe, so its client reports unattached unconditionally... it clears only via the reconciler's attach action, not by observing the plist", and names the follow-up (give Desktop a plist-reading probe).

So removing the warning does not remove a redundant signal any more. Since 0224, it removes the only signal: the wizard no longer re-asks, and status would no longer warn, leaving a declined Desktop install completely silent. That is a product regression, and CLAUDE.md's "Accepted docs are settled" says I cannot quietly reverse what 0224 settled.

I therefore narrowed the PR to the two surfaces that report attach state, and left the diagnostic alone:

  • attach claude-desktop [pending] to [n/a](fixed)
  • clients row not attached to attach n/a(fixed)
  • client_attach_missing(unchanged, deliberately)

This is also exactly the half of #544 the stuck note verified as still broken on master ("claude-desktop still shows a permanent, unclearable not attached / attach [pending]"), so nothing verified-broken is left unfixed. LLP 0229 #diagnostic-is-out-of-scope records the exception, the reasoning, and 0224's own follow-up as the thing that retires it, rather than arguing it away.

Consequently llp/0139-... and README.md are reverted to master, since neither the amendment box nor the diagnostics-table rewording is true any more. docs/ACCEPTANCE.md:302, the round-1 finding, is moot: that whole procedure was deleted by #570.

If you would rather have the diagnostic gated too and accept the silence, say so and I will extend 0224 properly instead. That is a product call, so I did not make it silently.

The test exemplar

test/core/status-probeless-client.test.js now seeds claude-desktop as the probe-less exemplar, with bothclaude (json probe) and openclaw (json_path, restored by 0169) as over-suppression guards. I confirmed from the bundled manifests that claude-desktop is now the only probe-less client shipping: probed = {claude, codex, openclaw}, probe-less = {claude-desktop}.

Third test rewritten from "raises no client_attach_missing" into the gate stops at attach state: client_attach_missing still fires for a probe-less client, asserting the warning is present and that its repair is ['hyp claude-desktop install']. The exception is now pinned in the same file as the rule, so it cannot be closed by accident along with the other two surfaces.

Mutation-checked both directions at this head:

mutationresult
attachable = true, inert = false (revert the gate)3/3 fail
attachable = false, inert = true (maximal over-suppression)3/3 fail

So the guards are load-bearing, not decorative.

docs/ACCEPTANCE.md

Master's version taken wholesale for the conflicted section, byte-identical, as instructed. I then corrected the secondary finding you cleared, since all three passages assert something now false in both directions:

Rendered status, live, at this head

Real bundled catalog, joined host, no settings files:

 clients:
- claude [configured, not attached] [central · locked]
- openclaw [configured, not attached] [central · locked]
- claude-desktop [configured, attach n/a] [central · locked]
client actions:
- attach claude [pending]
- attach claude-desktop [n/a]
- attach openclaw [pending]
diagnostics:
[WARN ] client_attach_missing: '@hypaware/claude' ... run 'hyp attach --client claude'
[WARN ] client_attach_missing: '@hypaware/openclaw' ... run 'hyp attach --client openclaw'
[WARN ] client_attach_missing: '@hypaware/claude-desktop' ... run 'hyp claude-desktop install'

Both probed clients keep their full trio; the probe-less one gets n/a on both state surfaces and keeps its 0224 prompt.

Checks, against a measured master baseline

origin/master in a clean sibling worktree, same node_modules:

mastere9c96db
npm test4021 pass / 23 fail / 1 skip4024 pass / 23 fail / 1 skip
npm run typecheck1 error (marked)1 error (marked)

Sorted failure sets are identical, diffed line by line: 2 report-render* (missing marked), 20 parquet/hyparquet skew, 1 duplicate-0223. All inherited, none mine.

Two regressions the merge surfaced were fixed rather than absorbed: test/core/init-configured-entry.test.js gained three ClientAttachReport construction sites on master that were missing the required attachable (3 typecheck errors, now clean), and status-needs-setup-repair.test.js is the LLP 0224 collision above.

For a human

The LLP 0224 scope call is the one thing worth a second opinion. Everything else is mechanical and verified in the committed tree with git show <sha>:<path>.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Triage - PR #553 @ 995db4b

Verdict: ship as narrowed. No true blocker. One preference-level finding deferred to #781 (the PR body still describes the pre-narrowing scope; details there).

The scope narrowing is correct, and it is the doc-compliant option, not a deviation that needs sign-off

I re-derived the judgement from the docs instead of accepting the worker's account:

Verified mechanically at this head

  • LLP 0229 is genuinely free. Scanned every llp/ path on all 46 remote refs plus llp/tombstones/ after git fetch --prune: exactly one 0229 document anywhere, this branch's. The concurrent 0230 (fix/issue-614, stranded-attach-warning) does not clash. llp-ref-hygiene passes 11/11 at this head, including no LLP number is claimed by two documents, so master's 0223 duplicate is gone from this merge base.
  • Reverts are byte-exact.llp/0143, llp/0139, and README.md are identical to origin/master; the only remaining "LLP 0143" mentions in code are master's own historical prose (client_detach_disk.js:134, status.js:1451).
  • All eight refs repointed to LLP 0229 and each anchor resolves: status.js:906, :1275, commands/status.js:369, types.d.ts:213, status-probeless-client.test.js:24, status-client-error.test.js:66, llp/0044:180 plus its Extended-by: line; the 0139 amendment withdrawn rather than repointed. One new ref (status.js:930) annotates the exception.
  • Tests and typecheck. CI fully green (test + typecheck, Node 22 and 24, plus the duplicate-numbers check); merge state CLEAN. Locally the failure set at head is byte-identical to an origin/master baseline in a sibling worktree (22 inherited environment failures: 2 marked, 20 hyparquet skew), so nothing here regresses. Targeted runs: probeless, client-error, needs-setup-repair, and init-configured-entry all pass, 14/14.
  • Mutation-checked at this exact head. Reverting the gate (attachable = true, inert = false) fails 2/3; maximal over-suppression (attachable = false, inert = true) fails 2/3; the diagnostic-pin test passes in both directions, which is correct, since the diagnostic is deliberately outside the gate. The guards are load-bearing.
  • The extra scope items are in scope and correct. The three docs/ACCEPTANCE.md corrections (Requires, step 1 pass condition, If it fails) fix passages that were false in both directions after LLP 0169, and the maintainer's rework note explicitly authorized touching them; the conflicted section itself is master's text wholesale. The three test/core/init-configured-entry.test.js sites gained the now-required attachable: true, which is exactly the typecheck enforcement LLP 0229 promises.
  • No em dashes anywhere in the diff.

Deferred

  • Follow-up: deferred review findings from PR #553 #781: the PR body predates the narrowing and asserts the diagnostic is gated, which the shipped code deliberately does not do. Worth rewriting before or at merge so the merge record matches what ships. The neutral-ack-applied comment is the accurate description meanwhile.

FYI, not this PR: issue #764 refers to an llp/0224-maintenance-skips-are-a-standing-surface.decision.md that exists on no remote ref; every branch's 0224 is desktop-setup-second-pass. Whoever picks up #764 should resolve which document those findings actually target.

@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 14, 2026
Sole conflict: llp/0044's Extended-by header. Master (#782) added a second
Extended-by line for LLP 0234; this branch appended LLP 0229 to the existing
LLP 0086 line. Both kept.
#782 changes no attach_probe manifest, so the probe-less set is unchanged
(claude-desktop only) and LLP 0229's rule is unaffected.
@philcunliffephilcunliffe removed the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 15, 2026
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Re-triage after the merge of master (PR #782, proxy-mode capture, LLP 0231-0239) into this branch at head 6da53e6. Path A: the prior clearance stands and nothing new needs tracking beyond issue #781.

Verified rather than assumed:

  • The one conflict hunk (LLP 0044 Extended-by:) keeps both forward-refs, LLP 0229 and LLP 0234, which extend different things and do not overlap.
  • PR Attach Claude Code by proxy so Remote Control keeps working (LLP 0231-0235) #782 changed no hypaware.plugin.json, so claude-desktop remains the sole probe-less client (codex, openclaw, claude all declare attach_probe). No attached-but-probe-less client exists: proxy-mode attach still writes the _hypaware marker unconditionally, including port and now mode, so a proxy-attached Claude Code stays probe-observable.
  • A refused proxy preflight (markActionRefused on a missing CA) renders refused, not n/a: the marker-based refused branch in status.js precedes the no-marker branch where inert derives n/a.
  • The deliberate non-gating of client_attach_missing (LLP 0229 #diagnostic-is-out-of-scope) survived the merge; test/core/status-probeless-client.test.js passes 3/3 at this head.
  • No duplicate LLP numbers: master runs to 0239, this branch adds only 0229. CI fully green at 6da53e6 (test and typecheck on Node 22/24, duplicate-numbers check).

@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 15, 2026
@philcunliffe
philcunliffe merged commit 86c527e into masterAug 17, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-544 branch August 17, 2026 21:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approvedneutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hyp status shows a permanent 'attach openclaw [pending]', 'not attached', and an inert client_attach_missing repair for probe-less clients

1 participant

@philcunliffe