From 48e793e2372e33c50f6b7d3b9e4a562340160ac6 Mon Sep 17 00:00:00 2001
From: Kit <344842+roodboi@users.noreply.github.com>
Date: Sat, 14 Nov 2026 00:05:56 +0000
Subject: [PATCH 01/44] feat(autostart): start opted-in Compose projects once
per boot
---
docs/always-on-projects.md | 118 +++++
docs/cli.md | 1 +
docs/diagrams/autostart-login.mmd | 27 +
docs/diagrams/autostart-login.svg | 66 +++
docs/reference/cli.md | 146 ++++++
docs/reference/v5-model-coverage.md | 1 +
docs/versions/v5/getting-started.md | 5 +
docs/versions/v5/index.md | 1 +
src/cli/spec.ts | 2 +
src/commands/autostart.ts | 211 ++++++++
src/commands/doctor.ts | 34 ++
src/commands/project.ts | 166 +++++-
src/commands/projects.ts | 100 +++-
src/daemon/autostart.ts | 571 +++++++++++++++++++++
src/daemon/server.ts | 15 +
src/lib/child-environment.ts | 9 +-
src/lib/keychain.ts | 8 +
src/lib/native-authored-command.ts | 23 +-
src/lib/native-compose-adoption-command.ts | 2 +-
src/lib/native-compose-command.ts | 7 +
src/lib/project-views.ts | 30 ++
src/lib/projects-registry.ts | 328 +++++++++++-
tests/autostart-cli.test.ts | 246 +++++++++
tests/autostart-keychain.test.ts | 55 ++
tests/autostart-lifecycle-intent.test.ts | 176 +++++++
tests/child-environment.test.ts | 9 +
tests/daemon-autostart.test.ts | 425 +++++++++++++++
tests/project-autostart.test.ts | 371 +++++++++++++
tests/status-native.test.ts | 51 +-
29 files changed, 3188 insertions(+), 16 deletions(-)
create mode 100644 docs/always-on-projects.md
create mode 100644 docs/diagrams/autostart-login.mmd
create mode 100644 docs/diagrams/autostart-login.svg
create mode 100644 src/commands/autostart.ts
create mode 100644 src/daemon/autostart.ts
create mode 100644 tests/autostart-cli.test.ts
create mode 100644 tests/autostart-keychain.test.ts
create mode 100644 tests/autostart-lifecycle-intent.test.ts
create mode 100644 tests/daemon-autostart.test.ts
create mode 100644 tests/project-autostart.test.ts
diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md
new file mode 100644
index 000000000..bbac87aa3
--- /dev/null
+++ b/docs/always-on-projects.md
@@ -0,0 +1,118 @@
+# Always-on projects
+
+Autostart is a choice for this machine. A repository cannot enable it. Install
+Hack's per-user login service, then mark a registered project:
+
+```sh
+hack daemon install
+hack autostart add sample
+hack autostart add sample --branch preview
+hack autostart list --json
+```
+
+Each project has one selected instance: base by default, or the named branch.
+Adding it again replaces that selection. In the project directory, omit `sample`.
+Opt in from the primary checkout. Linked-worktree opt-in is refused in this slice:
+a branch instance uses the primary checkout's code, not a different Git checkout.
+`hack up -d --always` marks the selected instance and starts it now. `add` only
+changes the marker; it does not start anything immediately.
+
+`hack down` pauses autostart for the selected marked instance before attempting to
+stop it. A later `hack up` clears the pause. Pausing does not stop running services:
+
+```sh
+hack autostart pause sample
+hack autostart resume sample
+hack autostart pause # all marked projects
+hack autostart resume # all marked projects
+hack autostart remove sample # forget the marker; leave running services alone
+```
+
+## At login
+
+
+
+The pass runs at the first `hackd` start in each OS boot. The installed service
+normally starts it at login; starting the daemon manually or through a
+daemon-backed CLI command can also trigger it. This slice supports Compose
+projects on Linux and macOS. Native-runtime startup is skipped as
+`native_unsupported` before probing Docker.
+
+The daemon waits up to 60 seconds for Docker, starts global routing only if it is
+already installed, then considers unpaused projects in name/ID order. Starts are
+at least 500 milliseconds apart, with at most two in progress. Each uses the
+running daemon's CLI executable and the ordinary `up -d --no-interactive` path.
+Already-running instances are adopted without repeating startup hooks. An unknown
+runtime state is skipped rather than taken as proof that the instance is down.
+
+No terminal or keychain prompt is allowed. Missing keys, required interaction,
+pending env migration, unavailable project paths and startup failures produce a
+recorded result. Run `hack up` manually to investigate and recover. The daemon does
+not install routing, start Docker, sign in, or migrate configuration at login.
+
+A claim is saved before each attempt, once per OS boot. Restarting the daemon in
+the same boot does not repeat starts. A crash after the claim can leave an
+interrupted attempt; its outcome is unknown, so this slice requires manual
+`hack up` recovery. A new boot permits a new attempt. Resume permits the next login
+attempt; use `hack up` to start immediately.
+An engine timeout or routing failure also consumes that boot's attempt. If Docker
+is slow to become ready, start the projects with `hack up` after it is ready.
+
+`HACK_DAEMON_AUTOSTART_DISABLE=1` disables this pass as well as implicit daemon
+startup. Test harnesses and the isolated `hack-v5` launcher set it; those runs do
+not start marked projects. Use the installed CLI/service for login startup.
+
+This first slice starts projects once. It does not restart a project after failure
+or replace terminal-based lifecycle supervision. Persistent host helpers retain
+the existing [lifecycle](lifecycle.md) behavior. Linux's systemd user service starts
+at login; startup before login requires separately opting into user lingering.
+No system service or `sudo` is needed for Hack's login service.
+
+## JSON contract
+
+`hack projects --json` and `hack status --json` add `autostart` to every project,
+including summary views. It is `null` for unmarked projects. `hack autostart list
+--json` returns `{ "projects": [...] }` containing only marked registrations; each
+entry has `project_id`, `name`, and the same `autostart` object:
+
+```json
+{
+ "project_id": "example-id",
+ "name": "sample",
+ "autostart": {
+ "branch": null,
+ "paused": false,
+ "last_result": {
+ "status": "skipped",
+ "reason": "engine_timeout",
+ "at": "2026-01-01T00:00:00.000Z"
+ }
+ }
+}
+```
+
+`branch` is `null` for base or a branch-instance slug. `paused` is a boolean.
+`last_result` is `null` before any recorded attempt, otherwise its `status` is
+`started`, `already_running`, `skipped`, or `failed`; `reason` is `null` on success
+or a machine-readable code; `at` is an ISO 8601 timestamp. Reasons never contain
+command output, environment values or credentials. Consumers should display an
+unknown future reason as a generic failure and retain its code. These fields are
+additive and stable; internal boot claims are not part of this public contract.
+
+Current skip reasons are `engine_timeout`, `routing_failed`,
+`boot_identity_unavailable`, `native_unsupported`, `runtime_unknown`,
+`interactive_required`, `env_key_missing`, `keychain_unavailable`,
+`pending_env_migration`, `project_unavailable`, `paused`, `target_changed`,
+`daemon_stopped`, and `invalid_marker`. Failure reasons are `start_failed`,
+`start_timeout`, and `output_limit`.
+
+An invalid or newer marker is shown as paused with `skipped/invalid_marker` and
+the sentinel timestamp `1970-01-01T00:00:00.000Z` (no attempt occurred). Other
+project commands keep working and unrelated registry writes preserve the original
+marker. Remove and re-add it with `hack autostart remove sample` then
+`hack autostart add sample` to repair it. Unknown future statuses fail closed in
+this version rather than enabling startup.
+
+`hack doctor` warns when a project is marked but no login-service file exists and
+names `hack daemon install`. File presence alone does not prove a real login run
+or that the operating system enabled the service.
diff --git a/docs/cli.md b/docs/cli.md
index 149ff4c25..cfa967aae 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -7,6 +7,7 @@ terminal).
## Core commands
+- `hack autostart add|remove|list|pause|resume` — choose [always-on projects](always-on-projects.md) for this machine; `hack up -d --always` marks and starts one. `down` pauses its next login start; `up` resumes it.
- `hack init` — generate `.hack/` (compose + config); `--with claude|codex` also hands off to
agent-assisted onboarding
- `hack up` / `hack down` / `hack restart` — start, stop and restart the project
diff --git a/docs/diagrams/autostart-login.mmd b/docs/diagrams/autostart-login.mmd
new file mode 100644
index 000000000..3111ea316
--- /dev/null
+++ b/docs/diagrams/autostart-login.mmd
@@ -0,0 +1,27 @@
+---
+title: Autostart at login
+---
+sequenceDiagram
+ autonumber
+ participant Login
+ participant Daemon
+ participant Engine
+ participant Registry
+ participant CLI
+ Login->>Daemon: Start user service
+ Daemon->>Registry: Read machine markers
+ Daemon->>Engine: Wait up to 60 seconds
+ opt Routing already installed
+ Daemon->>CLI: global up, no interaction
+ end
+ loop Unpaused projects in stable order
+ Daemon->>Registry: Claim once per boot
+ Daemon->>Engine: Inspect selected instance
+ alt Confirmed stopped
+ Daemon->>CLI: up -d, no interaction
+ CLI-->>Daemon: Result without prompts
+ else Running or unknown
+ Daemon->>Daemon: Adopt or skip
+ end
+ Daemon->>Registry: Save result and reason
+ end
diff --git a/docs/diagrams/autostart-login.svg b/docs/diagrams/autostart-login.svg
new file mode 100644
index 000000000..e628efb13
--- /dev/null
+++ b/docs/diagrams/autostart-login.svg
@@ -0,0 +1,66 @@
+
\ No newline at end of file
diff --git a/docs/reference/cli.md b/docs/reference/cli.md
index 46880255c..0309d326b 100644
--- a/docs/reference/cli.md
+++ b/docs/reference/cli.md
@@ -8,6 +8,151 @@ Run `hack help ` for the same content in the terminal. Global options:
## Project
+## `hack autostart`
+
+Manage always-on projects on this machine
+
+### Usage
+
+```bash
+hack autostart [options]
+```
+
+### Subcommands
+
+| Command | Summary |
+| --- | --- |
+| `hack autostart add [project]` | Start this registered project at login on this machine |
+| `hack autostart remove [project]` | Remove this project's machine autostart marker |
+| `hack autostart list` | List this machine's always-on projects |
+| `hack autostart pause [project]` | Pause one marked project, or all marked projects |
+| `hack autostart resume [project]` | Resume one marked project, or all marked projects |
+
+### Options
+
+| Option | Description |
+| --- | --- |
+| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) |
+| `--help, -h` | Show help |
+| `--version, -v` | Show version |
+
+## `hack autostart add [project]`
+
+Start this registered project at login on this machine
+
+### Usage
+
+```bash
+hack autostart add [project] [options]
+```
+
+### Arguments
+
+| Arg | Description |
+| --- | --- |
+| `project` | |
+
+### Options
+
+| Option | Description |
+| --- | --- |
+| `--branch ` | Run against a branch-specific instance (compose name + hostnames) |
+| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) |
+| `--help, -h` | Show help |
+| `--version, -v` | Show version |
+
+## `hack autostart remove [project]`
+
+Remove this project's machine autostart marker
+
+### Usage
+
+```bash
+hack autostart remove [project] [options]
+```
+
+### Arguments
+
+| Arg | Description |
+| --- | --- |
+| `project` | |
+
+### Options
+
+| Option | Description |
+| --- | --- |
+| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) |
+| `--help, -h` | Show help |
+| `--version, -v` | Show version |
+
+## `hack autostart list`
+
+List this machine's always-on projects
+
+### Usage
+
+```bash
+hack autostart list [options]
+```
+
+### Options
+
+| Option | Description |
+| --- | --- |
+| `--json` | Output JSON (machine-readable) |
+| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) |
+| `--help, -h` | Show help |
+| `--version, -v` | Show version |
+
+## `hack autostart pause [project]`
+
+Pause one marked project, or all marked projects
+
+### Usage
+
+```bash
+hack autostart pause [project] [options]
+```
+
+### Arguments
+
+| Arg | Description |
+| --- | --- |
+| `project` | |
+
+### Options
+
+| Option | Description |
+| --- | --- |
+| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) |
+| `--help, -h` | Show help |
+| `--version, -v` | Show version |
+
+## `hack autostart resume [project]`
+
+Resume one marked project, or all marked projects
+
+### Usage
+
+```bash
+hack autostart resume [project] [options]
+```
+
+### Arguments
+
+| Arg | Description |
+| --- | --- |
+| `project` | |
+
+### Options
+
+| Option | Description |
+| --- | --- |
+| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) |
+| `--help, -h` | Show help |
+| `--version, -v` | Show version |
+
+
## `hack project`
Inspect or manage project metadata
@@ -187,6 +332,7 @@ hack up [services...] [options]
| `--profile ` | Enable one or more compose profiles (comma-separated) |
| `--target ` | Execution target routing (auto routes to remote when project execution mode requires it) |
| `--json` | Output JSON (machine-readable) |
+| `--always` | Mark this project for machine-local autostart and start it |
| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) |
| `--help, -h` | Show help |
| `--version, -v` | Show version |
diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md
index d96eb13a3..cf467fdee 100644
--- a/docs/reference/v5-model-coverage.md
+++ b/docs/reference/v5-model-coverage.md
@@ -36,6 +36,7 @@ must be attached to the eventual exact integration revision.
| Property and critical states | Model coverage | Implementation/regression boundary | Remaining qualification |
| --- | --- | --- | --- |
+| Machine autostart intent, pause, per-boot claim and startup replay | No TLA+ model in the once-per-login slice; registry lock is the atomic claim boundary | `projects-registry.ts` persists claim before dispatch; `daemon/autostart.ts` rechecks pause and target after observation. `project-autostart.test.ts`, `autostart-lifecycle-intent.test.ts` and `daemon-autostart.test.ts` cover old registries, pause/readback, concurrent claims, restart suppression, stable order, stagger and cap | At most two starts; 60-second engine wait; one claim per project/boot. Crash after claim may skip an attempt. A pause after the final recheck can overlap an already-dispatched start; no distributed cancellation proof. Real Linux/macOS login, kernel scheduling, keychain behavior and terminal-independent lifecycle supervision remain external qualification. |
| Native status across startup lease stages; failed start with running services (HACK-2110) | [native-status-start](../../tests/models/tla/native-status-start/COVERAGE.md) | Observer-only presentation, stable journal fence, distinct authored Observe/Status replies; real-flock held-reader start test and expiry diagnostic; plain-down exact-proof recovery tests. Failure capture occurs at startup/effect verification and owned cleanup, never status; first failure wins, incomplete-rebind capture and owner-crash reconstruction are excluded | Seven acquisition stages, one reader, one start; 110 states. Negative control fails after services start. Second-mutator refusal and complete recovery proof are assumptions checked separately in code, not independently proved by this model. No new control-loop tick or lease; cleanup settle is bounded at ten seconds per exited listener. Lead Mac gate: ps every 0.5 s across ten starts, cross-project polling and failed-start plain down. |
| Guest control ready before exposed bridge sockets bind; identity changes or the startup deadline expires | No new TLA+ model; deterministic clock schedules both readiness orders against real Unix socket nodes | `provider/bridge.rs` waits only in fresh `finish_boot`, before adoption, and keeps running audits immediate. `bridge_startup_tests.rs` covers two slots, late creation and mode tightening, permanent absence/unsafe modes, type/UID/link refusals, provider loss before/after observation and late success rejection. No wait mutates sockets or grants cleanup authority; the existing verified-provider stop handles failure. `lifecycle/boot_sequence_tests.rs` drives the real waiter through the production fresh-boot sequence and rejects audit/adoption on timeout, unsafe sockets or provider loss. `native-runtime-client.test.ts` checks the fixed runtime recover command and identical-root hint reach the frontend | Two sockets, finite scheduled events, injected provider verification and monotonic clock. Kernel process identity, provider scheduling, disk durability and successful recovery remain host boundaries. Require ten consecutive real-VM Mac Ctrl-C scenarios with pool stop/start; portable tests are not that evidence. |
| CI cancellation must not lose merged-change coverage; only exact-head full evidence may qualify a prerelease | No maintained GitHub scheduler model. Finite fixture interleavings cover predecessor success/failure/cancellation, two competing full/scoped runs and two rerun attempts | `ci.yml` serializes next pushes in one non-cancelling group (GitHub cancels only the replaced pending run), cancels stale PR runs, and requires a successful before run for selection. `selectPrereleaseCiRun` reads current attempts; `requireBranchChecks` binds suite and job check IDs. `ci-trusted-policy`, `prerelease-plan` and `release-prepare-gate` regressions reject cancelled predecessors, green subsets, stale checks, skipped hosts and changed attempts | API snapshots and GitHub group/event semantics are assumptions; races after the last read remain possible. Publication rechecks the frozen protected head and full evidence but does not transactionally lock GitHub. Hosted dispatch/cancellation checks qualify wiring; no scheduler liveness or formal refinement claim. |
diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md
index 40d949f31..9b7537684 100644
--- a/docs/versions/v5/getting-started.md
+++ b/docs/versions/v5/getting-started.md
@@ -63,6 +63,11 @@ hack ps
hack open
```
+To start this project at future logins, use `hack up -d --always` and install
+the per-user daemon with `hack daemon install`. `hack down` pauses its marker;
+`hack up` resumes it. See [Always-on projects](https://github.com/hack-dance/hack/blob/next/docs/always-on-projects.md) for
+the startup bounds, no-prompt behavior and JSON contract.
+
Expect the declared services in `ps` and the app's local URL from `open`.
If a project has several URLs, choose the app's route. A worker-only project may
have no URL. Sign in or load a page that reads data to check the app itself.
diff --git a/docs/versions/v5/index.md b/docs/versions/v5/index.md
index 11e2ffa6b..9dbf0e4e4 100644
--- a/docs/versions/v5/index.md
+++ b/docs/versions/v5/index.md
@@ -34,6 +34,7 @@ v5 prerelease bundles are Apple Silicon macOS only; see
## Do a task
+- [Start projects at login](https://github.com/hack-dance/hack/blob/next/docs/always-on-projects.md)
- [Set up a Linux host](../guides/linux-setup.md): local DNS, trust and the daemon.
- [Configure Linux certificate trust](../guides/linux-certificate-trust.md): distribution and browser trust stores.
- [Upgrade a v4 machine to v5](guides/upgrade-from-v4.md): preparation, rollback and checks awaiting the dogfood run.
diff --git a/src/cli/spec.ts b/src/cli/spec.ts
index 063875158..d95b7a3f6 100644
--- a/src/cli/spec.ts
+++ b/src/cli/spec.ts
@@ -1,5 +1,6 @@
import pkg from "../../package.json";
import { agentCommand } from "../commands/agent.ts";
+import { autostartCommand } from "../commands/autostart.ts";
import { branchCommand } from "../commands/branch.ts";
import { commandsCommand } from "../commands/commands.ts";
import { configCommand } from "../commands/config.ts";
@@ -70,6 +71,7 @@ export const CLI_SPEC = defineCli({
statusCommand,
usageCommand,
projectsCommand,
+ autostartCommand,
projectCommand,
initCommand,
devCommand,
diff --git a/src/commands/autostart.ts b/src/commands/autostart.ts
new file mode 100644
index 000000000..e0a12fc99
--- /dev/null
+++ b/src/commands/autostart.ts
@@ -0,0 +1,211 @@
+import { relative, resolve } from "node:path";
+import {
+ type CliContext,
+ CliUsageError,
+ type CommandHandlerFor,
+ defineCommand,
+ withHandler,
+} from "../cli/command.ts";
+import { optBranch, optJson } from "../cli/options.ts";
+import {
+ parseProjectSelectorOption,
+ unknownProjectMessage,
+} from "../cli/project-selector.ts";
+import { isLinkedGitWorktree } from "../lib/git-worktree.ts";
+import { sanitizeBranchSlug } from "../lib/project.ts";
+import { PROJECT_SELECTOR_PATTERN } from "../lib/project-name.ts";
+import { serializeProjectAutostart } from "../lib/project-views.ts";
+import {
+ type RegisteredProject,
+ readProjectsRegistry,
+ removeProjectAutostart,
+ selectRegisteredProjectByName,
+ setProjectAutostart,
+ setProjectAutostartPaused,
+} from "../lib/projects-registry.ts";
+import { display } from "../ui/display.ts";
+
+const projectPositionals = [{ name: "project", required: false }] as const;
+const addSpec = defineCommand({
+ name: "add",
+ summary: "Start this registered project at login on this machine",
+ group: "Project",
+ options: [optBranch],
+ positionals: projectPositionals,
+ subcommands: [],
+} as const);
+const removeSpec = defineCommand({
+ name: "remove",
+ summary: "Remove this project's machine autostart marker",
+ group: "Project",
+ options: [],
+ positionals: projectPositionals,
+ subcommands: [],
+} as const);
+const pauseSpec = defineCommand({
+ name: "pause",
+ summary: "Pause one marked project, or all marked projects",
+ group: "Project",
+ options: [],
+ positionals: projectPositionals,
+ subcommands: [],
+} as const);
+const resumeSpec = defineCommand({
+ name: "resume",
+ summary: "Resume one marked project, or all marked projects",
+ group: "Project",
+ options: [],
+ positionals: projectPositionals,
+ subcommands: [],
+} as const);
+const listSpec = defineCommand({
+ name: "list",
+ summary: "List this machine's always-on projects",
+ group: "Project",
+ options: [optJson],
+ positionals: [],
+ subcommands: [],
+} as const);
+
+/** Registry selection changes only the machine marker, without reading project configuration. */
+async function selectProject(opts: {
+ readonly ctx: CliContext;
+ readonly name?: string;
+}): Promise {
+ const selector =
+ opts.name === undefined
+ ? null
+ : parseProjectSelectorOption({ projectOpt: opts.name });
+ const registry = await readProjectsRegistry();
+ if (selector) {
+ const project = selectRegisteredProjectByName({
+ projects: registry.projects,
+ name: selector.name,
+ });
+ if (!project) {
+ throw new CliUsageError(unknownProjectMessage(selector));
+ }
+ return project;
+ }
+ const cwd = resolve(opts.ctx.cwd);
+ const matches = registry.projects
+ .filter((project) => {
+ const roots = [
+ project.repoRoot,
+ ...(project.worktrees ?? []).map((worktree) => worktree.path),
+ ];
+ return roots.some((root) => {
+ const path = relative(root, cwd);
+ return (
+ path === "" ||
+ !(
+ path === ".." ||
+ path.startsWith("../") ||
+ resolve(root, path) !== cwd
+ )
+ );
+ });
+ })
+ .sort((a, b) => b.repoRoot.length - a.repoRoot.length);
+ const project = matches[0];
+ if (!project || (matches[1] && matches[1].repoRoot === project.repoRoot)) {
+ throw new CliUsageError(
+ "Select a registered project by name; run 'hack projects' to list projects."
+ );
+ }
+ return project;
+}
+
+const add: CommandHandlerFor = async ({ ctx, args }) => {
+ const project = await selectProject({ ctx, name: args.positionals.project });
+ const [cwdLinked, registeredLinked] = await Promise.all([
+ isLinkedGitWorktree({ repoRoot: ctx.cwd }),
+ isLinkedGitWorktree({ repoRoot: project.repoRoot }),
+ ]);
+ if (cwdLinked === true || registeredLinked === true) {
+ throw new CliUsageError(
+ "Autostart currently requires the primary checkout; run 'hack autostart add --branch ' there."
+ );
+ }
+ const branch =
+ args.options.branch === undefined
+ ? undefined
+ : sanitizeBranchSlug(args.options.branch);
+ if (
+ args.options.branch !== undefined &&
+ !(PROJECT_SELECTOR_PATTERN.test(args.options.branch.trim()) && branch)
+ ) {
+ throw new CliUsageError(
+ "Invalid --branch value; use a branch alias containing letters or digits."
+ );
+ }
+ await setProjectAutostart({ id: project.id, branch });
+ process.stdout.write(
+ "Autostart enabled on this machine. Install the login service with: hack daemon install\n"
+ );
+ return 0;
+};
+const remove: CommandHandlerFor = async ({ ctx, args }) => {
+ const project = await selectProject({ ctx, name: args.positionals.project });
+ await removeProjectAutostart({ id: project.id });
+ process.stdout.write("Autostart removed on this machine.\n");
+ return 0;
+};
+const pause: CommandHandlerFor = async ({ ctx, args }) => {
+ const project =
+ args.positionals.project === undefined
+ ? null
+ : await selectProject({ ctx, name: args.positionals.project });
+ await setProjectAutostartPaused({ id: project?.id, paused: true });
+ process.stdout.write("Autostart paused; running projects are unchanged.\n");
+ return 0;
+};
+const resume: CommandHandlerFor = async ({ ctx, args }) => {
+ const project =
+ args.positionals.project === undefined
+ ? null
+ : await selectProject({ ctx, name: args.positionals.project });
+ await setProjectAutostartPaused({ id: project?.id, paused: false });
+ process.stdout.write(
+ "Autostart resumed; use 'hack up -d' to start a project now.\n"
+ );
+ return 0;
+};
+const list: CommandHandlerFor = async ({ args }) => {
+ const registry = await readProjectsRegistry();
+ const projects = registry.projects
+ .filter((project) => project.autostart)
+ .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
+ if (args.options.json) {
+ process.stdout.write(
+ `${JSON.stringify({ projects: projects.map((project) => ({ project_id: project.id, name: project.name, autostart: serializeProjectAutostart(project.autostart) })) })}\n`
+ );
+ return 0;
+ }
+ await display.table({
+ columns: ["Project", "Branch", "Autostart", "Last result", "Reason"],
+ rows: projects.map((project) => [
+ project.name,
+ project.autostart?.branch ?? "base",
+ project.autostart?.paused ? "paused" : "always-on",
+ project.autostart?.lastResult?.status ?? "not attempted",
+ project.autostart?.lastResult?.reason ?? "",
+ ]),
+ });
+ return 0;
+};
+
+export const autostartCommand = defineCommand({
+ name: "autostart",
+ summary: "Manage always-on projects on this machine",
+ group: "Project",
+ options: [],
+ positionals: [],
+ subcommands: [
+ withHandler(addSpec, add),
+ withHandler(removeSpec, remove),
+ withHandler(listSpec, list),
+ withHandler(pauseSpec, pause),
+ withHandler(resumeSpec, resume),
+ ],
+} as const);
diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts
index 21f4bd3be..b6e0856c3 100644
--- a/src/commands/doctor.ts
+++ b/src/commands/doctor.ts
@@ -878,6 +878,11 @@ const handleDoctor: CommandHandlerFor = async ({
// Global files
results.push(await runCheck(s, "global files", () => checkGlobalFiles()));
results.push(await runCheck(s, "daemon", () => checkDaemonStatus()));
+ results.push(
+ await runCheck(s, "autostart login service", () =>
+ checkAutostartLoginService()
+ )
+ );
if (isLinux() && !isCandidateRun()) {
// Candidate trials do not manage the stable login service. Otherwise the
// row is omitted when systemd management is disabled for this process
@@ -1939,6 +1944,35 @@ async function checkGlobalFiles(): Promise {
};
}
+/** File-only observation: never starts the daemon or invokes a service manager. */
+async function checkAutostartLoginService(): Promise {
+ const registry = await readProjectsRegistry();
+ const marked = registry.projects.filter(
+ (project) => project.autostart
+ ).length;
+ if (marked === 0) {
+ return {
+ name: "autostart login service",
+ status: "ok",
+ message: "No always-on projects marked.",
+ };
+ }
+ let unitPath: string | null = null;
+ if (isMac()) {
+ unitPath = resolveDaemonPaths({}).launchdPlistPath;
+ } else if (isLinux()) {
+ unitPath = resolveSystemdUnitPath();
+ }
+ const installed = unitPath !== null && (await pathExists(unitPath));
+ return {
+ name: "autostart login service",
+ status: installed ? "ok" : "warn",
+ message: installed
+ ? "Always-on projects have an installed login service."
+ : "Always-on projects are marked but the login service is not installed. Run: hack daemon install",
+ };
+}
+
async function checkDaemonStatus(): Promise {
const paths = resolveDaemonPaths({});
const status = await readDaemonStatus({ paths });
diff --git a/src/commands/project.ts b/src/commands/project.ts
index cd2a76425..9f9bda93d 100644
--- a/src/commands/project.ts
+++ b/src/commands/project.ts
@@ -21,6 +21,7 @@ export function wrapLifecyclePersistentCommand(
return wrapSharedLifecyclePersistentCommand(opts);
}
+import { realpath } from "node:fs/promises";
import { basename, dirname, resolve } from "node:path";
import {
autocompleteMultiselect,
@@ -117,7 +118,10 @@ import {
optTail,
optUntil,
} from "../cli/options.ts";
-import { resolveRegisteredProjectOption } from "../cli/project-selector.ts";
+import {
+ parseProjectSelectorOption,
+ resolveRegisteredProjectOption,
+} from "../cli/project-selector.ts";
import { globalUp } from "../commands/global.ts";
import {
DEFAULT_GRAFANA_HOST,
@@ -245,6 +249,7 @@ import {
writeTextFileIfChanged,
} from "../lib/fs.ts";
import { ensureHackGitInfoExclude } from "../lib/git-info-exclude.ts";
+import { isLinkedGitWorktree } from "../lib/git-worktree.ts";
import { resolveInstalledGrafanaHost } from "../lib/global-service-host.ts";
import { getString, isRecord } from "../lib/guards.ts";
import { resolveHackInvocation } from "../lib/hack-cli.ts";
@@ -322,6 +327,7 @@ import {
HACK_DIR_GITIGNORE_READ_ONLY_WARNING,
hasUnmigratedLegacyDotenv,
migrateLegacyProjectEnv,
+ ProjectEnvKeyMissingError,
projectEnvConfigExists,
resolveProjectEnvConfig,
selectProjectEnvValuesForExecutionTarget,
@@ -335,6 +341,7 @@ import {
assertLegacyProjectDiscovery,
assertLegacyProjectInputFamily,
} from "../lib/project-input-selection.ts";
+import { normalizeProjectName } from "../lib/project-name.ts";
import {
readProjectRuntimeStateEntry,
removeProjectRuntimeStateEntry,
@@ -361,6 +368,8 @@ import {
} from "../lib/project-shared-helpers.ts";
import {
readProjectsRegistry,
+ setProjectAutostart,
+ setProjectAutostartPaused,
upsertProjectRegistration,
} from "../lib/projects-registry.ts";
import {
@@ -382,6 +391,7 @@ import {
import {
formatSecretStoreDescriptor,
resolveSecretStore,
+ SecretStoreKeyUnavailableError,
} from "../lib/secret-store.ts";
import { exec, findExecutableInPath, run as runShell } from "../lib/shell.ts";
import { assertComposeSpawnEnvFits } from "../lib/spawn-env-limit.ts";
@@ -567,6 +577,12 @@ const upOptions = [
optProfile,
optTarget,
optJson,
+ defineOption({
+ name: "always",
+ type: "boolean",
+ long: "--always",
+ description: "Mark this project for machine-local autostart and start it",
+ } as const),
] as const;
const downOptions = [
optPath,
@@ -903,6 +919,10 @@ export async function resolveEffectiveBranchForCommand(opts: {
if (explicit) {
return explicit;
}
+ // A saved null instance means base, even when its checkout is a worktree.
+ if (process.env.HACK_AUTOSTART_LOGIN === "1") {
+ return null;
+ }
const cfg = await readProjectConfig(opts.project);
const resolved = await resolveEffectiveBranch({
@@ -1141,7 +1161,20 @@ function emitLifecycleResult(opts: {
return opts.exitCode;
}
-function buildLifecycleJsonErrorResult(opts: { readonly error: unknown }) {
+export function buildLifecycleJsonErrorResult(opts: {
+ readonly error: unknown;
+}) {
+ if (
+ process.env.HACK_AUTOSTART_LOGIN === "1" &&
+ (opts.error instanceof SecretStoreKeyUnavailableError ||
+ opts.error instanceof ProjectEnvKeyMissingError)
+ ) {
+ return errorResult({
+ code: "E_ENV_KEY_MISSING",
+ message:
+ "Autostart skipped: a required secret key is unavailable. Run hack up manually.",
+ });
+ }
if (opts.error instanceof CliUsageError) {
return errorResult({ code: "E_USAGE", message: opts.error.message });
}
@@ -2903,7 +2936,7 @@ export async function mergeLifecycleCommandEnv(
): Promise> {
const base: Record = {};
for (const [key, value] of Object.entries(process.env)) {
- if (typeof value === "string") {
+ if (typeof value === "string" && key !== "HACK_AUTOSTART_LOGIN") {
base[key] = value;
}
}
@@ -6362,6 +6395,126 @@ async function restartNativeImageProject({
});
}
+/** Publish explicit user intent before runtime effects so a subsequent login respects down. */
+async function updateAutostartIntent(opts: {
+ readonly ctx: CliContext;
+ readonly action: "up" | "down";
+ readonly args: {
+ readonly options: {
+ readonly path?: string;
+ readonly project?: string;
+ readonly branch?: string;
+ readonly always?: boolean;
+ };
+ };
+}): Promise {
+ const login = process.env.HACK_AUTOSTART_LOGIN === "1";
+ const registry = await readProjectsRegistry();
+ const selector =
+ opts.args.options.project === undefined
+ ? null
+ : parseProjectSelectorOption({ projectOpt: opts.args.options.project });
+ const requestedStartDir = resolveStartDir(opts.ctx, opts.args.options.path);
+ const startDir = await realpath(requestedStartDir).catch(
+ () => requestedStartDir
+ );
+ const marked = registry.projects
+ .filter((entry) => entry.autostart)
+ .find((entry) =>
+ selector
+ ? normalizeProjectName(entry.name) === selector.name
+ : [
+ entry.repoRoot,
+ ...(entry.worktrees?.map((worktree) => worktree.path) ?? []),
+ ].some((path) => startDir === path || startDir.startsWith(`${path}/`))
+ );
+ if (!(login || opts.args.options.always || marked)) {
+ return;
+ }
+ const project = await resolveProjectForArgs({
+ ctx: opts.ctx,
+ pathOpt: opts.args.options.path,
+ projectOpt: opts.args.options.project,
+ touchRegistration: false,
+ });
+ if (login) {
+ // Login must never continue with a legacy environment that needs migration.
+ const contract = await pathExists(
+ resolve(project.projectDir, PROJECT_ENV_CONTRACT_FILENAME)
+ );
+ const modern = await projectEnvConfigExists({
+ projectDir: project.projectDir,
+ });
+ const legacy = await findUnmigratedLegacyDotenv({
+ projectRoot: project.projectRoot,
+ projectDir: project.projectDir,
+ serviceNames: await discoverComposeServiceNames({
+ composeFile: project.composeFile,
+ }),
+ });
+ const dotenv = Bun.file(resolve(project.projectDir, PROJECT_ENV_FILENAME));
+ const legacyDotenv = !modern && (await dotenv.exists()) && dotenv.size > 0;
+ if (
+ (!modern && contract) ||
+ legacyDotenv ||
+ hasUnmigratedLegacyDotenv(legacy)
+ ) {
+ throw new HackCliError({
+ code: "E_CONFIG_INVALID",
+ message:
+ "Autostart skipped: pending env migration. Run hack doctor --migrate-env-config.",
+ detail: { reason: "pending_env_migration" },
+ });
+ }
+ return;
+ }
+ // Inspect selection without printing the normal up/down worktree notice twice.
+ const cfg = await readProjectConfig(project);
+ const resolvedBranch = await resolveEffectiveBranch({
+ explicitBranch: resolveBranchSlug(opts.args.options.branch),
+ projectRoot: project.projectRoot,
+ autoBranchEnabled: resolveWorktreeAutoBranch(cfg),
+ });
+ if (resolvedBranch.source === "detached-worktree") {
+ throw new CliUsageError(
+ "Detached linked worktree requires an explicit --branch."
+ );
+ }
+ const branch = resolvedBranch.branch;
+ if (opts.action === "up" && opts.args.options.always) {
+ if (await isLinkedGitWorktree({ repoRoot: project.projectRoot })) {
+ throw new CliUsageError(
+ "Autostart currently requires the primary checkout. Run hack up --always there with --branch ."
+ );
+ }
+ const registered = await upsertProjectRegistration({ project });
+ if (registered.status === "conflict") {
+ throw new CliUsageError(
+ "Project registration conflicts; inspect hack projects before enabling autostart."
+ );
+ }
+ await setProjectAutostart({ id: registered.project.id, branch });
+ return;
+ }
+ const projectDir = await realpath(project.projectDir).catch(
+ () => project.projectDir
+ );
+ const projectRoot = await realpath(project.projectRoot).catch(
+ () => project.projectRoot
+ );
+ const registered = registry.projects.find(
+ (entry) =>
+ entry.projectDir === projectDir ||
+ entry.worktrees?.some((worktree) => worktree.path === projectRoot)
+ );
+ if (registered?.autostart && registered.autostart.branch === branch) {
+ await setProjectAutostartPaused({
+ id: registered.id,
+ paused: opts.action === "down",
+ });
+ }
+}
+
async function handleUp({
ctx,
args,
@@ -6371,6 +6524,7 @@ async function handleUp({
}): Promise {
// Refuse a malformed --target before any project or runtime work.
parseRequestedTarget({ raw: args.options.target });
+ await updateAutostartIntent({ ctx, args, action: "up" });
if (resolveNativeRuntimeSelection()) {
// Once per `hack up`, whichever native start path follows (HACK-2026).
noteNativeBackendBeta({ json: args.options.json === true });
@@ -7121,6 +7275,7 @@ async function handleDown({
}): Promise {
// Refuse a malformed --target before any project or runtime work.
parseRequestedTarget({ raw: args.options.target });
+ await updateAutostartIntent({ ctx, args, action: "down" });
const authored = await tryNativeComposeCommand({
cwd: ctx.cwd,
path: args.options.path,
@@ -8392,7 +8547,10 @@ async function handleRestart({
if (native) {
return await handleNativeUp({
ctx,
- args: { ...args, options: { ...args.options, detach: false } },
+ args: {
+ ...args,
+ options: { ...args.options, detach: false, always: false },
+ },
native,
restart: true,
recovery:
diff --git a/src/commands/projects.ts b/src/commands/projects.ts
index 72f0263a8..e74943440 100644
--- a/src/commands/projects.ts
+++ b/src/commands/projects.ts
@@ -25,6 +25,7 @@ import {
resolveGlobalCaddyIp,
} from "../lib/caddy-hosts.ts";
import { emitCliResult, okResult } from "../lib/cli-result.ts";
+import { isRecord } from "../lib/guards.ts";
import { confirmSafe } from "../lib/interactivity.ts";
import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts";
import { tryNativeComposeCommand } from "../lib/native-compose-command.ts";
@@ -52,6 +53,7 @@ import {
import type { ProjectView } from "../lib/project-views.ts";
import {
buildProjectViews,
+ serializeProjectAutostart,
serializeProjectSummary,
serializeProjectView,
} from "../lib/project-views.ts";
@@ -496,6 +498,7 @@ async function runNativeStatus(opts: {
readonly json: boolean;
readonly ps?: typeof nativeProjectPs;
readonly authored?: typeof tryNativeComposeCommand;
+ readonly readRegistry?: typeof readProjectsRegistry;
}): Promise {
// Once, whichever native observer answers (HACK-2026).
noteNativeBackendBeta({ json: opts.json });
@@ -507,6 +510,27 @@ async function runNativeStatus(opts: {
project: opts.projectOpt,
operation: "ps",
json: opts.json,
+ statusMetadata: async () => {
+ const registry = await (opts.readRegistry ?? readProjectsRegistry)();
+ const registration =
+ opts.projectOpt === undefined
+ ? registry.projects
+ .filter((entry) =>
+ [
+ entry.repoRoot,
+ ...(entry.worktrees ?? []).map((worktree) => worktree.path),
+ ].some(
+ (root) => opts.cwd === root || opts.cwd.startsWith(`${root}/`)
+ )
+ )
+ .sort((a, b) => b.repoRoot.length - a.repoRoot.length)[0]
+ : selectRegisteredProjectByName({
+ projects: registry.projects,
+ name: parseProjectSelectorOption({ projectOpt: opts.projectOpt })
+ .name,
+ });
+ return { autostart: serializeProjectAutostart(registration?.autostart) };
+ },
});
if (authored !== null) {
return authored;
@@ -541,7 +565,28 @@ async function runNativeStatus(opts: {
branch,
},
});
- await displayNativePs(result, opts.json);
+ const registry = await (opts.readRegistry ?? readProjectsRegistry)();
+ const registration = registry.projects.find(
+ (entry) =>
+ entry.projectDir === project.projectDir ||
+ entry.worktrees?.some((worktree) => worktree.path === project.projectRoot)
+ );
+ const autostart = serializeProjectAutostart(registration?.autostart);
+ if (opts.json) {
+ emitCliResult({ result: okResult({ data: { ...result, autostart } }) });
+ } else {
+ await displayNativePs(result, false);
+ if (registration?.autostart) {
+ const policy = registration.autostart;
+ await display.kv({
+ entries: [
+ ["Autostart", policy.paused ? "paused" : "always-on"],
+ ["Last autostart", policy.lastResult?.status ?? "not attempted"],
+ ["Autostart reason", policy.lastResult?.reason ?? ""],
+ ],
+ });
+ }
+ }
return 0;
}
@@ -641,7 +686,15 @@ async function runProjects(opts: {
await display.section("Projects");
await display.table({
- columns: ["Name", "Status", "Services", "Dev Host", "Repo Root"],
+ columns: [
+ "Name",
+ "Status",
+ "Autostart",
+ "Last autostart",
+ "Services",
+ "Dev Host",
+ "Repo Root",
+ ],
rows: views.map((p) => {
const definedCount = p.definedServices ? p.definedServices.length : null;
const runningCount = runtime.ok ? countRunningServices(p.runtime) : null;
@@ -650,9 +703,16 @@ async function runProjects(opts: {
runningCount,
runtimeOk: runtime.ok,
});
+ const autostartState = p.autostart?.paused ? "paused" : "always-on";
return [
p.name,
p.status,
+ p.autostart ? autostartState : "",
+ p.autostart?.lastResult
+ ? [p.autostart.lastResult.status, p.autostart.lastResult.reason]
+ .filter(Boolean)
+ .join(": ")
+ : "",
servicesCell,
p.devHost ?? "",
p.repoRoot ?? "",
@@ -727,7 +787,13 @@ async function outputDaemonProjects({
daemon.json &&
(!opts.summary || daemon.json.detail_level === "summary")
) {
- const { profiling, ...payload } = daemon.json;
+ const { profiling, ...cached } = daemon.json;
+ // Policy changes do not wait for Docker's next cache refresh.
+ const registry = await readProjectsRegistry();
+ const payload = overlayAutostartPolicy({
+ cached,
+ projects: registry.projects,
+ });
writeProfiledProjects({
payload,
profiler,
@@ -741,6 +807,33 @@ async function outputDaemonProjects({
return false;
}
+/** Machine intent is fresh even when runtime rows came from an older daemon snapshot. */
+function overlayAutostartPolicy(opts: {
+ readonly cached: Readonly>;
+ readonly projects: readonly RegisteredProject[];
+}): Record {
+ const markers = new Map(
+ opts.projects.map((project) => [project.id, project.autostart])
+ );
+ return {
+ ...opts.cached,
+ projects: Array.isArray(opts.cached.projects)
+ ? opts.cached.projects.map((project: unknown) =>
+ isRecord(project)
+ ? {
+ ...project,
+ autostart: serializeProjectAutostart(
+ typeof project.project_id === "string"
+ ? markers.get(project.project_id)
+ : undefined
+ ),
+ }
+ : project
+ )
+ : opts.cached.projects,
+ };
+}
+
type RuntimeRecoveryMeta = {
readonly resetCount: number;
readonly lastResetSummary: string | null;
@@ -1521,6 +1614,7 @@ function readRepairOutcomeField(opts: {
}
export const __testOnlyProjectsCommand = {
+ overlayAutostartPolicy,
buildRuntimeRecoveryNotice,
runNativeStatus,
filterPruneRegistryProjects,
diff --git a/src/daemon/autostart.ts b/src/daemon/autostart.ts
new file mode 100644
index 000000000..f9aa204f7
--- /dev/null
+++ b/src/daemon/autostart.ts
@@ -0,0 +1,571 @@
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { resolveNativeRuntimeSelection } from "../backends/native-runtime-client.ts";
+import {
+ GLOBAL_CADDY_COMPOSE_FILENAME,
+ GLOBAL_CADDY_DIR_NAME,
+} from "../constants.ts";
+import { childEnvironment } from "../lib/child-environment.ts";
+import { resolveComposeProjectName } from "../lib/compose-project-name.ts";
+import { resolveGlobalHackDir } from "../lib/config-paths.ts";
+import { isRecord } from "../lib/guards.ts";
+import { findProjectContextAtRoot, readProjectConfig } from "../lib/project.ts";
+import {
+ claimProjectAutostart,
+ type RegisteredProject,
+ readProjectsRegistry,
+ recordProjectAutostartResult,
+} from "../lib/projects-registry.ts";
+
+export type AutostartResult = {
+ readonly status: "started" | "skipped" | "failed" | "already_running";
+ readonly reason: string | null;
+ readonly at: string;
+};
+
+type StartResult = Omit;
+
+/** Side effects are explicit so ordering, bounds and restart recovery can be tested without an engine. */
+export type AutostartDependencies = {
+ readonly bootId: () => Promise;
+ readonly startupRefusal?: () => "native_unsupported" | null;
+ readonly now: () => number;
+ readonly sleep: (ms: number, signal?: AbortSignal) => Promise;
+ readonly engineReady: () => Promise;
+ readonly routingInstalled: () => Promise;
+ readonly startRouting: (signal?: AbortSignal) => Promise;
+ readonly inspect: (
+ project: RegisteredProject,
+ signal?: AbortSignal
+ ) => Promise<"running" | "stopped" | "unknown">;
+ readonly start: (
+ project: RegisteredProject,
+ signal?: AbortSignal
+ ) => Promise;
+};
+
+const MAC_BOOT_TIME_PATTERN = /sec = (\d+), usec = (\d+)/;
+
+const ENGINE_WAIT_MS = 60_000;
+const ENGINE_POLL_MS = 2000;
+const START_STAGGER_MS = 500;
+const MAX_CONCURRENT_STARTS = 2;
+
+/**
+ * One replay per OS boot, with a durable claim before each launch. An interrupted
+ * claim is deliberately not retried in this first slice: unknown launch outcome
+ * is not proof of absence. Explicit `hack up` remains the recovery path.
+ */
+export async function runAutostartAtLogin(
+ opts: {
+ readonly signal?: AbortSignal;
+ readonly registryPath?: string;
+ readonly dependencies?: AutostartDependencies;
+ } = {}
+): Promise {
+ const deps = opts.dependencies ?? productionDependencies();
+ const registry = await readProjectsRegistry({
+ registryPath: opts.registryPath,
+ });
+ const candidates = registry.projects
+ .filter((project) => project.autostart && !project.autostart.paused)
+ .sort(
+ (a, b) =>
+ a.name.localeCompare(b.name, "en") || a.id.localeCompare(b.id, "en")
+ );
+ if (candidates.length === 0) {
+ return;
+ }
+ const machineBootId = await deps.bootId().catch(() => null);
+ const bootId = machineBootId ?? "unavailable";
+ const pending = candidates.filter(
+ (project) => project.autostart?.lastAttemptBootId !== bootId
+ );
+ if (pending.length === 0) {
+ return;
+ }
+ const refusal = await prepareAutostartRuntime({
+ deps,
+ machineBootId,
+ signal: opts.signal,
+ });
+ if (opts.signal?.aborted) {
+ return;
+ }
+
+ const active = new Set>();
+ const tasks: Promise[] = [];
+ let lastStartAt: number | null = null;
+ try {
+ for (const candidate of pending) {
+ if (opts.signal?.aborted) {
+ break;
+ }
+ while (active.size >= MAX_CONCURRENT_STARTS) {
+ await Promise.race(active);
+ }
+ if (lastStartAt !== null) {
+ const delay = START_STAGGER_MS - (deps.now() - lastStartAt);
+ if (delay > 0) {
+ await deps.sleep(delay, opts.signal);
+ }
+ }
+ if (opts.signal?.aborted) {
+ break;
+ }
+ const project = await claimProjectAutostart({
+ id: candidate.id,
+ bootId,
+ registryPath: opts.registryPath,
+ });
+ if (!project) {
+ continue;
+ }
+ let dispatched = () => {
+ /* Replaced synchronously by the Promise executor. */
+ };
+ const dispatch = new Promise((done) => {
+ dispatched = done;
+ });
+ const task = startClaimedProject({
+ project,
+ bootId,
+ refusal,
+ deps,
+ onDispatched: dispatched,
+ ...opts,
+ });
+ const tracked = task.then(
+ () => {
+ active.delete(tracked);
+ },
+ () => {
+ active.delete(tracked);
+ }
+ );
+ active.add(tracked);
+ tasks.push(task);
+ await dispatch;
+ lastStartAt = deps.now();
+ }
+ } finally {
+ await Promise.all(tasks);
+ }
+}
+
+async function prepareAutostartRuntime(opts: {
+ readonly deps: AutostartDependencies;
+ readonly machineBootId: string | null;
+ readonly signal?: AbortSignal;
+}): Promise {
+ const refusal = opts.deps.startupRefusal?.();
+ if (refusal) {
+ return refusal;
+ }
+ if (!opts.machineBootId) {
+ return "boot_identity_unavailable";
+ }
+ const { deps, signal } = opts;
+ const deadline = deps.now() + ENGINE_WAIT_MS;
+ let ready = false;
+ while (!signal?.aborted) {
+ ready = await deps.engineReady().catch(() => false);
+ if (ready || deps.now() >= deadline) {
+ break;
+ }
+ await deps.sleep(Math.min(ENGINE_POLL_MS, deadline - deps.now()), signal);
+ }
+ if (!ready) {
+ return "engine_timeout";
+ }
+ if (
+ (await deps.routingInstalled()) &&
+ !(await deps.startRouting(signal).catch(() => false))
+ ) {
+ return "routing_failed";
+ }
+ return null;
+}
+
+async function startClaimedProject(opts: {
+ readonly project: RegisteredProject;
+ readonly bootId: string;
+ readonly refusal: string | null;
+ readonly onDispatched: () => void;
+ readonly deps: AutostartDependencies;
+ readonly signal?: AbortSignal;
+ readonly registryPath?: string;
+}): Promise {
+ let result: StartResult = { status: "skipped", reason: opts.refusal };
+ try {
+ const registry = await readProjectsRegistry({
+ registryPath: opts.registryPath,
+ });
+ const current = registry.projects.find(
+ (project) => project.id === opts.project.id
+ );
+ if (
+ !current?.autostart ||
+ current.autostart.paused ||
+ opts.signal?.aborted
+ ) {
+ result = {
+ status: "skipped",
+ reason: opts.signal?.aborted ? "daemon_stopped" : "paused",
+ };
+ } else if (!opts.refusal) {
+ const state = await opts.deps.inspect(current, opts.signal);
+ if (state === "running") {
+ result = { status: "already_running", reason: null };
+ } else if (state === "unknown") {
+ result = { status: "skipped", reason: "runtime_unknown" };
+ } else {
+ result = await dispatchIfStillWanted({ ...opts, project: current });
+ }
+ }
+ } catch {
+ result = { status: "failed", reason: "start_failed" };
+ } finally {
+ opts.onDispatched();
+ }
+ await recordProjectAutostartResult({
+ id: opts.project.id,
+ bootId: opts.bootId,
+ result: { ...result, at: new Date(opts.deps.now()).toISOString() },
+ registryPath: opts.registryPath,
+ });
+}
+
+async function dispatchIfStillWanted(opts: {
+ readonly project: RegisteredProject;
+ readonly onDispatched: () => void;
+ readonly deps: AutostartDependencies;
+ readonly signal?: AbortSignal;
+ readonly registryPath?: string;
+}): Promise {
+ const latest = (
+ await readProjectsRegistry({ registryPath: opts.registryPath })
+ ).projects.find((project) => project.id === opts.project.id);
+ if (!latest?.autostart || latest.autostart.paused || opts.signal?.aborted) {
+ return {
+ status: "skipped",
+ reason: opts.signal?.aborted ? "daemon_stopped" : "paused",
+ };
+ }
+ if (latest.autostart.branch !== opts.project.autostart?.branch) {
+ return { status: "skipped", reason: "target_changed" };
+ }
+ const start = opts.deps.start(latest, opts.signal);
+ opts.onDispatched();
+ return await start;
+}
+
+/** Only closed reason codes cross into the registry; subprocess output is never retained. */
+export function classifyAutostartExit(opts: {
+ readonly exitCode: number;
+ readonly stdout: string;
+ readonly reason?: "output_limit" | "daemon_stopped" | "start_timeout";
+}): StartResult {
+ if (opts.reason) {
+ return {
+ status: opts.reason === "daemon_stopped" ? "skipped" : "failed",
+ reason: opts.reason,
+ };
+ }
+ if (opts.exitCode === 0) {
+ return { status: "started", reason: null };
+ }
+ let code: unknown;
+ let detail: unknown;
+ try {
+ const value: unknown = JSON.parse(opts.stdout);
+ if (isRecord(value) && isRecord(value.error)) {
+ code = value.error.code;
+ detail = value.error.detail;
+ }
+ } catch {
+ /* A malformed response is a failure, never a reason to retain output. */
+ }
+ if (
+ code === "E_CONFIG_INVALID" &&
+ isRecord(detail) &&
+ (detail.reason === "pending_env_migration" ||
+ detail.reason === "keychain_unavailable")
+ ) {
+ return { status: "skipped", reason: detail.reason };
+ }
+ if (code === "E_PROJECT_NOT_FOUND") {
+ return { status: "skipped", reason: "project_unavailable" };
+ }
+ const skipReasons: Readonly> = {
+ E_INTERACTIVE_REQUIRED: "interactive_required",
+ E_ENV_KEY_MISSING: "env_key_missing",
+ };
+ const reason = typeof code === "string" ? skipReasons[code] : undefined;
+ return reason
+ ? { status: "skipped", reason }
+ : {
+ status: "failed",
+ reason: opts.exitCode === 124 ? "start_timeout" : "start_failed",
+ };
+}
+
+/** Native startup waits for separate runtime/login qualification; never probe Docker for it. */
+export function autostartRuntimeRefusal(
+ env: Readonly> = process.env
+): "native_unsupported" | null {
+ try {
+ return resolveNativeRuntimeSelection(env) ? "native_unsupported" : null;
+ } catch {
+ return "native_unsupported";
+ }
+}
+
+function productionDependencies(): AutostartDependencies {
+ const invocation = [
+ process.execPath,
+ ...(Bun.main.startsWith("/$bunfs/")
+ ? []
+ : [fileURLToPath(new URL("../../index.ts", import.meta.url))]),
+ ];
+ return {
+ startupRefusal: autostartRuntimeRefusal,
+ now: Date.now,
+ sleep: async (ms, signal) => {
+ if (signal?.aborted) {
+ return;
+ }
+ await new Promise((done) => {
+ const finish = () => {
+ clearTimeout(timer);
+ signal?.removeEventListener("abort", finish);
+ done();
+ };
+ const timer = setTimeout(finish, ms);
+ signal?.addEventListener("abort", finish, { once: true });
+ });
+ },
+ bootId: readMachineBootId,
+ engineReady: async () =>
+ (
+ await runAutostartSubprocess(
+ ["docker", "info", "--format", "{{.ID}}"],
+ {
+ timeoutMs: 2000,
+ }
+ )
+ ).exitCode === 0,
+ routingInstalled: async () =>
+ Bun.file(
+ resolve(
+ resolveGlobalHackDir(),
+ GLOBAL_CADDY_DIR_NAME,
+ GLOBAL_CADDY_COMPOSE_FILENAME
+ )
+ ).exists(),
+ startRouting: async (signal) =>
+ (
+ await runAutostartSubprocess(
+ [...invocation, "global", "up", "--no-interactive"],
+ {
+ timeoutMs: 60_000,
+ signal,
+ }
+ )
+ ).exitCode === 0,
+ inspect: inspectComposeProject,
+ start: async (project, signal) => {
+ const args = [
+ ...invocation,
+ "up",
+ "-d",
+ "--no-interactive",
+ "--json",
+ "--path",
+ project.repoRoot,
+ ];
+ if (project.autostart?.branch) {
+ args.push(`--branch=${project.autostart.branch}`);
+ }
+ return classifyAutostartExit(
+ await runAutostartSubprocess(args, { timeoutMs: 120_000, signal })
+ );
+ },
+ };
+}
+
+async function inspectComposeProject(
+ project: RegisteredProject,
+ signal?: AbortSignal
+): Promise<"running" | "stopped" | "unknown"> {
+ const context = await findProjectContextAtRoot({
+ projectRoot: project.repoRoot,
+ projectDirName: project.projectDirName,
+ });
+ if (!context) {
+ return "unknown";
+ }
+ const cfg = await readProjectConfig(context);
+ if (cfg.parseError) {
+ return "unknown";
+ }
+ const name = await resolveComposeProjectName({ project: context, cfg });
+ const composeName = project.autostart?.branch
+ ? `${name}--${project.autostart.branch}`
+ : name;
+ const result = await runAutostartSubprocess(
+ [
+ "docker",
+ "ps",
+ "-a",
+ "--filter",
+ `label=com.docker.compose.project=${composeName}`,
+ "--filter",
+ "label=com.docker.compose.oneoff=False",
+ "--format",
+ "{{.ID}}",
+ ],
+ { timeoutMs: 5000, signal }
+ );
+ if (result.exitCode !== 0) {
+ return "unknown";
+ }
+ const ids = result.stdout.trim().split("\n").filter(Boolean);
+ if (ids.length === 0) {
+ return "stopped";
+ }
+ const inspected = await runAutostartSubprocess(
+ [
+ "docker",
+ "inspect",
+ "--format",
+ "{{.State.Status}}|{{.State.ExitCode}}",
+ ...ids,
+ ],
+ { timeoutMs: 5000, signal }
+ );
+ if (inspected.exitCode !== 0) {
+ return "unknown";
+ }
+ return classifyAutostartContainers(inspected.stdout);
+}
+
+/** Completed successful one-shot services are adopted alongside running services. */
+export function classifyAutostartContainers(
+ text: string
+): "running" | "stopped" | "unknown" {
+ const states = text.trim().split("\n").filter(Boolean);
+ if (states.length === 0) {
+ return "unknown";
+ }
+ let running = false;
+ for (const state of states) {
+ const [status, exitCode, extra] = state.split("|");
+ if (extra !== undefined || !status || !exitCode) {
+ return "unknown";
+ }
+ if (status !== "running" && !(status === "exited" && exitCode === "0")) {
+ return "stopped";
+ }
+ running ||= status === "running";
+ }
+ return running ? "running" : "stopped";
+}
+
+async function readMachineBootId(): Promise {
+ if (process.platform === "linux") {
+ try {
+ return (
+ (await Bun.file("/proc/sys/kernel/random/boot_id").text()).trim() ||
+ null
+ );
+ } catch {
+ return null;
+ }
+ }
+ if (process.platform === "darwin") {
+ const result = await runAutostartSubprocess(
+ ["sysctl", "-n", "kern.boottime"],
+ {
+ timeoutMs: 2000,
+ }
+ );
+ const match = result.stdout.match(MAC_BOOT_TIME_PATTERN);
+ return result.exitCode === 0 && match ? `${match[1]}:${match[2]}` : null;
+ }
+ return null;
+}
+
+/** Own process group, no TTY, no keychain, no recursive daemon startup, bounded output and lifetime. */
+export async function runAutostartSubprocess(
+ argv: readonly string[],
+ opts: {
+ readonly timeoutMs: number;
+ readonly signal?: AbortSignal;
+ readonly env?: Readonly>;
+ }
+): Promise<{
+ exitCode: number;
+ stdout: string;
+ reason?: "output_limit" | "daemon_stopped" | "start_timeout";
+}> {
+ if (opts.signal?.aborted) {
+ return { exitCode: 124, stdout: "", reason: "daemon_stopped" };
+ }
+ const proc = Bun.spawn([...argv], {
+ env: {
+ ...childEnvironment(opts.env ?? process.env, {
+ HACK_NO_INTERACTIVE: "1",
+ HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1",
+ HACK_DAEMON_AUTOSTART_DISABLE: "1",
+ }),
+ HACK_AUTOSTART_LOGIN: "1",
+ },
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "ignore",
+ detached: true,
+ });
+ let reason: "output_limit" | "daemon_stopped" | "start_timeout" | undefined;
+ const stop = (
+ failure: "output_limit" | "daemon_stopped" | "start_timeout"
+ ) => {
+ reason ??= failure;
+ try {
+ process.kill(-proc.pid, "SIGKILL");
+ } catch {
+ /* Own child may already have exited. */
+ }
+ };
+ const timer = setTimeout(() => stop("start_timeout"), opts.timeoutMs);
+ const abort = () => stop("daemon_stopped");
+ opts.signal?.addEventListener("abort", abort, { once: true });
+ let stdout = "";
+ const decoder = new TextDecoder();
+ const reader = proc.stdout.getReader();
+ let outputBytes = 0;
+ try {
+ while (true) {
+ const { value: chunk, done } = await reader.read();
+ if (done) {
+ break;
+ }
+ outputBytes += chunk.byteLength;
+ if (outputBytes > 64 * 1024) {
+ stop("output_limit");
+ break;
+ }
+ stdout += decoder.decode(chunk, { stream: true });
+ }
+ const exitCode = await proc.exited;
+ return {
+ exitCode: reason ? 124 : exitCode,
+ stdout: reason ? "" : stdout,
+ ...(reason ? { reason } : {}),
+ };
+ } finally {
+ reader.releaseLock();
+ clearTimeout(timer);
+ opts.signal?.removeEventListener("abort", abort);
+ }
+}
diff --git a/src/daemon/server.ts b/src/daemon/server.ts
index 1504efdf0..ab6895c28 100644
--- a/src/daemon/server.ts
+++ b/src/daemon/server.ts
@@ -20,6 +20,7 @@ import { createShellService } from "../control-plane/extensions/supervisor/shell
import type { ControlPlaneConfig } from "../control-plane/sdk/config.ts";
import { ensureDir } from "../lib/fs.ts";
import { resolveRegisteredProjectById } from "../lib/projects-registry.ts";
+import { runAutostartAtLogin } from "./autostart.ts";
import {
evaluateGatewayRouteAccess,
validateControlPlaneRouteTarget,
@@ -337,8 +338,12 @@ export async function runDaemon({
}
}
+ const autostartAbort = new AbortController();
+ let autostartWork: Promise = Promise.resolve();
const shutdown = async ({ reason }: { readonly reason: string }) => {
logger.warn({ message: `Shutting down hackd (${reason})` });
+ autostartAbort.abort();
+ await autostartWork;
refreshScheduler.stop();
watcher.stop();
await daemonProxy?.close();
@@ -359,6 +364,16 @@ export async function runDaemon({
logger.info({
message: `hackd started (pid ${process.pid}, version ${packageJson.version})`,
});
+ if (process.env.HACK_DAEMON_AUTOSTART_DISABLE !== "1") {
+ autostartWork = runAutostartAtLogin({
+ signal: autostartAbort.signal,
+ }).catch(() => {
+ logger.warn({
+ message:
+ "Autostart could not complete; inspect hack autostart list and run hack up manually. Values omitted.",
+ });
+ });
+ }
}
async function handleRequest({
diff --git a/src/lib/child-environment.ts b/src/lib/child-environment.ts
index 6db8e26ed..bc454b156 100644
--- a/src/lib/child-environment.ts
+++ b/src/lib/child-environment.ts
@@ -19,7 +19,11 @@ export function childEnvironment(
): Record {
const env: Record = Object.create(null);
for (const [key, value] of Object.entries(base)) {
- if (typeof value === "string" && !unsetKeys.includes(key)) {
+ if (
+ typeof value === "string" &&
+ !unsetKeys.includes(key) &&
+ key !== "HACK_AUTOSTART_LOGIN"
+ ) {
env[key] = value;
}
}
@@ -41,6 +45,9 @@ export function hackReexecEnvironment(
base: Readonly> = process.env
): Record {
const env = childEnvironment(base);
+ if (base.HACK_AUTOSTART_LOGIN === "1") {
+ env.HACK_AUTOSTART_LOGIN = "1";
+ }
for (const key of HACK_KEY_MATERIAL_VARIABLES) {
const value = base[key];
if (typeof value === "string") {
diff --git a/src/lib/keychain.ts b/src/lib/keychain.ts
index 02bf033a7..d383864f0 100644
--- a/src/lib/keychain.ts
+++ b/src/lib/keychain.ts
@@ -1,3 +1,4 @@
+import { HackCliError } from "./cli-result.ts";
import { nativeKeychain } from "./native-keychain.ts";
/** The single boundary for native keychain access, injectable for isolated callers. */
@@ -17,6 +18,13 @@ export function isKeychainDisabled(): boolean {
function assertKeychainEnabled(): void {
if (isKeychainDisabled()) {
+ if (process.env.HACK_AUTOSTART_LOGIN === "1") {
+ throw new HackCliError({
+ code: "E_ENV_KEY_MISSING",
+ message:
+ "Autostart skipped: required keychain access is unavailable without interaction.",
+ });
+ }
throw new Error(
"Keychain access is disabled by HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK."
);
diff --git a/src/lib/native-authored-command.ts b/src/lib/native-authored-command.ts
index 2c7515aeb..e17e781d0 100644
--- a/src/lib/native-authored-command.ts
+++ b/src/lib/native-authored-command.ts
@@ -417,10 +417,15 @@ const STATUS_NOTES: Readonly<
async function showStatus(options: {
readonly result: NativeAuthoredProjectStatus;
readonly json?: boolean;
+ readonly statusMetadata?: NativeComposeCommandOptions["statusMetadata"];
}): Promise {
const { result } = options;
if (options.json) {
- emitCliResult({ result: okResult({ data: result }) });
+ emitCliResult({
+ result: okResult({
+ data: { ...result, ...(await options.statusMetadata?.()) },
+ }),
+ });
return;
}
if (result.status !== "observed") {
@@ -435,6 +440,16 @@ async function showStatus(options: {
item.health ?? "",
]),
});
+ const policy = (await options.statusMetadata?.())?.autostart;
+ if (policy) {
+ await display.kv({
+ entries: [
+ ["Autostart", policy.paused ? "paused" : "always-on"],
+ ["Last autostart", policy.last_result?.status ?? "not attempted"],
+ ["Autostart reason", policy.last_result?.reason ?? ""],
+ ],
+ });
+ }
}
function commandFailure(options: {
@@ -526,7 +541,11 @@ async function runStatus(input: LifecycleInput): Promise {
if (input.signal.aborted) {
return 130;
}
- await showStatus({ result, json: input.options.json });
+ await showStatus({
+ result,
+ json: input.options.json,
+ statusMetadata: input.options.statusMetadata,
+ });
return 0;
}
diff --git a/src/lib/native-compose-adoption-command.ts b/src/lib/native-compose-adoption-command.ts
index 55a00e331..ba56fb10f 100644
--- a/src/lib/native-compose-adoption-command.ts
+++ b/src/lib/native-compose-adoption-command.ts
@@ -273,7 +273,7 @@ async function observe(opts: {
}));
process.stdout.write(
options.json
- ? `${JSON.stringify({ owner: "legacy-compose", services: report })}\n`
+ ? `${JSON.stringify({ owner: "legacy-compose", services: report, ...(await options.statusMetadata?.()) })}\n`
: report.map((entry) => `${entry.service}\t${entry.status}\n`).join("")
);
return 0;
diff --git a/src/lib/native-compose-command.ts b/src/lib/native-compose-command.ts
index a02db2113..954500cc7 100644
--- a/src/lib/native-compose-command.ts
+++ b/src/lib/native-compose-command.ts
@@ -152,10 +152,15 @@ import {
} from "./native-compose-selection.ts";
import { NativeConfigCompilerError } from "./native-config-compiler.ts";
import { nativeFilePlanningRequired } from "./native-file-plan-protocol.ts";
+import type { serializeProjectAutostart } from "./project-views.ts";
import { run } from "./shell.ts";
type Operation = "up" | "restart" | "down" | "ps" | "logs" | "exec" | "run";
export type NativeComposeCommandOptions = {
+ /** Add machine policy only for status callers; ordinary ps keeps its existing shape. */
+ readonly statusMetadata?: () => Promise<{
+ readonly autostart: ReturnType;
+ }>;
readonly cwd: string;
readonly path?: string;
readonly project?: string;
@@ -736,6 +741,7 @@ async function savedCommand(opts: {
beforeHooksPending: true,
hostHookPhase: state.hostHookPhase,
services: [],
+ ...(await options.statusMetadata?.()),
},
}),
});
@@ -1029,6 +1035,7 @@ async function savedCommand(opts: {
oneoff,
})
),
+ ...(await options.statusMetadata?.()),
},
}),
});
diff --git a/src/lib/project-views.ts b/src/lib/project-views.ts
index 40d95b80d..c824e405c 100644
--- a/src/lib/project-views.ts
+++ b/src/lib/project-views.ts
@@ -84,6 +84,7 @@ export type ProjectLifecycleView = {
export type ProjectView = {
readonly projectId?: string;
+ readonly autostart?: RegisteredProject["autostart"];
readonly name: string;
readonly devHost: string | null;
readonly repoRoot: string | null;
@@ -312,6 +313,7 @@ async function buildRegisteredProjectView(opts: {
return {
projectId: opts.registration.id,
+ autostart: opts.registration.autostart,
name: opts.name,
devHost: opts.registration.devHost ?? null,
repoRoot: opts.registration.repoRoot,
@@ -340,6 +342,7 @@ function buildUnavailableProjectView(opts: {
}): ProjectView {
return {
projectId: opts.registration.id,
+ autostart: opts.registration.autostart,
name: opts.name,
devHost: opts.registration.devHost ?? null,
repoRoot: opts.registration.repoRoot,
@@ -394,6 +397,31 @@ function buildUnregisteredProjectView(opts: {
};
}
+/** Stable desktop-facing machine policy; per-boot admission bookkeeping remains internal. */
+export function serializeProjectAutostart(
+ marker: RegisteredProject["autostart"]
+): {
+ readonly branch: string | null;
+ readonly paused: boolean;
+ readonly last_result: NonNullable<
+ RegisteredProject["autostart"]
+ >["lastResult"];
+} | null {
+ return marker
+ ? {
+ branch: marker.branch,
+ paused: marker.paused,
+ last_result: marker.lastResult
+ ? {
+ status: marker.lastResult.status,
+ reason: marker.lastResult.reason,
+ at: marker.lastResult.at,
+ }
+ : null,
+ }
+ : null;
+}
+
/** Summary projection deliberately excludes container labels, mounts and command definitions. */
export function serializeProjectSummary(
view: ProjectView
@@ -407,6 +435,7 @@ export function serializeProjectSummary(
);
return {
project_id: view.projectId ?? null,
+ autostart: serializeProjectAutostart(view.autostart),
name: view.name,
repo_root: view.repoRoot,
dev_host: view.devHost,
@@ -435,6 +464,7 @@ export function serializeProjectView(
): Record {
return {
project_id: view.projectId ?? null,
+ autostart: serializeProjectAutostart(view.autostart),
name: view.name,
dev_host: view.devHost ?? null,
repo_root: view.repoRoot ?? null,
diff --git a/src/lib/projects-registry.ts b/src/lib/projects-registry.ts
index 90e416007..4c17236c0 100644
--- a/src/lib/projects-registry.ts
+++ b/src/lib/projects-registry.ts
@@ -46,6 +46,28 @@ export interface RegisteredProjectWorktree {
readonly lastSeenAt: string;
}
+/** Value-free result of the daemon's most recent startup attempt. */
+export interface ProjectAutostartResult {
+ readonly status: "started" | "skipped" | "failed" | "already_running";
+ readonly reason: string | null;
+ readonly at: string;
+}
+
+/** Machine-owned intent; repository configuration never creates this marker. */
+export interface ProjectAutostart {
+ readonly branch: string | null;
+ readonly paused: boolean;
+ readonly lastResult: ProjectAutostartResult | null;
+ /** Persisted before starting: daemon replacement in the same boot cannot replay it. */
+ readonly lastAttemptBootId: string | null;
+ /** Invalid/future intent is visible but never eligible for automatic starts. */
+ readonly invalid?: true;
+}
+
+// Invalid markers remain repairable and survive unrelated registry writes byte-for-JSON-value.
+// The weak key follows the marker through project spreads without exposing raw data in views.
+const invalidAutostartValues = new WeakMap