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
2 changes: 1 addition & 1 deletion .codex/skills/hack-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Use `hack` as the primary interface for local-first development.
## 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.
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; opt out with config `worktree.auto_branch=false` or an explicit `--branch`.
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance.
- `hack doctor` flags divergent secret keys and dev_host collisions across checkouts.

## Advanced networking (extra_hosts + local proxies/tunnels)
Expand Down
2 changes: 1 addition & 1 deletion .cursor/rules/hack.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is
## 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.
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; opt out with config `worktree.auto_branch=false` or an explicit `--branch`.
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance.
- `hack doctor` flags divergent secret keys and dev_host collisions across checkouts.

## Standard workflow
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ Project files (managed vs generated):

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.
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; opt out with config `worktree.auto_branch=false` or an explicit `--branch`.
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance.
- `hack doctor` flags divergent secret keys and dev_host collisions across checkouts.

Advanced networking (extra_hosts + local proxies/tunnels):
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Project files (managed vs generated):

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.
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; opt out with config `worktree.auto_branch=false` or an explicit `--branch`.
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance.
- `hack doctor` flags divergent secret keys and dev_host collisions across checkouts.

Advanced networking (extra_hosts + local proxies/tunnels):
Expand Down
14 changes: 14 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ Projects can run host-side hooks around `hack up/down` and start managed host pr
local proxies/tunnels). Processes are started inside a mux session (tmux or zellij) so they have a
stable home and can be torn down on `hack down`.

Lifecycle state is checkout-local at `.hack/.internal/lifecycle/state.json`. For tmux processes, the
saved process group belongs to the wrapped user command rather than its mux pane shell. Each compose
instance has its own entry, so starting a branch instance does not replace the base instance's entry
in the same checkout. Cleanup reconciles the saved process group with the live process table; if the group
leader exited while members remain, `hack doctor` reports the orphan. `hack down` terminates the
persisted leaderless group and descendant process groups only while a matching lifecycle mux session
still proves ownership; without that session, cleanup stays non-destructive because the numeric PGID
may have been reused. A live group leader without its saved pane PID is likewise not trusted as
lifecycle ownership.

For fixed-port helpers such as SSM/database/search tunnels, lifecycle config can also declare a
`singleton` listener set. This lets Hack reuse an already-running equivalent helper or fail fast on
partial conflicts instead of launching a competing duplicate supervisor. The intent is to reduce local
Expand Down Expand Up @@ -267,6 +277,10 @@ the primary checkout through the shared git common dir (see "Project env + secre
`hack doctor` checks for divergent secret keys and `dev_host` collisions across checkouts sharing the
same repo.

If the linked worktree is detached, Hack refuses to silently target the base compose project because
there is no branch name from which to derive an isolated instance. Pass `--branch <name>` to select
one, or set `worktree.auto_branch=false` to opt into the base instance explicitly.

## Files and directories

- `~/.hack/` (default; override the whole root with `HACK_HOME`)
Expand Down
4 changes: 4 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ branch when no `--branch` is passed (`worktree.auto_branch`), so two checkouts n
same hostnames. A one-line notice is printed to stderr when the default kicks in, so captured
stdout stays clean.

A detached linked worktree has no branch name to derive, so these commands fail instead of silently
targeting the base instance. Pass `--branch <name>` to select an isolated instance, or set
`worktree.auto_branch` to `false` only when intentionally opting into the base instance.

Opt out:

- pass `--branch <name>` explicitly (always wins), or
Expand Down
4 changes: 3 additions & 1 deletion docs/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ alive. You do not need gateway, remote nodes, or extension setup to use this pat
`HACK_NO_INTERACTIVE=1`) so scripted/agent runs never block on prompts.
- In a linked git worktree, `hack up` defaults to a branch instance named after the worktree's git
branch (`worktree.auto_branch`), and the secret key is inherited automatically from the primary
checkout via the shared git common dir.
checkout via the shared git common dir. A detached linked worktree has no branch name to derive;
pass `--branch <name>`, or set `worktree.auto_branch=false` when intentionally targeting the base
instance.

## When to leave core

Expand Down
4 changes: 2 additions & 2 deletions docs/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,8 @@ Lifecycle session name:
Notes:
- If no mux backend is available, lifecycle process startup fails with an actionable error.
- Teardown is implemented by killing the lifecycle session; anything running inside that session will be stopped.
- For tmux-backed lifecycle sessions, Hack also persists pane PID and process-group metadata to
`.hack/.internal/lifecycle/state.json`, with per-hook/process output logged to
- For tmux-backed lifecycle sessions, Hack also persists the pane PID and the wrapped command's actual
process-group metadata to `.hack/.internal/lifecycle/state.json`, with per-hook/process output logged to
`.hack/.internal/lifecycle/*.log`. If tmux pane state disappears before teardown, `hack down` still uses that persisted metadata to clean up any live lifecycle process groups instead of leaving orphaned host processes behind.
- `hack doctor` reports stale lifecycle state when the persisted lifecycle entry no longer has a live mux session and points operators to `hack down` so cleanup and state removal happen through the supported path. `hack doctor --fix` does not tear down lifecycle sessions itself — use `hack down` for that.

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 @@ -122,7 +122,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [
surfaces: ALL_SURFACES,
bullets: [
"Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments.",
"`hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; opt out with config `worktree.auto_branch=false` or an explicit `--branch`.",
"`hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance.",
"`hack doctor` flags divergent secret keys and dev_host collisions across checkouts.",
],
},
Expand Down
13 changes: 10 additions & 3 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1845,7 +1845,10 @@ async function checkProjectLifecycleHygiene({
const inspection = await inspectProjectLifecycleHygiene({
projectDir: ctx.projectDir,
});
if (inspection.staleEntries.length === 0) {
if (
inspection.staleEntries.length === 0 &&
inspection.orphanedProcessGroups.length === 0
) {
return {
name: "lifecycle hygiene",
status: "ok",
Expand All @@ -1854,15 +1857,19 @@ async function checkProjectLifecycleHygiene({
};
}

const orphanedGroups = new Set<number>();
const orphanedGroups = new Set<number>(inspection.orphanedProcessGroups);
for (const entry of inspection.staleEntries) {
for (const group of entry.liveProcessGroups) {
orphanedGroups.add(group);
}
}

const details = [
`${inspection.staleEntries.length} stale lifecycle state entr${inspection.staleEntries.length === 1 ? "y" : "ies"}`,
...(inspection.staleEntries.length > 0
? [
`${inspection.staleEntries.length} stale lifecycle state entr${inspection.staleEntries.length === 1 ? "y" : "ies"}`,
]
: []),
...(orphanedGroups.size > 0
? [
`${orphanedGroups.size} orphaned lifecycle process group${orphanedGroups.size === 1 ? "" : "s"}`,
Expand Down
103 changes: 88 additions & 15 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { rm } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import {
autocompleteMultiselect,
Expand Down Expand Up @@ -210,6 +211,9 @@ const LABELS_LINE_PATTERN = /^(\s*)labels:\s*$/;
/** Regex to extract leading whitespace (indentation). */
const INDENT_PATTERN = /^(\s*)/;

const LIFECYCLE_COMMAND_PID_WAIT_ATTEMPTS = 50;
const LIFECYCLE_COMMAND_PID_WAIT_INTERVAL_MS = 20;

/** Regex to match caddy label line in YAML. */
const CADDY_LABEL_PATTERN = /^(\s*)caddy:\s*(.*)$/;

Expand Down Expand Up @@ -594,6 +598,11 @@ async function resolveEffectiveBranchForCommand(opts: {
projectRoot: opts.project.projectRoot,
autoBranchEnabled: resolveWorktreeAutoBranch(cfg),
});
if (resolved.source === "detached-worktree") {
throw new CliUsageError(
"Detached linked worktree cannot auto-select an isolated instance. Pass --branch <name>, or set worktree.auto_branch=false to target the base instance explicitly."
Comment thread
roodboi marked this conversation as resolved.
);
Comment thread
roodboi marked this conversation as resolved.
}
if (resolved.source === "worktree" && resolved.branch) {
const message = `Linked worktree detected → using branch instance "${resolved.branch}" (override with --branch <name>, disable with worktree.auto_branch=false)`;
if (opts.noticeToStderr === true) {
Expand Down Expand Up @@ -702,6 +711,13 @@ function emitLifecycleResult(opts: {
return opts.exitCode;
}

function buildLifecycleJsonErrorResult(opts: { readonly error: unknown }) {
if (opts.error instanceof CliUsageError) {
return errorResult({ code: "E_USAGE", message: opts.error.message });
}
return errorResultFromUnknown({ error: opts.error });
}

/**
* Envelope for lifecycle actions that were routed to a remote node — the
* remote runner owns per-service detail, so the payload reports the routing
Expand Down Expand Up @@ -1969,13 +1985,16 @@ async function startLifecycleProcess(opts: {
projectDir: opts.projectDir,
composeProject: opts.composeProject,
});
const wrappedCommand = wrapLifecyclePersistentCommand({
command: opts.process.command,
logPath,
serviceName: opts.process.name,
});

if (opts.backend === "tmux") {
const commandPidPath = `${logPath}.${windowName}.pid`;
await ensureDir(dirname(commandPidPath));
await rm(commandPidPath, { force: true });
const wrappedCommand = wrapLifecyclePersistentCommand({
command: opts.process.command,
commandPidPath,
logPath,
serviceName: opts.process.name,
});
const result = await exec(
[
"tmux",
Expand All @@ -1993,6 +2012,7 @@ async function startLifecycleProcess(opts: {
{ stdin: "ignore" }
);
if (result.exitCode !== 0) {
await rm(commandPidPath, { force: true });
throw new Error(
`Failed to start lifecycle process "${opts.process.name}": ${result.stderr.trim()}`
);
Expand All @@ -2002,10 +2022,17 @@ async function startLifecycleProcess(opts: {
windowName,
});
const panePid = panePids[0];
const processGroupId =
panePid !== undefined
? await readProcessGroupIdForPid({ pid: panePid })
: null;
let processGroupId: number | null = null;
try {
processGroupId = await waitForLifecycleCommandProcessGroupId({
commandPidPath,
});
} finally {
await rm(commandPidPath, { force: true });
}
if (!processGroupId && panePid !== undefined) {
processGroupId = await readProcessGroupIdForPid({ pid: panePid });
}
await appendLifecycleLogRecord({
projectDir: opts.projectDir,
composeProject: opts.composeProject,
Expand All @@ -2025,6 +2052,12 @@ async function startLifecycleProcess(opts: {
};
}

const wrappedCommand = wrapLifecyclePersistentCommand({
command: opts.process.command,
commandPidPath: null,
logPath,
serviceName: opts.process.name,
});
const result = await exec(
["zellij", "run", "--", "sh", "-c", wrappedCommand],
{
Expand Down Expand Up @@ -2184,6 +2217,29 @@ async function readProcessGroupIdForPid(opts: {
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}

async function waitForLifecycleCommandProcessGroupId(opts: {
readonly commandPidPath: string;
}): Promise<number | null> {
for (
let attempt = 0;
attempt < LIFECYCLE_COMMAND_PID_WAIT_ATTEMPTS;
attempt += 1
) {
const commandPidRaw = await readTextFile(opts.commandPidPath);
const commandPid = Number.parseInt(commandPidRaw?.trim() ?? "", 10);
if (Number.isInteger(commandPid) && commandPid > 1) {
const processGroupId = await readProcessGroupIdForPid({
pid: commandPid,
});
if (processGroupId) {
return processGroupId;
}
}
await Bun.sleep(LIFECYCLE_COMMAND_PID_WAIT_INTERVAL_MS);
}
return null;
}

async function resolveLifecycleProcessGroupIds(opts: {
readonly sessionName: string;
readonly lifecycleEntry: LifecycleStateEntry | null;
Expand Down Expand Up @@ -2262,16 +2318,22 @@ function resolveLifecycleCommandServiceName(opts: {

export function wrapLifecyclePersistentCommand(opts: {
readonly command: string;
readonly commandPidPath: string | null;
readonly logPath: string;
readonly serviceName: string;
}): string {
const logPath = shellSingleQuote(opts.logPath);
const service = shellSingleQuote(opts.serviceName);
const command = shellSingleQuote(opts.command);
const commandPidPath =
opts.commandPidPath === null ? null : shellSingleQuote(opts.commandPidPath);
return [
`HACK_LIFECYCLE_LOG=${logPath}`,
`HACK_LIFECYCLE_SERVICE=${service}`,
`HACK_LIFECYCLE_COMMAND=${command}`,
...(commandPidPath
? [`HACK_LIFECYCLE_COMMAND_PID_FILE=${commandPidPath}`]
: []),
`fifo="$(mktemp -u "\${TMPDIR:-/tmp}/hack-lifecycle.XXXXXX")"`,
'mkfifo "$fifo"',
"cleanup_lifecycle() {",
Expand All @@ -2289,6 +2351,7 @@ export function wrapLifecyclePersistentCommand(opts: {
` if [ -n "\${reader_pid:-}" ]; then`,
' wait "$reader_pid" 2>/dev/null || true',
" fi",
...(commandPidPath ? [' rm -f "$HACK_LIFECYCLE_COMMAND_PID_FILE"'] : []),
' rm -f "$fifo"',
"}",
'trap "cleanup_lifecycle; exit 130" INT TERM HUP',
Expand All @@ -2299,11 +2362,21 @@ export function wrapLifecyclePersistentCommand(opts: {
' done < "$fifo" ) &',
"reader_pid=$!",
"if command -v python3 >/dev/null 2>&1; then",
' python3 -c \'import os, sys; os.setsid(); os.execvp("sh", ["sh", "-c", sys.argv[1]])\' "$HACK_LIFECYCLE_COMMAND" >"$fifo" 2>&1 &',
...(commandPidPath
? [
' python3 -c \'import os, sys; os.setsid(); pid_file = open(sys.argv[2], "w"); pid_file.write(str(os.getpid())); pid_file.close(); os.execvp("sh", ["sh", "-c", sys.argv[1]])\' "$HACK_LIFECYCLE_COMMAND" "$HACK_LIFECYCLE_COMMAND_PID_FILE" >"$fifo" 2>&1 &',
]
: [
' python3 -c \'import os, sys; os.setsid(); os.execvp("sh", ["sh", "-c", sys.argv[1]])\' "$HACK_LIFECYCLE_COMMAND" >"$fifo" 2>&1 &',
]),
" cmd_pid=$!",
"else",
' sh -c "$HACK_LIFECYCLE_COMMAND" >"$fifo" 2>&1 &',
" cmd_pid=$!",
...(commandPidPath
? [' printf "%s\\n" "$cmd_pid" > "$HACK_LIFECYCLE_COMMAND_PID_FILE"']
: []),
"fi",
"cmd_pid=$!",
'wait "$cmd_pid"',
"cmd_status=$?",
'cmd_pid=""',
Expand Down Expand Up @@ -5237,7 +5310,7 @@ async function handleUp({
return await runUpCommand({ ctx, args, json: true, startedAtMs });
} catch (error: unknown) {
return emitLifecycleResult({
result: errorResultFromUnknown({ error }),
result: buildLifecycleJsonErrorResult({ error }),
exitCode: 1,
});
}
Expand Down Expand Up @@ -5562,7 +5635,7 @@ async function handleDown({
});
}
return emitLifecycleResult({
result: errorResultFromUnknown({ error }),
result: buildLifecycleJsonErrorResult({ error }),
exitCode: 1,
});
}
Expand Down Expand Up @@ -5960,7 +6033,7 @@ async function handleRestart({
});
}
return emitLifecycleResult({
result: errorResultFromUnknown({ error }),
result: buildLifecycleJsonErrorResult({ error }),
exitCode: 1,
});
}
Expand Down
18 changes: 14 additions & 4 deletions src/lib/branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,11 @@ export async function touchBranchUsage(opts: {
return { updated: true, created, path: read.path };
}

export type EffectiveBranchSource = "explicit" | "worktree" | "none";
export type EffectiveBranchSource =
| "explicit"
| "worktree"
| "detached-worktree"
| "none";

export type EffectiveBranchResolution = {
/** Branch instance slug to target, or null for the base instance. */
Expand All @@ -178,8 +182,10 @@ export type EffectiveBranchResolution = {
* - otherwise, in a LINKED git worktree with `autoBranchEnabled` (config
* `worktree.auto_branch`, default true), default to the sanitized current
* git branch name (`source: "worktree"`)
* - primary checkouts, non-git paths, detached HEAD, and opted-out projects
* resolve to the base instance (`branch: null`, `source: "none"`)
* - a detached linked worktree is reported separately so callers can refuse
* an implicit base instance instead of colliding with the primary checkout
* - primary checkouts, non-git paths, and opted-out projects resolve to the
* base instance (`branch: null`, `source: "none"`)
*/
export async function resolveEffectiveBranch(opts: {
readonly explicitBranch: string | null;
Expand All @@ -206,7 +212,11 @@ export async function resolveEffectiveBranch(opts: {
repoRoot: opts.projectRoot,
});
if (!gitBranch) {
return { branch: null, source: "none", gitBranch: null };
return {
branch: null,
source: "detached-worktree",
gitBranch: null,
};
}

const slug = sanitizeBranchSlug(gitBranch);
Expand Down
Loading
Loading