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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -741,13 +741,16 @@ kcap daemon service status --json # machine-readable status (pids, bina
kcap daemon service stop # stop the running service (stays installed)
kcap daemon service start # start it again
kcap daemon service start --verify # start, then verify readiness/ownership before exiting 0
kcap daemon service ensure # install-or-start from a fresh status read (flow-driven)
kcap daemon service uninstall # stop and remove the service
```

`install` pins the active profile via `KCAP_PROFILE` and captures your current `PATH` into the unit, so the supervised daemon resolves the same server URL, `claude`/`codex` binaries, and profile settings it would from your shell. Pass `--profile P` to pin a different profile, `--max-agents N` to bake an override, or `--no-start` to register without starting (`--no-start` cannot be combined with `--verify`, whose whole job is to prove the *started* daemon is ready). The service restarts the daemon on crash/`SIGKILL` but **not** on a clean stop. `stop` unloads it from the OS supervisor (launchd `bootout` / equivalent; the unit file is retained) rather than merely signaling the process.

`status --json` prints a machine-readable snapshot (service/job/daemon pids, binary paths, and transaction-marker state) instead of the human summary, and exits non-zero if the underlying service state can't be determined — for scripts that need to decide whether to attach, start, or repair a service without parsing human-readable text.

`ensure` is the flow-driven ladder: from a fresh status read it installs when there is no unit (baking the born-`prompt` consent directive — the daemon is installed `prompt`, so nothing runs unattended on someone else's say-so) or starts when the unit is present but stopped, and reports "already enabled" when the daemon is running — on launchd that additionally requires the running service job to own the validated daemon pid, the same ownership the verified start polls for. On macOS/launchd both arms run the verified transaction exactly as an app-managed start does, so a gate refusal exits with the coded verify exit plus one `start_gate_reason=<token>` line, and `ensure` maps the token to a machine-readable `recovery_surface=takeover|reinstall|attention` (never guessed from prose) — the flow can then offer the right next step. A gated-install viability abort with the engine's coded `package_inconsistent` reason routes to `recovery_surface=reinstall`, and an attributed boot refusal (e.g. `consent_seed_unwritable`) routes through its own table (`storage`/`takeover`/`attention`); refused launchd transactions serialize `"verified":true`, since the verified transaction did run. On Windows/Linux the ladder degrades to plain install/start (no gates, no rollback), and `ensure --json` reports `"verified":false` so the flow's copy can say so. On macOS a resolvable profile is required: a unit baked without one could never pass the start gate's identity half, so `ensure` refuses with `no_profile_configured` rather than report a dead-end install. `ensure` never mutates into an ambiguous state: an unreadable probe, an active transaction, an orphaned label (including a running one whose unit file has disappeared) or a stale marker all fail closed to attention with a coded reason — the ambiguity checks precede even the "already enabled" arm.

`start --verify` polls the started service until it answers a well-formed local-socket hello **and** the OS-reported job pid matches the daemon's own validated pid, rolling back (stopping the service again, plist retained) and exiting non-zero with a coded stderr token (e.g. `verify_readiness_timeout`) if that never happens within the poll budget — useful for scripted installs that need to know the daemon is actually up before proceeding. **`--verify` is macOS/launchd only in this release** — `start --verify` is rejected on Linux/Windows, same as `install --verify` below.

`install --verify` (fresh installs only — a service that's already installed exits with the coded `verify_contended`, since clearing an existing label is `--replace`'s job) additionally requires the started daemon's reported version, protocol version, and reported name to match the installing CLI's own expectations, and rechecks the unit file on disk against a fingerprint taken at write time — so a foreign writer replacing the file between install and the recheck is detected (`verify_restore_verification`) rather than silently accepted. On any failure it rolls back by uninstalling the unit it just wrote (never a foreign one) and exits with a coded stderr token. **`--verify` is macOS/launchd only in this release** — `install --verify` is rejected on Linux/Windows.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# AI-2039 — `kcap daemon service ensure`: the flow's daemon-install ladder

## Problem

The first-run flow's Done detour ("reach this machine from anywhere") needs one action that makes
the daemon service-installed and running. Today the ladder exists only in `Capacitor.App` (the
Avalonia wizard being retired by AI-2053): `DaemonStepViewModel` classifies a fresh
`service status --json`, `DaemonMutationLane` dispatches and classifies the mutation, and
`ReasonRouting` maps `start_gate_reason=` tokens to a recovery surface. The CLI has all the
primitives — `service status --json`, `service install --verify`, `service start --verify`, the
`ServiceVerify` transaction engine with coded exits — but nothing that composes them into the
ladder the flow needs.

## The ladder

From a fresh `service status --json`:

| Fresh state | Action | Notes |
| --- | --- | --- |
| no unit | `service install --verify` | bakes `KCAP_CONSENT_SEED_DEFAULT=prompt` — the app-installed daemon is born `prompt` |
| unit present, stopped | `service start --verify` | gated when the invoking launcher carries the seed directive |
| running (validated daemon pid, and on launchd the service job owning it) | none | already enabled — reachable is the flow's done state; service-vs-manual ownership is the detour's visibility question, not the ladder's |
| anything ambiguous | none — fail closed | unknown probe, txn marker/active, orphan label (running included — a loaded label whose unit file is gone cannot survive a relaunch), running-unconfirmed — attention, never guessed; the repair rows precede even the success arm so ambiguity can never ride a validated pid into "already enabled" |

Gate failures surface as coded exits (`verify_start_gate` = 28, `verify_start_gate_drift` = 29)
with one machine-readable `start_gate_reason=` line, mapped by `ReasonRouting` to **takeover**,
**reinstall** or **fail-closed attention** — never derived from prose. The engine's other coded
reasons travel the same wire: a gated-install viability abort with `package_inconsistent` routes
to **reinstall**, and an attributed readiness-timeout boot refusal (`consent_seed_unwritable`,
`server_expectation_mismatch`, …) routes through `ReasonRouting.ForBootRefusal` to
**storage**/**takeover**/**attention** — the flow never sees a bare `verify_viability`/
`verify_readiness_timeout` when the engine already named a reason. Refused launchd transactions
serialize `verified:true` (the verified transaction did run); `verified:false` remains the plain
off-macOS signal. On launchd a resolvable profile is a hard precondition: a unit baked without one
could never pass the start gate's identity half (it demands a non-empty invoking `KCAP_PROFILE`),
so ensure refuses with `no_profile_configured` before any mutation rather than reporting a
dead-end install. Pinning a profile strips any ambient `KCAP_URL` from the unit env — URL
resolution outranks profile resolution, so baking both would point the daemon at the wrong server
and refuse on the expectation mismatch; the pin is the sole URL authority.

## Changes

### 1. `ReasonRouting` / `RecoverySurface` move to Core

Both `Capacitor.Cli` and `Capacitor.App` reference `Capacitor.Cli.Core`; the CLI cannot reference
the app. The pinned token→surface table therefore moves from
`Capacitor.App/Services/Mutation/MutationModel.cs` to Core (namespace `Capacitor.Cli.Core`), and
the app's existing references (its `DaemonMutationLane`, `App.axaml.cs` presentation switch,
`MutationRequestFactory`) pick it up via `using`. The app's tests for the table move with it. This
is the same rescue shape as AI-2167 (classes leaving the app before AI-2053 deletes it).

### 2. `kcap daemon service ensure`

New verb in `DaemonServiceCommands`:

- fresh status query + the same lifecycle evidence `status --json` reads (probe/state/unit
presence, txn marker/active, validated daemon pid, and — for the already-enabled arm on launchd —
the service job's own pid, which must own the validated daemon);
- pure classification (unit absent → install; unit present + stopped → start; running → already
enabled; ambiguous → attention), mirroring `ServiceStatusRender`'s "unknown never masquerades";
- install path builds the spec env via `ServiceEnvironment.Capture` and **force-bakes**
`KCAP_CONSENT_SEED_DEFAULT=prompt` plus the `KCAP_EXPECT_SERVER_URL` pin (the identity half of
the start gate re-reads it) and the `KCAP_DAEMON_SUPERVISED` pin — the app's `MutationEnv`
equivalent in-process;
- launchd runs the verified transaction with a `gateEnv` carrying the seed directive (so the
start gate fires — this is the app-managed start contract); other platforms run the plain
install/start (the degraded end state whose copy the flow reflects);
- machine-readable result: coded exit + `start_gate_reason=` (already emitted by the engine) + a
`recovery_surface=` line and a `--json` payload the flow can act on without parsing prose —
including on the pre-flight refusals (no server configured, daemon binary missing), which emit
a `refused` row rather than an undocumented empty stdout.

### 3. README + `help-daemon.txt`

Document `ensure` in the service command list and help text, noting the macOS/launchd `--verify`
scope and the plain install on Windows/Linux.

## Established: the Windows answer

AI-2039's open question asked what the daemon actually does on Windows, because the answer changes
copy on two screens (the install ladder's and the Done detour's). Established while wiring the
install:

- The daemon itself is **fully cross-platform**: ConPTY (`Pty/Windows/ConPtyProcess`), a Windows
Scheduled Task service manager, job objects, a win-x64 npm package, and Windows CI legs. Hosted
agents (ACP-over-stdio runtimes + ConPTY for PTY vendors) and the server→daemon SignalR path
(agent launches, permission prompts) work there. So the detour's pitch — "reach this machine
from anywhere, runs happen here" — holds on Windows.
- What is Windows-gated: the **local `kcap agent` terminal drive** ("not supported on Windows
yet"), and the **verified transaction** (`install/start --verify`) which is **launchd-only** — on
Windows/Linux the ladder degrades to plain install/start with no gates, no `start_gate_reason=`,
no takeover/reinstall classification, and no rollback.

So the flow shows the detour everywhere; the copy reflects plain install off-macOS (`--json`
reports `verified:false`). The Avalonia wizard being macOS-only and the MAUI host being
maccatalyst+windows are both irrelevant to this decision — the browser flow is the first surface
that can give "which platforms does first run actually support" a single answer.

## Out of scope

- Flow/screen wiring (AI-2048), the flow's CLI create+poll half (AI-2156), and anything server-side.
- The Avalonia wizard itself (AI-2053) — only the shared mapping moves now.
- Takeover as an ensure *action*: ensure classifies and reports the surface; performing
`install --replace --verify` stays a separate, consent-bearing step the flow offers.
24 changes: 2 additions & 22 deletions src/Capacitor.App/Services/Mutation/MutationModel.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
using Capacitor.Cli.Core;

namespace Capacitor.App.Services.Mutation;

public enum MutationVerb { Install, Replace, StartVerified, DetachedStart }

public sealed record MutationRequest(
MutationVerb Verb, string Profile, string CanonicalServer, string DaemonName);

public enum RecoverySurface { Takeover, Reinstall, Attention, Storage, None }

/// One classified result of a daemon-mutation attempt (spec §3/§4); the mutation lane maps every raw outcome onto exactly one case.
public abstract record MutationOutcome {
public sealed record Succeeded : MutationOutcome;
Expand All @@ -19,23 +19,3 @@ public sealed record Failed(int ExitCode, string? Reason, RecoverySurface Surfac
}

public sealed record OutcomeEnvelope(MutationRequest Request, MutationOutcome Outcome);

/// Maps a machine-readable reason token to a recovery surface (spec §3/§4 pinned tables); unknown tokens fail closed to Attention.
public static class ReasonRouting {
public static RecoverySurface ForStartGate(string token) => token switch {
"directive_missing" or "directive_invalid" or "identity_mismatch" or "foreign_binary" => RecoverySurface.Takeover,
"package_inconsistent" => RecoverySurface.Reinstall,
_ => RecoverySurface.Attention,
};

public static RecoverySurface ForDaemonStart(string token) => token switch {
"package_inconsistent" => RecoverySurface.Reinstall,
_ => RecoverySurface.Attention,
};

public static RecoverySurface ForBootRefusal(string token) => token switch {
"server_expectation_mismatch" or "consent_seed_invalid" => RecoverySurface.Takeover,
"consent_seed_unwritable" => RecoverySurface.Storage,
_ => RecoverySurface.Attention,
};
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Auth;

namespace Capacitor.App.Services.Mutation;
Expand Down
32 changes: 32 additions & 0 deletions src/Capacitor.Cli.Core/RecoveryRouting.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace Capacitor.Cli.Core;

/// <summary>
/// The recovery a failed daemon mutation offers its caller (spec §3/§4 pinned tables). Shared by the
/// CLI's <c>daemon service ensure</c> ladder and the (retiring) desktop supervisor, so the pinned
/// token→surface mapping has exactly one home.
/// </summary>
public enum RecoverySurface { Takeover, Reinstall, Attention, Storage, None }

/// <summary>
/// Maps a machine-readable reason token to a recovery surface (spec §3/§4 pinned tables); unknown
/// tokens fail closed to Attention — a newer CLI's reason must not be destructively interpreted by
/// an older consumer.
/// </summary>
public static class ReasonRouting {
public static RecoverySurface ForStartGate(string token) => token switch {
"directive_missing" or "directive_invalid" or "identity_mismatch" or "foreign_binary" => RecoverySurface.Takeover,
"package_inconsistent" => RecoverySurface.Reinstall,
_ => RecoverySurface.Attention,
};

public static RecoverySurface ForDaemonStart(string token) => token switch {
"package_inconsistent" => RecoverySurface.Reinstall,
_ => RecoverySurface.Attention,
};

public static RecoverySurface ForBootRefusal(string token) => token switch {
"server_expectation_mismatch" or "consent_seed_invalid" => RecoverySurface.Takeover,
"consent_seed_unwritable" => RecoverySurface.Storage,
_ => RecoverySurface.Attention,
};
}
21 changes: 21 additions & 0 deletions src/Capacitor.Cli.Core/Resources/help-daemon.txt
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,27 @@ Subcommands for service (per-user; auto-restarts on crash/SIGKILL, starts at log
stop [--name N] Stop the running service (unloads/deregisters it with
the OS supervisor; stays installed — returns at next
login or `service start`).
ensure [--name N] [--profile P] [--json]
Flow-driven ladder: from a fresh status read, install
when there is no unit (baking the born-prompt consent
directive) or start when the unit is present but
stopped; "already enabled" when the daemon is running
(on launchd only when the service job owns the
validated daemon pid). On macOS/launchd both arms run
the verified transaction, so a gate refusal exits with
the coded verify exit plus start_gate_reason=, mapped
to recovery_surface=takeover|reinstall|attention —
never guessed from prose; coded viability/refusal
reasons route likewise (package_inconsistent →
reinstall, boot refusals → storage/takeover), and
refused launchd transactions report verified:true.
Windows/Linux degrade to plain install/start (no
gates; --json reports verified:false). macOS requires
a resolvable profile (no_profile_configured refusal
otherwise). Ambiguous states (unknown probe, active
transaction, orphan label — running included — stale
marker) fail closed to attention with a coded reason
— never mutated into.
status [--name N] [--json]
Show installed / running state. --json emits a
machine-readable snapshot (service/job/daemon pids,
Expand Down
Loading