Skip to content

Daemon lifecycle management + PATH shim (desktop supervisor) - #542

Merged
alexeyzimarev merged 65 commits into
mainfrom
alexeyzimarev/ai-1654-daemon-lifecycle-management-path-shim-desktop-supervisor
Aug 12, 2026
Merged

Daemon lifecycle management + PATH shim (desktop supervisor)#542
alexeyzimarev merged 65 commits into
mainfrom
alexeyzimarev/ai-1654-daemon-lifecycle-management-path-shim-desktop-supervisor

Conversation

@alexeyzimarev

Copy link
Copy Markdown
Member

Closes #541. AI-1654.

Slice 3 of the desktop supervisor app: the app manages the kcap daemon's launchd lifecycle, offers takeover on version skew, and installs a PATH shim — built on a crash-safe CLI transaction so no lifecycle path can silently kill or spin a user's daemon. macOS-first; Windows/Linux left open via the cross-platform IServiceManager and the shim interface.

Design (survived 8 rounds of Codex spec-review): docs/superpowers/specs/2026-08-10-ai1654-daemon-lifecycle-path-shim-design.md.

CLI (src/Capacitor.Cli, src/Capacitor.Cli.Daemon)

  • daemon service status --json — machine-readable status with tri-state launchd classification (loaded/absent/unknownabsent only on the positive could-not-find signature; --json fails non-zero on unknown rather than masquerading as not_installed), the validated PID-file owner, the launchd job PID, and transaction-marker/lock liveness.
  • daemon service install/start --verify — one CLI process owns the mutation, verification, and rollback end-to-end. Success requires ownership AND readiness (a local-control hello, not just the PID file, which lands long before the daemon's host is up) plus a final on-disk plist-fingerprint recheck. A durable, phase-recording, fsync-ordered marker plus a fingerprint-scoped recovery authority make it crash- and power-loss-safe; a two-phase deadline reserves a rollback budget; the child completes even if the app (or the npm launcher's grandchild) is force-quit.
  • install --replace (requires --verify, macOS/launchd-only) — the takeover ownership matrix: clears a non-owning/orphan label, stops-and-confirms a validated live owner via an internal raw-kill helper, then installs — all in the one transaction, so the app never orchestrates a multi-command destructive sequence.
  • service stop now unloads the label (bootout, plist retained) — a SIGTERM can't stop a lock-losing KeepAlive job between incarnations. service uninstall distinguishes benign label-absence from a failed bootout.
  • Supervised deliberate-refusal exits 0 — a supervised daemon exits 0 (not 2/3) on a local name-lock or server NameInUse refusal at initial connect, so KeepAlive can't respin a name held elsewhere. The mid-run contest keeps exit 3 (one respawn settles it) — deliberately scoped.
  • Per-label cross-process flock and the transaction marker live under the fixed DaemonLockPaths.Directory namespace, immune to KCAP_CONFIG_DIR.

Core (src/Capacitor.Cli.Core)

  • The hello reply's DaemonVersion propagates CycleOutcome → LocalControlEvent.Unreachable → AttachStatus, deduped on (reason, version), so an incompatible old daemon — the takeover offer's primary audience — actually reaches the app. Zero wire-protocol changes.

App (src/Capacitor.App)

  • DaemonLifecycleController — startup-phase state machine with a once-per-run auto-action arm, a connection-generation token discarding stale evidence, and a reconciliation query on every startup path (surfacing crash residue, orphan labels, and live transactions without ever mutating into a held flock).
  • Skew → takeover/restart — triggered on Connected version mismatch or an incompatible hello; same-binary vs different-binary classification (path equality is not provenance — both dialogs disclose the rewrite); accept is one install --replace --verify; decline memory is claim-before-show with retract-on-accept/stale-abort, so a crash-at-dialog never re-nags and an accepted-but-failed install re-offers.
  • Service-aware Start + repair affordance, the production lifecycle dialog surface (serialized, cancellation-honoring so shutdown-quiesce can't hang), the PathShimInstaller (lstat taxonomy, argv-passed osascript target, POSIX-escaped sudo fallback, -128 cancel detection), and the shim offer + tray item.
  • IProcessRunner v2 (stdout, env overlay, kill-tree vs abandon-wait), CliResolver + typed IKcapCli, the interactive-login-shell PATH probe (so the launchd unit bakes the terminal PATH, not the GUI's minimal one — decision 7), and an atomic serialized app-state store.

Testing

502 app unit tests; ~430 new/changed CLI unit tests; real-process integration tests for the transaction's parent-death and closed-stdio guarantees (macOS-manual). E2E stays a manual checklist (no signed bundle yet). Docs/help/README updated in this PR.

Built task-by-task with per-task spec+quality review, adversarial fix loops, and a final whole-branch review.

🤖 Generated with Claude Code

alexeyzimarev and others added 30 commits August 10, 2026 17:16
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-install verification with rollback (name-lock TOCTOU), ownership-
branched takeover handoff with stop verification, four-state service
status JSON with install_binary_path provenance, valid-profile gate with
--profile/KCAP_PROFILE pinning, login-shell PATH injection, incompatible-
daemon skew trigger via hello version, lstat-checked non-forcing shim,
serialized lifecycle gate + app-state store, IProcessRunner stdout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Startup-phase eligibility shared with the shim latch, ownership-verified
post-mutation checks (job_pid==daemon_pid + fresh attach), hardened
service status (launchd query without plist, job/daemon pids) and
uninstall (bootout failure propagated, plist retained), gated kickstart
with post-start verification, branch-specific failure recovery from
re-queried evidence, env-overlay + kill-tree/abandon-wait process
semantics, same-binary (not app-managed) classification with universal
recapture disclosure, (reason,version) incompatible dedupe, -lic sentinel
terminal-PATH probe gating silent mutations, dialoged reinstall
affordance reusing takeover machinery, POSIX-escaped sudo fallback,
atomic claim-before-show app state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shutdown defers through mutation verification/rollback (detach-on-cancel
scoped to detached start), service stop/start hardened to bootout/
bootstrap (signal-only stop cannot stop a lock-losing KeepAlive job),
uninstall distinguishes benign label absence, orphan-label rows keyed on
job state before plist presence across startup/Start/takeover,
daemon_pid live identity-validated via IsOurDaemon, unknown-PATH gate
narrowed to unit-writing mutations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ownership verification and rollback move into the CLI as a --verify
transaction on install/start (crash/force-quit safe), stopped units
auto-start on relaunch via bootstrap (stop->relaunch contradiction),
tri-state launchd classification with status --json failing on unknown,
per-label cross-process flock closing the uninstall TOCTOU, install
preconditions before any destructive step, no-live-owner branch skips
daemon stop, unknown PATH no longer closes the startup phase, startup
reconciliation query on every path surfaces crash residue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verify predicate gains readiness (local-control hello + version + final
ownership recheck) since the PID file lands before the host builds;
takeover collapses into one CLI transaction (install --replace --verify)
so app death between commands cannot strand a removed daemon; install's
initial bootout is classifier-gated (no write on loaded/unknown); a
durable txn marker + broadened startup reconciliation catch mutation-
process death; command-layer lock scope with mixed-version residual
documented; verified-safe failure states replace "exact pre-op state";
closed-stdio tolerance for the npm grandchild topology.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decision 6 amended (user-approved): NameInUse exits 0 so a cross-machine
name collision rests stopped instead of respinning past any verification
window. Distinct fixed-namespace service flock + phase-recording durable
marker with fingerprint-scoped recovery authority and txn_active
liveness, fsync ordering, explicit --replace ownership matrix with an
internal raw-kill helper below StopByName, two-phase deadline with a
reserved rollback budget and bounded ServiceProcess, final on-disk
fingerprint recheck, per-verb one-shot hello probe contract, viability
scoped to install/replace only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Supervised local lock-refusal joins NameInUse in the exit-0 deliberate-
refusal family (plain non-verify service verbs cannot spin), and
read-only status probes the transaction flock non-blockingly so
txn_active is actually observable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement Task 1 of AI-1654 slice: add tri-state LabelProbe classification
for launchd service discovery, rich ServiceQuery record with unit presence
and job pid, and Query method across all three IServiceManager implementations.

- Add LabelProbe enum (Loaded/Absent/Unknown) to classify launchctl results
- Add ServiceQuery record with probe, unit presence, state, binary path, job pid
- Add Query method to IServiceManager interface
- Implement LaunchdUnit.ClassifyPrint: tri-state on exit code + stderr signature
- Implement LaunchdUnit.PidFromPrint: extract job pid from launchctl print output
- Implement Query in LaunchdServiceManager: full probe + optional plist read
- Implement Query in SystemdServiceManager: minimal mapping of state to probe
- Implement Query in WindowsScheduledTaskServiceManager: minimal mapping
- Add LaunchdClassifyTests: 5 tests covering classify/pid parsing edge cases

All LaunchdClassifyTests pass; systemd and Windows managers delegate to
existing Status method for backward compatibility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move DaemonCommands' private ReadPidFile/IsOurDaemon PID-file validation
into Capacitor.Cli.Services.DaemonPidProbe and add the public ValidatedPid
seam (spec §3.4 daemon_pid). daemon stop delegates its ownership+liveness
check to it; start/status/doctor call the moved primitives directly so
their finer-grained (absent vs. unparseable vs. stale) messaging and exit
codes stay byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
StopByName routed its live/stale classification through
DaemonPidProbe.ValidatedPid, which re-reads the PID file. A concurrent
`daemon start` between the outer ReadPidFile check and that re-read could
hand the kill branch a PID this call never validated from the same
snapshot. Bind entry once from ReadPidFile and call IsOurDaemon(entry.Pid,
entry.StartToken) directly for the live/stale branch and the kill target,
matching the pre-refactor single-read behavior exactly. ValidatedPid stays
as the seam for new callers (e.g. status --json), not for StopByName.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Call DaemonLockPaths.EnsureDirectory() in TryAcquire to prevent DirectoryNotFoundException masquerading as contention
- Wrap lock acquire in try/finally to release on assertion failure
- Add regression test for missing directory creation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a machine-readable status verb (spec §3.4) wiring the tri-state
launchd Query, validated daemon PID, and service-txn lock probe added
in Tasks 1-3. The renderer refuses to mask an Unknown launchd probe as
not_installed, exiting non-zero with no JSON instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds ServiceTxnMarker (temp-file + rename + FlushToDisk before and after
the rename) so a resumer can read the last recorded phase of an in-flight
install/replace/start. Directory-entry durability is best-effort on .NET,
which has no portable directory fsync; the marker content itself is
torn-proof. Wires status --json's txn_marker through ServiceTxnMarker.Exists
instead of a raw File.Exists check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Read and Delete used typed catch clauses that let an unexpected exception
type propagate, breaking the "null on missing/corrupt, never throws"
contract. Match the sibling DaemonRestartMarker.TryRead/Delete pattern with
a bare catch. Also documents that ServiceTxnMarker does no locking itself
(callers serialize per-serviceId writes via ServiceTxnLock), and adds a
test that actually exercises Delete's swallow path (deleting a marker path
that is a directory, which File.Delete cannot remove).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Spec §3.4: a non-zero launchctl bootout is not automatically a failure -
re-query with launchctl print and treat LabelProbe.Absent as an idempotent
success (delete the plist), while Loaded/Unknown retain it and fail. Changes
IServiceManager.Uninstall to bool Uninstall(string, out string? error) across
all three managers and callers; the daemon-service command case now wraps the
call in ServiceTxnLock.TryAcquire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LaunchdServiceManager.Uid() P/Invokes libc's getuid, which has no Windows
implementation; the new Uninstall tests would fault on the Windows CI leg.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stop now issues `launchctl bootout` instead of `kill SIGTERM`, since a
signal cannot stop a KeepAlive job caught between short-lived
incarnations; the plist is never deleted. Start probes the label first
(print + ClassifyPrint) and issues `bootstrap` when unloaded or
`kickstart` when already loaded, failing without mutating on an
ambiguous probe. IServiceManager.Start/Stop now return bool + out
error; systemd/Windows managers wrap their existing behavior. Both CLI
verbs acquire the per-service ServiceTxnLock, matching uninstall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 8 of AI-1654: ServiceProcess.RunBounded tree-kills and awaits on
timeout (draining stdout/stderr on background tasks to avoid a
pipe-buffer deadlock), and HelloProbe does a single dial+Hello+reply
against a daemon's local control socket without LocalControlClient's
status/1 capability gate, since start-verify must accept a
capability-incompatible hello.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ServiceVerify runs marker -> mutate -> ownership+readiness poll -> final
recheck -> commit, or rollback to the verified-safe unloaded state, per
spec §3.4. Injectable manager/pid-probe/hello/TimeProvider seams make every
outcome drivable without launchctl. start --verify accepts any well-formed
hello (no capability/version gate) and performs no viability check.

Wires into `kcap daemon service start --verify`, which now runs the engine
(the engine acquires ServiceTxnLock itself) instead of the plain start path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…art (review round 1)

manager.Start's own error was silently discarded before — the readiness
poll is still the source of truth for pass/fail, but the reason is worth
Say-ing if it never recovers. The command layer's post-success print is
now IOException-tolerant like the engine's own Say, so a closed stdout on
an already-successful verified start can't crash the process.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cation (review round 2)

The confirmation recheck shared the same forward deadline as the primary
readiness check, so a slow-but-genuine primary hello landing right at the
deadline starved the recheck (remaining <= 0 => auto-fail without probing),
rolling back a daemon just verified healthy. The recheck now gets a floor of
one poll interval instead of being budget-starved by the same clock.

Rollback's restore verification was a single-shot re-query after Stop, which
can race a launchctl bootout that returns before the label is actually gone.
It now polls (bounded by the previously-unused rollbackReserve) the same way
the forward phase does. Stop's own error is now surfaced via Say, matching
the existing treatment of Start's error.

FakeServiceManager now records every verb in argv-order (List<string> Calls)
per the brief, rather than bare counters, so rollback tests can assert Start
precedes Stop and the restore Query keeps polling after Stop. Two new tests
cover the recheck floor and a predicate-holds-once-then-fails-recheck case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements InstallVerifiedAsync for replace:false: lock -> leftover-marker
self-heal (trivial committed case only; anything else surfaces as
RestoreVerification, Task 11 owns full resumption) -> viability -> pre-query
classify (Loaded -> Contended, Unknown -> BootoutUnknown, since a fresh
install must never clear an existing label) -> marker through
captured/written/bootstrapped -> WriteAndBootstrap -> poll for hello version
match + ownership -> floored final recheck that also fingerprints the on-disk
plist against the marker, so a foreign writer between bootstrap and recheck
is detected and never deleted -> committed -> Ok.

WriteAndBootstrap is new on IServiceManager: write + bootstrap with no
leading bootout, since the engine (not the manager) now owns the bootout
decision on the fresh path. Systemd/Windows delegate it mechanically to
their existing Install; launchd's goes through the injectable _runProcess
(unlike Install's static ServiceProcess calls) so it's testable.

Also aligns Start's rollback reserve-expiry exit to RollbackBudget (26)
instead of RestoreVerification (27), matching the pinned distinction that
26 is a timeout and 27 is an affirmatively-observed wrong state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d races, and non-launchd use (review round 1)

Critical: manager.WriteAndBootstrap/GenerateFiles could throw (EPERM under
MDM, launchctl I/O errors) and escape uncaught to Program.cs's top-level
guard, leaving an orphaned marker and — when WriteAndBootstrap had already
written the plist — an unregistered file on disk. Both mutation steps are
now wrapped: a GenerateFiles throw is pure (nothing written) and just clears
its own marker; a WriteAndBootstrap throw runs the same fingerprint-gated
InstallRollback as every other failure path.

Critical: the pre-mutation classifier keyed only on the label, so a
stopped-but-installed service (Probe=Absent, UnitPresent=true — `service
stop` retains the plist by design) was treated as a fresh Absent slot,
overwritten, and then deleted by any later rollback. Absent-with-UnitPresent
now joins Loaded under Contended, matching the README's stated contract.

Important: threaded the manager-provided GeneratedFile.Path through the
final recheck and both rollback paths instead of a hardcoded
LaunchdUnit.PlistPath call, so the engine has no launchd-specific
dependency; gated `--verify` to LaunchdServiceManager at the command-wiring
layer (GenerateFiles().Single() and the recheck assume one generated file,
which only launchd currently satisfies).

Important: 26 vs 27 now turns on the LAST observation at reserve expiry,
not just "reserve expired" — Unknown is a genuine timeout (26), but a still-
Loaded (or file-still-present) last observation is an affirmatively wrong
state (27) even though the reserve also ran out. Fixed both the start and
install rollback loops and the start-path test whose own scenario (stays
Loaded) was asserting the wrong code.

Important: extended the closed-stdio try/catch to the whole `service
install` success tail, not just its first line — a broken pipe on any of
the trailing Log/Stop/Remove/reviewer-notice lines was still able to turn
an already-successful install into a crash.

Added coverage for all of the above plus the previously-untested
RollbackBudget(26) install path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds DaemonKill.KillValidatedOwner (a raw, seam-free kill of an already-validated
owner PID, mirroring StopByName's exception handling) and wires it into
ServiceVerify.InstallVerifiedAsync's --replace path: an ownership matrix that
clears/takes over an existing label, stopped-but-installed unit, or manual
(non-service) owner before writing the new one, gated by a stop-confirmation
poll (StopUnconfirmed on timeout). Also replaces the placeholder entry-time
marker recovery with full content-scoped recovery (fingerprint match cleans up
the dead transaction's own residue; mismatch surfaces rather than paves over
it). --replace is wired through DaemonCommands (requires --verify).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Self-review surfaced that the --replace matrix trusted manager.Uninstall's
success unconditionally and never confirmed the label actually went Absent
before writing a new unit over it (a slow/failed bootout could clobber a
still-loaded service). ClearLabelAsync now polls for confirmed Absent
(mirroring InstallRollback's own 26-vs-27 reasoning) and aborts with the
marker retained on failure/timeout, used by both matrix branches and by
entry-time marker recovery's own Uninstall call. Also: viability now checks
before marker recovery (so its "nothing touched" doc comment stays true),
DaemonKill.KillValidatedOwner no longer lets AggregateException/Win32Exception
escape mid-transaction, and the shared tail no longer regresses a --replace
marker's phase back to "captured" after the matrix already advanced it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…view round 2)

Coordinator review of round 1 found four Important issues, all fixed:

1. Pid-staleness window: the matrix captured validatedPid ONCE before
   clearing the label, then used that pre-clear snapshot for the kill —
   but clearing (bootout) can itself terminate the true owner as a side
   effect, and the clear's own confirmation poll can run for up to
   _rollbackReserve. Now re-reads validatedDaemonPid AFTER the clear and
   skips the kill entirely if no live owner remains, so "kill the
   validated owner IF ONE REMAINS" is literally true rather than trusting
   a pid that may have been recycled in the interim.

2. RecoverLeftoverMarker treated an unreadable-but-present plist the same
   as "already gone" (readPlist returns null for both). Added a plistExists
   seam (defaults to File.Exists) to distinguish them: present-but-unreadable
   now surfaces RestoreVerification untouched instead of guessing it's safe
   to pave over.

3. Entry-time recovery's cleanup called the raw manager.Uninstall bool
   instead of the confirmed-Absent ClearLabelAsync the --replace matrix
   uses — a bootout that returns success without actually having unloaded
   yet would delete the marker on an unconfirmed clear. RecoverLeftoverMarker
   is now async and reuses ClearLabelAsync.

4. Two ServiceVerifyReplaceTests injected a plain small pid (4242) as the
   validated-owner seam in scenarios where DaemonKill's real, unmocked
   Process.Kill(entireProcessTree: true) could in principle be reached if
   the guard they exercise regressed. Switched both to the existing
   ManualOwnerPid sentinel (999_999_111, guaranteed not to resolve to a
   real process) so a regression fails loudly rather than risking a real kill.

Also added the doc sentence pinning the 22 (pre-mutation abort) vs 26
(post-mutation undetermined-at-reserve-expiry) split on ClearLabelAsync,
per the coordinator's ruling, plus three new regression tests (stale-pid
re-read, unreadable-plist, entry-recovery clear-never-confirms).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real-process integration coverage for spec §7 (parent death & stdio):
kcap daemon service start --verify still exits with a coded VerifyExit
when its stdio pipes close, and the service flock is released even when
the CLI is orphaned from its parent — guarantees unit tests with fakes
can't prove. No production code changed; Say() was already broken-pipe-safe.
alexeyzimarev and others added 14 commits August 11, 2026 20:09
Fix round 1 from task review (1 Critical + 5 Important):

- Reconciliation now runs on the daemon_incompatible path too, not just
  Connected — a skewed daemon is exactly the prior-run-residue scenario a
  stale marker or orphan label shows up in.
- The startup matrix no longer mutates while service_status reports
  txn_active: it waits out the one bounded requery (spec §6) and only
  proceeds once the flock is confirmed clear.
- Event admission is now separate from startup auto-action eligibility:
  every AttachStatus transition still reaches OnAttachStatus's switch: the
  once-per-run arm only gates whether the startup branch/reconciliation
  actually runs, not whether the dispatcher observes the event at all —
  clearing the way for a later Task 20 skew hook on a non-first
  daemon_incompatible.
- Stale-evidence handling now distinguishes a genuine query failure
  (surfaced honestly and logged) from evidence that raced a newer,
  meaningfully different attach outcome (re-evaluated once, so a racing
  Connected still gets its reconciliation) from a merely duplicate
  re-observation (no wasted query — keeps the once-per-run arm honest).
- DisposeAsync is idempotent.

Plus the trivial minors flagged alongside: the confirm-window timer is
cancelled early once a fresh Connected arrives instead of leaking until
its own deadline; the reconciliation-only txn-active requery is gated by
the operation gate (not just the startup branch's own); RestartLoopAsync
fires after any attempted mutation, not only a successful one; fire-and-
forget entry points log unexpected exceptions instead of dropping them;
the reconciliation ownership-mismatch checks are attached-only (was a
latent double-report path with a startup-matrix row); test harness temp
dirs are cleaned up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…econcile

Fix round 2 from re-review (residual on finding 3):

The startup branch's forced re-evaluation (a racing Connected mid-query)
still reconciled with a hardcoded attached: false, so the run's only
reconciliation pass silently ran in permanently-unattached mode for
exactly the race window the round-1 fix targeted — the ownership-mismatch
and coexistence checks were unreachable. IsCurrentlyAttached() now reads
_lastObserved fresh at each Reconcile call site (the startup branch's own
call, and both txn-active requery helpers, which had the same
captured-parameter staleness risk across their own async gaps) instead of
threading a boolean captured before the async work began.

Also documents (comment only, no behavior change) why the no-unit install
row doesn't pre-check DaemonPid: a racing/wedged manual daemon there is
the install --verify transaction's own job to detect and roll back from,
not a pre-flight guess by the app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends DaemonLifecycleController (spec §4.3): on every Connected (paired with
the latest Snapshots-stream daemon version) and every daemon_incompatible
(paired with the hello DaemonVersion), a version mismatch against the cached
CLI version offers a takeover dialog — classified same-binary ("restart-update")
vs different-binary ("takeover") by canonical binary-path comparison, both
carrying the decision-3 disclosure. Accept is exactly one
ServiceInstallVerifiedAsync(replace:true) reusing Task 19's fresh-Connected
confirmation; decline persists the (daemonVersion, cliVersion) pair in
AppStateStore to suppress future offers of the same pair. At most one skew
dialog per run, and a version change between prompt and accept aborts with no
mutation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fix round 1 from review:

- Critical: RunSkewCheckAsync now awaits the held CacheVersionAsync task
  (was fire-and-forget) before reading CliVersion — the attach cycle (a
  sub-ms socket dial) could win the race against the version probe
  (a process spawn), silently dropping the first-connect skew offer for
  the whole run since nothing re-triggers it.
- Decline memory is now claim-before-show (spec §3.5): the pair is
  persisted before ConfirmAsync is even called, so a crash mid-dialog
  still suppresses a re-offer next run. Accept retracts the claim
  (success or coded failure); a stale-consent abort retracts it too and
  clears the once-per-run flag so the next trigger can re-offer — a
  coded-failure accept also clears the flag, since the user got no
  resolution.
- ClassifyTakeover treats a blank binary_path as different-binary instead
  of letting Path.GetFullPath("") throw; CanonicalPath's try/catch now
  wraps GetFullPath itself.
- The skew dialog gates on the same missing-binary/missing-profile
  preconditions the silent-install row uses (not the PATH check —
  decision 7 lets a dialoged install proceed on PathDegraded disclosure
  instead of blocking).
- LifecyclePrompt.Kind* consts replace string literals; added a symlink
  canonical-compare test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fix round 2 from review:

RetractDeclineAsync ran after awaiting RunVerifiedMutationAsync, which
doesn't catch around the CLI call — an install that throws (shutdown
mid-spawn, a process/IO fault) skipped the retract entirely, leaving an
accepted pair mislabeled "declined" on disk. Since acceptance itself is
the fact that invalidates the decline claim, the retract now runs
immediately once the stale-consent check passes and before the mutation
starts, so it can never be skipped by an exception the mutation raises.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 21 of AI-1654: StartActionAsync now implements the full §4.4 branch
table (loaded/orphan/coexisting/bootstrap/nothing-at-all), routing any
mismatch through a dialoged repair affordance that shares its accept path
with skew's takeover (ConfirmAndTakeoverAsync). The Start command on the
main window is repointed to it, and app shutdown now waits (capped at 60s)
for QuiescedAsync before tearing the lifecycle controller down, so an
internally-triggered mutation is never abandoned mid-flight. Composes the
controller into App.axaml.cs for the first time, subscribing before the
daemon client's attach pump starts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… dialog

The interim lifecycle prompt dialog ignored its ConfirmAsync CancellationToken
entirely - the tcs only resolved on a button click or the window's own Closed
event. Since ConfirmAndTakeoverAsync holds the operation gate across the whole
ConfirmAsync await, a dialog left open through a lifetime-cancel (app
shutdown) held that gate forever, so QuiescedAsync - the very backstop
shutdown relies on - never completed. WireDialogCancellation closes the
dialog on cancellation (marshaled to the UI thread, since Cancel() can arrive
from any thread), resolving false through the same Closed handler a manual
Cancel click uses, and disposes its registration once the dialog resolves on
its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 22 of the AI-1654 plan: production ILifecycleSurface replacing Task 21's
interim NotifierLifecycleSurface. LifecyclePromptWindow/LifecyclePromptViewModel
render Kind-specific dialogs (styled after ConsentPromptWindow); LifecycleSurface
serializes ConfirmAsync calls with a SemaphoreSlim(1,1) and preserves Task 21's
ct-cancellation contract. Status routes into MainWindowViewModel's start-message
lane; Attention upgrades TrayViewModel's tray state, both via new optional
constructor observables so every existing caller is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…text

Task 22 fix round 1: TrayViewModel.Build judged lifecycleAttentionActive
against the post-upgrade `state`, which already includes TrayState.Attention
produced by rows 2/5/6/9/10 (daemon_incompatible, reconnecting, disconnected,
malformed count, unrecognized values) on their own. Any co-occurring lifecycle
Attention message then won the header over that row's own text, masking
genuine connection trouble behind a stale/unrelated line. Judge both
pendingAttention and lifecycleAttentionActive against baseState (the row's
own pre-upgrade verdict) instead, so a lifecycle message only ever wins the
header when the underlying row was genuinely fine (Idle/Running).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Preflight lstat taxonomy, argv-safe osascript install, post-install PATH
probe, cancel/failure classification, and a POSIX-escaped sudo fallback for
/usr/local/bin/kcap (spec §5). Mechanics only — the once-ever offer/tray
wiring is a later task.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Match AppleScript's parenthesized "(-128)" cancellation marker instead of a
bare "-128" substring — a genuine osascript/do-shell-script failure can embed
the unescaped target path in its own error text (e.g. ".../app-128/kcap"),
and a target under a PR/build-numbered directory containing "-128" would
otherwise misclassify a real Failed as Cancelled, silently discarding the
Detail and SudoFallback. Also cleans up the PathShimInstallerTests temp dirs
in an [After(Test)] hook (best-effort delete).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AI-1654 Task 24 (spec §5 offer surface): ShimOfferCoordinator waits for
DaemonLifecycleController.PhaseClosed, then offers the once-ever PATH-shim
dialog only when the CLI resolver has an absolute target and the login-shell
probe positively finds kcap absent, claiming the AppState offered flag before
showing the dialog. The offer reuses ILifecycleSurface.ConfirmAsync so it
never stacks with the skew dialog. A new "Install command-line tool…" tray
item stays visible while applicable-but-absent and lets the user retry the
install manually regardless of the once-ever claim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Documents the daemon service surface added over Tasks 1-13: status --json
(machine-readable snapshot), install --verify/--replace (crash-safe
transactional install with ownership+readiness verification, rollback, and
takeover), and start --verify. Updates the ServiceUsage() inline usage text,
kcap daemon --help (help-daemon.txt), and the README daemon-service section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The launchd unit was baking the GUI's inherited PATH, not the probed
terminal PATH: KcapCli was constructed with terminalPath: null at the
composition root and never reconstructed, so the PATH overlay was dead
in production. KcapCli now takes a lazy Func<CancellationToken,
Task<string?>> resolved from the shared LoginShellProbe and applies it
only to ServiceInstallVerifiedAsync (the unit-writing mutation, per
decision 7) — start-verify and read-only calls stay exempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 12, 2026

Copy link
Copy Markdown

AI-1654

CLAUDE.md forbids Linear IDs in comments; replace the AI-1654/AI-1653
spec citations with neutral "spec §"/"the lifecycle slice" references,
keeping the useful section pointers.

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8e94762379

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Func<string, string[], (int ExitCode, string StdOut, string StdErr)>? runProcess = null
) : IServiceManager {
readonly UnitFileWriter _writeUnit = writeUnit ?? ((path, content, encoding) => ServiceFiles.WriteOwnerOnly(path, content, encoding));
readonly Func<string, string[], (int ExitCode, string StdOut, string StdErr)> _runProcess = runProcess ?? ServiceProcess.Run;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bound launchctl calls inside verified transactions

Route verified service operations through the bounded process runner rather than ServiceProcess.Run. If any launchctl invocation hangs, the forward and rollback deadlines in ServiceVerify cannot run because every manager query/mutation blocks here indefinitely; terminal invocations never return, while the app eventually kills the entire CLI process and may interrupt it before rollback, leaving a transaction marker or partially mutated service.

Useful? React with 👍 / 👎.

case "stop": manager.Stop(id); await Console.Out.WriteLineAsync($"Service '{id}' stopped (still installed)."); return 0;
case "status": return await ServiceStatus(manager, id);
case "uninstall": return await ServiceUninstall(manager, id);
case "start": return rest.Contains("--verify") ? await ServiceStartVerified(manager, id) : await ServiceStart(manager, id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject start --verify on unsupported service backends

Reject start --verify on Linux and Windows, as is already done for verified installs, or implement those backends' ownership probes. Both SystemdServiceManager.Query and WindowsScheduledTaskServiceManager.Query always return a null JobPid, so ServiceVerify.IsReadyAsync can never succeed there; invoking this advertised command starts the service, waits through the full verification budget, then stops it during rollback and reports failure.

Useful? React with 👍 / 👎.

Comment on lines +512 to +514
var h = await hello(serviceId, budget);
if (!h.WellFormed) return InstallReady.NotReady;
if (h.DaemonVersion != expectedVersion) return InstallReady.VersionMismatch;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Validate protocol and daemon name before committing installs

Require the install hello's ProtocolVersion to be supported and its DaemonName to match serviceId, not merely that the frame deserialized and its version string matched. HelloProbeResult deliberately carries both fields, but this predicate ignores them, so an incompatible daemon or a daemon identifying as another name can satisfy the two readiness checks and cause install --verify to commit instead of rolling back.

Useful? React with 👍 / 👎.

Comment on lines +27 to +28
case ShimPreflight.AlreadyInstalled:
return new ShimResult(ShimOutcome.Installed, null, null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recheck PATH when the shim already exists

Run the login-shell PATH probe for AlreadyInstalled just as after a newly created link. This branch is reachable specifically when /usr/local/bin/kcap already points at the target but /usr/local/bin is absent from the user's PATH; it currently returns Installed, causing the UI to claim that kcap is now on the terminal PATH even though the coordinator's preceding probe established the opposite.

Useful? React with 👍 / 👎.

Comment on lines +121 to +123
Task SurfaceResultAsync(ShimResult result) => result.Outcome switch {
ShimOutcome.Installed => Status("kcap is now on your terminal PATH."),
ShimOutcome.InstalledButNotOnPath => Status(result.Detail ?? "kcap was linked, but is not yet on your terminal PATH."),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Hide the shim action after a successful installation

Publish _offerable.OnNext(false) when installation succeeds and the probe confirms kcap is on PATH. Once RunAsync has emitted true, neither the automatic nor manual success path ever resets the BehaviorSubject, so the tray continues showing “Install command-line tool…” for the rest of the run and allows redundant repeated installs despite the documented applicable-but-absent visibility condition.

Useful? React with 👍 / 👎.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Crash-safe daemon service lifecycle + PATH shim for desktop supervisor

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Adds daemon service status --json plus hardened start/stop/uninstall semantics for supervised
 daemons.
• Introduces a crash-safe --verify/--replace service transaction (lock + durable marker +
 rollback).
• Desktop app adds lifecycle controller (auto-start/install, skew takeover offers) and a macOS PATH
 shim offer.
• Adds comprehensive unit/integration test coverage and design/spec documentation.
Diagram

graph TD
  App["Capacitor.App"] --> Ctrl["Lifecycle controller"] --> Proc(("kcap CLI")) --> Verify["ServiceVerify txn"] --> Launchd{{"launchd"}} --> Daemon(("kcap-daemon"))
  App --> Shim["Shim offer"] --> Link["/usr/local/bin/kcap"]
  Verify --> Lock[("service lock")]
  Verify --> Marker[("txn marker")]
  subgraph Legend
    direction LR
    _svc["Service"] ~~~ _proc(("Process")) ~~~ _ext{{"OS"}} ~~~ _file["File"] ~~~ _db[("Lock/Marker")]
  end
Loading
High-Level Assessment

The chosen architecture—routing all lifecycle mutations through a single CLI process that owns verification and rollback—is the most robust option for crash/power-loss safety and avoids app-orchestrated multi-step destructive sequences. Alternatives like app-side orchestration/journaling or moving service management in-proc were considered in the spec but would weaken the safety boundary or depend on the app relaunching to finish recovery.

Files changed (76) +10118 / -230

Enhancement (36) +3199 / -200
App.axaml.csWire lifecycle controller + shim coordinator into app startup/shutdown +172/-11

Wire lifecycle controller + shim coordinator into app startup/shutdown

• Adds composition for 'DaemonLifecycleController' and 'ShimOfferCoordinator', ensures subscribe-before-pump ordering, plumbs lifecycle status/attention into UI, and adds shutdown quiescing to wait out in-flight mutations.

src/Capacitor.App/App.axaml.cs

AppStateStore.csPersist app UX state for shim/takeover decisions +76/-0

Persist app UX state for shim/takeover decisions

• Adds an atomic, serialized JSON store ('app-state.json') to remember one-time shim offer/denial and declined takeover pairs.

src/Capacitor.App/Services/AppStateStore.cs

AttachStatus.csPropagate daemon version on incompatible/unreachable states +5/-2

Propagate daemon version on incompatible/unreachable states

• Extends attach status modeling to carry hello-derived daemon version information where available for skew/takeover logic.

src/Capacitor.App/Services/AttachStatus.cs

CliResolver.csCentralize CLI path + version parsing +39/-0

Centralize CLI path + version parsing

• Adds 'CliResolver' to resolve 'kcap' path (incl. 'KCAP_APP_CLI_PATH' override semantics) and strictly parse 'kcap --version' output for skew detection.

src/Capacitor.App/Services/CliResolver.cs

DaemonClientService.csIntegrate shared CLI resolution/process runner semantics +55/-24

Integrate shared CLI resolution/process runner semantics

• Adjusts daemon client startup wiring to align with the new process runner result shape and CLI resolution used by lifecycle/shim flows.

src/Capacitor.App/Services/DaemonClientService.cs

DaemonLifecycleController.csAdd daemon lifecycle state machine (auto-install/start, skew takeover, reconciliation) +719/-0

Add daemon lifecycle state machine (auto-install/start, skew takeover, reconciliation)

• Introduces the core lifecycle controller: startup-phase gating, serialized mutation gate, reconciliation via 'service status --json', and version-skew handling that offers takeover via 'install --replace --verify'. Surfaces status/attention via 'ILifecycleSurface' and uses generation tokens to avoid stale confirmations.

src/Capacitor.App/Services/DaemonLifecycleController.cs

ILifecycleSurface.csIntroduce lifecycle UI surface abstraction +24/-0

Introduce lifecycle UI surface abstraction

• Adds 'ILifecycleSurface' and 'LifecyclePrompt' to standardize status lines, attention affordances, and confirmation dialogs for lifecycle and shim flows.

src/Capacitor.App/Services/ILifecycleSurface.cs

IProcessRunner.csExpand process runner for env overlays, timeouts, and cancel modes +15/-1

Expand process runner for env overlays, timeouts, and cancel modes

• Reworks the process runner interface to return stdout/stderr, support environment overlays, bounded timeouts (tree-kill on expiry), and distinct cancel behaviors (abandon-wait vs kill-tree).

src/Capacitor.App/Services/IProcessRunner.cs

KcapCli.csAdd typed app facade over CLI lifecycle commands +128/-0

Add typed app facade over CLI lifecycle commands

• Introduces 'IKcapCli'/'KcapCli' for '--version', 'daemon service status --json', verified start/install (with PATH overlay only for unit-writing installs), and detached start.

src/Capacitor.App/Services/KcapCli.cs

LifecycleSurface.csImplement non-stacking lifecycle prompt surface +40/-0

Implement non-stacking lifecycle prompt surface

• Adds 'LifecycleSurface' implementation that serializes dialogs and routes status/attention into UI sinks while remaining Avalonia-free for unit testing.

src/Capacitor.App/Services/LifecycleSurface.cs

LoginShellProbe.csProbe interactive login-shell PATH and kcap discovery +99/-0

Probe interactive login-shell PATH and kcap discovery

• Adds a cached '$SHELL -lic'/'-lc' probe to obtain terminal PATH and detect whether 'kcap' resolves on the terminal PATH, using sentinel parsing to tolerate shell chatter.

src/Capacitor.App/Services/LoginShellProbe.cs

PathShimInstaller.csInstall '/usr/local/bin/kcap' symlink via osascript with verification +114/-0

Install '/usr/local/bin/kcap' symlink via osascript with verification

• Adds macOS shim installation: lstat-based preflight (non-forcing), admin prompt via 'osascript', post-install PATH verification, cancellation detection, and a safe sudo fallback command.

src/Capacitor.App/Services/PathShimInstaller.cs

ShimOfferCoordinator.csCoordinate once-ever PATH shim offer and tray menu action +143/-0

Coordinate once-ever PATH shim offer and tray menu action

• Implements one-time offer after lifecycle startup phase closes, persists offer/denial state, exposes an observable for tray menu visibility, and supports manual retries via the same install path.

src/Capacitor.App/Services/ShimOfferCoordinator.cs

LifecyclePromptViewModel.csAdd view model for lifecycle/shim confirmation dialogs +63/-0

Add view model for lifecycle/shim confirmation dialogs

• Introduces a dialog view model encapsulating prompt content and accept/decline completion for lifecycle and shim flows.

src/Capacitor.App/ViewModels/LifecyclePromptViewModel.cs

MainWindowViewModel.csSurface lifecycle start action and status lane in main window +22/-2

Surface lifecycle start action and status lane in main window

• Extends the main window view model to accept a lifecycle-aware Start action and display lifecycle status messages.

src/Capacitor.App/ViewModels/MainWindowViewModel.cs

TrayModels.csExtend tray models for new lifecycle/shim menu concepts +3/-1

Extend tray models for new lifecycle/shim menu concepts

• Adds/adjusts tray model structures to support lifecycle attention and shim-install affordances.

src/Capacitor.App/ViewModels/TrayModels.cs

TrayViewModel.csAdd tray attention + shim install menu handling +71/-22

Add tray attention + shim install menu handling

• Wires lifecycle attention stream and shim offerability into the tray view model, adding a menu action to trigger manual shim installation.

src/Capacitor.App/ViewModels/TrayViewModel.cs

LifecyclePromptWindow.axamlAdd lifecycle/shim prompt window UI +30/-0

Add lifecycle/shim prompt window UI

• Adds a new Avalonia window for lifecycle/takeover/repair/shim prompts.

src/Capacitor.App/Views/LifecyclePromptWindow.axaml

LifecyclePromptWindow.axaml.csCode-behind for lifecycle prompt window +21/-0

Code-behind for lifecycle prompt window

• Adds window wiring to host the lifecycle prompt view model and support cancellation/closing behavior.

src/Capacitor.App/Views/LifecyclePromptWindow.axaml.cs

TrayMenuBuilder.csAdd tray menu entry for shim install when applicable +6/-0

Add tray menu entry for shim install when applicable

• Extends tray menu building to include the manual “Install command-line tool…” action when 'ShimOfferCoordinator' reports offerable.

src/Capacitor.App/Views/TrayMenuBuilder.cs

LocalControlClient.csCarry hello DaemonVersion through incompatible/unreachable events +31/-18

Carry hello DaemonVersion through incompatible/unreachable events

• Captures hello reply 'DaemonVersion' and includes it in 'Unreachable' events, with dedupe keyed on (reason, version) instead of reason alone.

src/Capacitor.Cli.Core/LocalIpc/LocalControlClient.cs

DaemonCommands.csAdd verified service workflows, JSON status, and shared PID probing +176/-102

Add verified service workflows, JSON status, and shared PID probing

• Adds 'service status --json', 'start --verify', 'install --verify'/'--replace', introduces per-label service locks for mutations, and factors PID-file ownership validation into 'DaemonPidProbe'. Also hardens stop/uninstall/start/stop flows and help/usage output.

src/Capacitor.Cli/Commands/DaemonCommands.cs

ServiceStatusJson.csDefine machine-readable service status JSON payload and renderer +40/-0

Define machine-readable service status JSON payload and renderer

• Adds a source-generated snake_case JSON DTO and a pure renderer that refuses to emit JSON when launchd classification is unknown.

src/Capacitor.Cli/Commands/ServiceStatusJson.cs

UninstallCommand.csAlign uninstall behavior with new service manager contracts +6/-2

Align uninstall behavior with new service manager contracts

• Adjusts uninstall command logic to reflect the updated IServiceManager uninstall semantics (explicit error propagation and idempotent absence handling).

src/Capacitor.Cli/Commands/UninstallCommand.cs

DaemonKill.csAdd raw-kill helper for verified replace takeover +50/-0

Add raw-kill helper for verified replace takeover

• Introduces a kill helper used by 'install --replace --verify' to terminate a validated live owner without StopByName’s interactive guards or additional locking.

src/Capacitor.Cli/Services/DaemonKill.cs

DaemonPidProbe.csCentralize PID-file parsing and identity validation +85/-0

Centralize PID-file parsing and identity validation

• Adds PID-file parsing plus start-token-based identity validation (with image-name fallback) for safe ownership checks and service status reporting.

src/Capacitor.Cli/Services/DaemonPidProbe.cs

HelloProbe.csAdd bounded one-shot hello probe for readiness verification +43/-0

Add bounded one-shot hello probe for readiness verification

• Adds a minimal hello/hello-reply dial that deliberately bypasses capability gating so verify logic can validate version/readiness appropriately.

src/Capacitor.Cli/Services/HelloProbe.cs

IServiceManager.csExtend service manager surface for query, tri-state probes, and verify hooks +20/-3

Extend service manager surface for query, tri-state probes, and verify hooks

• Adds 'LabelProbe' tri-state classification, a richer 'ServiceQuery', verify-aware 'WriteAndBootstrap', and boolean Start/Stop/Uninstall with error strings.

src/Capacitor.Cli/Services/IServiceManager.cs

LaunchdServiceManager.csImplement launchd query/tri-state classification and hardened start/stop/uninstall +108/-6

Implement launchd query/tri-state classification and hardened start/stop/uninstall

• Adds 'Query()' using 'launchctl print' classification, implements verified 'WriteAndBootstrap', and hardens start/stop/uninstall to re-query and avoid guessing on unknown label states.

src/Capacitor.Cli/Services/LaunchdServiceManager.cs

LaunchdUnit.csAdd launchctl print classifier and pid extraction +16/-0

Add launchctl print classifier and pid extraction

• Adds parsing utilities to classify print output into loaded/absent/unknown and extract the running job PID.

src/Capacitor.Cli/Services/LaunchdUnit.cs

ServiceProcess.csAdd bounded process runner to avoid deadlocks/hangs +31/-0

Add bounded process runner to avoid deadlocks/hangs

• Introduces 'RunBounded' to run subprocesses with timeouts, draining output safely and killing the process tree on expiry.

src/Capacitor.Cli/Services/ServiceProcess.cs

ServiceTxnLock.csAdd per-label cross-process lock for service mutations +57/-0

Add per-label cross-process lock for service mutations

• Adds a dedicated flock under a fixed directory namespace to serialize mutating service verbs and expose 'txn_active' via non-blocking probe.

src/Capacitor.Cli/Services/ServiceTxnLock.cs

ServiceTxnMarker.csAdd durable phase-recording transaction marker + fingerprinting +74/-0

Add durable phase-recording transaction marker + fingerprinting

• Implements a crash-safe marker file (temp+rename + fsync) and SHA-256 fingerprinting for content-scoped recovery authority.

src/Capacitor.Cli/Services/ServiceTxnMarker.cs

ServiceVerify.csAdd crash-safe verified service transaction engine with rollback +563/-0

Add crash-safe verified service transaction engine with rollback

• Introduces the 'ServiceVerify' engine for 'install/start --verify' and 'install --replace --verify': acquires a lock, writes a durable marker, performs mutation, polls readiness/ownership, rechecks on-disk unit fingerprint, and rolls back to verified-safe states on failure.

src/Capacitor.Cli/Services/ServiceVerify.cs

SystemdServiceManager.csAdapt systemd manager to new IServiceManager contract +28/-3

Adapt systemd manager to new IServiceManager contract

• Adds 'Query()' and updates Start/Stop/Uninstall signatures; verify path delegates to regular install for now.

src/Capacitor.Cli/Services/SystemdServiceManager.cs

WindowsScheduledTaskServiceManager.csAdapt Windows scheduled-task manager to new IServiceManager contract +26/-3

Adapt Windows scheduled-task manager to new IServiceManager contract

• Adds 'Query()' and updates Start/Stop/Uninstall signatures; verify path delegates to regular install for now.

src/Capacitor.Cli/Services/WindowsScheduledTaskServiceManager.cs

Bug fix (1) +35 / -6
DaemonRunner.csMake supervised deliberate refusals exit 0 to prevent respin +35/-6

Make supervised deliberate refusals exit 0 to prevent respin

• Changes local name-lock refusal and initial-connect server 'NameInUse' refusal to exit 0 when supervised, preserving non-zero exits for manual daemons and mid-run contests.

src/Capacitor.Cli.Daemon/DaemonRunner.cs

Tests (34) +5655 / -17
AppStartupTests.csUpdate app startup tests for lifecycle/shim wiring +99/-2

Update app startup tests for lifecycle/shim wiring

• Extends startup tests to cover new lifecycle controller composition and startup ordering behavior.

test/Capacitor.App.Tests.Unit/AppStartupTests.cs

AppStateStoreTests.csAdd AppStateStore unit tests +90/-0

Add AppStateStore unit tests

• Adds tests for load/update behavior, corruption/default handling, and atomic write semantics.

test/Capacitor.App.Tests.Unit/AppStateStoreTests.cs

CliResolverTests.csAdd CliResolver unit tests +63/-0

Add CliResolver unit tests

• Covers override/path resolution behavior and strict version parsing.

test/Capacitor.App.Tests.Unit/CliResolverTests.cs

DaemonClientServiceTests.csAdjust DaemonClientService tests for new attach/version semantics +24/-6

Adjust DaemonClientService tests for new attach/version semantics

• Updates unit tests to match new process runner result shape and daemon version propagation.

test/Capacitor.App.Tests.Unit/DaemonClientServiceTests.cs

DaemonLifecycleControllerTests.csAdd comprehensive lifecycle controller tests +1163/-0

Add comprehensive lifecycle controller tests

• Adds an extensive suite covering startup matrix behavior, skew/takeover prompts, gating, reconciliation, and surfaced status/attention outcomes.

test/Capacitor.App.Tests.Unit/DaemonLifecycleControllerTests.cs

FakeDaemonClientService.csUpdate fake daemon client service for lifecycle tests +5/-2

Update fake daemon client service for lifecycle tests

• Extends the fake to emit the attach/snapshot signals needed by the lifecycle controller tests.

test/Capacitor.App.Tests.Unit/FakeDaemonClientService.cs

FakeLifecycleSurface.csAdd fake ILifecycleSurface for controller tests +22/-0

Add fake ILifecycleSurface for controller tests

• Implements a test surface capturing status/attention and controlling confirmation responses.

test/Capacitor.App.Tests.Unit/FakeLifecycleSurface.cs

KcapCliTests.csAdd KcapCli facade tests +267/-0

Add KcapCli facade tests

• Covers JSON parsing, error/timeout behavior, env overlay rules (profile/PATH), and command argument construction.

test/Capacitor.App.Tests.Unit/KcapCliTests.cs

LifecyclePromptViewModelTests.csAdd tests for lifecycle prompt view model behavior +111/-0

Add tests for lifecycle prompt view model behavior

• Validates accept/decline resolution and prompt rendering data flow.

test/Capacitor.App.Tests.Unit/LifecyclePromptViewModelTests.cs

LifecycleSurfaceTests.csAdd tests for dialog serialization and cancellation behavior +135/-0

Add tests for dialog serialization and cancellation behavior

• Ensures 'ConfirmAsync' does not stack dialogs and properly releases its gate on cancellation paths.

test/Capacitor.App.Tests.Unit/LifecycleSurfaceTests.cs

LoginShellProbeTests.csAdd login-shell PATH probe tests +322/-0

Add login-shell PATH probe tests

• Covers sentinel parsing, '-lic'/'-lc' fallback behavior, caching rules, and unknown outcomes.

test/Capacitor.App.Tests.Unit/LoginShellProbeTests.cs

MainWindowViewModelTests.csUpdate/add tests for lifecycle status integration +51/-0

Update/add tests for lifecycle status integration

• Validates lifecycle start/status wiring in the main window view model.

test/Capacitor.App.Tests.Unit/MainWindowViewModelTests.cs

PathShimInstallerTests.csAdd PATH shim installer tests +339/-0

Add PATH shim installer tests

• Covers preflight taxonomy, osascript args quoting, cancel detection, failure fallback command, and post-install PATH verification behavior.

test/Capacitor.App.Tests.Unit/PathShimInstallerTests.cs

ProcessRunnerTests.csUpdate tests for new process runner contract +98/-5

Update tests for new process runner contract

• Adjusts tests to validate stdout/stderr capture, env overlays, timeouts, and cancel-mode behavior.

test/Capacitor.App.Tests.Unit/ProcessRunnerTests.cs

ShimOfferCoordinatorTests.csAdd tests for once-ever shim offer and manual retry +349/-0

Add tests for once-ever shim offer and manual retry

• Covers phase-closed gating, state persistence rules, offerable observable behavior, and manual install path.

test/Capacitor.App.Tests.Unit/ShimOfferCoordinatorTests.cs

TrayAdapterTests.csUpdate tray adapter tests for new menu items/streams +57/-2

Update tray adapter tests for new menu items/streams

• Adjusts tray-related tests to account for shim install menu behavior and lifecycle attention.

test/Capacitor.App.Tests.Unit/TrayAdapterTests.cs

TrayViewModelTests.csAdd/update tray view model tests for lifecycle/shim surfaces +154/-0

Add/update tray view model tests for lifecycle/shim surfaces

• Validates attention stream handling and shim offerable/manual install wiring in the tray view model.

test/Capacitor.App.Tests.Unit/TrayViewModelTests.cs

ServiceVerifyProcessTests.csAdd integration tests for verified service transactions +225/-0

Add integration tests for verified service transactions

• Introduces process-level tests to validate verify/rollback behavior across subprocess lifetime boundaries.

test/Capacitor.Cli.Tests.Integration/ServiceVerifyProcessTests.cs

DaemonCommandsServiceInstallTests.csAdd unit tests for 'service install --verify/--replace' command behavior +34/-0

Add unit tests for 'service install --verify/--replace' command behavior

• Covers argument validation and verified install command path selection.

test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceInstallTests.cs

ServiceStatusJsonTests.csAdd tests for 'service status --json' rendering and exit codes +41/-0

Add tests for 'service status --json' rendering and exit codes

• Validates snake_case JSON output, field mapping, and refusal to emit JSON on unknown classification.

test/Capacitor.Cli.Tests.Unit/Commands/ServiceStatusJsonTests.cs

DeliberateRefusalExitTests.csAdd tests for supervised deliberate-refusal exit codes +96/-0

Add tests for supervised deliberate-refusal exit codes

• Ensures supervised daemons exit 0 on name-lock and initial NameInUse refusals while manual daemons keep non-zero codes.

test/Capacitor.Cli.Tests.Unit/Daemon/DeliberateRefusalExitTests.cs

LocalControlClientTests.csUpdate tests for daemon version propagation in unreachable events +59/-0

Update tests for daemon version propagation in unreachable events

• Covers '(reason, version)' dedupe and inclusion of DaemonVersion when hello was read.

test/Capacitor.Cli.Tests.Unit/LocalControlClientTests.cs

DaemonKillTests.csAdd tests for raw-kill helper behavior +68/-0

Add tests for raw-kill helper behavior

• Validates kill-tree semantics and validated-PID post-checks for takeover flows.

test/Capacitor.Cli.Tests.Unit/Services/DaemonKillTests.cs

DaemonPidProbeTests.csAdd tests for PID ownership validation +77/-0

Add tests for PID ownership validation

• Covers PID-file parsing, token matching, and fallback process-name validation behavior.

test/Capacitor.Cli.Tests.Unit/Services/DaemonPidProbeTests.cs

HelloProbeTests.csAdd tests for hello probe parsing and well-formed detection +105/-0

Add tests for hello probe parsing and well-formed detection

• Validates bounded hello behavior and correct classification of malformed replies/failures.

test/Capacitor.Cli.Tests.Unit/Services/HelloProbeTests.cs

LaunchdClassifyTests.csAdd tests for launchd tri-state classification +31/-0

Add tests for launchd tri-state classification

• Ensures 'launchctl print' outputs map to loaded/absent/unknown correctly.

test/Capacitor.Cli.Tests.Unit/Services/LaunchdClassifyTests.cs

LaunchdStartStopTests.csAdd tests for hardened launchd start/stop behavior +191/-0

Add tests for hardened launchd start/stop behavior

• Covers bootstrap vs kickstart selection, stop-as-bootout, and unknown-state refusal.

test/Capacitor.Cli.Tests.Unit/Services/LaunchdStartStopTests.cs

LaunchdUninstallTests.csAdd tests for uninstall re-query and plist retention rules +102/-0

Add tests for uninstall re-query and plist retention rules

• Validates benign absence handling vs real failures and correct file cleanup/retention behavior.

test/Capacitor.Cli.Tests.Unit/Services/LaunchdUninstallTests.cs

ServiceProcessBoundedTests.csAdd tests for bounded service process execution +29/-0

Add tests for bounded service process execution

• Covers timeout behavior and process-tree termination semantics.

test/Capacitor.Cli.Tests.Unit/Services/ServiceProcessBoundedTests.cs

ServiceTxnLockTests.csAdd tests for service transaction lock semantics +52/-0

Add tests for service transaction lock semantics

• Validates contention behavior, non-blocking 'IsHeld', and lock lifecycle.

test/Capacitor.Cli.Tests.Unit/Services/ServiceTxnLockTests.cs

ServiceTxnMarkerTests.csAdd tests for durable marker read/write/fingerprint +85/-0

Add tests for durable marker read/write/fingerprint

• Covers marker serialization, corrupt/missing handling, and fingerprint stability.

test/Capacitor.Cli.Tests.Unit/Services/ServiceTxnMarkerTests.cs

ServiceVerifyInstallTests.csAdd extensive unit tests for verified install paths and rollback +471/-0

Add extensive unit tests for verified install paths and rollback

• Covers viability checks, marker recovery authority, readiness/version validation, final disk recheck, and verified-safe failure states.

test/Capacitor.Cli.Tests.Unit/Services/ServiceVerifyInstallTests.cs

ServiceVerifyReplaceTests.csAdd tests for verified replace takeover ownership matrix +349/-0

Add tests for verified replace takeover ownership matrix

• Validates replace branching (owning label, non-owning label + manual owner, no live owner), stop confirmation, and rollback behavior.

test/Capacitor.Cli.Tests.Unit/Services/ServiceVerifyReplaceTests.cs

ServiceVerifyStartTests.csAdd tests for verified start behavior +291/-0

Add tests for verified start behavior

• Covers readiness/ownership predicate, rollback on timeout, and marker cleanup rules for start transactions.

test/Capacitor.Cli.Tests.Unit/Services/ServiceVerifyStartTests.cs

Documentation (5) +1229 / -7
README.mdDocument new daemon service flags and semantics +13/-1

Document new daemon service flags and semantics

• Updates daemon-service usage to include '--verify', '--replace', and 'status --json', and explains verified start/install rollback behavior and stop-as-unload semantics.

README.md

2026-08-10-ai1654-daemon-lifecycle-path-shim.mdAdd detailed implementation plan for AI-1654 +960/-0

Add detailed implementation plan for AI-1654

• Introduces a comprehensive plan covering the lifecycle controller, CLI transaction engine, service status JSON, PATH probe/shim, and testing strategy.

docs/superpowers/plans/2026-08-10-ai1654-daemon-lifecycle-path-shim.md

2026-08-10-ai1654-daemon-lifecycle-path-shim-design.mdAdd approved design spec for daemon lifecycle + PATH shim +216/-0

Add approved design spec for daemon lifecycle + PATH shim

• Adds the slice design: app/CLI responsibilities, crash-safe transaction marker + lock, launchd classification, takeover rules, PATH shim behavior, and error handling contracts.

docs/superpowers/specs/2026-08-10-ai1654-daemon-lifecycle-path-shim-design.md

ExitCodes.csDocument supervised deliberate-refusal exit semantics +15/-0

Document supervised deliberate-refusal exit semantics

• Clarifies exit-code meaning: supervised daemons exit 0 on deliberate refusal (name-lock/NameInUse initial connect) to prevent launchd respin loops.

src/Capacitor.Cli.Core/ExitCodes.cs

help-daemon.txtUpdate CLI help for verified service verbs and JSON status +25/-6

Update CLI help for verified service verbs and JSON status

• Documents 'service install/start --verify', 'install --replace --verify', 'status --json', and stop-as-unload behavior.

src/Capacitor.Cli.Core/Resources/help-daemon.txt

@qodo-code-review

qodo-code-review Bot commented Aug 12, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Verbose startup comments added ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
Several newly added multi-line comments are overly verbose and include process/spec bookkeeping,
including internal Linear issue IDs (e.g., AI-1654/AI-1653), which reduces readability and will
become stale. Tighten these comments to only the minimal non-obvious intent and remove/replace
internal tracking references (moving any broader design notes to docs if needed).
Code

src/Capacitor.App/App.axaml.cs[R97-101]

+            // AI-1654 Task 22: BehaviorSubjects, not plain Subjects — MainWindowViewModel and
+            // TrayViewModel don't exist yet at this point in StartAsync (built further down), so a
+            // BehaviorSubject replays its latest value to whichever one subscribes later, meaning a
+            // Status/Attention call this early (the startup-phase reconciliation, e.g.) is never
+            // silently dropped for having no subscriber yet.
Evidence
PR Compliance ID 9 requires concise comments focused on non-obvious intent, but the added multi-line
comment block is lengthy and includes process metadata like task/spec references beyond what’s
needed to understand the code. PR Compliance ID 8 additionally forbids Linear issue IDs in code
comments, and the cited additions explicitly include AI-1654 and AI-1653 identifiers in the
referenced source locations, demonstrating the presence of disallowed internal tracking IDs
alongside the overly verbose commentary.

CLAUDE.md: Keep code comments concise; prefer self-explanatory code
src/Capacitor.App/App.axaml.cs[97-101]
src/Capacitor.App/App.axaml.cs[19-21]
src/Capacitor.App/Services/CliResolver.cs[11-12]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New inline comments are too verbose and include task/spec bookkeeping as well as internal Linear issue IDs (e.g., `AI-1654`/`AI-1653`), which hurts readability/maintainability and violates the rule against embedding Linear IDs in code comments.

## Issue Context
Compliance guidance requires comments to be concise and focused on non-obvious intent/constraints, with self-explanatory code preferred. It also requires avoiding Linear IDs in code comments; if a reference is truly necessary, use a public/stable reference (e.g., a GitHub issue) or remove the tracking reference entirely, and consider moving broader design notes to documentation instead of inline comments.

## Fix Focus Areas
- src/Capacitor.App/App.axaml.cs[19-21]
- src/Capacitor.App/App.axaml.cs[97-101]
- src/Capacitor.App/Services/CliResolver.cs[11-12]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. CliPath null throws ✓ Resolved 🐞 Bug ☼ Reliability
Description
KcapCli.Run uses CliPath! and will throw when CliResolver.ResolvePath returns null (broken
KCAP_APP_CLI_PATH), even though the app treats null as “no CLI”. This leads to avoidable
exceptions (e.g., the startup version probe) and makes any missed guard at call sites a runtime
failure instead of a clean “CLI missing” result.
Code

src/Capacitor.App/Services/KcapCli.cs[R109-110]

+    Task<ProcessResult> Run(string[] args, RunOptions options, CancellationToken ct) =>
+        _runner.RunAsync(CliPath!, args, options, ct);
Evidence
The app explicitly allows cliPath to be null (broken override treated as “no CLI”), but KcapCli
forces CliPath non-null with ! when spawning the process. The lifecycle controller starts a
version probe on Start(), which calls into KcapCli.VersionAsync; with CliPath == null this
becomes an exception path rather than a clean null result.

src/Capacitor.App/Services/KcapCli.cs[104-111]
src/Capacitor.App/App.axaml.cs[307-335]
src/Capacitor.App/Services/DaemonLifecycleController.cs[96-123]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`IKcapCli.CliPath` is nullable by design, but `KcapCli` assumes it is non-null (`CliPath!`) when spawning processes. When `CliResolver.ResolvePath(...)` returns null (broken `KCAP_APP_CLI_PATH`), the app intends to treat that as “no CLI”, yet `KcapCli` will still throw if any method is invoked (notably the unconditional version probe at startup).

### Issue Context
- App wiring explicitly allows `cliPath` to be null for a broken override and treats it as “no CLI”.
- `DaemonLifecycleController.Start()` always kicks off `CacheVersionAsync()`, which calls `_cli.VersionAsync(...)`.
- `KcapCli.Run(...)` uses `CliPath!` and passes it into `_runner.RunAsync(...)`.

### Fix Focus Areas
- src/Capacitor.App/Services/KcapCli.cs[100-112]
- src/Capacitor.App/Services/DaemonLifecycleController.cs[96-123]
- src/Capacitor.App/App.axaml.cs[307-336]

### Suggested fix
1. Make `KcapCli` methods resilient when `CliPath` is null:
  - In `VersionAsync` and `ServiceStatusAsync`, early-return `null` if `CliPath is null`.
  - In mutation methods (e.g., `ServiceInstallVerifiedAsync`, `ServiceStartVerifiedAsync`, `DetachedStartAsync`), either:
    - return a deterministic `ProcessResult` (e.g., exit code 127, stderr like "kcap CLI not found"), or
    - throw a dedicated, well-labeled exception that callers already treat as “CLI missing” (prefer the former to avoid noisy logs).
  - Remove the null-forgiving operator usage in `Run(...)`.
2. In `DaemonLifecycleController.CacheVersionAsync`, add a fast-path guard:
  - if `_cli.CliPath is null`, skip the version probe entirely (this avoids logging "unexpectedly" for an expected configuration state).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Nullable version forces rollback ✓ Resolved 🐞 Bug ≡ Correctness
Description
ServiceVerify’s install verification treats any mismatch between h.DaemonVersion and
expectedVersion (including when expectedVersion is null) as a deterministic VersionMismatch and
immediately rolls back. This contradicts the nullable expectedVersion API and prevents callers
from intentionally skipping version validation (or representing “unknown expected version”) without
causing rollback.
Code

src/Capacitor.Cli/Services/ServiceVerify.cs[R512-515]

+        var h = await hello(serviceId, budget);
+        if (!h.WellFormed) return InstallReady.NotReady;
+        if (h.DaemonVersion != expectedVersion) return InstallReady.VersionMismatch;
+
Evidence
The verification API explicitly allows expectedVersion to be null, but readiness checks treat null
as a hard mismatch and propagate it to the install loop, which immediately rolls back with
HelloValidation. This makes a null expected version unusable as a ‘skip validation’ signal.

src/Capacitor.Cli/Services/ServiceVerify.cs[199-205]
src/Capacitor.Cli/Services/ServiceVerify.cs[295-310]
src/Capacitor.Cli/Services/ServiceVerify.cs[505-520]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`InstallVerifiedAsync(..., string? expectedVersion)` accepts a nullable `expectedVersion`, but `IsInstallReadyAsync` performs an unconditional comparison:

```csharp
if (h.DaemonVersion != expectedVersion) return InstallReady.VersionMismatch;
```

When `expectedVersion` is null and the daemon reports a non-null `DaemonVersion`, this is treated as a version mismatch and triggers an immediate rollback (HelloValidation path).

### Issue Context
`InstallVerifiedAsync` treats `InstallReady.VersionMismatch` as a deterministic failure and calls `InstallRollback(..., VerifyExit.HelloValidation, ...)` immediately.

### Fix Focus Areas
- src/Capacitor.Cli/Services/ServiceVerify.cs[199-205]
- src/Capacitor.Cli/Services/ServiceVerify.cs[295-310]
- src/Capacitor.Cli/Services/ServiceVerify.cs[505-521]

### Suggested fix
- Change the version validation to be conditional:
 - Only enforce version equality when `expectedVersion` is non-null (and optionally non-empty / not "unknown" if that is a sentinel in your ecosystem).

Example:
```csharp
if (expectedVersion is not null && h.DaemonVersion != expectedVersion)
   return InstallReady.VersionMismatch;
```

- Add/adjust unit tests to cover `expectedVersion == null` so it behaves as “no version validation” (ready state depends only on hello well-formed + pid ownership/readiness).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can enable the Remediation agent and Qodo fixes findings in a dedicated fix PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/Capacitor.App/App.axaml.cs Outdated
Comment thread src/Capacitor.App/Services/KcapCli.cs Outdated
Comment thread src/Capacitor.Cli/Services/ServiceVerify.cs Outdated
alexeyzimarev and others added 8 commits August 12, 2026 11:20
- CLI: gate `start --verify` to launchd, matching install --verify's gate
- CLI: install/replace hello validation now checks daemon name and protocol
  version, not just DaemonVersion; version check is skipped when
  expectedVersion is null (honors the nullable contract)
- App: PathShimInstaller's AlreadyInstalled path re-runs the login-shell PATH
  probe instead of declaring success on the symlink alone
- App: ShimOfferCoordinator resets Offerable to false after a confirmed
  on-PATH install, so the tray item disappears once the shim actually works
- App: KcapCli degrades honestly (null / exit 127) instead of throwing when
  CliPath is null

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes 8 verified findings in the service install/start --verify transaction:

- Plain `service install` now serializes on the per-label service lock like
  every other mutating verb (extracted ServiceInstallPlain).
- Bound the transaction to one forward cutoff + a separate rollback reserve
  (30s advertised bound, exposed as ServiceVerify.AdvertisedBound), and route
  the verify path's launchctl calls through ServiceProcess.RunBounded so a hung
  tool maps to a bounded failure instead of blocking past the deadline. The
  legacy non-verify path is unchanged.
- Prove viability (plist render + pinned-profile server-URL validity) before
  the first destructive step, in both fresh and replace paths.
- ClearLabelAsync confirms the plist is gone (not just the label absent),
  re-uninstalling a bootout-retained orphan or failing coded.
- Owning-label replace confirms the old daemon pid exited before write/bootstrap.
- Readiness pins the observed job pid and the final recheck requires the same
  incarnation, so a KeepAlive respawn can't bless a crash-looping unit.
- Start-verify records phase=committed before deleting the marker.
- fsync the marker's containing directory (libc, Unix-guarded, best-effort).
- Reject `--no-start` combined with `--verify`; help/usage/README updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PathShimInstaller.ProbeOutcomeAsync reused ShimOfferCoordinator's cached
pre-install "absent" answer, so every successful shim install reported
InstalledButNotOnPath and the tray item never hid. LoginShellProbe now
supports a forceRefresh probe that bypasses and repopulates the cache;
the post-install/AlreadyInstalled verification uses it, while the
offer-decision call keeps using the cached (correct) pre-install state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ShimOfferCoordinator unconditionally offered the shim and spawned
PathShimInstaller, which unconditionally targets osascript and
/usr/local/bin/kcap — an operation the app could offer on Linux/Windows
and then fail to spawn. The coordinator now forces its link target to
null off macOS (an injectable OS-check seam for testability), reusing
the existing "nothing to link" no-op path everywhere: no probe, no
offer, Offerable stays false, no installer spawn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing (AI-1654)

Three fixes plus one bound adjustment from hosted-reviewer findings on
the daemon lifecycle controller:

- Revalidate takeover evidence inside the gate after the confirmation
  dialog: a terminal replacing the plist/unit while the dialog is open
  produces no attach event, so the generation-token check alone misses
  it. ConfirmAndTakeoverAsync now re-queries fresh status and rejects a
  classification that no longer matches what was disclosed, aborting
  with a Status line exactly like the existing stale-consent path.
- Never read a forced-kill's exit code as a verify outcome: when a
  mutation's ProcessResult.TimedOut is true, RunVerifiedMutationAsync
  re-queries service status and surfaces an Attention state from the
  evidence instead of treating the killed process's exit code as
  success or a coded failure.
- Parse the wire service state into a ServiceState enum with an
  explicit Unknown arm, shared by the startup matrix, Start action, and
  reconciliation — an unrecognized value is no longer read as
  NotInstalled and can't fall into an auto-install/start path.
- Raise KcapCli's mutation timeout from 45s to 60s: the CLI
  transaction's true worst case is ~50s (forward + rollback reserve +
  lock-wait + crash-recovery pre-phase + KillWait), above the prior
  bound.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…llback plist

Two crash-safety fixes in the service --verify transaction engine, plus a
comment cleanup.

Readiness observation now honors a single absolute forward deadline. IsReadyAsync
and IsInstallReadyAsync take the forward DateTimeOffset and recompute Remaining()
immediately before each bounded sub-call (hello, then Query), returning NotReady
when no budget remains rather than starting a sub-call. Previously a late-but-
well-formed hello could consume almost the whole budget and a hung launchctl print
was then handed the SAME full budget, letting readiness spend ~2x the intended
forward time and making AdvertisedBound (20s forward + 10s reserve) untrue. The
final recheck now runs inside a small confirm slice reserved WITHIN the forward
budget (the primary poll runs to deadline - reserve), so total forward work
(poll + recheck) stays inside the one forward cutoff instead of overrunning it by
the old max(remaining, PollInterval) floor.

InstallRollback now fails closed on a plist it cannot verify is ours: a null read
is absence OR a present-but-unreadable/foreign file, so it only uninstalls when
the file is genuinely absent (read null AND !plistExists) or verified-ours
(readable AND fingerprint matches). A lock-unaware writer that replaces the plist
between bootstrap and rollback is now surfaced (restore_verification), never paved
over — the same rule RecoverLeftoverMarker already applies at entry.

Restated two comments to their invariant, dropping leaked review-narrative labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
State each invariant once at its home (the _confirmReserve field, the
IsReadyAsync docs); trim the repeated restatements and drop the
historical narrative from two tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alexeyzimarev
alexeyzimarev merged commit 2730df8 into main Aug 12, 2026
6 checks passed
@alexeyzimarev
alexeyzimarev deleted the alexeyzimarev/ai-1654-daemon-lifecycle-management-path-shim-desktop-supervisor branch August 12, 2026 14:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Daemon lifecycle management + PATH shim (desktop supervisor slice 3)

1 participant