Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
fac6a2c
docs: spec for daemon service supervisor (install/uninstall/start/stop)
alexeyzimarev Jun 10, 2026
21fb091
docs: address spec review (env/profile, systemd per-instance, escaping)
alexeyzimarev Jun 10, 2026
11f6f6e
docs: fix Windows env model in service spec (cmd wrapper)
alexeyzimarev Jun 10, 2026
5d237d7
docs: implementation plan for daemon service supervisor
alexeyzimarev Jun 10, 2026
0e05979
feat(daemon): service contracts + text-escaping helpers
alexeyzimarev Jun 10, 2026
fe21169
feat(daemon): launchd plist + command vectors (pure)
alexeyzimarev Jun 10, 2026
c09cd5e
feat(daemon): systemd per-instance unit + vectors (pure)
alexeyzimarev Jun 10, 2026
d0d072b
feat(daemon): windows task xml + cmd wrapper + vectors (pure)
alexeyzimarev Jun 10, 2026
43980af
feat(daemon): thin service managers + process runner + factory
alexeyzimarev Jun 10, 2026
26de322
feat(daemon): capture env + pin profile for service units
alexeyzimarev Jun 10, 2026
f6b9cf3
feat(daemon): kcap daemon service install/uninstall/start/stop/status
alexeyzimarev Jun 10, 2026
187e169
feat(daemon): status/stop/doctor awareness of installed services
alexeyzimarev Jun 10, 2026
9ad65c6
docs(daemon): document service install/uninstall/start/stop/status
alexeyzimarev Jun 10, 2026
ff2279f
feat(daemon): synchronous stderr backstop for death-rattle messages
alexeyzimarev Jun 10, 2026
03559f5
fix(daemon): address PR #137 review (service teardown, escaping, doctor)
alexeyzimarev Jun 10, 2026
eaa2c87
fix(daemon): quote-aware systemd ExecStart binary parse for doctor
alexeyzimarev Jun 10, 2026
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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ In `--no-prompt` mode, the wizard installs hooks for every detected agent by def

> **Need at least one agent to capture sessions:** the setup wizard runs to completion without an agent CLI on `PATH` (it'll still configure your profile, auth, and daemon), but kcap only records work once Claude Code or Codex CLI is installed and the hooks are in place.

> **Keep the daemon running:** `kcap daemon start -d` stops when the process dies (a crash, or an OS memory-pressure kill — macOS jetsam / Linux OOM). To auto-restart it and start it at login, install it as a per-user service: `kcap daemon service install`. See [Daemon](#daemon).

### 3. Import existing sessions (optional)

```bash
Expand Down Expand Up @@ -310,6 +312,23 @@ kcap daemon doctor --clean # also remove stale lock/pid files (held ent

`KCAP_DAEMON_NAME` overrides the active profile's daemon name (superseded by an explicit `--name` flag).

#### Run it as a service (auto-restart)

`kcap daemon start -d` runs only until the process dies — a crash, or an OS memory-pressure kill (macOS **jetsam** / Linux **OOM killer**) that sends an uncatchable `SIGKILL`. To have the daemon auto-restart and start at login, install it as a **per-user** OS service:

```bash
kcap daemon service install # launchd (macOS) / systemd --user (Linux) / Scheduled Task (Windows)
kcap daemon service install --name laptop # a service per daemon name
kcap daemon service status # installed / running state
kcap daemon service stop # stop the running service (stays installed)
kcap daemon service start # start it again
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. The service restarts the daemon on crash/`SIGKILL` but **not** on a clean stop.

Because the service auto-restarts, stop a service-managed daemon with `kcap daemon service stop` (or `uninstall`) rather than `kcap daemon stop` — a raw stop would be relaunched immediately. `kcap daemon status` and `kcap daemon doctor` both report installed services.

Each daemon process holds an exclusive `flock` on `~/.config/kcap/daemons/<name>.lock` for its entire lifetime. The kernel releases the lock automatically when the daemon exits (including `SIGKILL` or power-off), so leftover lock files on disk are never a blocker — only a live process holding the kernel-level lock can prevent another daemon from acquiring the same name.

Two daemons with **different** `--name` values can run side-by-side. Two daemons under the **same name** on the same machine collide on the flock and the second one exits with code 2. Even if that guard is bypassed somehow, the server rejects the second daemon's `DaemonConnect` with a typed error and the second daemon exits with code 3 — no more silent slot-displacement oscillation.
Expand Down
1,343 changes: 1,343 additions & 0 deletions docs/superpowers/plans/2026-06-10-daemon-service-supervisor.md

Large diffs are not rendered by default.

428 changes: 428 additions & 0 deletions docs/superpowers/specs/2026-06-10-daemon-service-supervisor-design.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions src/Capacitor.Cli.Core/Resources/help-daemon.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Subcommands:
status [--name N] Show daemon status (lists all when --name omitted)
logs Show recent daemon log output
doctor [--clean] Diagnose lock-file state, optionally clean stale entries
service <action> Manage the OS service (launchd/systemd/Scheduled Task)

Options for start:
--name <name> Daemon name (defaults to OS username)
Expand All @@ -28,6 +29,17 @@ Options for doctor:
gone). Held entries are never touched. Without
--clean, doctor reports state without modifying.

Subcommands for service (per-user; auto-restarts on crash/SIGKILL, starts at login):
install [--name N] [--profile P] [--max-agents N] [--no-start]
Register the daemon as an OS service and start it.
Pins the profile via KCAP_PROFILE and captures PATH so
claude/codex resolve as they do in your shell.
uninstall [--name N] Stop and remove the service unit.
start [--name N] Start the installed service now.
stop [--name N] Stop the running service (stays installed; returns at
next login or `service start`).
status [--name N] Show installed / running state.

Notes:
Each daemon holds an exclusive flock on ~/.config/kcap/daemons/<name>.lock
for its entire lifetime. The kernel releases the lock automatically on
Expand Down
38 changes: 35 additions & 3 deletions src/Capacitor.Cli.Daemon/DaemonRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,14 @@ await Console.Error.WriteLineAsync(
// Lifetime-driven log lines — pair with the AppDomain/signal hooks so
// we can distinguish a cooperative StopApplication (e.g. NameInUse,
// Ctrl+C consumed by ConsoleLifetime) from an outside-the-runtime kill.
lifetime.ApplicationStopping.Register(() => LogLifetimeStopping(logger));
lifetime.ApplicationStopped.Register(() => LogLifetimeStopped(logger));
lifetime.ApplicationStopping.Register(() => {
DeathRattle("Lifetime: ApplicationStopping fired");
LogLifetimeStopping(logger);
});
lifetime.ApplicationStopped.Register(() => {
DeathRattle("Lifetime: ApplicationStopped fired");
LogLifetimeStopped(logger);
});

// AI-630: if the server rejects our DaemonConnect because another
// live daemon owns the (owner, name) slot, signal host shutdown
Expand Down Expand Up @@ -315,19 +321,24 @@ await Console.Error.WriteLineAsync(
static void RegisterDeathRattle(ILogger logger, IHostApplicationLifetime lifetime) {
AppDomain.CurrentDomain.UnhandledException += (_, args) => {
if (args.ExceptionObject is Exception ex) {
DeathRattle($"AppDomain.UnhandledException (terminating={args.IsTerminating}): {ex.GetType().Name}: {ex.Message}");
LogUnhandledException(logger, ex, args.IsTerminating);
}
};

TaskScheduler.UnobservedTaskException += (_, args) => {
DeathRattle($"TaskScheduler.UnobservedTaskException: {args.Exception.GetType().Name}: {args.Exception.Message}");
LogUnobservedTaskException(logger, args.Exception);
// Mark observed so the default policy (a no-op in .NET 5+, but a
// process-killing rethrow on legacy/AOT configurations) can't
// escalate this past the logging step.
args.SetObserved();
};

AppDomain.CurrentDomain.ProcessExit += (_, _) => LogProcessExit(logger);
AppDomain.CurrentDomain.ProcessExit += (_, _) => {
DeathRattle("AppDomain.ProcessExit fired (this is the last log line)");
LogProcessExit(logger);
};

// POSIX signal hooks. ConsoleLifetime already wires SIGINT and SIGTERM
// to lifetime.StopApplication(); our registration is additive (multiple
Expand All @@ -345,6 +356,7 @@ static void RegisterDeathRattle(ILogger logger, IHostApplicationLifetime lifetim
foreach (var signal in new[] { PosixSignal.SIGINT, PosixSignal.SIGTERM, PosixSignal.SIGHUP, PosixSignal.SIGQUIT }) {
try {
_signalRegistrations.Add(PosixSignalRegistration.Create(signal, ctx => {
DeathRattle($"Received POSIX signal {ctx.Signal} — requesting cooperative shutdown");
LogPosixSignal(logger, ctx.Signal);
ctx.Cancel = true;
lifetime.StopApplication();
Expand All @@ -368,4 +380,24 @@ static void RegisterDeathRattle(ILogger logger, IHostApplicationLifetime lifetim
/// lifetime as the process.
/// </summary>
static readonly List<PosixSignalRegistration> _signalRegistrations = [];

/// <summary>
/// Synchronous stderr backstop for death-rattle messages. The default
/// <c>AddSimpleConsole</c> provider uses a background-thread queue
/// (<c>ConsoleLoggerProcessor</c>) that can drop messages enqueued during
/// runtime teardown — so a <c>ProcessExit</c> or signal-handler log line
/// may never reach the terminal even though the hook fired. Writing
/// directly to <see cref="Console.Error"/> bypasses the queue and lands
/// on stderr immediately. Best-effort: a closed terminal or broken pipe
/// must not throw out of an exit hook.
/// </summary>
static void DeathRattle(string message) {
try {
Console.Error.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [death-rattle] {message}");
Console.Error.Flush();
} catch {
// Stderr might be redirected to a closed pipe, the terminal
// might be gone, etc. Already exiting — nothing useful to do.
}
}
}
143 changes: 131 additions & 12 deletions src/Capacitor.Cli/Commands/DaemonCommands.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Diagnostics;
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Config;
using Capacitor.Cli.Services;

namespace Capacitor.Cli.Commands;

Expand All @@ -18,12 +19,13 @@ public static async Task<int> HandleAsync(string[] args) {
var remaining = args[2..];

return subcommand switch {
"start" => await StartAsync(remaining),
"stop" => await StopAsync(remaining),
"status" => await Status(remaining),
"logs" => await Logs(),
"doctor" => await DoctorAsync(remaining),
_ => PrintUsage()
"start" => await StartAsync(remaining),
"stop" => await StopAsync(remaining),
"status" => await Status(remaining),
"logs" => await Logs(),
"doctor" => await DoctorAsync(remaining),
"service" => await ServiceAsync(remaining),
_ => PrintUsage()
};
}

Expand Down Expand Up @@ -275,6 +277,15 @@ static async Task<int> StopAsync(string[] args) {
}

static int StopByName(string name) {
var manager = TryServiceManager();
if (manager is not null && manager.Status(ServiceText.ServiceId(name)).State != ServiceState.NotInstalled) {
Console.Out.WriteLine(
$"Daemon '{name}' is managed by {manager.Describe()}; a raw stop would be auto-restarted.");
Console.Out.WriteLine($"Use: kcap daemon service stop --name {name} (or uninstall to remove it)");

return 0;
}

if (ReadPidFile(name) is not { } entry) {
Console.Error.WriteLine($"No daemon '{name}' running (no PID file found).");

Expand Down Expand Up @@ -316,7 +327,12 @@ static async Task<int> Status(string[] args) {
return 1;
}

var names = explicitName is not null ? [explicitName] : EnumerateRunningNames();
var manager = TryServiceManager();
var serviceIds = manager?.ListInstalled() ?? [];

var names = explicitName is not null
? new List<string> { explicitName }
: EnumerateRunningNames().Concat(serviceIds).Distinct().Order().ToList();

if (names.Count == 0) {
await Console.Out.WriteLineAsync("Daemon: not running");
Expand All @@ -327,11 +343,7 @@ static async Task<int> Status(string[] args) {
foreach (var name in names) {
if (ReadPidFile(name) is not { } entry) {
await Console.Out.WriteLineAsync($"Daemon '{name}': not running");

continue;
}

if (IsOurDaemon(entry.Pid, entry.StartTicks)) {
} else if (IsOurDaemon(entry.Pid, entry.StartTicks)) {
await Console.Out.WriteLineAsync($"Daemon '{name}': running (PID {entry.Pid})");
} else {
await Console.Out.WriteLineAsync($"Daemon '{name}': not running (stale PID file)");
Expand All @@ -340,6 +352,12 @@ static async Task<int> Status(string[] args) {
/* best-effort */
}
}

if (manager is not null) {
var st = manager.Status(ServiceText.ServiceId(name)).State;
if (st != ServiceState.NotInstalled)
await Console.Out.WriteLineAsync($" service: {st} ({manager.Describe()})");
}
}

return 0;
Expand All @@ -364,6 +382,7 @@ static async Task<int> DoctorAsync(string[] args) {

if (names.Count == 0) {
await Console.Out.WriteLineAsync($"No daemon files found under {DaemonLockPaths.Directory}.");
await ReportInstalledServices();

return 0;
}
Expand Down Expand Up @@ -461,6 +480,8 @@ static async Task<int> DoctorAsync(string[] args) {
await Console.Out.WriteLineAsync("Re-run with --clean to remove stale entries.");
}

await ReportInstalledServices();

return 0;
}

Expand Down Expand Up @@ -613,6 +634,104 @@ static async Task<int> Logs() {
return 0;
}

// ── service (OS supervisor: launchd / systemd / scheduled task) ───────────

static async Task<int> ServiceAsync(string[] args) {
if (args.Length == 0) return ServiceUsage();

var action = args[0];
var rest = args[1..];
var noStart = rest.Contains("--no-start");

IServiceManager manager;
try {
manager = ServiceManagerFactory.ForCurrentOs();
} catch (PlatformNotSupportedException ex) {
await Console.Error.WriteLineAsync(ex.Message);
return 1;
}

var id = ServiceText.ServiceId(ResolveName(rest));

switch (action) {
case "install": return await ServiceInstall(manager, rest, id, startNow: !noStart);
case "uninstall": manager.Uninstall(id); await Console.Out.WriteLineAsync($"Service '{id}' uninstalled ({manager.Describe()})."); return 0;
case "start": manager.Start(id); await Console.Out.WriteLineAsync($"Service '{id}' started."); return 0;
case "stop": manager.Stop(id); await Console.Out.WriteLineAsync($"Service '{id}' stopped (still installed)."); return 0;
case "status": return await ServiceStatus(manager, id);
default: return ServiceUsage();
}
}

static async Task<int> ServiceInstall(IServiceManager manager, string[] args, string id, bool startNow) {
var daemonPath = ResolveDaemonBinary();
if (daemonPath is null) { await Console.Error.WriteLineAsync(DaemonNotFoundMessage()); return 1; }

var profileName = ExtractFlagValue(args, "--profile") ?? AppConfig.ResolvedProfile?.ProfileName;
var env = ServiceEnvironment.Capture(profileName);

var extra = new List<string>();
if (ExtractFlagValue(args, "--max-agents") is { } mx) { extra.Add("--max-agents"); extra.Add(mx); }

var logPath = PathHelpers.ConfigPath($"daemon-{id}.log");
var spec = new ServiceSpec(id, daemonPath, logPath, env, extra);

manager.Install(spec, startNow);

await Console.Out.WriteLineAsync($"Service '{id}' installed ({manager.Describe()}).");
await Console.Out.WriteLineAsync(" Auto-restarts on crash/SIGKILL; starts at login.");
await Console.Out.WriteLineAsync($" Log: {logPath}");
await Console.Out.WriteLineAsync($" Stop: kcap daemon service stop --name {id}");
await Console.Out.WriteLineAsync($" Remove: kcap daemon service uninstall --name {id}");
return 0;
}

static async Task<int> ServiceStatus(IServiceManager manager, string id) {
var status = manager.Status(id);
await Console.Out.WriteLineAsync($"Service '{id}': {status.State} ({manager.Describe()})");
if (status.BinaryPath is { } bin) await Console.Out.WriteLineAsync($" binary: {bin}");
return 0;
}

static int ServiceUsage() {
Console.Error.WriteLine("Usage: kcap daemon service <install|uninstall|start|stop|status> [--name N]");
Console.Error.WriteLine();
Console.Error.WriteLine(" install [--name N] [--profile P] [--max-agents N] [--no-start]");
Console.Error.WriteLine(" uninstall [--name N] Stop and remove the service unit");
Console.Error.WriteLine(" start [--name N] Start the installed service now");
Console.Error.WriteLine(" stop [--name N] Stop the running service (stays installed)");
Console.Error.WriteLine(" status [--name N] Show installed/running state");
return 1;
}

/// <summary>Service manager for this OS, or null if the OS is unsupported.</summary>
static IServiceManager? TryServiceManager() {
try { return ServiceManagerFactory.ForCurrentOs(); }
catch (PlatformNotSupportedException) { return null; }
}

/// <summary>
/// Doctor add-on: list OS-installed services and flag any whose baked binary
/// path no longer exists (e.g. a moved npm prefix — fixed by re-running
/// <c>kcap daemon service install</c>). No-op on unsupported OSes / when none.
/// </summary>
static async Task ReportInstalledServices() {
var manager = TryServiceManager();
if (manager is null) return;

var installed = manager.ListInstalled();
if (installed.Count == 0) return;

await Console.Out.WriteLineAsync($"\nInstalled services ({manager.Describe()}):");

foreach (var sid in installed) {
var st = manager.Status(sid);
var bad = st.BinaryPath is { } b && !File.Exists(b);
var note = bad ? " ⚠ binary missing — re-run `kcap daemon service install`" : "";
await Console.Out.WriteLineAsync($" {sid,-20} {st.State}{note}");
}
}

/// <summary>
/// Resolve the kcap-daemon executable shipped alongside this binary.
/// </summary>
Expand Down
18 changes: 18 additions & 0 deletions src/Capacitor.Cli/Commands/UninstallCommand.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Cursor;
using Capacitor.Cli.Services;

namespace Capacitor.Cli.Commands;

Expand Down Expand Up @@ -74,6 +75,23 @@ await Console.Out.WriteLineAsync(
// — this flag captures the boolean for the final decision only.
var hadFailures = false;

// Deregister any OS-managed daemon services FIRST. `daemon stop` defers to
// the supervisor for service-managed daemons (a raw kill would be
// auto-restarted), so without this the launchd/systemd/Scheduled-Task unit
// would survive uninstall and keep relaunching a daemon whose config we're
// about to delete. Uninstalling the unit also stops the running instance
// (launchctl bootout / systemctl disable --now), after which the plain
// `daemon stop --yes` below mops up any non-service daemons.
try {
var services = ServiceManagerFactory.ForCurrentOs();
foreach (var id in services.ListInstalled()) {
services.Uninstall(id);
await Console.Out.WriteLineAsync($" • Removed daemon service '{id}' ({services.Describe()})");
}
} catch (PlatformNotSupportedException) {
// No service backend on this OS — nothing to deregister.
}

// Stop daemons first — they hold lock files inside the config dir we're
// about to delete. --yes silences the multi-daemon confirmation so this
// works non-interactively. A non-zero exit code means at least one
Expand Down
Loading
Loading