Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
16 changes: 11 additions & 5 deletions docs/ACCEPTANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 8 additions & 2 deletions llp/0044-client-attach-on-join.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 16 additions & 2 deletions llp/0139-desktop-picker-consent.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,15 +209,29 @@ back to `hyp attach --client <name>` 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 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

- Ticking Claude Desktop in `hyp init` now works end to end: compose,
explain, confirm, login, helper, residue, plist, restart.
- 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
Expand Down
42 changes: 42 additions & 0 deletions llp/0143-openclaw-registers-no-attach-probe.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<a id="status-derives-by-the-same-gate"></a>**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
Expand All @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/core/commands/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down Expand Up @@ -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`)
}
Expand Down
61 changes: 43 additions & 18 deletions src/core/daemon/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -588,29 +588,45 @@ 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) {
// 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)
// 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`.
// @ref LLP 0139#repair-must-be-runnable [implements]: an adapterless client's attach-missing repair names its configure_command, not the inert generic attach
if (configured && attachable && !probe.attached) {
// 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. 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({
Expand Down Expand Up @@ -885,25 +901,33 @@ function buildClientActionsReport({ status, config, hasCentral, clientDescriptor
if (entry.enabled === false) continue
enabledByPlugin.set(entry.name, entry)
}
/** @type {Map<string, { onJoin: boolean }>} */
/** @type {Map<string, { onJoin: boolean, inert?: boolean }>} */
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<string, Map<string, { onJoin: boolean }>>} */
/** @type {Record<string, Map<string, { onJoin: boolean, inert?: boolean }>>} */
const declaredByKind = { backfill: declared, attach: declaredAttach }
/** @type {Set<string>} */
const kinds = new Set(Object.keys(byKind))
Expand Down Expand Up @@ -940,10 +964,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 })
}
Expand Down
10 changes: 9 additions & 1 deletion src/core/daemon/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions test/core/status-client-error.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
Loading
Loading