Skip to content

Uninstalling the daemon detaches Claude and Codex too - #703

Merged
platypii merged 5 commits into
masterfrom
uninstall-detaches-clients
Aug 11, 2026
Merged

Uninstalling the daemon detaches Claude and Codex too#703
platypii merged 5 commits into
masterfrom
uninstall-detaches-clients

Conversation

@platypii

@platypii platypii commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

hyp daemon uninstall removed the launchd / systemd service and nothing else, by design. That left ~/.claude/settings.json still setting ANTHROPIC_BASE_URL to http://127.0.0.1:<port> and ~/.codex/config.toml still naming the hypaware provider on that port, with nothing listening. A client pointed at a dead local port does not fall back to talking to Anthropic directly; it fails every request.

Uninstall now sweeps the clients after the service is gone:

  • The sweep runs only once the service teardown succeeds. A failed uninstall leaves a daemon still serving that port, and detaching from a working gateway would cost capture for no reason.
  • It reverses through the single core disk undo hyp detach and the reconciler's reverse() already share, so org-installed assets come off with it and there is no third implementation to drift.
  • Every known client is asked rather than probed first; the undo is already an honest no-op on one that was never attached, so a machine with nothing attached prints nothing extra.
  • Per-client failures are collected, not thrown, so one wedged client cannot leave the rest attached. Each names the hyp detach <client> that finishes it and the command exits nonzero.

hyp daemon stop and hyp daemon restart still touch no client settings: their port comes back, and detaching there would break capture on every restart.

This is a second exception to LLP 0063's "each connection level exits with its own verb", so the rationale lands as LLP 0206 with an Extended-by: forward reference on 0063: an exit verb may cascade down when the lower-level state it would leave behind is broken rather than merely stale.

Tests: three new cases covering both clients reverted, the no-attach no-op, and a one-client sweep not reporting the untouched one. Full suite and typecheck pass.

Removing the service left both clients pointed at a local gateway port
that no longer answers, which fails every request rather than falling
back to the vendor. Uninstall now runs the same disk undo `hyp detach`
uses, once the service is actually gone, and names each settings file it
reverted. `stop` and `restart` still touch nothing: their port comes back.
@platypii
platypii force-pushed the uninstall-detaches-clients branch from 773dfed to 44c1da2 Compare August 10, 2026 22:22
@platypii platypii added the neutral:review Delegate this PR to neutral for a review pass (approve or request changes; never merges) label Aug 10, 2026
philcunliffe pushed a commit that referenced this pull request Aug 10, 2026
Master's highest LLP is 0205. PR #703 (uninstall-detaches-clients) also
mints llp/0206 for an unrelated decision, and PR #701 (fix/issue-700)
already claims 0207, so this PR's compaction-file-size decision moves to
0208, the next free number. Mechanical renumber only (LLP 0156): the
filename, the doc header, the Extended-by forward-ref on LLP 0199, and
every @ref annotation in src/ and test/ move together; no meaning
changes.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor

Review - head 44c1da2 (review-only delegation)

Verdict: request changes. npm test (3899 pass / 0 fail) and npm run typecheck green; style clean (no semicolons, no em dashes). Three of the four PR-body claims verify. One does not, and the sweep has a client-shaped hole.

This PR carries neutral:review, so nothing was pushed to the branch and nothing will be: the findings below are recommendations for you to apply.

Confirmed correct

  • Shared-undo claim holds. detachAllClientsFromDisk -> detachClientViaCore -> detachClientFromDisk (client_detach_disk.js:120), the same entry point hyp detach and action_attach.js:312's reverse() use. No third implementation.
  • Failure collection is genuine, verified empirically: with claude attached and openclaw wedged, claude still detaches and openclaw lands in failed.
  • Destructive scope is safe. Exercised the hand-edit cases: a user who re-points ANTHROPIC_BASE_URL after attach keeps their value; a malformed settings file produces a collected failure with the file byte-identical; the sweep is idempotent and restores the pre-attach value.

blocker - src/core/commands/daemon.js:264-274: the sweep cannot detach OpenClaw, because it runs after teardown

OpenClaw is a third attachable client (openclaw/hypaware.plugin.json, format: json_path). Its undo needs expectedBaseUrl, and detachJsonPathProviders throws EXPECTED_BASE_URL_UNKNOWN (client_detach_disk.js:782-793) when a provider entry is present and the URL is unresolvable.

resolveExpectedGatewayBaseUrl (clients.js:1016-1043) has three rungs and no DEFAULT_GATEWAY_ENDPOINT fallback, and at sweep time all three are dead on a default install:

  1. capabilities - registered but not bound in this process (the usual CLI case).
  2. configuredGatewayEndpoint - undefined, because hyp init deliberately writes no listen (LLP 0114 #init-writes-no-listen, pinned by test/core/init-gateway-listen-default.test.js).
  3. resolveLiveGatewayEndpointFromStatus (daemon/status.js:447-456) - gated on processIsAlive(pidEntry.pid), and uninstallDaemon has just removed the service.

Reproduced: staging ~/.openclaw/openclaw.json with an anthropic provider on a default config yields detached: [], failed: [openclaw: "cannot reverse ...: the gateway's base URL is unknown"], exit 1, config unchanged. The ordering gate is the direct cause: hyp detach openclaw run before uninstall resolves via rung 3 and works; run after, it fails identically. So the printed remedy run 'hyp detach openclaw' to finish reversing it is a dead end, and OpenClaw is left pointed at the dead port - the exact bug this PR exists to fix, unfixed for one of the three attachable clients.

Recommend: resolve the gateway base URL before calling uninstallDaemon and thread it into the sweep (an expectedBaseUrl parameter on detachAllClientsFromDisk / detachClientViaCore), so the sweep uses the URL the still-live daemon proved. A DEFAULT_GATEWAY_ENDPOINT fallback would also close it, but is weaker.

major - same site: a user who never attached OpenClaw gets a false uninstall failure

The throw fires on present.length > 0 regardless of ownership, so any OpenClaw config carrying an anthropic or openai provider the user set up themselves makes hyp daemon uninstall exit 1 with "a provider entry HypAware wrote cannot be told from one it did not". That contradicts this PR's own LLP 0206 D1: "the undo is already an honest no-op on a client that was never attached". It is not a no-op for json_path. Fixing the blocker above also fixes this; additionally consider treating EXPECTED_BASE_URL_UNKNOWN as non-fatal for the sweep when nothing HypAware-owned is identifiable.

major - src/core/commands/clients.js:1131: the sweep silently discards every undo warning

quiet: true skips writeCoreDetachOutput, which is the only place result.warning, result.removed, result.restoredValue and result.restoredPaths are emitted (clients.js:1327-1337). detachAllClientsFromDisk propagates only name and settingsPath, so those are dropped.

Verified: a user who re-pointed ANTHROPIC_BASE_URL after attaching gets Detached claude (/path) on stdout and empty stderr, while the undo's ANTHROPIC_BASE_URL was overridden externally; leaving in place is thrown away. hyp detach prints it; the sweep does not. Same for the prev_malformed notices, about which the source itself says (client_detach_disk.js:286-291) the marker "held the only copy ... the user who reads this line is the last person who can act on it."

This matters most in this command: uninstall reporting "Detached claude" while the client is still routed somewhere the user must now fix is precisely the silent half-detach the PR is trying to eliminate. The new JSDoc at clients.js:1053-1057 is also inaccurate - it claims quiet never suppresses warnings that name files left behind, but result.warning is a stdout write and is suppressed.

Recommend: carry warning (and restoredPaths) on each detached entry, print them indented under the client's line in daemon.js, and correct the JSDoc.

major - test/core/daemon-uninstall-detaches-clients.test.js: the two headline claims have zero coverage

grep -rl runDaemonUninstall test/ hypaware-core/smoke/ returns nothing. All three tests call detachAllClientsFromDisk directly and every one asserts sweep.failed is empty. So:

  • The ordering gate is untested - deleting the return 1 at the teardown catch would break no test, leaving the PR's primary claim unenforced.
  • Per-client failure collection and the run 'hyp detach <client>' remedy line are untested.
  • No test stages an OpenClaw config, which is why the blocker above is invisible to CI.

No smoke covers it either (claude_attach_detach, daemon_foreground_start_stop, daemon_install_render none exercise the sweep).

Recommend: a test driving runDaemonUninstall with a failing teardown, asserting return 1 and byte-identical settings files; a wedged-client test asserting the others still detach, exit 1, and stderr naming hyp detach <name>; and an OpenClaw fixture.

minor - only two of three clients named

core_commands.js:488-493 says "every attached client (Claude, Codex)" and the outer catch suggests hyp detach claude / hyp detach codex. OpenClaw is omitted from both, and LLP 0206 names only Claude and Codex throughout. Recommend: say "every attached client" without enumerating, so the text cannot drift as clients are added.

nit - the per-client failure loop does not repeat that the service is gone

The outer catch says "the service is gone; run ... by hand"; the per-client loop does not, so a user seeing exit 1 from a partly-failed sweep may think the uninstall itself failed. One trailing line would fix it.

LLP hygiene - clean

The llp/0063 change is strictly an Extended-by: header line, which is the sanctioned mechanism; nothing 0063 settled was edited. All @ref anchors resolve (LLP 0206#d1, 0063#connection-levels, 0045#part-3-..., 0138#marker-undo). Status: Draft matches other in-flight decision docs on master.

Cross-PR note: an LLP 0206 numbering collision (already resolved elsewhere)

origin/master tops out at llp/0205. This branch adds llp/0206-uninstall-detaches-its-clients.decision.md, while open PR #698 independently adds llp/0206-compaction-file-size.decision.md, and PR #701 already claims 0207. Whichever of #698/#703 merged second would have collided, and 0207 was not available as a landing spot. Because this delegation is review-only, this branch was not touched: #698 is being renumbered to 0208 instead. No action needed here - flagging so you know the collision existed and how it was handled.

@philcunliffe philcunliffe added the neutral:changes-requested neutral reviewed an adopted PR and requests changes (non-binding; maintainer decides) label Aug 10, 2026

@philcunliffe philcunliffe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head 44c1da2 under the neutral:review delegation (review-only: nothing pushed to this branch).

One blocker: the sweep cannot detach OpenClaw, because it runs after teardown. detachJsonPathProviders needs expectedBaseUrl, and all three rungs of resolveExpectedGatewayBaseUrl are dead once the service is gone (capabilities unbound in the CLI, hyp init writes no listen per LLP 0114, and the live-status rung is gated on processIsAlive). Reproduced: a staged OpenClaw provider yields failed: [openclaw], exit 1, config unchanged - so OpenClaw is left pointed at the dead port, which is the bug this PR exists to fix. The suggested remedy hyp detach openclaw is a dead end after uninstall for the same reason. Recommend resolving the base URL before uninstallDaemon and threading it into the sweep.

Plus three majors: a user who never attached OpenClaw gets a false uninstall failure (contradicting this PR LLP 0206 D1); quiet: true silently discards every undo warning, including the "overridden externally" and prev_malformed notices that only the sweep-time reader can act on; and the two headline claims (the teardown ordering gate, per-client failure collection) have zero test coverage - no test drives runDaemonUninstall at all, so deleting the return 1 would break nothing.

Full evidence, plus a minor/nit and an LLP 0206 numbering-collision note, in the review record comment above. The shared-undo, failure-collection and destructive-scope claims all verified correct, and LLP hygiene is clean.

…ery client

The json_path undo judges provider ownership by the gateway's base URL,
and every rung of that resolution dies with the service, so uninstall now
captures it first and hands it to the sweep. A config whose providers
carry no HypAware marker header is a no-op rather than a failure when the
origin is unknown. The quiet sweep now carries each undo's warnings back
to the uninstall output, help text stops enumerating clients, and the
uninstall command itself is under test through a teardown seam.
@platypii

Copy link
Copy Markdown
Contributor Author

Addressed in caf18b4:

  • Blocker: the gateway origin is now resolved before teardown and threaded into the sweep (detachAllClientsFromDisk / detachClientViaCore take an expectedBaseUrl), so the json_path undo can judge ownership after the service is gone. A test pins the ordering by deleting the pid file during a fake teardown.
  • False failure: when the origin is unknown and no present provider entry carries the HypAware marker header, the undo returns changed:false instead of throwing; a never-attached OpenClaw config no longer fails the uninstall. Entries that are ours by signature but unconfirmable still fail loudly, per LLP 0169.
  • Warnings: sweep entries now carry warning/removed/restoredValue/restoredPaths, runDaemonUninstall renders them under each Detached line, and the quiet JSDoc was corrected.
  • Coverage: runDaemonUninstall is now driven directly through a deps.uninstallDaemon test seam: failing teardown (exit 1, settings byte-identical), wedged client (others detach, exit 1, remedy line), OpenClaw fixtures, and warning propagation.
  • Minor/nit: help and remedy text no longer enumerate clients, and a trailing line says the service itself was removed when only the sweep failed. LLP 0206 (Draft) updated to record the pre-teardown resolution and the honest-no-op refinement.

@philcunliffe

Copy link
Copy Markdown
Contributor

Review - head caf18b4 (review-only delegation)

Verdict: request changes. Thank you for the push - all six earlier findings are genuinely resolved, and each was verified by reproduction or mutation rather than by reading the commit message. Two new findings remain: one major that is a narrower survivor of the original blocker, and one objective CLAUDE.md violation. npm test 3906 pass / 0 fail (up from 3899, 7 new tests), npm run typecheck clean.

Nothing was pushed to this branch, per the neutral:review grant.

The six earlier findings, re-verified

  1. blocker (OpenClaw undetachable because the sweep ran after teardown) - resolved. daemon.js:272 now resolves the origin before the uninstallDaemon try block and threads it through. Reproduced end-to-end through the real CLI with a staged OpenClaw provider and a live pid: Detached openclaw, Removed http://127.0.0.1:4123, exit 0, models.providers left {}. The failed-teardown gate still holds.
  2. major (false uninstall failure for a never-attached OpenClaw) - resolved. The anyOurs marker probe returns { changed: false } instead of throwing. A user-authored openclaw.json with no marker leaves uninstall at exit 0 and the file byte-identical, and hyp detach openclaw now says "nothing to do" rather than erroring.
  3. major (undo warnings silently discarded) - resolved. clients.js:1296-1301 propagates removed/restoredValue/restoredPaths/warning and daemon.js:296-303 re-renders them indented, mirroring writeCoreDetachOutput. Verified: the re-pointed-ANTHROPIC_BASE_URL case now prints warning: ANTHROPIC_BASE_URL was overridden externally; leaving in place.
  4. major (zero coverage of the headline claims) - resolved. runDaemonUninstall is now driven by five of the seven new tests. Five mutations, all caught: teardown return 1 to return 0 (test 8 fails); that return 1 deleted (test 8); moving the resolve after teardown (test 7); deleting the anyOurs early return (test 5); dropping the warning propagation or its render (test 10).
  5. minor (help text named only two clients) - resolved.
  6. nit (failure loop did not repeat the service was removed) - resolved.

NEW-1 - major - daemon.js:272 / status.js:456: uninstall still cannot detach OpenClaw when the daemon is not running, and the remedy it prints is a dead end

Resolving before teardown fixes the daemon-alive case. It does not help when the daemon is already stopped or crashed: rung 3 (resolveLiveGatewayEndpointFromStatus) is gated on processIsAlive(pidEntry.pid), so with no pid file there is nothing to capture before teardown either. Reproduced through the real CLI with status.json present and no pid file:

hyp daemon uninstall: detach 'openclaw' failed: cannot reverse .../openclaw.json:
  the gateway's base URL is unknown ...
  run 'hyp detach openclaw' to finish reversing it
exit=1

$ hyp detach openclaw
error: detach client 'openclaw' failed: ... base URL is unknown
exit=1

The config is unchanged, OpenClaw is left pointed at the dead port, and there is no CLI path out short of hand-editing. This is not an exotic sequence: README.md:370-371 lists hyp daemon stop on the line directly above hyp daemon uninstall. It is also a new loud failure - on master, hyp daemon uninstall exits 0 on such a machine.

Recommend: for the ownership judgement the relevant fact is historical ("what origin did attach write"), not live, so read the last-known bound port out of status.json without the liveness gate when the gated rung yields nothing. status.js:768-773 already sets that precedent for recentEntrypoints ("a last-seen timestamp survives its daemon"), and status.json survives a stop (there is no clearStatusFile). Failing that, at minimum the remedy line should not point at a command that cannot work.

NEW-2 - minor - daemon.js:250: an inline import('...') type, which CLAUDE.md forbids outright

* @param {{ uninstallDaemon?: typeof import('../daemon/install.js').uninstallDaemon }} [deps]

CLAUDE.md: "Never use inline import('...') types. Declare type imports at the top of the file with @import JSDoc comments, then reference the bare names," with repo-root-anchored specifiers. This is the only such occurrence in src/, and the repo lints third-party plugins for exactly this pattern (plugin_doctor/diagnose.js:221). Recommend: add uninstallDaemon to the file's @import block and reference the bare name.

NEW-3 - minor - provider_entry_ownership.js:47-55: the shared predicate's JSDoc is now false

It states ours is undefined only on the attach side, and that "Detach always passes the set (detachJsonPathProviders refuses outright rather than run without it)". As of this commit detachJsonPathProviders calls it with undefined (client_detach_disk.js:791) and no longer refuses. The behaviour is safe (the probe is read-only and the delete loop still requires the set), but this is the contract for the one predicate both halves share, which is precisely the drift it exists to prevent. Recommend: name the read-only ownership probe as the third caller.

NEW-4 - nit - README.md:418-430 contradicts LLP 0206's new consequence bullet

The "Uninstalling" recipe still tells users to run hyp detach claude / hyp detach codex first, enumerating two of three clients, while LLP 0206 now claims "user-facing text names no fixed client list, so a new client adapter joins the sweep without a docs change". The steps are redundant rather than wrong, but the enumeration is the drift this PR set out to remove.

Checked and clean

  • client_detach_disk.js blast radius on its other callers. The reconciler's reverse() passes ctx.endpoint, the proven-bound gateway URL, so ours is never undefined there and the new branch is unreachable on that path. hyp leave and hyp detach keep the old fallback resolution; their only behaviour change is the never-attached no-op, a strict improvement. hyp detach and hyp detach --json output shapes verified by hand.
  • Test seam arity: dispatch.js:470 calls run(rest, cmdCtx) with exactly two arguments, so deps can never be injected from the CLI.
  • LLP 0206 is Status: Draft, so the edits are permitted; the added ordering and warning paragraphs match the implemented behaviour, and all @ref anchors resolve.
  • One pre-existing quirk, not introduced here: a result with changed: false but a non-empty warning is dropped by both the sweep and writeCoreDetachOutput's else branch, so hyp detach and uninstall are at parity.

@philcunliffe philcunliffe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at head caf18b4 (review-only: nothing pushed to this branch).

All six earlier findings are resolved, verified by reproduction and by five mutations that are each now caught by a test. The OpenClaw blocker is genuinely fixed for the daemon-alive case, the never-attached false failure is gone, undo warnings surface, and runDaemonUninstall is finally driven by tests.

One major survivor (NEW-1): uninstall still cannot detach OpenClaw when the daemon is not running. Resolving the origin before teardown does not help if there was never a live pid to resolve from - rung 3 is gated on processIsAlive. Reproduced with status.json present and no pid file: exit 1, config unchanged, OpenClaw left pointed at the dead port, and the printed remedy hyp detach openclaw fails identically. README.md:370 lists hyp daemon stop directly above hyp daemon uninstall, so this sequence is ordinary - and it is a new loud failure, since master exits 0 there. Suggested fix: read the last-known port from status.json without the liveness gate (the file survives a stop, and status.js:768 already sets that precedent), or at minimum stop printing a remedy that cannot work.

Plus a minor CLAUDE.md violation (an inline import() type at daemon.js:250, the only one in src/), a now-false JSDoc on the shared ownership predicate, and a README recipe that still enumerates two of three clients.

Full evidence in the review record comment above.

…ient's undo

The json_path undo no longer needs the gateway's live base URL: the
marker header attach writes is HypAware's own name, and attach already
trusted it alone. Ownership is now that signature on both sides
(LLP 0210), so the expectedBaseUrl threading, the pre-teardown origin
capture, and the EXPECTED_BASE_URL_UNKNOWN refusal are gone, and the
uninstall sweep works whether the daemon is running, stopped, or long
dead. A not-ours entry is left in place instead of moved to a backup
key, the cache purge follows the settings disposal, and the README
uninstall recipe stops enumerating clients.
@platypii

Copy link
Copy Markdown
Contributor Author

b316c79 reworks the OpenClaw handling instead of patching NEW-1. Stepping back from the survivor finding: the root cause was that the json_path undo was the only one needing a fact from a running daemon (the gateway's live origin) to judge ownership, while the json and toml undos read everything off the settings file. That asymmetry is what forced the pre-teardown origin capture, and it still left a stopped-daemon machine unable to detach OpenClaw at all.

The rework (LLP 0210, extending LLP 0169 and LLP 0172): ownership is the entry's own signature - the x-hypaware-upstream marker header naming its key plus the shape attach produces. Attach has always trusted that signature alone (it must, since a drift re-attach sees its own entry carrying the old origin), so detach now matches it. Consequences:

  • expectedBaseUrl is gone from detachClientFromDisk, reverse(), detachClientViaCore, and the sweep; resolveExpectedGatewayBaseUrl and the EXPECTED_BASE_URL_UNKNOWN refusal are deleted. NEW-1 disappears rather than being patched: uninstall detaches OpenClaw with the daemon running, stopped, or crashed, and 'hyp detach openclaw' is a truthful remedy in any daemon state (new stopped-daemon test).
  • A stale entry from an old port is now correctly deleted (previously it was misclassified as not-ours and backed up). The trade: an entry forged to be byte-indistinguishable from ours is deleted too; accepted and recorded in LLP 0210 D1.
  • The _hypaware_detach_backup disposal is retired (D2). Reworking exposed that it displaced a never-attached user's own provider entries into the backup key - and the cache purge deleted their cache rows - whenever the origin resolved. Not-ours entries are now left in place with a path-only warning (gated on a partial undo), mirroring the json undo's overridden-value disposal, and the cache purge only runs once a deletion proves an attach existed, skipping keys a user value still holds.
  • NEW-2 (inline import type), NEW-3 (predicate JSDoc, now rewritten for the single signature), and NEW-4 (README enumeration) are all addressed; the README uninstall recipe now leans on the sweep.

Net: 309 insertions, 342 deletions, and no client undo depends on daemon liveness anymore.

@philcunliffe

Copy link
Copy Markdown
Contributor

Review - head b316c79 (review-only delegation)

Verdict: request changes. The round-2 major is genuinely fixed, and the approach you took is better than the one I recommended. What remains is a small set of minors, two of which are the same class of stale-documentation issue NEW-3 already flagged, re-introduced in a new form.

npm test 3906 pass / 0 fail (3912 total), npm run typecheck exit 0, smokes client_attach_idempotent, claude_attach_detach, client_attach_on_join ok. No em dashes, no semicolons in the diff. Nothing was pushed to this branch.

On the approach

I recommended recovering the historical origin from status.json. You dropped expectedBaseUrl entirely instead, making isOwnedProviderEntry signature-only (marker header names its own key, baseUrl is a string, models is empty) with both callers on the identical predicate.

That is the better design. The origin comparison never protected anything the signature does not already decide, and it created a class - "ours, but at a stale port" - that the old code misclassified as not-ours and backed up rather than deleting. It also fixes a latent bug my recommendation would have missed entirely: the reconciler's reverse() passed ctx.endpoint, which after an LLP 0086 ephemeral rebind is the new port, so reverse would have refused over its own entry from the old one.

Per-finding status

NEW-1 (major) - resolved. Reproduced both directions through the real CLI with status.json present, no pid file, and OpenClaw carrying both provider entries:

  • on b316c79: Detached openclaw, Removed http://127.0.0.1:4123, exit 0, models.providers emptied, the unrelated theme key preserved.
  • on caf18b4, same fixture: the gateway's base URL is unknown, exit 1, config byte-identical.

NEW-2 (minor) - resolved. daemon.js:12 now declares @import { uninstallDaemon as uninstallDaemonFn } and line 251 uses the bare name. No inline import('...') type remains anywhere under src/.

NEW-3 (minor) - NOT resolved. The false sentence about ours is gone, but src/core/config/provider_entry_ownership.js:5-13 now carries two new false claims:

  • line 8-9 says core "deletes its own entry and backs up anyone else's". LLP 0210 D2 retired the backup; probed and confirmed a not-ours entry is left exactly in place and _hypaware_detach_backup is never written.
  • lines 6 and 9-10 say the predicate is shared by three sides including "read-only ownership probes either side runs". There are exactly two callers in-tree (openclaw/src/attach.js:254, client_detach_disk.js:783); probeClientAttachFromDescriptor matches the marker header directly and never imports this module.

Recommend: "two sides", and "deletes its own entry and leaves anyone else's in place".

NEW-4 (nit) - resolved. README.md:421-431 no longer enumerates clients.

Signature-only ownership, probed directly

fixture outcome
user-authored provider, no marker changed:false, byte-identical, no warning
marker present, models hand-edited non-empty not ours, left in place
marker names the other key not ours, left in place
ours at an old port :59999 deleted (the fix)
ours at openai plus user's own anthropic openai deleted, anthropic kept, warning names the path only
forged: user's baseUrl and x-api-key, but carrying x-hypaware-upstream and models: [] deleted, no backup

The forged case is the one real give-up. LLP 0210 D1 names and accepts it, and the symmetry argument holds under verification: conflictingProviderKeys calls the identical predicate, so attach would silently overwrite the same entry on the next drift pass. Not blocking - but note hyp detach --json signals such a deletion only as removed: <their baseUrl>, with no warning.

Blast radius on the shared undo

Reconciler reverse() now calls detach({ descriptor, env }) with ctx.endpoint untouched for perform(), and is strictly improved. hyp leave never passed an origin and no longer depends on resolving one (leave-command.test.js passes). hyp detach dry-run unchanged; no dead imports left behind.

Mutation coverage of the new logic

Seven mutations applied and reverted; six caught. M4 - ungating the cache purge from deleted.length > 0 - caught nothing, which is finding N7.

New findings

N5 (minor) - src/core/config/types.d.ts:733-745. The exported ClientDetachFromDisk type still declares expectedBaseUrl?: string, documented as "ownership can only be decided by comparing that entry's baseUrl against the URL attach would have written (LLP 0172 section 2.1)" - the exact clause LLP 0210 retires. typecheck passes because an extra optional property is structurally harmless, so nothing catches it. This is the detach seam's type in CreateAttachHandlerOptions and it ships in the published types/ build, so a plugin author reads a parameter no implementation consumes. Recommend deleting the field and its JSDoc.

N6 (minor) - the cache purge is no longer retryable. client_detach_disk.js:806-820. Gating the purge on deleted.length > 0 means a detach whose settings half landed but whose cache half failed cannot be finished by rerunning it. Verified: with models.providers already empty and agents/main/agent/models.json still holding a stale entry, caf18b4 purges to {} on rerun while b316c79 leaves the cache dirty with changed:false. This matters because purgeProviderCaches' own doc says these caches "do not self-heal ... leaving them keeps a detached client pointed at a dead gateway", every purge failure is a warning that invites a retry, and docs/ACCEPTANCE.md:398-406 greps for exactly this residue. Recommend keeping D2's never-attached guarantee but moving the gate per-row: purge a cache row when it carries the marker header (the same signature D1 already trusts) rather than gating the whole purge on the settings deletion. That also stops the current code deleting a user's cached row for a managed key absent from the settings.

N7 (minor) - no test for LLP 0210 D2's purge gate. Removing the deleted.length > 0 gate at client_detach_disk.js:812 fails nothing, because the never-attached test in test/core/client-detach-json-path.test.js stages no cache file. D2 states the rule explicitly ("a never-attached machine's derived caches are never edited") and it is the only new rule in this commit with zero coverage. Recommend staging a cache file in that test and asserting it is untouched.

N8 (nit) - README.md:262-270 still lists only hyp detach claude / hyp detach codex, omitting openclaw. Same class as NEW-4 but a different region, pre-existing on the branch base and outside this diff; noting only because LLP 0206's "names no fixed client list" consequence applies here equally.

For the next round

N5 and NEW-3 are one-line edits; N7 is a few lines of fixture. N6 is the only one needing a decision rather than an edit - if the gate is deliberate, LLP 0210 D2 should say that a failed cache purge is not recoverable by rerunning hyp detach.

@philcunliffe philcunliffe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at head b316c79 (review-only: nothing pushed to this branch).

The round-2 major is resolved, and your approach is better than the one I recommended. I suggested recovering the historical origin from status.json; you dropped expectedBaseUrl entirely and made ownership signature-only. That is the stronger design: the origin comparison never protected anything the signature does not already decide, and it created a "ours, but at a stale port" class the old code misclassified as not-ours and backed up rather than deleting. It also fixes a latent bug my recommendation would have missed - the reconcilers reverse()passedctx.endpoint`, which after an LLP 0086 ephemeral rebind is the new port, so reverse would have refused over its own entry from the old one.

Verified both directions through the real CLI: on b316c79 a stopped-daemon machine now detaches OpenClaw with exit 0; on caf18b4 the same fixture failed with "base URL is unknown". NEW-2 and NEW-4 are also resolved. Six of seven mutations on the new logic are caught by tests.

Remaining, none blocking: NEW-3 is not resolved - the JSDoc on the shared predicate swapped one false claim for two new ones (it still says core "backs up anyone elses", which LLP 0210 D2 retired, and says "three sides" where there are two callers). Plus three new minors: the exported ClientDetachFromDisktype still declaresexpectedBaseUrlwith a JSDoc citing the exact clause 0210 retires, and it ships in the publishedtypes/ build; the cache purge is no longer retryable after a partial detach; and D2s purge gate has zero test coverage (removing it fails nothing).

The retryability one is the only item needing a decision rather than an edit - if the gate is deliberate, LLP 0210 D2 should say a failed cache purge is not recoverable by rerunning hyp detach. Full evidence in the review record above.

…overage

The cache purge now runs when the managed surface is already empty, not
only when this pass deleted an entry, so a detach whose settings half
landed can be rerun to finish the cache half; keys a user value holds in
the settings still keep their cache rows. The published detach type
drops the retired expectedBaseUrl field, the shared predicate's JSDoc
matches the leave-in-place disposal, and the README attach/detach
section stops enumerating clients.
@platypii

Copy link
Copy Markdown
Contributor Author

1249a8e addresses the round-3 findings:

  • NEW-3: the shared predicate's JSDoc now says two sides and 'leaves anyone else's in place'.
  • N5: ClientDetachFromDisk in types.d.ts drops the retired expectedBaseUrl field and its LLP 0172 gloss.
  • N6: decided in favor of retryability, with a refinement of your suggestion. Per-row signature gating is not available (cache rows carry only a baseUrl; the purge deliberately refuses to pin the cache shape), so the gate moved to: purge runs when this pass deleted an entry OR the managed surface was already empty, and always skips a key a user value holds in the settings. The empty-surface case is exactly the rerun-after-partial-failure scenario, restoring the behavior ACCEPTANCE.md greps for; the never-attached guarantee is kept where it is provable (a user's own entries protect both their settings and their cache rows). An orphaned cache row with nothing at the managed keys is treated as detach residue, as the unconditional purge always did. LLP 0210 D2 and the code comment spell out the trade.
  • N7: the never-attached test now stages a cache file and asserts it is byte-identical after the sweep, and a new test pins the rerun: empty managed surface plus a dirty cache purges the residue and keeps the foreign row.
  • N8: the README attach/detach section uses 'hyp attach ' with the client list as a comment, so it cannot drift as adapters are added.

3911 tests pass, typecheck clean.

@philcunliffe

Copy link
Copy Markdown
Contributor

Review - head 1249a8e (review-only delegation)

Verdict: request changes. Three of the four round-3 items are resolved. But the N6 fix takes a different route than suggested, and in doing so introduces silent deletion of a user's own config - a worse problem than the one it solves. Gates: npm test 3907 pass / 0 fail, typecheck clean, build:types clean, npm pack --dry-run clean. Nothing pushed to this branch.

Round-3 items

item status
NEW-3 (false JSDoc on the shared predicate) resolved - now says "the two sides", drops the backup claim and the phantom third caller. Grep confirms exactly two importers.
N5 (expectedBaseUrl in the published types) resolved - gone from types.d.ts; repo-wide it survives only in llp/ history. types.d.ts does ship in the tarball, so the removal really does clean the published surface.
N6 (non-retryable cache purge) partially resolved, and it introduces a worse problem - see below.
N7 (no coverage on the purge gate) not resolved - mutation M4 still passes 9/9.
N8 (README enumerated two of three clients) resolved - now hyp detach <client> with # claude, codex, openclaw, ....

The gate became deleted.length > 0 || present.length === 0 rather than the suggested per-row check. That buys retryability only when the managed surface is entirely empty, and it pays for it by reinstating unconditional purging in exactly the case round 3 warned about.

NEW-4 - major - a never-attached machine's own cache rows are silently deleted

src/core/config/client_detach_disk.js:822. When the settings file exists but holds no managed key, present.length === 0 fires and the purge deletes anthropic and openai from every agents/*/agent/models.json. Reproduced against the real detachClientFromDisk:

S1  never attached, settings = {theme, agents}   (no models.providers key at all)
    cache before: {anthropic:{baseUrl:"https://api.anthropic.com", models:[{id:"claude-sonnet-4"}]},
                   openai:{..., models:[{id:"gpt-5"}]}}
    result:       {"changed": false}      <- no warning, no output line
    cache after:  {}

S2  never attached, models.providers = {google:{...}}  ->  same: cache after {}

models.providers is optional in openclaw.json, so S1 is the ordinary state of an OpenClaw user who never overrode a provider. LLP 0167 verify item 3 records that these caches run in merge mode and "carry every existing cache provider forward wholesale", so rows at those keys are expected to be there. And because detachAllClientsFromDisk sweeps every registered client with no attach-state precheck, hyp daemon uninstall - the whole point of this PR - does this on any machine that has OpenClaw installed and was never attached. It reports changed:false and prints nothing, so the deletion is entirely silent.

LLP 0210 D2's new justification is contradicted by this repo's own acceptance procedure. D2 now claims an orphaned cache row "is indistinguishable from that residue", but docs/ACCEPTANCE.md:398-406 greps agents/*/agent/models.json for x-hypaware-upstream and treats a match as un-purged residue - i.e. the repo already asserts the marker is present in the cache file and is the distinguishing signal. The user's rows in S1/S2 carry no marker; ours do. So D2's sentence "a never-attached machine's own providers are never edited in either file" is false as written, and the per-row check is well-founded on the repo's own documented cache shape.

Recommend: purge a cache row when it carries the marker header naming its key (D1's signature, the same thing ACCEPTANCE greps for); for a row with no marker, purge only when this run deleted the matching settings entry. That keeps D1's trust model, keeps the never-attached guarantee whole, and is retryable in every case rather than only the fully-empty one. If the real cache shape is not trusted to carry the marker, the safe fallback is reverting to deleted.length > 0 and accepting the retry gap: a residue the user is warned about is strictly better than silent deletion of their config.

NEW-5 - minor - the retryability the gate was added for does not hold in the mixed case

Same line. With a user entry at one managed key, a rerun never purges residue at the other:

S4  settings = {models.providers: {openai: <user's own>}}
    cache    = {anthropic: <ours, marker present>, openai: <user's own>}
    result:      {"changed": false}
    cache after: anthropic residue still there, marker and all

present.length === 1, deleted.length === 0, so the gate blocks. That residue plainly carries x-hypaware-upstream - the same signature D1 trusts to delete a settings entry - and is left pointing at a dead gateway forever. The per-row fix closes this too.

N7 - minor, third round - the purge gate still has no coverage on its guard side

M4 (if (true)) survives the whole file. The new never-attached test stages a cache, but both managed keys are user-owned, so they land in left and providerKeys filters to [] - the assertion is guaranteed by the left filter, not by the gate. Confirmed by mutation: M5 (ignore left) fails 1, which is what that test actually pins; M4b (revert to deleted.length > 0) fails 1; M6 (present.length === 0 only) fails 3. A test that would catch M4 is the S4 fixture above, or S1 with a cache row at a managed key the settings do not mention.

NEW-6 - nit - stale @ref gloss

client_detach_disk.js:728 still says "only the deleted keys are purged from the derived caches". The purge covers every managed key not held by a user settings entry, including keys never present in the settings at all - which is the mechanism behind NEW-4. Per CLAUDE.md "keep refs honest", reword when the gate changes.

Checked and clean

LLP 0210 is Status: Draft, so editing D2 in place is permitted, and both #d1/#d2 anchors resolve. The types.d.ts removal breaks no consumer (tsc -p tsconfig.build.json emits, npm pack packs, and nothing references the removed names). No em dashes, no added semicolons. Round 3's headline judgement stands and is not relitigated: signature-only ownership beats the origin comparison, D1 and its two callers are consistent and correct. Everything above is confined to the cache-purge gate.

@philcunliffe philcunliffe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at head 1249a8e (review-only: nothing pushed).

NEW-3, N5 and N8 are resolved. But the N6 fix took deleted.length > 0 || present.length === 0 rather than the per-row check, and that introduces a worse problem than the one it solves.

NEW-4 (major): a never-attached machine now has its own cache rows silently deleted. When the settings file holds no managed key, present.length === 0 fires and the purge wipes anthropic and openai from every agents/*/agent/models.json. Reproduced: a user with models.providers absent entirely (the ordinary state for someone who never overrode a provider) loses both cached rows, with changed:false, no warning and no output line. Since detachAllClientsFromDisk sweeps every registered client with no attach-state precheck, hyp daemon uninstall does this on any machine that has OpenClaw installed and was never attached.

LLP 0210 D2 justifies this by saying an orphaned row "is indistinguishable from that residue" — but docs/ACCEPTANCE.md:398-406 greps those cache files for x-hypaware-upstream and treats a match as un-purged residue, so the repo already asserts the marker is present in the cache and is the distinguishing signal. Ours carry it; the user`s do not.

Recommend the per-row check: purge a row carrying the marker that names its key (D1s own signature), and for an unmarked row purge only when this run deleted the matching settings entry. That keeps the never-attached guarantee whole and is retryable in every case, not just the fully-empty one. If the cache shape is not trusted to carry the marker, reverting to deleted.length > 0` is the safe fallback — a residue the user is warned about beats silent deletion of their config.

Also: NEW-5, the retryability this gate was added for still does not hold in the mixed case (a marked residue at one key with a user entry at the other is never purged); and N7 for the third round — mutation if (true) on the gate still passes the whole file, because the new tests assertion is guaranteed by the left` filter rather than by the gate.

Round 3`s judgement that signature-only ownership beats the origin comparison stands and is not relitigated. Everything here is confined to the cache-purge gate. Full evidence and fixtures in the review record above.

The round-4 gate traded one bug for a worse one: with the managed
surface empty it deleted a never-attached user's own cache rows, and in
the mixed case it never retried our residue. The caches carry provider
entries forward headers and all (ACCEPTANCE greps them for the marker),
so each row is judged by the same signature as the settings undo: a row
whose marker header names its key is ours and purges whenever seen, a
marker-less row rides out only on this run's settings deletions, and
everything else is the user's and stays. Tests pin the never-attached
untouched cases and the mixed-case rerun.
@platypii

Copy link
Copy Markdown
Contributor Author

037bc88 takes the per-row recommendation - you were right that the marker is available in the caches, and ACCEPTANCE.md's residue grep settles it. I had wrongly inferred the cache shape from the test fixtures, which wrote marker-less rows; the fixtures now carry the entries forward wholesale, marker included, matching LLP 0167 verify item 3.

  • NEW-4 / NEW-5: the caller-side gate is gone entirely. purgeProviderCaches judges each row itself: a row whose x-hypaware-upstream names its own key is ours and purges whenever seen (any daemon state, any rerun); a marker-less row purges only when this run deleted the matching settings entry; every other row is the user's and stays. Both of your repro scenarios are now tests: S1 (settings with no models.providers at all, user rows in the cache) stays byte-identical, and S4 (user's openai entry plus our marked anthropic residue) purges the residue and keeps theirs on a rerun with changed:false.
  • N7: the guard side is pinned - deleting the row test fails four tests, and the marker-less fallback is pinned separately by the left-in-settings test.
  • NEW-6: both stale glosses reworded (the detachJsonPathProviders @ref and its step 3).
  • LLP 0210 D2 rewritten around the per-row rule, citing the ACCEPTANCE grep and LLP 0167 verify item 3 as the grounds for trusting the cache shape.

3913 tests pass, typecheck clean.

@philcunliffe

Copy link
Copy Markdown
Contributor

Review - head 037bc88 (review-only delegation)

Verdict: approve. All four open findings are resolved, each verified by reproduction and mutation rather than by reading the diff. npm test 3909 pass / 0 fail, typecheck clean, no semicolons or em dashes added. Nothing was pushed to this branch across any of the five rounds.

Per-finding resolution

NEW-4 (major) - resolved. Reproduced all three scenarios against the real detachClientFromDisk with the real OpenClaw descriptor:

scenario settings cache result
S1 never attached, no models.providers key, user rows in cache byte-identical byte-identical changed:false
S2 never attached, models.providers={google} byte-identical byte-identical changed:false
S2b same, nested models.providers cache spelling byte-identical byte-identical changed:false

At 1249a8e S1 and S2 deleted both rows. The gate is gone and safety moved into the row test, so a never-attached machine's rows fail that test and the property holds by construction rather than by a caller-side precheck. That is the stronger shape.

NEW-5 (minor) - resolved. The mixed case now works: a marked anthropic residue is purged while the user's openai row is kept and settings stay byte-identical.

N7 (minor, open three rounds) - resolved. The mutation that survived three rounds (if (false) continue, ungating the row test) now fails 4 tests, including "leaves a never-attached machine untouched". Five further mutations fail 3-5 tests each. The tests also lock out a tempting refactor: substituting isOwnedProviderEntry for the row test fails, which is correct, since the cache row's models is populated by OpenClaw's merge and the row test must be the marker alone rather than the full triple.

One mutation survives: headers[markerHeader] !== undefined in place of === key. It only loosens the "names its own key" half, and a row so marked cannot be produced by attach. Noted, not raised.

NEW-6 (nit) - resolved. The @ref gloss and the surrounding JSDoc now describe the implemented rule.

The load-bearing premise, probed

The recommendation assumed the marker lands in the cache row, so that assumption was tested rather than inherited:

  • marker names its own key with a stale port: purged (correct per D1 - a rebound-port residue is still ours).
  • marker names its own key with models populated by the cache merge: purged. This is the shape a real cache row has, and a full-triple test would have missed it.
  • marker naming a different key: left in place.
  • older/renamed marker header: left in place - and no prior HypAware version wrote a different config-side marker (the pre-0167 era attached the header per request via the steering plugin, never into openclaw.json), so no real residue is missed.
  • headers as an array, or marker value true: left in place, no throw.

Cross-checked against the repo's own assertions: docs/ACCEPTANCE.md:401 greps those cache files for x-hypaware-upstream with pass condition "no matches", and LLP 0167 #attach-detach records that the merge "carries every existing cache provider forward wholesale". Both support the premise. It degrades gracefully too: if a future OpenClaw stripped headers from the cache, an ordinary full detach still purges via the deletedKeys arm and only the retry/mixed case would quietly not purge - and the openclaw_capture acceptance grep is the standing check for exactly that.

Docs and blast radius

LLP 0210 is Status: Draft, so editing it here is within convention; D2 now states the implemented per-row rule including the marker-less fallback, and the contradicted "indistinguishable from that residue" text is gone. docs/ACCEPTANCE.md's residue grep is unchanged and still describes reality. openclaw is the only manifest declaring format: json_path/cache_glob, and all four consumers (hyp detach, hyp leave, the uninstall sweep, and the reconciler's reverse()) share detachClientFromDisk, which the probes exercised directly.

One observation, not a finding

In the retry and mixed cases the purge rewrites a cache file while result.changed stays false, so hyp detach openclaw prints "nothing to do" and the sweep omits the client from detached. master's unconditional purge had the same mismatch over a strictly wider set of cases, so this PR narrows it rather than introducing it. Not worth holding for.

Closing note

Five rounds, and every round's findings were answered. Two of them changed neutral's mind: the signature-only ownership design (round 3) and this per-row purge both ended up better than what the review originally recommended. Held for you to merge - neutral never merges a review-only delegation.

@philcunliffe philcunliffe added neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) and removed neutral:changes-requested neutral reviewed an adopted PR and requests changes (non-binding; maintainer decides) labels Aug 11, 2026

@philcunliffe philcunliffe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at head 037bc88 (review-only: nothing pushed to this branch across all five rounds).

All four open findings resolved. The per-row purge is verified by reproduction: a never-attached machine with user-owned anthropic/openai cache rows now comes through byte-identical in all three shapes that previously lost them. Safety moved from a caller-side gate into the row test, so the never-attached property holds by construction rather than by a precheck - the stronger shape.

N7, open three rounds, is finally closed: the mutation that survived all of them (ungating the row test) now fails four tests, and five further mutations fail 3-5 each. The tests also lock out substituting isOwnedProviderEntry for the row test, which is correct, since the cache rows models is populated by OpenClaws merge.

I probed the premise the recommendation rested on rather than assuming it: a marker with a stale port purges, a marker naming a different key does not, a populated models row still purges (the shape a real cache row has), and an older/renamed marker is left alone - with no real residue missed, since no prior version wrote a config-side marker. docs/ACCEPTANCE.md:401 and LLP 0167 both corroborate, and the behaviour degrades gracefully if a future OpenClaw ever stripped those headers.

Worth saying: two of the five rounds ended with your approach beating the review`s own recommendation - signature-only ownership, and this per-row purge. Held for you to merge.

@platypii
platypii merged commit 1be5421 into master Aug 11, 2026
9 checks passed
@platypii
platypii deleted the uninstall-detaches-clients branch August 11, 2026 04:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) neutral:review Delegate this PR to neutral for a review pass (approve or request changes; never merges)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants