diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md
index 8aa66678..cbacae8f 100644
--- a/docs/ACCEPTANCE.md
+++ b/docs/ACCEPTANCE.md
@@ -218,12 +218,16 @@ procedure has nothing to assert about a "deferred" turn.
because the reader upstream of it has nothing to hand it. A red step 5
or 6 against an unmerged #552 is not evidence of a Lane B regression;
confirm the merge before filing anything.
-- **The `client_attach` status-row re-confirmation in steps 1 and 7 needs
- PR #553 (fix/issue-544) merged.** Without it, a now-probed `openclaw`
- (its `attach_probe` is real again as of this change set, for the first
- time since [LLP 0143](../llp/0143-openclaw-registers-no-attach-probe.decision.md))
- falls back to whatever pre-#553 `hyp status` did for a client that used
- to be probe-less, which this procedure was not written to describe.
+- **The `client_attach` status-row re-confirmation in steps 1 and 7 needs no
+ pending PR.** `openclaw` declares a real `attach_probe` again as of this
+ change set, for the first time since
+ [LLP 0143](../llp/0143-openclaw-registers-no-attach-probe.decision.md)
+ removed it and [LLP 0169](../llp/0169-openclaw-attach-surface-returns.decision.md)
+ brought it back, so `hyp status` derives its row from disk like any other
+ probed client. The probe-less `attach n/a` rendering
+ ([LLP 0229 #status-derives-by-the-same-gate](../llp/0229-status-derives-attach-state-by-the-desired-gate.decision.md#status-derives-by-the-same-gate))
+ applies to `claude-desktop`, not to `openclaw`, and is not what this
+ procedure checks.
**Related:** [LLP 0167](../llp/0167-openclaw-capture-via-config-provider-override.rfc.md)
(the override design and the verify-results this procedure re-confirms),
@@ -269,9 +273,9 @@ procedure checks, R11 in particular), [LLP 0172](../llp/0172-openclaw-two-lane-c
Pass condition: `hyp status` shows a running daemon and
`openclaw [configured, attached]` among the clients, with no
- `client_attach_missing` diagnostic (this is the PR #553 re-confirmation:
- a probe-less `openclaw` used to be stuck reading as `attach n/a`
- regardless of what was on disk). The `jq` output shows
+ `client_attach_missing` diagnostic (this is the LLP 0169 re-confirmation:
+ with the probe back, that row is read off disk again rather than reported
+ as not applicable). The `jq` output shows
`anthropic.baseUrl` as the bare gateway origin and `openai.baseUrl` as
the same origin plus `/v1`, both carrying `headers["x-hypaware-upstream"]`
set to their own key, and **both carrying `models: []`**. That empty
@@ -475,9 +479,10 @@ procedure checks, R11 in particular), [LLP 0172](../llp/0172-openclaw-two-lane-c
relies on hot reload, it only prints the restart instruction.
- Step 1 finds `client_attach_missing` still firing after a successful
attach and restart: check `openclaw --version` against the floor in
- **Requires** first, then confirm PR #553 is actually in the binary under
- test (a probe-less-client `attach n/a` state is exactly what an unmerged
- #553 reproduces here).
+ **Requires** first, then re-run step 1's `jq` check. The warning is
+ probe-derived, so it means the probe read `openclaw.json` and found no
+ HypAware-owned entry: either the write did not land, or it landed
+ somewhere other than the `OPENCLAW_HOME` the probe reads.
- Step 4 finds no rows at all: check `hyp status` for a stopped daemon,
then re-run step 1's `jq` check for a config that did not actually write
(a concurrent edit under `openclaw.json` fails the write's mtime guard
diff --git a/llp/0044-client-attach-on-join.decision.md b/llp/0044-client-attach-on-join.decision.md
index 858ac689..98677123 100644
--- a/llp/0044-client-attach-on-join.decision.md
+++ b/llp/0044-client-attach-on-join.decision.md
@@ -7,7 +7,7 @@
**Date:** 2026-06-26
**Related:** LLP 0011, LLP 0016, LLP 0025, LLP 0031, LLP 0036, LLP 0037, LLP 0041
**Designed-by:** LLP 0045, client attach implementation design
-**Extended-by:** LLP 0086, attach tracks the gateway's ephemeral port (the "attach once, done forever" model becomes endpoint-aware: re-attach on a daemon rebind, and manual `hyp attach` discovers the live port)
+**Extended-by:** LLP 0086, attach tracks the gateway's ephemeral port (the "attach once, done forever" model becomes endpoint-aware: re-attach on a daemon rebind, and manual `hyp attach` discovers the live port); [LLP 0229](./0229-status-derives-attach-state-by-the-desired-gate.decision.md) (#status-derives-by-the-same-gate: the §Status surface `n/a` case is widened to a client `desired()` would never name, because `pending` is a claim the reconciler will act and must be derived by `desired()`'s own gate)
**Extended-by:** LLP 0234, decryption follows the routing table and recording follows the path anchor (#context's "the gateway records only traffic a client actually routes to it" is narrowed to "only traffic a registered upstream's path anchor claims" once proxy-mode attach means the transport no longer enforces the aperture; the rows produced are unchanged)
> When a machine joins a fleet the central config pulls and the gateway binds,
@@ -177,8 +177,14 @@ central config or flip `overall` to `degraded`
[LLP 0041](./0041-central-config-client-actions.design.md#idempotency-and-completion-state)
`client_action` section, **per client**: `done` (attached, with when) / `failed`
(reason + last attempt) / `pending` (named, not yet attached) / `n/a`
-(`on_join: false` or non-joined). A failed or pending attach does **not** make
-`overall` `degraded`.
+(`on_join: false`, non-joined, or a client `desired()` would never name: see
+[LLP 0229 #status-derives-by-the-same-gate](./0229-status-derives-attach-state-by-the-desired-gate.decision.md#status-derives-by-the-same-gate)).
+A failed or pending attach does **not** make `overall` `degraded`.
+
+`pending` is a claim that the reconciler *will* act, so status must derive it
+by `desired()`'s own rule, not a looser one. Where the two can drift, they
+share a reader: `readAttachPolicy` for the `on_join` tri-state, and the
+`attachProbe` presence check for attach-eligibility.
## Relationship to manual attach
diff --git a/llp/0229-status-derives-attach-state-by-the-desired-gate.decision.md b/llp/0229-status-derives-attach-state-by-the-desired-gate.decision.md
new file mode 100644
index 00000000..e9b5fd26
--- /dev/null
+++ b/llp/0229-status-derives-attach-state-by-the-desired-gate.decision.md
@@ -0,0 +1,167 @@
+# LLP 0229: status derives attach state by the reconciler's own desired() gate
+
+**Type:** Decision
+**Status:** Accepted
+**Systems:** CLI, Config, Plugins
+**Author:** Phil / Claude
+**Date:** 2026-08-14
+**Related:** LLP 0044 (#status-surface: the attach-on-join loop and the surface this constrains), LLP 0045 (the attach design `desired()` implements), LLP 0115 (#no-attach-on-join: Claude Desktop, the probe-less client this is derived from), LLP 0139 (#repair-must-be-runnable: the repair rule this leaves standing), LLP 0224 (#repair-surface: why the `client_attach_missing` diagnostic is deliberately outside this gate), LLP 0143 (superseded; made the same observation about OpenClaw, before LLP 0169 gave OpenClaw a probe back), LLP 0169 (the attach surface OpenClaw regained), [#544](https://github.com/hyparam/hypaware/issues/544)
+
+> `hyp status` and the attach reconciler answered "is this client an attach
+> target?" by two different rules, so a client the reconciler will never act on
+> was rendered as one it has not acted on *yet*: a `pending` that never
+> resolves and a `not attached` that no command can change. Status derives
+> attach state by `desired()`'s own gate, or it does not derive it. The
+> `client_attach_missing` diagnostic is deliberately left outside the gate,
+> because LLP 0224 made it something other than attach state.
+
+## Context {#context}
+
+Attach-on-join ([LLP 0044](./0044-client-attach-on-join.decision.md)) is a
+reconciled, reversible action. Its precondition is a **reversible write to the
+client's own settings file**, and the manifest's `attach_probe` is what makes
+that write observable and therefore reversible. So
+`action_attach.desired()` skips any client descriptor that declares no
+`attach_probe` (`src/core/config/action_attach.js`): attach must be reversible,
+and only the probe can reverse it.
+
+The consequence is total, not partial. For a probe-less client `perform()`
+never runs, so **no marker is ever written**, and no marker will ever be
+written, on any host, at any time.
+
+`hyp status` derived two surfaces of attach *state* against the attach contract
+without that gate. Both read the reconciler's permanent silence as a permanent
+negative:
+
+- `buildClientActionsReport` built its declared-attach set from every enabled
+ client descriptor on a joined host, so *no marker* plus *declared* plus
+ *joined* resolved to `pending`, forever.
+- the clients row mapped a probe-less descriptor to `{ attached: false }` and
+ printed `not attached`, where nothing is attachable.
+
+This was a missed gate rather than a design choice: the doc comment above
+`buildClientActionsReport` already stated the intended invariant, that
+`pending` and `n/a` are derived for declared targets the reconciler *would act
+on* but has not yet.
+
+The corpus already has the sibling of this rule. `readAttachPolicy` and
+`readBackfillPolicy` exist so that status and the reconciler cannot disagree
+about what an `on_join` block means. Attach-eligibility is the second thing the
+two sides can disagree about, and until now nothing kept them together.
+
+## Decision {#decision}
+
+**Every `hyp status` surface that
+reports attach *state* derives it by the same gate `action_attach.desired()`
+uses.** A client descriptor with no `attach_probe` is one the reconciler will
+never name; status says so rather than reporting a state that can never resolve
+([#544](https://github.com/hyparam/hypaware/issues/544)).
+
+Concretely, and in the general form, not per client:
+
+- **Client actions.** A probe-less declared target carries `inert: true` and
+ derives `n/a`. That joins `on_join: false` and a non-joined host as the third
+ way of saying the same thing: the reconciler is a no-op for this target. It
+ is **not** dropped from the report. A vanished row is its own wrong answer,
+ since the reader cannot tell "nothing to do here" from "status forgot to
+ look".
+- **The clients row.** `ClientAttachReport` carries a required
+ `attachable: boolean`, set from `!!descriptor.attachProbe` and never from a
+ probe *result*. The text surface prints `attach n/a` instead of
+ `not attached`. `hyp status --json` carries `attachable` beside an
+ **unchanged** `attached` 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 `client_attach_missing`
+diagnostic stays outside this gate, and keeps firing for a probe-less client.**
+It looks like the third surface and is not one.
+[LLP 0224 #repair-surface](./0224-desktop-setup-second-pass.decision.md#repair-surface)
+made it the *standing incomplete-setup prompt*, and stopped the wizard
+re-offering setup on every reconfigure specifically because that prompt exists.
+Gating it here would leave a declined or failed Claude Desktop setup with **no
+surface at all**, which is a worse answer than an imprecise one.
+
+LLP 0224 reached that position with its eyes open: it records, as a known
+limitation, that Desktop declares no probe, so the diagnostic "keys on the
+enabled plugin alone" and clears only via the reconciler, never by observing
+the plist. The fix it names is to give Desktop a plist-reading `attach_probe`,
+at which point the client becomes `attachable`, the warning becomes clearable
+by observation, and it rejoins this rule with no further decision. That is the
+follow-up, and it is the only thing that should retire this exception. Until
+then the exception is recorded rather than argued away, because a warning that
+over-fires is a nag and a warning that never fires is a silent failure.
+
+**A probe-less client is unattachable,
+not unattached.** A negative we never observed must not be rendered as one we
+did. This is a gate, not a new signal: nothing here reports whether a
+probe-less client is in fact routing. That remains each adapter's own question,
+answered by its own command (Claude Desktop's is
+`hyp claude-desktop verify`), not by the attach surface.
+
+**The gate keys on the
+descriptor, never on the probe's outcome.** A client whose probe resolves and
+finds no marker, and a client whose probe *errors*, are both `attachable: true`
+and keep the full `not attached` / `pending` / `client_attach_missing` trio. An
+unresolvable probe is a real negative that a user can act on; the absence of a
+probe is not. Collapsing the two would suppress exactly the warning the surface
+exists to raise.
+
+**The rule is stated over the
+manifest, not over a client roster.** Which clients are probe-less is a fact
+about today's shipped set and changes under us: OpenClaw was probe-less under
+[LLP 0143](./0143-openclaw-registers-no-attach-probe.decision.md) and is
+probed again under [LLP 0169](./0169-openclaw-attach-surface-returns.decision.md);
+`claude-desktop` is the only probe-less client at the time of writing
+([LLP 0115 #no-attach-on-join](./0115-claude-desktop-managed-config-attach.decision.md#no-attach-on-join)),
+and a fleet's third-party plugin can add another tomorrow without touching
+core. Nothing in core enumerates them, and nothing should: the gate reads the
+descriptor, so a client that gains or loses a probe changes its status answer
+by construction.
+
+## Consequences {#consequences}
+
+- Claude Desktop's permanent `attach claude-desktop [pending]` becomes `n/a`,
+ and its clients row reads `attach n/a`. This is the remaining half of #544:
+ the OpenClaw half closed when
+ [LLP 0169](./0169-openclaw-attach-surface-returns.decision.md) gave OpenClaw
+ a probe back, which made its rows correct without any change here.
+- [LLP 0139 #repair-must-be-runnable](./0139-desktop-picker-consent.decision.md#repair-must-be-runnable)
+ and [LLP 0224 #repair-surface](./0224-desktop-setup-second-pass.decision.md#repair-surface)
+ are untouched. `hyp status` still prints
+ `client_attach_missing: ... run 'hyp claude-desktop install'` for a declined
+ or failed Desktop setup, and the `configure_command` lookup is still reached.
+ A reader of the clients row now sees `attach n/a` beside that warning: the
+ two are answering different questions, and the warning's own text names the
+ command that answers its one.
+- `ClientAttachReport.attachable` is **required**, not optional, so a new
+ construction site that forgets it fails `npm run typecheck` rather than
+ silently reintroducing the wrong negative.
+- A client that regains a probe regains all three states with no core change,
+ which is what happened to OpenClaw between LLP 0143 and LLP 0169 and is the
+ reason this rule is worth stating separately from either.
+
+## Open questions {#open-questions}
+
+- `desired()` has a second skip this gate does not mirror: it also skips a
+ descriptor whose plugin registers no runtime client. Status deliberately does
+ not activate plugins, so it cannot observe that fact and cannot close the
+ rule the same way. It is unreachable in the shipped set today (the only
+ adapterless client is also probe-less, and so already gated), and it is named
+ here so "status derives by the same gate" is not read as fully closed.
+- A `done` attach marker written before a client lost its probe still renders
+ `attach [done]` beside a clients row saying `attach n/a`.
+ Marker-derived, outside this gate's path, and arguably the honest reading of
+ both facts, but worth revisiting if a client ever loses a probe with an
+ installed base.
+
+## References {#references}
+
+- [LLP 0044](./0044-client-attach-on-join.decision.md): attach on join, and the status surface this constrains
+- [LLP 0045](./0045-client-attach.design.md): the client attach design `desired()` realizes
+- [LLP 0115](./0115-claude-desktop-managed-config-attach.decision.md): Claude Desktop registers no `attach_probe`
+- [LLP 0139](./0139-desktop-picker-consent.decision.md): the repair-must-be-runnable rule this leaves standing
+- [LLP 0224](./0224-desktop-setup-second-pass.decision.md): #repair-surface, the standing incomplete-setup prompt this gate is kept away from
+- [LLP 0143](./0143-openclaw-registers-no-attach-probe.decision.md) (Superseded): where this observation was first written down, against OpenClaw
+- [LLP 0169](./0169-openclaw-attach-surface-returns.decision.md): OpenClaw's probe returns, which is why the rule is stated over the manifest
+- [#544](https://github.com/hyparam/hypaware/issues/544): a probe-less client reads as permanently unattached
diff --git a/src/core/commands/status.js b/src/core/commands/status.js
index cd5648fc..7b553fd6 100644
--- a/src/core/commands/status.js
+++ b/src/core/commands/status.js
@@ -180,6 +180,10 @@ export function renderStatusJson({ report, clientNames, datasets, cacheRoot }) {
client_attach: report.clients.map((c) => ({
name: c.name,
configured: c.configured,
+ // `attached` stays a boolean for every row so a consumer can keep
+ // pinning it; `attachable: false` is what says the boolean carries no
+ // information for this client (#544).
+ attachable: c.attachable !== false,
attached: c.attached,
...(report.layered
? { provenance: report.layered.centralPlugins.includes(c.plugin) ? 'central' : 'local' }
@@ -359,7 +363,11 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std
seen.add(c.name)
const state = []
state.push(c.configured ? 'configured' : 'not in config')
- state.push(c.attached ? 'attached' : 'not attached')
+ // A client with no attach probe has no attach state to report: printing
+ // `not attached` for it invites a `hyp attach` that is a documented
+ // no-op and can never change the line (#544).
+ // @ref LLP 0229#status-derives-by-the-same-gate [implements]: the clients row says attach n/a, not "not attached", for a probe-less client
+ state.push(c.attachable === false ? 'attach n/a' : c.attached ? 'attached' : 'not attached')
stdout.write(` - ${c.name} [${state.join(', ')}]${provenanceTag(report.layered, isCentralPlugin(report.layered, c.plugin))}\n`)
if (c.error) stdout.write(` error: ${c.error}\n`)
}
diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js
index ec137dd7..5c5bfe1a 100644
--- a/src/core/daemon/status.js
+++ b/src/core/daemon/status.js
@@ -893,19 +893,41 @@ export async function collectHypAwareStatus(opts = {}) {
const clientDescriptors = catalog?.clientDescriptors ?? new Map()
for (const [clientName, descriptor] of clientDescriptors) {
const configured = activePlugins.includes(descriptor.plugin)
- const probe = descriptor.attachProbe
+ // Attach state is only a real state for a client that declares an
+ // `attach_probe`. Without one there is no settings-file write to read back,
+ // `action_attach.desired()` skips the descriptor for exactly that reason
+ // (attach must be reversible), so no attach is ever performed and no marker
+ // is ever written. Deriving `attached: false` from that silence is the wrong
+ // negative indistinguishable from a right one (#544): the honest answer is
+ // "not applicable", so the two surfaces that report attach *state* (the
+ // clients row, and the attach action in `buildClientActionsReport`) read
+ // this flag rather than a probe result that was never taken. The
+ // `client_attach_missing` diagnostic just below deliberately does not.
+ // @ref LLP 0229#status-derives-by-the-same-gate [implements]: a probe-less client is unattachable, not unattached
+ const attachable = !!descriptor.attachProbe
+ const probe = attachable
? await probeClientAttachFromDescriptor({ descriptor, homeDir, env })
: { attached: false }
clients.push({
name: clientName,
plugin: descriptor.plugin,
configured,
+ attachable,
attached: probe.attached,
...(probe.settingsPath ? { settingsPath: probe.settingsPath } : {}),
...(probe.version !== undefined ? { version: probe.version } : {}),
...(probe.port !== undefined ? { port: probe.port } : {}),
...(probe.error !== undefined ? { error: probe.error } : {}),
})
+ // Deliberately ungated by `attachable`, unlike the two derived-state
+ // surfaces above and below. This is not attach state: it is the standing
+ // incomplete-setup prompt LLP 0224 #repair-surface leans on after it
+ // stopped the wizard re-offering setup on every reconfigure. For a
+ // probe-less client it cannot be cleared by observation, which LLP 0224
+ // records as a known limitation with its own named follow-up (give Desktop
+ // a plist-reading probe); until that lands, an unclearable prompt beats the
+ // only alternative, which is no surface at all.
+ // @ref LLP 0229#diagnostic-is-out-of-scope [constrained-by]: the gate governs derived attach state, not the setup-completeness prompt
if (configured && !probe.attached) {
// The repair is `hyp attach` only for a client whose plugin registers a
// runtime adapter the generic reconciler can drive. A client that
@@ -1239,25 +1261,33 @@ function buildClientActionsReport({ status, config, hasCentral, clientDescriptor
if (entry.enabled === false) continue
enabledByPlugin.set(entry.name, entry)
}
- /** @type {Map} */
+ /** @type {Map} */
const declaredAttach = new Map()
for (const [clientName, descriptor] of clientDescriptors ?? new Map()) {
const entry = enabledByPlugin.get(descriptor.plugin)
if (!entry) continue
+ // A probe-less descriptor is the third way the reconciler is a no-op, next
+ // to `on_join: false` and a non-joined host. `desired()` skips it because
+ // attach must be reversible and only the probe can reverse it, so no marker
+ // will ever appear and `pending` would be permanent (#544). Same shape as
+ // the `readAttachPolicy` sharing above: status must not derive a target the
+ // reconciler would never name.
+ // @ref LLP 0229#status-derives-by-the-same-gate [implements]: a probe-less attach target is n/a, never pending
+ const inert = !descriptor.attachProbe
const raw = entry.config?.attach
const hasBlock = !!raw && typeof raw === 'object' && !Array.isArray(raw)
if (hasBlock) {
const onJoin = readAttachPolicy(entry).onJoin !== false
- declaredAttach.set(clientName, { onJoin })
+ declaredAttach.set(clientName, { onJoin, inert })
} else if (hasCentral) {
- declaredAttach.set(clientName, { onJoin: true })
+ declaredAttach.set(clientName, { onJoin: true, inert })
}
}
// Kinds to render: every kind the markers record, plus a kind for each
// handler that declared a target (so a configured-but-unrun target shows even
// with no marker yet). `backfill` keys by plugin, `attach` by client name.
- /** @type {Record>} */
+ /** @type {Record>} */
const declaredByKind = { backfill: declared, attach: declaredAttach }
/** @type {Set} */
const kinds = new Set(Object.keys(byKind))
@@ -1303,10 +1333,11 @@ function buildClientActionsReport({ status, config, hasCentral, clientDescriptor
})
} else {
// No marker: a declared backfill or attach target. Suppressed
- // (on_join:false) or inert (host never joined → the reconciler is a
- // no-op) → n/a; otherwise desired and simply not run yet → pending.
+ // (on_join:false), inert (host never joined, or the handler's own
+ // `desired()` would skip this target) → the reconciler is a no-op →
+ // n/a; otherwise desired and simply not run yet → pending.
const decl = declaredForKind?.get(requestKey)
- const suppressed = decl ? !decl.onJoin : false
+ const suppressed = decl ? !decl.onJoin || decl.inert === true : false
const state = suppressed || !hasCentral ? 'n/a' : 'pending'
actions.push({ kind, requestKey, state })
}
diff --git a/src/core/daemon/types.d.ts b/src/core/daemon/types.d.ts
index 16551df2..26bbeb2a 100644
--- a/src/core/daemon/types.d.ts
+++ b/src/core/daemon/types.d.ts
@@ -205,7 +205,17 @@ export interface ClientAttachReport {
plugin: string
/** Plugin enabled in config. */
configured: boolean
- /** Settings file carries the HypAware marker. */
+ /**
+ * The client declares an `attach_probe`, so attach is a state that can be
+ * observed (and reversed). False for a probe-less client (`claude-desktop`
+ * is the only one shipping today, LLP 0115 #no-attach-on-join), whose attach
+ * state is not applicable rather than negative: `attached` is then
+ * structurally false and means nothing (LLP 0229
+ * #status-derives-by-the-same-gate). Read off the descriptor, so a client
+ * that gains or loses a probe changes this answer with no core change.
+ */
+ attachable: boolean
+ /** Settings file carries the HypAware marker. Only meaningful when `attachable`. */
attached: boolean
/** Path the probe inspected. */
settingsPath?: string
diff --git a/test/core/init-configured-entry.test.js b/test/core/init-configured-entry.test.js
index 2c388e77..9793131c 100644
--- a/test/core/init-configured-entry.test.js
+++ b/test/core/init-configured-entry.test.js
@@ -76,9 +76,9 @@ test('renderConfigSummary: a fleet-managed install marks each client synced vs l
const report = makeReport({
layered: /** @type {any} */ ({ hasCentral: true, centralPlugins: [], centralSinks: [], drops: [], centralQueryIgnored: false }),
clients: [
- { name: 'claude', plugin: '@hypaware/claude', configured: true, attached: true },
- { name: 'codex', plugin: '@hypaware/codex', configured: true, attached: true },
- { name: 'openclaw', plugin: '@hypaware/openclaw', configured: true, attached: false },
+ { name: 'claude', plugin: '@hypaware/claude', configured: true, attachable: true, attached: true },
+ { name: 'codex', plugin: '@hypaware/codex', configured: true, attachable: true, attached: true },
+ { name: 'openclaw', plugin: '@hypaware/openclaw', configured: true, attachable: true, attached: false },
],
clientSync: { syncing: ['claude', 'codex'], localOnly: ['openclaw'] },
sinks: [{ instance: 'central', plugin: '@hypaware/central', kind: 'request' }],
diff --git a/test/core/status-client-error.test.js b/test/core/status-client-error.test.js
index 70c6d3e6..d4563d0f 100644
--- a/test/core/status-client-error.test.js
+++ b/test/core/status-client-error.test.js
@@ -61,6 +61,10 @@ async function reportWithProbeError(hypHome) {
name: 'claude-desktop',
plugin: '@hypaware/claude-desktop',
configured: false,
+ // The row under test is one whose probe *was* declared and then failed to
+ // resolve, which is the whole point of `error`: an unresolvable probe is
+ // not the same as no probe (LLP 0229 #status-derives-by-the-same-gate).
+ attachable: true,
attached: false,
error: PROBE_ERROR,
})
diff --git a/test/core/status-probeless-client.test.js b/test/core/status-probeless-client.test.js
new file mode 100644
index 00000000..e29a0a79
--- /dev/null
+++ b/test/core/status-probeless-client.test.js
@@ -0,0 +1,155 @@
+// @ts-check
+
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+
+import { collectHypAwareStatus } from '../../src/core/daemon/status.js'
+import { defaultConfigPath } from '../../src/core/config/schema.js'
+import { centralSeedPath } from '../../src/core/config/apply.js'
+import { renderStatusJson, renderStatusText } from '../../src/core/commands/status.js'
+
+/**
+ * @import { ClientActionReport } from '../../src/core/daemon/types.js'
+ */
+
+// A probe-less client (`contributes.client` with no `attach_probe`:
+// claude-desktop per LLP 0115 #no-attach-on-join, the only one shipping today)
+// is never named by the attach reconciler's `desired()`, so no marker is ever
+// written for it. Status must derive by that same rule or it reports two states
+// that can never resolve: a permanent `pending` attach action and a
+// `not attached` client row (#544).
+// @ref LLP 0229#decision [tests]: attach-on-join stays inert for a probe-less client, and status says so
+// @ref LLP 0229#diagnostic-is-out-of-scope [tests]: the gate stops at attach state, so the incomplete-setup prompt still fires
+
+async function makeHome() {
+ const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hyp-status-probeless-'))
+ await fs.mkdir(path.join(hypHome, 'hypaware'), { recursive: true })
+ return hypHome
+}
+
+/** @param {string} hypHome */
+function env(hypHome) {
+ return { ...process.env, HYP_HOME: hypHome, HYP_CONFIG: '' }
+}
+
+function makeBuf() {
+ let value = ''
+ return { write(/** @type {string} */ chunk) { value += String(chunk); return true }, text() { return value } }
+}
+
+/** @param {ClientActionReport[]} actions */
+function attachByKey(actions) {
+ /** @type {Map} */
+ const m = new Map()
+ for (const a of actions) if (a.kind === 'attach') m.set(a.requestKey, a)
+ return m
+}
+
+/**
+ * A joined host whose central layer enables the probe-less client adapter
+ * (claude-desktop) and two probed ones: claude, whose probe format is `json`,
+ * and openclaw, whose `json_path` probe returned with LLP 0169. No attach
+ * markers yet. The probed clients are the over-suppression guards: whatever
+ * gates claude-desktop must leave their pending / `not attached` /
+ * `client_attach_missing` trio intact. openclaw earns its place twice over,
+ * since it is the client that crossed this gate in the other direction.
+ *
+ * @param {string} hypHome
+ */
+async function joinedWithDesktop(hypHome) {
+ const stateRoot = path.join(hypHome, 'hypaware')
+ const seedPath = centralSeedPath(stateRoot)
+ await fs.mkdir(path.dirname(seedPath), { recursive: true })
+ await fs.writeFile(seedPath, JSON.stringify({
+ version: 2,
+ plugins: [
+ { name: '@hypaware/central' },
+ { name: '@hypaware/ai-gateway' },
+ { name: '@hypaware/claude-desktop' },
+ { name: '@hypaware/claude' },
+ { name: '@hypaware/openclaw' },
+ ],
+ sinks: { central: { plugin: '@hypaware/central', config: {} } },
+ }) + '\n')
+ await fs.writeFile(defaultConfigPath(hypHome), JSON.stringify({ version: 2, plugins: [] }) + '\n')
+ // A home with no client settings files at all: nothing carries a marker, so
+ // the probed clients are genuinely unattached and the probe-less one has
+ // nothing that could ever carry one.
+ const homeDir = path.join(hypHome, 'home')
+ await fs.mkdir(homeDir, { recursive: true })
+ return await collectHypAwareStatus({ env: env(hypHome), homeDir })
+}
+
+test('a probe-less client on a joined host renders attach n/a, never a permanent pending (#544)', async () => {
+ const hypHome = await makeHome()
+ const report = await joinedWithDesktop(hypHome)
+
+ const attach = attachByKey(report.clientActions?.actions ?? [])
+ // The reconciler's `desired()` skips a probe-less descriptor, so `perform()`
+ // never runs and no marker is ever written: `pending` would be permanent.
+ assert.equal(attach.get('claude-desktop')?.state, 'n/a')
+ // Over-suppression guards: a probed client with no marker is still pending.
+ assert.equal(attach.get('claude')?.state, 'pending')
+ assert.equal(attach.get('openclaw')?.state, 'pending')
+
+ const stdout = makeBuf()
+ renderStatusText({ report, clientNames: [], datasets: [], cacheRoot: '/tmp/cache', stdout })
+ const text = stdout.text()
+ assert.match(text, /attach claude-desktop\s+\[n\/a\]/)
+ assert.match(text, /attach claude\s+\[pending\]/)
+ assert.match(text, /attach openclaw\s+\[pending\]/)
+})
+
+test('the gate stops at attach state: client_attach_missing still fires for a probe-less client (#544)', async () => {
+ const hypHome = await makeHome()
+ const report = await joinedWithDesktop(hypHome)
+
+ const missing = report.diagnostics.filter((d) => d.kind === 'client_attach_missing')
+ // Deliberately NOT gated. LLP 0224 #repair-surface made this the standing
+ // incomplete-setup prompt and stopped the wizard re-offering setup because
+ // it exists, so gating it here would leave a declined Desktop setup with no
+ // surface at all. Pinned so the exception cannot be closed by accident along
+ // with the two state surfaces above.
+ const desktop = missing.find((d) => d.message.includes('claude-desktop'))
+ assert.ok(desktop, `expected client_attach_missing for claude-desktop, got: ${missing.map((d) => d.message).join(' | ')}`)
+ assert.deepEqual(desktop.repair, ['hyp claude-desktop install'])
+ // The probed clients are unaffected in the other direction.
+ assert.ok(missing.some((d) => d.message.includes("'@hypaware/claude'")))
+ assert.ok(missing.some((d) => d.message.includes("'@hypaware/openclaw'")))
+})
+
+test('a probe-less client row reads attach n/a, not "not attached" (#544)', async () => {
+ const hypHome = await makeHome()
+ const report = await joinedWithDesktop(hypHome)
+
+ const desktop = report.clients.find((c) => c.name === 'claude-desktop')
+ assert.ok(desktop, 'expected a claude-desktop client row')
+ assert.equal(desktop.configured, true)
+ assert.equal(desktop.attachable, false)
+
+ for (const name of ['claude', 'openclaw']) {
+ const probed = report.clients.find((c) => c.name === name)
+ assert.ok(probed, `expected a ${name} client row`)
+ assert.equal(probed.attachable, true)
+ assert.equal(probed.attached, false)
+ }
+
+ const stdout = makeBuf()
+ renderStatusText({ report, clientNames: [], datasets: [], cacheRoot: '/tmp/cache', stdout })
+ const text = stdout.text()
+ assert.match(text, /- claude-desktop {2}\[configured, attach n\/a\]/)
+ assert.match(text, /- claude {2}\[configured, not attached\]/)
+ assert.match(text, /- openclaw {2}\[configured, not attached\]/)
+
+ const json = renderStatusJson({ report, clientNames: [], datasets: [], cacheRoot: '/tmp/cache' })
+ const rows = /** @type {any[]} */ (json.client_attach)
+ assert.equal(rows.find((r) => r.name === 'claude-desktop')?.attachable, false)
+ assert.equal(rows.find((r) => r.name === 'claude')?.attachable, true)
+ assert.equal(rows.find((r) => r.name === 'openclaw')?.attachable, true)
+ // `attached` keeps its type and its place for every row, so a consumer
+ // pinning it does not break on the new key beside it.
+ assert.equal(rows.find((r) => r.name === 'claude-desktop')?.attached, false)
+})