Skip to content
Merged
2 changes: 1 addition & 1 deletion .codex/skills/hack-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Use `hack` as the primary interface for local-first development.
- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose.<branch>.override.yml`.
- Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands).
- Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`.
- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).
- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).

## Linked git worktrees

Expand Down
2 changes: 1 addition & 1 deletion .cursor/rules/hack.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is
- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose.<branch>.override.yml`.
- Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands).
- Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`.
- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).
- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).

## Linked git worktrees

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ Project files (managed vs generated):
- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose.<branch>.override.yml`.
- Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands).
- Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`.
- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).
- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).

Linked git worktrees:
- Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Project files (managed vs generated):
- Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose.<branch>.override.yml`.
- Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands).
- Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`.
- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).
- Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).

Linked git worktrees:
- Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ same repo.
- `hack.config.json`
- `hack.branches.json` (optional)
- `.gitignore` (committed, self-healing on `init`/`up`; covers machine-local generated files —
`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`. If generated files
`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`. If generated files
leaked into git, `hack doctor --fix` untracks them without deleting them from disk.)
- `hack.env.default.yaml` plus optional `hack.env.<overlay>.yaml` (committed env)
- `hack.env.local.yaml` / `hack.env.<overlay>.local.yaml` (worktree-local overrides)
Expand Down
3 changes: 2 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ materialized `.hack/.env` or `.hack/.env.state.json` is stale and should be rege
## Project files

Hack owns a committed `.hack/.gitignore` (self-healing on `init`/`up`) that ignores machine-local
generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`). Keep
generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`,
`tickets/`). Keep
it committed. If generated files ever leak into git, `hack doctor --fix` untracks them (the files
stay on disk). See [Architecture](architecture.md) for the full file map.

Expand Down
1 change: 1 addition & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ worktrees inherit the rules with zero setup. It covers (patterns relative to
- `.env.state.json`
- `hack.env.local.yaml`
- `hack.env.*.local.yaml`
- `tickets/` (local tickets-extension cache)

How it is maintained:

Expand Down
2 changes: 1 addition & 1 deletion src/agents/instruction-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [
"Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose.<branch>.override.yml`.",
"Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands).",
"Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`.",
"Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).",
"Ignore rules: hack owns a committed `.hack/.gitignore` (self-healing on init/up) covering machine-local generated files (`.internal/`, `.branch/`, `.env`, `.env.state.json`, `hack.env*.local.yaml`, `tickets/`); keep it committed, and if generated files leaked into git, `hack doctor --fix` untracks them (files stay on disk).",
],
},
{
Expand Down
126 changes: 119 additions & 7 deletions src/commands/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@ import {
installLaunchdService,
kickstartLaunchdService,
type LaunchdServiceStatus,
repairLaunchdProgramIfInvalid,
uninstallLaunchdService,
} from "../daemon/launchd.ts";
import { type DaemonPaths, resolveDaemonPaths } from "../daemon/paths.ts";
import { removeFileIfExists, waitForProcessExit } from "../daemon/process.ts";
import {
findOrphanDaemonProcesses,
removeFileIfExists,
terminateOrphanDaemonProcesses,
waitForProcessExit,
} from "../daemon/process.ts";
import { runDaemon } from "../daemon/server.ts";
import {
buildDaemonRepairMessage,
Expand Down Expand Up @@ -220,11 +226,38 @@ async function handleDaemonStart({
const paths = resolveDaemonPaths({});
const status = await readDaemonStatus({ paths });

if (status.running) {
logger.success({
message: `hackd already running (pid ${status.pid ?? "unknown"})`,
if (status.running && status.pid !== null) {
const api = await checkDaemonApi({
socketExists: status.socketExists,
paths,
});
return 0;
if (api.reachable && !api.compatible) {
// An incompatible daemon (usually a pre-upgrade binary) must be
// replaced, not reported as success — leaving it running is how
// machines end up with doctor/status contradictions.
logger.warn({
message: `Replacing incompatible hackd (pid ${status.pid})`,
});
await stopDaemonProcess({ pid: status.pid, paths });
} else {
logger.success({
message: `hackd already running (pid ${status.pid})`,
});
return 0;
}
}

// Daemons that outlived their pid file (e.g. a launchd-managed instance
// surviving a manual clear/restart) hold the API socket invisibly and
// make every freshly spawned daemon exit cleanly. Sweep them first.
const orphans = await findOrphanDaemonProcesses({
trackedPid: status.pid,
});
if (orphans.length > 0) {
logger.warn({
message: `Stopping orphaned hackd process(es) not tracked by the pid file: ${orphans.join(", ")}`,
});
await terminateOrphanDaemonProcesses({ pids: orphans });
}

await removeFileIfExists({ path: paths.socketPath });
Expand All @@ -235,6 +268,43 @@ async function handleDaemonStart({
return 0;
}

// When launchd manages the daemon, start through launchd — spawning a
// bare process next to a loaded agent means two process managers fight
// over one socket and pid file.
const launchdStatus = await resolveLaunchdStatus({ paths });
if (launchdStatus?.loaded) {
const repair = await repairLaunchdProgramIfInvalid({ paths });
if (repair === "repaired") {
logger.warn({
message:
"Repaired launchd service: its program path was invalid (stale or compiled-binary virtual path)",
});
}
// launchd owns the daemon: never spawn a bare process next to it —
// two process managers for one socket is how machines end up with
// start-then-exit flapping and stale-pid contradictions.
const kick = await kickstartLaunchdService();
if (!kick.ok) {
logger.error({
message: `launchd kickstart failed: ${kick.error ?? "unknown error"} | Check: hack daemon logs --tail 200`,
});
return 1;
}
const startedViaLaunchd = await waitForDaemonStart({
paths,
timeoutMs: 8000,
});
if (startedViaLaunchd) {
logger.success({ message: "hackd started (launchd)" });
return 0;
}
logger.warn({
message:
"launchd kickstarted hackd but it did not report ready yet | Check: hack daemon logs --tail 200",
});
return 1;
}

const invocation = await resolveHackInvocation();
const cmd = [...invocation.args, "daemon", "start", "--foreground"];
const proc = Bun.spawn([invocation.bin, ...cmd], {
Expand All @@ -256,6 +326,31 @@ async function handleDaemonStart({
return 0;
}

async function stopDaemonProcess(opts: {
readonly pid: number;
readonly paths: DaemonPaths;
}): Promise<void> {
try {
process.kill(opts.pid, "SIGTERM");
} catch {
// Already gone.
}
const exited = await waitForProcessExit({
pid: opts.pid,
timeoutMs: 2000,
pollMs: 200,
});
if (!exited) {
try {
process.kill(opts.pid, "SIGKILL");
} catch {
// Already gone.
}
}
await removeFileIfExists({ path: opts.paths.pidPath });
await removeFileIfExists({ path: opts.paths.socketPath });
}

async function handleDaemonStop({
args: _args,
}: {
Expand Down Expand Up @@ -506,11 +601,26 @@ async function handleDaemonClear({
return 1;
}

const orphans = await findOrphanDaemonProcesses({
trackedPid: status.pid,
});
if (orphans.length > 0) {
logger.warn({
message: `Stopping orphaned hackd process(es): ${orphans.join(", ")}`,
});
await terminateOrphanDaemonProcesses({ pids: orphans });
}

const pidExists = await pathExists(paths.pidPath);
const socketExists = await pathExists(paths.socketPath);

if (!(pidExists || socketExists)) {
logger.info({ message: "No stale hackd state found" });
logger.info({
message:
orphans.length > 0
? "Cleared orphaned hackd process(es); no stale files found"
: "No stale hackd state found",
});
return 0;
}

Expand Down Expand Up @@ -549,10 +659,12 @@ async function handleDaemonRestart({

async function waitForDaemonStart({
paths,
timeoutMs,
}: {
readonly paths: ReturnType<typeof resolveDaemonPaths>;
readonly timeoutMs?: number;
}): Promise<boolean> {
const deadline = Date.now() + 2000;
const deadline = Date.now() + (timeoutMs ?? 2000);
while (Date.now() < deadline) {
const status = await readDaemonStatus({ paths });
if (status.running && status.socketExists) {
Expand Down
Loading
Loading