From daca753bb3a40b8225962aed1b35856104ebc92d Mon Sep 17 00:00:00 2001 From: test Date: Fri, 31 Jul 2026 22:00:57 +0000 Subject: [PATCH 1/2] status: a probe-less client is unattachable, not unattached (#544) `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 --- llp/0044-client-attach-on-join.decision.md | 10 +- llp/0139-desktop-picker-consent.decision.md | 15 +- ...claw-registers-no-attach-probe.decision.md | 42 ++++++ src/core/commands/status.js | 10 +- src/core/daemon/status.js | 46 ++++-- src/core/daemon/types.d.ts | 10 +- test/core/status-client-error.test.js | 4 + test/core/status-probeless-client.test.js | 138 ++++++++++++++++++ 8 files changed, 257 insertions(+), 18 deletions(-) create mode 100644 test/core/status-probeless-client.test.js diff --git a/llp/0044-client-attach-on-join.decision.md b/llp/0044-client-attach-on-join.decision.md index c21e2272..5cdeb7f2 100644 --- a/llp/0044-client-attach-on-join.decision.md +++ b/llp/0044-client-attach-on-join.decision.md @@ -176,8 +176,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 0143 #status-derives-by-the-same-gate](./0143-openclaw-registers-no-attach-probe.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/0139-desktop-picker-consent.decision.md b/llp/0139-desktop-picker-consent.decision.md index dbb4bc15..2781096a 100644 --- a/llp/0139-desktop-picker-consent.decision.md +++ b/llp/0139-desktop-picker-consent.decision.md @@ -209,6 +209,15 @@ back to `hyp attach --client ` otherwise. A client that declares adapter has to name its own setup command, or the repair we print answers `unknown client`. +> **Amended by [LLP 0143 #status-derives-by-the-same-gate](./0143-openclaw-registers-no-attach-probe.decision.md#status-derives-by-the-same-gate) +> (#544).** The rule stands, and the `configure_command` lookup stays, but +> `client_attach_missing` no longer fires at all for a client with no +> `attach_probe`, Claude Desktop included. A runnable repair is necessary and +> not sufficient: the warning also has to be one the repair can clear, and +> with no probe to read back this one fired identically before the consent +> prompt, after a decline, and after a successful install. The fallback below +> is now reached only by a probed client. + ## Consequences - Ticking Claude Desktop in `hyp init` now works end to end: compose, @@ -216,8 +225,10 @@ adapter has to name its own setup command, or the repair we print answers - Declining leaves `@hypaware/claude-account` and `@hypaware/claude-desktop` in the written config with no credential and no plist. That is the converging state, not a broken one: the re-run - repair works precisely because the plugins are present, and `hyp status` - now names that re-run as the repair. + repair works precisely because the plugins are present. `hyp status` named + that re-run as the repair until LLP 0143 #status-derives-by-the-same-gate + withdrew the warning it hung off, which could not tell a decline from a + success; `hyp claude-desktop verify` is the check that can. - `V1_EXCLUDED_FROM_DEFAULT` still excludes both plugins from default activation. Nothing enables them without either a picker tick or an explicit `plugins[]` edit; the exclusion governs defaults, and the picker diff --git a/llp/0143-openclaw-registers-no-attach-probe.decision.md b/llp/0143-openclaw-registers-no-attach-probe.decision.md index a25c5b04..5b6f1268 100644 --- a/llp/0143-openclaw-registers-no-attach-probe.decision.md +++ b/llp/0143-openclaw-registers-no-attach-probe.decision.md @@ -80,6 +80,34 @@ One further fact makes this cheap: **OpenClaw is the sole remaining The `MALFORMED_MARKER` guard and the refuse-over-half-reverse principle are untouched. Nothing about `json`/`toml` clients changes. +### Status derives by the same gate + +**Every `hyp status` attach +surface reads `descriptor.attachProbe` the way `action_attach.desired()` +does.** "Inert" above is a statement about the reconciler; status has to make +the same statement or it reports a state that can never resolve +([#544](https://github.com/hyparam/hypaware/issues/544)). `desired()` skips a +probe-less descriptor, so `perform()` never runs and no marker is ever +written; three status surfaces derived against the attach contract without +that gate and each turned that permanent silence into a permanent negative: + +- the **client actions** row derived `pending` (declared, no marker) forever, + where the truthful state is `n/a`, the same answer `on_join: false` and a + non-joined host already get, and for the same reason: the reconciler is a + no-op for this target; +- the **clients** row printed `not attached`, where nothing is attachable: + it reads `attach n/a`, and the report carries `attachable: false` so the + JSON surface says which of the two a `false` in `attached` is; +- the **`client_attach_missing`** diagnostic fired on `!attached`, printing a + repair (`hyp attach --client openclaw`) that resolves this document's own + deliberate no-op and so could never clear the warning it was printed under. + +A probe-less client is therefore **unattachable, not unattached**. This is +the LLP 0045 §`settings_file`-is-home-relative rule applied one level up: a +negative we never actually observed must not be rendered as one we did. It is +a gate, not a signal: nothing here reports whether OpenClaw is in fact +routing, which stays the open question below. + ## Consequences - Core loses a whole probe/undo format that LLP 0109 added and that now has @@ -89,6 +117,20 @@ untouched. Nothing about `json`/`toml` clients changes. it should learn to report it from OpenClaw's plugin registry instead is deliberately left open below; showing nothing is preferable to showing a probe that cannot be reversed. +- Claude Desktop gets the same correction for free, since it dropped its + probe first (LLP 0115 #no-attach-on-join, #444/#445): its permanent + `attach claude-desktop [pending]` becomes `n/a` too. It also loses its + `client_attach_missing` warning, which LLP 0139 #repair-must-be-runnable + had pointed at `hyp claude-desktop install`. No signal is lost: with no + probe to read back, that warning fired identically before the consent + prompt, after a decline, and after a fully successful install, so it never + distinguished the case it named. A real "did the plist land?" check is + `hyp claude-desktop verify`, or the registry-derived signal left open below. +- `ClientAttachReport` gains a required `attachable` field, and `hyp status + --json` a per-client `attachable` key. `attached` keeps its type and its + place for every row, so a consumer pinning it does not break; a consumer + that wants to distinguish "no marker" from "no such thing as a marker" + reads the new key. - Anyone who attached under LLP 0109 keeps a `models.providers.hypaware` entry and a repointed `model.primary` in their `openclaw.json`, and removing the format removes the code that would have reversed it. This is diff --git a/src/core/commands/status.js b/src/core/commands/status.js index b31098c3..d22a9909 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' } @@ -356,7 +360,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 0143#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 3e7e9d91..e17d1336 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -588,28 +588,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", and every attach surface below reads this flag rather + // than a probe result that was never taken. + // @ref LLP 0143#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 } : {}), }) - if (configured && !probe.attached) { + if (configured && attachable && !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 - // declares `contributes.client` for probe/status plumbing but no - // adapter (claude-desktop: its plist is placed by an attended command - // with its own sudo prompt and consent gate, never by attach-on-join) + // declares `contributes.client` for probe/status plumbing but no adapter // has to name its own setup command instead, or the repair we print is // one that answers `unknown client`. The command comes from the same // plugin's picker row, which already declares it as `configure_command`. + // The `attachable` guard above already excludes the probe-less clients + // (claude-desktop, openclaw) whose repair could never clear the warning + // it was printed under, so what reaches here is a probed client whose + // marker is genuinely absent. // @ref LLP 0139#repair-must-be-runnable [implements]: an adapterless client's attach-missing repair names its configure_command, not the inert generic attach const configureCommand = catalog?.pickerDescriptors.get(clientName)?.configureCommand const repair = configureCommand ? `hyp ${configureCommand}` : `hyp attach --client ${clientName}` @@ -885,25 +898,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 0143#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)) @@ -940,10 +961,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 a5f6d65e..85ec8a6e 100644 --- a/src/core/daemon/types.d.ts +++ b/src/core/daemon/types.d.ts @@ -163,7 +163,15 @@ 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 (openclaw, LLP + * 0143; claude-desktop, LLP 0115), whose attach state is not applicable + * rather than negative: `attached` is then structurally false and means + * nothing (LLP 0143 #status-derives-by-the-same-gate). + */ + 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/status-client-error.test.js b/test/core/status-client-error.test.js index 70c6d3e6..33d2cbfc 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 0143 #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..87874aee --- /dev/null +++ b/test/core/status-probeless-client.test.js @@ -0,0 +1,138 @@ +// @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`: openclaw +// per LLP 0143, claude-desktop per LLP 0115) 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 three states that can never resolve: +// a permanent `pending` attach action, a `not attached` client row, and an +// inert `client_attach_missing` repair (#544). +// @ref LLP 0143#decision [tests]: attach-on-join stays inert for a probe-less client, and status says so + +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 both a probe-less client adapter + * (openclaw) and a probed one (claude), with no attach markers yet. The probed + * client is the over-suppression guard: whatever gates openclaw must leave + * claude's pending/`not attached`/`client_attach_missing` trio intact. + * + * @param {string} hypHome + */ +async function joinedWithOpenClaw(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/openclaw' }, + { name: '@hypaware/claude' }, + ], + 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 client is 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 joinedWithOpenClaw(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('openclaw')?.state, 'n/a') + // Over-suppression guard: a probed client with no marker is still pending. + assert.equal(attach.get('claude')?.state, 'pending') + + const stdout = makeBuf() + renderStatusText({ report, clientNames: [], datasets: [], cacheRoot: '/tmp/cache', stdout }) + const text = stdout.text() + assert.match(text, /attach openclaw\s+\[n\/a\]/) + assert.match(text, /attach claude\s+\[pending\]/) +}) + +test('a probe-less client raises no client_attach_missing, the probed one still does (#544)', async () => { + const hypHome = await makeHome() + const report = await joinedWithOpenClaw(hypHome) + + const missing = report.diagnostics.filter((d) => d.kind === 'client_attach_missing') + // The printed repair (`hyp attach --client openclaw`) resolves the adapter's + // deliberate LLP 0143 no-op and writes no marker, so it clears nothing. + assert.ok( + !missing.some((d) => d.message.includes('openclaw')), + `expected no client_attach_missing for openclaw, got: ${missing.map((d) => d.message).join(' | ')}` + ) + // Over-suppression guard: the probed client's attach really is missing. + assert.ok(missing.some((d) => d.message.includes('claude'))) +}) + +test('a probe-less client row reads attach n/a, not "not attached" (#544)', async () => { + const hypHome = await makeHome() + const report = await joinedWithOpenClaw(hypHome) + + const openclaw = report.clients.find((c) => c.name === 'openclaw') + assert.ok(openclaw, 'expected an openclaw client row') + assert.equal(openclaw.configured, true) + assert.equal(openclaw.attachable, false) + + const claude = report.clients.find((c) => c.name === 'claude') + assert.ok(claude, 'expected a claude client row') + assert.equal(claude.attachable, true) + assert.equal(claude.attached, false) + + const stdout = makeBuf() + renderStatusText({ report, clientNames: [], datasets: [], cacheRoot: '/tmp/cache', stdout }) + const text = stdout.text() + assert.match(text, /- openclaw {2}\[configured, attach n\/a\]/) + assert.match(text, /- claude {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 === 'openclaw')?.attachable, false) + assert.equal(rows.find((r) => r.name === 'claude')?.attachable, true) +}) From 03f6d4757dab872a1c73fce19dbf32807cb21d6d Mon Sep 17 00:00:00 2001 From: test Date: Fri, 31 Jul 2026 22:11:38 +0000 Subject: [PATCH 2/2] review: keep the openclaw acceptance gate and the 0139 ref honest (#553) 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 --- README.md | 2 +- docs/ACCEPTANCE.md | 16 +++++++++++----- llp/0139-desktop-picker-consent.decision.md | 7 +++++-- src/core/daemon/status.js | 21 ++++++++++++--------- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index f4822558..7623d9f2 100644 --- a/README.md +++ b/README.md @@ -378,7 +378,7 @@ run directly. The common Phase 8 conditions: | `gateway_missing_anthropic_upstream` | `@hypaware/claude` enabled but no Anthropic upstream is registered on the gateway | re-run `hyp init` and pick the Anthropic upstream | | `gateway_missing_openai_upstream` | `@hypaware/codex` enabled but no OpenAI upstream is registered | re-run `hyp init` and pick the OpenAI upstream | | `sink_missing_encoder` | a local-fs sink is configured but no encoder plugin is enabled | re-run `hyp init` and pick "local Parquet export" | -| `client_attach_missing` | a client plugin is enabled but its settings file shows no HypAware marker | `hyp attach --client claude` or `hyp attach --client codex` | +| `client_attach_missing` | a client plugin with an attach probe is enabled but shows no HypAware marker | `hyp attach --client claude` or `hyp attach --client codex` | | `daemon_binary_missing` | the daemon installer references a binary that no longer exists on disk | `hyp daemon install` | | `daemon_loaded_no_pid` | the daemon service file is installed but launchd / systemd is not loading it | `hyp daemon restart` | | `recent_errors` | the local telemetry directory has recent error log entries | inspect `~/.hyp/hypaware/dev-telemetry`, then `hyp daemon restart` | diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 0d6f623c..3272a7a7 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -299,11 +299,17 @@ requirements this procedure checks), [LLP 0161](../llp/0161-openclaw-full-captur row there is only reachable through both shadow providers and a hook that steered, so it proves at once what this step checks separately. - Expect `hyp status` to also carry a `client_attach_missing` warning for - `openclaw`, telling you to run `hyp attach openclaw`. It is inert here - and running it changes nothing: the generic attach probe has no marker - to find by design (R7). Note it and move on; it says nothing about - capture either way. + Expect `hyp status` to carry **no** `client_attach_missing` warning for + `openclaw`, and its clients row to read `[configured, attach n/a]` + rather than `not attached`. That absence is the pass condition, not a + regression: with no `attach_probe` (R7) there is no marker to miss, + `hyp attach openclaw` is a documented no-op, and status derives attach + state by the same gate the attach reconciler does + ([LLP 0143 #status-derives-by-the-same-gate](../llp/0143-openclaw-registers-no-attach-probe.decision.md#status-derives-by-the-same-gate), + #544). On a joined host the `client actions:` section shows `attach + openclaw [n/a]` for the same reason; a `backfill @hypaware/openclaw + [pending]` row beside it is a real, unrelated target. None of these + lines says anything about capture either way. 2. Note the current row count and pin the window, so steps 4 and 6 measure only new traffic: diff --git a/llp/0139-desktop-picker-consent.decision.md b/llp/0139-desktop-picker-consent.decision.md index 2781096a..7614ae59 100644 --- a/llp/0139-desktop-picker-consent.decision.md +++ b/llp/0139-desktop-picker-consent.decision.md @@ -215,8 +215,11 @@ adapter has to name its own setup command, or the repair we print answers > `attach_probe`, Claude Desktop included. A runnable repair is necessary and > not sufficient: the warning also has to be one the repair can clear, and > with no probe to read back this one fired identically before the consent -> prompt, after a decline, and after a successful install. The fallback below -> is now reached only by a probed client. +> prompt, after a decline, and after a successful install. The lookup below +> is now reached only by a probed client, and since Claude Desktop was the +> only picker row declaring a `configure_command`, no plugin shipping today +> takes its first branch; it stays as the contract for a probed client that +> still owns its own setup command. ## Consequences diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index e17d1336..cdc48ef3 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -613,17 +613,20 @@ export async function collectHypAwareStatus(opts = {}) { ...(probe.error !== undefined ? { error: probe.error } : {}), }) if (configured && attachable && !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 - // declares `contributes.client` for probe/status plumbing but no adapter - // has to name its own setup command instead, or the repair we print is - // one that answers `unknown client`. The command comes from the same - // plugin's picker row, which already declares it as `configure_command`. - // The `attachable` guard above already excludes the probe-less clients + // The repair has to be a command that runs: `hyp attach` only answers for + // a client whose plugin registers a runtime adapter the generic + // reconciler can drive, so a client that needs its own setup command + // names it here instead, from the same plugin's picker row that already + // declares it as `configure_command`. + // The `attachable` guard above excludes every probe-less client // (claude-desktop, openclaw) whose repair could never clear the warning // it was printed under, so what reaches here is a probed client whose - // marker is genuinely absent. - // @ref LLP 0139#repair-must-be-runnable [implements]: an adapterless client's attach-missing repair names its configure_command, not the inert generic attach + // marker is genuinely absent. That is also why no plugin shipping today + // takes the `configureCommand` branch: claude-desktop was the only picker + // row declaring one and it is probe-less. The lookup stays as the + // contract for a probed client that still owns its own setup command - + // dropping it would reinstate the `unknown client` repair for that case. + // @ref LLP 0139#repair-must-be-runnable [implements]: the repair is the client's own picker `configure_command` when it declares one, never a generic attach that would answer `unknown client` const configureCommand = catalog?.pickerDescriptors.get(clientName)?.configureCommand const repair = configureCommand ? `hyp ${configureCommand}` : `hyp attach --client ${clientName}` diagnostics.push({