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
5 changes: 5 additions & 0 deletions .changeset/sour-pandas-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@caplets/core": minor

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 Release the CLI package with the daemon surface

The published caplets binary lives in packages/cli/package.json, and its packed dependency on @caplets/core is versioned at publish time; this changeset only bumps @caplets/core. If only core is released, npm users installing caplets keep the old CLI package/dependency and never receive caplets daemon or the migration behavior, so add a caplets changeset as well so the CLI package is republished with the new core.

Useful? React with 👍 / 👎.

---

Move daemon lifecycle management from `caplets serve ...` to `caplets daemon ...`, with native per-user service manager support, install-time HTTP configuration, environment overrides, status, logs, and uninstall behavior.
22 changes: 21 additions & 1 deletion CONCEPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,32 @@

Shared domain vocabulary for this project -- entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all.

## Code Mode
## Caplets Runtime

### Caplet

A configured capability surface that exposes a backend to agents through a stable handle, progressive wrapper tools, or direct tool operations.

### Caplets Daemon

A per-user native service managed by `caplets daemon` that runs local HTTP `caplets serve` through the operating system service manager.

The Caplets Daemon is installed and updated through an install-time service contract. Runtime lifecycle commands operate on the installed service rather than changing its persisted serve or environment configuration.

### Install-Time Service Contract

The persisted daemon agreement that defines what command runs, which environment model applies, which native service identity owns it, and how updates become active.

### Native Service Manager

The operating system facility that owns per-user service registration and lifecycle for the Caplets Daemon.

### Service Descriptor

The native service-manager artifact that declares how the Caplets Daemon should be launched and supervised.

## Code Mode

### Code Mode

The Caplets execution surface where an agent runs a bounded TypeScript workflow against generated Caplet handles and receives compact structured output.
Expand Down
6 changes: 6 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ The HTTP server in `packages/core/src/serve/http.ts` exposes versioned MCP, atta

`/v1/attach` is the Caplets runtime attach API. Attached clients read `/v1/attach/manifest`, subscribe to `/v1/attach/events`, and invoke revision-scoped exports through `/v1/attach/invoke` before merging remote projections with local/project overlays.

### Caplets Daemon

`packages/core/src/daemon/` owns the default per-user daemon lifecycle. `caplets daemon install` persists HTTP `caplets serve` configuration, explicit service environment variables, optional shell inheritance intent, user-only log paths, and native service descriptors under the `daemon/default` identity. Runtime lifecycle commands (`start`, `restart`, `stop`, `status`, `logs`, and `uninstall`) read that installed service state instead of accepting serve flags.

The daemon uses the native per-user service manager for the host platform: launchd UserAgents on macOS, `systemd --user` services on Linux, and current-user Windows Scheduled Tasks on Windows. There is no detached-process fallback when a native manager is unavailable. Foreground `caplets serve` remains stdio/HTTP serving only.

### Code Mode

Code Mode is implemented under `packages/core/src/code-mode/`.
Expand Down
222 changes: 222 additions & 0 deletions docs/brainstorms/2026-06-19-caplets-daemon-service-requirements.md

Large diffs are not rendered by default.

417 changes: 417 additions & 0 deletions docs/plans/2026-06-19-001-feat-caplets-daemon-service-plan.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion docs/product/caplets-code-mode-prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ Caplets should make backends usable as capability domains without hiding the exa
The `caplets` package installs the CLI. Important commands include:

- `caplets setup` for Codex, Claude Code, OpenCode, Pi, or generic MCP client setup.
- `caplets serve` for a local MCP server.
- `caplets serve` for foreground stdio or HTTP MCP serving.
- `caplets daemon` for installing, starting, stopping, inspecting, tailing logs for, and uninstalling the default local HTTP service through the native per-user service manager.
- `caplets attach` for a remote-backed MCP server and Project Binding session.
- `caplets install` for shared Caplet files.
- `caplets add` for MCP, OpenAPI, GraphQL, HTTP, and CLI-backed Caplets.
Expand All @@ -57,6 +58,8 @@ The `caplets` package installs the CLI. Important commands include:

`caplets serve` registers a `code_mode` MCP tool when any configured backend resolves to Code Mode exposure. Progressive wrapper tools and direct tools are registered only for Caplets whose exposure policy enables those surfaces.

`caplets daemon install` is the configuration mutation point for the long-running local HTTP service. It accepts the HTTP serve flags from `caplets serve` except `--transport`, supports explicit `--env` overrides and optional shell inheritance, and writes stdout/stderr logs that remain readable until purged.

### Native Integrations

OpenCode and Pi use the native service from `@caplets/core/native`. They expose `caplets__code_mode` for Code Mode, `caplets__<id>` tools for progressive exposure, and operation-level `caplets__<id>__<operation>` tools for direct exposure. Native integrations share the same local, remote, and Cloud selection rules as the CLI.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
---
title: Native Daemon Management Belongs Behind an Install-Time Service Contract
date: 2026-06-19
category: architecture-patterns
module: Caplets daemon
problem_type: architecture_pattern
component: tooling
severity: medium
applies_when:
- "Moving a foreground CLI server into a native per-user service"
- "Service install flags persist configuration but runtime lifecycle commands should not mutate it"
- "Supporting launchd, systemd user services, and Windows Scheduled Tasks from one CLI surface"
tags: [daemon, native-services, launchd, systemd, scheduled-tasks, cli]
---

# Native Daemon Management Belongs Behind an Install-Time Service Contract

## Context

The Caplets daemon work moved lifecycle management out of `caplets serve` and into a top-level `caplets daemon` surface. Foreground `serve` remains the process entrypoint, while `daemon install` writes persistent service configuration and registers a per-user native service through launchd, systemd user services, or Windows Scheduled Tasks.

The product requirement looked simple at first: create `install`, `start`, `restart`, `stop`, `uninstall`, `status`, and `logs`. The hard part was that each command has different ownership. `install` is allowed to mutate service configuration, descriptors, environment settings, validation behavior, and optional restart decisions. Runtime lifecycle commands should only operate on the already-installed service. Session history also surfaced review failures when this boundary was blurred: partial registration could leave stale artifacts, platform descriptors could misquote commands, and launchd could report an already-loaded job while still running the old descriptor.

## Guidance

Model the daemon as an install-time service contract, not as a detached variant of the foreground server.

The public CLI should expose daemon lifecycle under the daemon noun:

```text
caplets daemon install
caplets daemon uninstall
caplets daemon start
caplets daemon restart
caplets daemon stop
caplets daemon status
caplets daemon logs
```

Keep `caplets serve` focused on foreground serving. If old daemon subcommands existed under `serve`, leave migration guidance there, but do not keep a second daemon implementation alive.

Separate configuration mutation from lifecycle mutation:

```ts
// install owns config merge, validation, descriptor writing, and registration
await installDaemon({
host,
port,
env,
inheritEnv,
start,
restart,
noRestart,
});

// lifecycle commands only read the installed service
await startDaemon();
await restartDaemon();
await stopDaemon();
```

Build a native manager seam that normalizes the service lifecycle while preserving raw platform details for troubleshooting:

```ts
type NativeDaemonStatus = {
state: "not_installed" | "installed_stopped" | "running" | "failed" | "unavailable" | "unknown";
installed: boolean;
running: boolean;
pid?: number;
message?: string;
raw?: Record<string, unknown>;
};

type DaemonManager = {
descriptor(config: DaemonConfig): DaemonDescriptor;
install(config: DaemonConfig): Promise<DaemonManagerAction>;
uninstall(config: DaemonConfig | undefined, paths: DaemonPaths): Promise<DaemonManagerAction>;
start(config: DaemonConfig): Promise<DaemonManagerAction>;
restart(config: DaemonConfig): Promise<DaemonManagerAction>;
stop(config: DaemonConfig): Promise<DaemonManagerAction>;
status(config: DaemonConfig | undefined, paths: DaemonPaths): Promise<NativeDaemonStatus>;
};
```

Treat native registration as transactional. Write descriptor artifacts before invoking the native manager, but back them up and restore them if registration fails. Only write installed Caplets config/state after the native install succeeds. This avoids claiming the daemon is installed when the OS service manager rejected it.

Render service commands as argv for each platform, not as a reused display string. The managed process may still invoke `caplets serve --transport http` internally, but public `daemon install` should not accept `--transport` because the daemon is intentionally HTTP-only. Quote descriptors per platform:

- systemd units need escaped `%`, quotes, backslashes, and newlines in `Environment=`, `WorkingDirectory=`, and `ExecStart=`.
- Windows Scheduled Tasks need a wrapper when argument handling, working directory, and file-backed logs cannot be represented safely in the task XML alone.
- launchd restart/update needs an explicit reload path, such as `bootout`, `bootstrap`, then `kickstart`, when a changed plist must become active.

Make logs file-backed and manager-independent. Service descriptors or wrappers should redirect stdout and stderr into Caplets-managed files, and `caplets daemon logs` should read those files with tail-like options. This keeps logs readable after uninstall when logs are preserved.

## Why This Matters

Native service managers are not interchangeable process launchers. launchd has loaded-job semantics, systemd unit files have their own quoting rules, and Windows Scheduled Tasks are XML plus command-line interpretation. A daemon abstraction that hides all platform detail too early will either lose useful status information or silently produce descriptors that pass validation but fail after installation.

The install/lifecycle split also protects user intent. Users expect `install --env FOO=bar --inherit-env --port 5388` to change persistent service configuration. They do not expect `start` or `restart` to change those values. Keeping that boundary strict makes failures easier to explain: if the service is missing, lifecycle commands fail with the install command; if the config changed while running, install updates the service and then requires an explicit restart decision.

## When to Apply

- Use this pattern when a CLI has both a foreground server mode and a persistent daemon/service mode.
- Use it when service configuration must survive terminal exits and login restarts.
- Use it when more than one native service manager must be supported behind one command surface.
- Use it when shell environment inheritance is product behavior, not an incidental launcher detail.
- Do not use this pattern for one-off detached processes that are intentionally not managed by the operating system.

## Examples

Fail closed on transport selection at the daemon boundary:

```ts
export function resolveDaemonHttpServeOptions(raw: RawDaemonServeOptions): HttpServeOptions {
if ((raw as RawServeOptions).transport !== undefined) {
throw new CapletsError(
"REQUEST_INVALID",
"caplets daemon install does not accept --transport.",
);
}

return resolveServeOptions({ ...raw, transport: "http" }) as HttpServeOptions;
}
```

Make lifecycle commands read the installed service and verify health after native start/restart:

```ts
async function daemonLifecycle(
action: "start" | "restart" | "stop",
options: DaemonOperationOptions,
) {
const config = readDaemonConfig(paths);
if (!config || !existsSync(paths.descriptorFile)) {
throw new CapletsError(
"REQUEST_INVALID",
`Caplets daemon is not installed. Run caplets daemon install${action === "stop" ? "" : " --start"} first.`,
);
}

const before = await manager.status(config, paths);
const effectiveAction = action === "start" && before.running ? "restart" : action;
const native = await runNativeLifecycle(effectiveAction, config);
const status = await daemonStatus({ ...options, manager });

if (effectiveAction !== "stop") {
assertDaemonHealth(status.health, "Native daemon health check");
}

return { action: effectiveAction, native, status };
}
```

Regression coverage should include the platform behaviors that are easiest to miss:

```ts
it("rolls back descriptor artifacts when native registration fails", async () => {
await expect(installDaemon({}, { manager: failingManager })).rejects.toThrow(
/registration failed/u,
);

expect(readDaemonConfig(paths)).toBeUndefined();
expect(readFileSync(paths.descriptorFile, "utf8")).toBe(previousDescriptor);
});

it("reloads launchd descriptors before restart", async () => {
await restartDaemon(options);

expect(commands).toContainEqual(["launchctl", "bootout", "gui/501", descriptorPath]);
expect(commands).toContainEqual(["launchctl", "bootstrap", "gui/501", descriptorPath]);
expect(commands).toContainEqual([
"launchctl",
"kickstart",
"-k",
"gui/501/dev.caplets.daemon.default",
]);
});
```

## Related

- [Caplets daemon service requirements](../../brainstorms/2026-06-19-caplets-daemon-service-requirements.md)
- [Caplets daemon implementation plan](../../plans/2026-06-19-001-feat-caplets-daemon-service-plan.md)
Loading