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
20 changes: 17 additions & 3 deletions src/commands/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,18 @@ import { type DaemonPaths, resolveDaemonPaths } from "../daemon/paths.ts";
import { removeFileIfExists, waitForProcessExit } from "../daemon/process.ts";
import { runDaemon } from "../daemon/server.ts";
import {
buildDaemonRepairMessage,
buildDaemonStatusReport,
type DaemonStatusReport,
readDaemonStatus,
} from "../daemon/status.ts";
import { updateGlobalConfig } from "../lib/config.ts";
import { pathExists, readTextFile } from "../lib/fs.ts";
import { resolveHackInvocation } from "../lib/hack-cli.ts";
import {
buildDockerStatusProbe,
detectDockerBackend,
} from "../lib/runtime-guidance.ts";
import { logger } from "../ui/logger.ts";

const optForeground = defineOption({
Expand Down Expand Up @@ -309,6 +314,8 @@ async function handleDaemonStatus({
apiCompatible: api.compatible,
});
const launchdStatus = await resolveLaunchdStatus({ paths });
const dockerBackend = await detectDockerBackend();
const dockerStatus = await buildDockerStatusProbe();

if (args.options.json) {
outputDaemonStatusJson({
Expand All @@ -322,6 +329,8 @@ async function handleDaemonStatus({
return reportDaemonStatus({
report,
launchdStatus,
dockerBackendName: dockerBackend?.name ?? null,
dockerReachable: dockerStatus.reachable,
});
}

Expand Down Expand Up @@ -386,6 +395,8 @@ function outputDaemonStatusJson(opts: {
function reportDaemonStatus(opts: {
readonly report: DaemonStatusReport;
readonly launchdStatus: LaunchdServiceStatus | null;
readonly dockerBackendName: string | null;
readonly dockerReachable: boolean;
}): number {
const { report, launchdStatus } = opts;
if (report.status === "running") {
Expand All @@ -412,9 +423,12 @@ function reportDaemonStatus(opts: {
}

logger.warn({
message: report.stale
? `hackd stopped (stale state detected; run \`${report.nextStep}\`)`
: "hackd is not running",
message: buildDaemonRepairMessage({
report,
launchdStatus,
dockerBackendName: opts.dockerBackendName,
dockerReachable: opts.dockerReachable,
}),
});
logLaunchdStatus({ launchdStatus, running: false });
return 1;
Expand Down
55 changes: 10 additions & 45 deletions src/commands/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ import {
reconcileRemoteCaddyRoutesStack,
stopRemoteCaddyRoutesStack,
} from "../lib/remote-caddy-routes.ts";
import {
detectDockerBackend,
formatDockerConnectionGuidance,
} from "../lib/runtime-guidance.ts";
import { exec, execOrThrow, findExecutableInPath, run } from "../lib/shell.ts";
import { resolveSessionsMuxMode } from "../mux/mux-config.ts";
import {
Expand Down Expand Up @@ -299,7 +303,12 @@ async function ensureDockerRunning(): Promise<void> {
const backend = await detectDockerBackend();
if (!backend) {
throw new Error(
"Docker does not seem to be running and no Docker backend was detected.\nInstall Docker Desktop or OrbStack, then retry."
formatDockerConnectionGuidance({
backend,
failureText:
"Docker does not seem to be running and no Docker backend was detected.",
retryCommand: "hack global install",
})
);
}

Expand All @@ -326,50 +335,6 @@ async function ensureDockerRunning(): Promise<void> {

logger.success({ message: `${backend.name} is running` });
}

type DockerBackend = {
readonly name: string;
readonly startCommand: readonly string[];
};

/**
* Detects the installed Docker backend on macOS (OrbStack, Docker Desktop)
* or checks for the docker socket on Linux.
*/
async function detectDockerBackend(): Promise<DockerBackend | null> {
if (isMac()) {
if (await pathExists("/Applications/OrbStack.app")) {
const hasOrbctl = await findExecutableInPath("orbctl");
return {
name: "OrbStack",
startCommand: hasOrbctl
? ["orbctl", "start"]
: ["open", "-a", "OrbStack"],
};
}
if (await pathExists("/Applications/Docker.app")) {
return {
name: "Docker Desktop",
startCommand: ["open", "-a", "Docker"],
};
}
return null;
}

const hasDocker = await findExecutableInPath("docker");
if (hasDocker) {
const hasSystemctl = await findExecutableInPath("systemctl");
if (hasSystemctl) {
return {
name: "Docker (systemd)",
startCommand: ["sudo", "systemctl", "start", "docker"],
};
}
}

return null;
}

async function waitForDocker(opts: {
readonly timeoutMs: number;
readonly intervalMs: number;
Expand Down
74 changes: 74 additions & 0 deletions src/daemon/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ export interface DaemonStatusReport {
readonly nextStep: string | null;
}

type DaemonRepairLaunchdStatus = {
readonly installed: boolean;
readonly loaded: boolean;
readonly running: boolean;
readonly pid: number | null;
readonly exitStatus: number | null;
};

export async function readDaemonStatus({
paths,
}: {
Expand Down Expand Up @@ -166,3 +174,69 @@ export function buildDaemonStatusReport(opts: {
nextStep: "hack daemon start",
};
}

export function buildDaemonRepairMessage(opts: {
readonly report: DaemonStatusReport;
readonly launchdStatus: DaemonRepairLaunchdStatus | null;
readonly dockerBackendName: string | null;
readonly dockerReachable: boolean;
}): string {
if (opts.report.status === "running") {
return `hackd running (pid ${opts.report.pid ?? "unknown"})`;
}

if (opts.report.status === "starting") {
return `hackd starting (pid ${
opts.report.pid ?? "unknown"
}): API not responding yet | If this persists, check: hack daemon logs --tail 200`;
}

if (opts.report.stale) {
return "hackd stopped with stale local state | Run: hack daemon clear | Then run: hack daemon start";
}

const crashExitStatus = opts.launchdStatus?.loaded
? opts.launchdStatus.exitStatus
: null;
const startCommand =
opts.launchdStatus?.loaded || crashExitStatus !== null
? "hack daemon restart"
: "hack daemon start";
const segments = ["hackd is not running"];

if (crashExitStatus !== null && crashExitStatus !== 0) {
segments.push(`launchd last exit status ${crashExitStatus}`);
}

if (!opts.dockerReachable) {
segments.push(
buildDockerStartHint({ backendName: opts.dockerBackendName })
);
}

segments.push(`Run: ${startCommand}`);

if (crashExitStatus !== null && crashExitStatus !== 0) {
segments.push("Then check: hack daemon logs --tail 200");
}

return segments.join(" | ");
}

function buildDockerStartHint(opts: {
readonly backendName: string | null;
}): string {
if (opts.backendName === "Docker Desktop") {
return "Start Docker Desktop";
}

if (opts.backendName === "OrbStack") {
return "Start OrbStack";
}

if (opts.backendName === "Docker (systemd)") {
return "Start the Docker system service";
}

return "Start Docker";
}
3 changes: 2 additions & 1 deletion src/lib/config-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ export function resolveGlobalConfigPath(): string {
if (override.length > 0) {
return override;
}
return resolve(homedir(), GLOBAL_HACK_DIR_NAME, GLOBAL_CONFIG_FILENAME);
const home = (process.env.HOME ?? homedir()).trim();
return resolve(home, GLOBAL_HACK_DIR_NAME, GLOBAL_CONFIG_FILENAME);
}
2 changes: 1 addition & 1 deletion src/mcp/agent-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export function renderAgentDocsSnippet(): string {
"- Use `lifecycle.up.before` for pre-start hooks and `lifecycle.processes` for long-running host tasks.",
"- Inspect lifecycle status via `hack projects --details` and stream via `hack logs <service-or-process>`.",
"",
"Workspaces (mux-managed):",
"Workspaces (mux-managed, tmux-first by default):",
"- Picker: `hack session` for persistent project workspaces.",
"- Reuse/create: `hack session start <project>`",
"- Force isolated agent workspace: `hack session start <project> --new --name agent-1` (`<project>--agent-1`).",
Expand Down
3 changes: 1 addition & 2 deletions tests/config-paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { afterEach, expect, test } from "bun:test";
import { homedir } from "node:os";
import { resolve } from "node:path";

import { resolveGlobalConfigPath } from "@lib/config-paths.ts";
Expand Down Expand Up @@ -27,7 +26,7 @@ test("resolveGlobalConfigPath prefers HOME when no explicit override is set", ()
process.env.HOME = "/tmp/hack-home";

expect(resolveGlobalConfigPath()).toBe(
resolve(homedir(), GLOBAL_HACK_DIR_NAME, GLOBAL_CONFIG_FILENAME)
resolve("/tmp/hack-home", GLOBAL_HACK_DIR_NAME, GLOBAL_CONFIG_FILENAME)
);
});

Expand Down
73 changes: 72 additions & 1 deletion tests/daemon-status.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { expect, test } from "bun:test";

import { buildDaemonStatusReport } from "../src/daemon/status.ts";
import {
buildDaemonRepairMessage,
buildDaemonStatusReport,
} from "../src/daemon/status.ts";

test("buildDaemonStatusReport marks running when API is reachable", () => {
const report = buildDaemonStatusReport({
Expand Down Expand Up @@ -94,3 +97,71 @@ test("buildDaemonStatusReport marks incompatible daemon with guided restart", ()
expect(report.nextStep).toBe("hack daemon restart");
expect(report.stale).toBe(false);
});

test("buildDaemonRepairMessage points stale state to daemon clear", () => {
const report = buildDaemonStatusReport({
pid: 123,
processRunning: false,
socketExists: true,
logExists: true,
apiReachable: false,
apiCompatible: false,
});

const message = buildDaemonRepairMessage({
report,
launchdStatus: null,
dockerBackendName: null,
dockerReachable: true,
});

expect(message).toContain("hack daemon clear");
});

test("buildDaemonRepairMessage calls out launchd crashes and restart guidance", () => {
const report = buildDaemonStatusReport({
pid: null,
processRunning: false,
socketExists: false,
logExists: true,
apiReachable: false,
apiCompatible: false,
});

const message = buildDaemonRepairMessage({
report,
launchdStatus: {
installed: true,
loaded: true,
running: false,
pid: null,
exitStatus: 78,
},
dockerBackendName: null,
dockerReachable: true,
});

expect(message).toContain("last exit status 78");
expect(message).toContain("hack daemon restart");
});

test("buildDaemonRepairMessage tells Docker Desktop users to start Docker first", () => {
const report = buildDaemonStatusReport({
pid: null,
processRunning: false,
socketExists: false,
logExists: false,
apiReachable: false,
apiCompatible: false,
});

const message = buildDaemonRepairMessage({
report,
launchdStatus: null,
dockerBackendName: "Docker Desktop",
dockerReachable: false,
});

expect(message).toContain("Start Docker Desktop");
expect(message).toContain("hack daemon start");
});
40 changes: 40 additions & 0 deletions tests/project-views.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,46 @@ test("buildProjectViews includes matching project sessions from tmux", async ()
expect(serializedSessions?.[0]?.source).toBe("hack");
});

test("buildProjectViews treats double-dash and legacy colon hack sessions as project sessions", async () => {
const alpha = await createProject({ name: "alpha", services: ["api"] });

const views = await buildProjectViews({
registryProjects: [alpha],
runtime: [],
runtimeOk: true,
filter: null,
includeUnregistered: false,
muxSessions: [
{
name: "alpha--agent-1",
backend: "tmux",
attached: false,
path: null,
windows: 1,
createdAt: 1_735_000_100,
},
{
name: "alpha:agent-legacy",
backend: "tmux",
attached: false,
path: null,
windows: 1,
createdAt: 1_735_000_101,
},
],
});

const alphaView = views.find((view) => view.name === "alpha");
expect(alphaView?.sessions.map((session) => session.name)).toEqual([
"alpha--agent-1",
"alpha:agent-legacy",
]);
expect(alphaView?.sessions.map((session) => session.source)).toEqual([
"hack",
"hack",
]);
});

test("buildProjectViews matches tmux sessions when path is a symlink to repo root", async () => {
const alpha = await createProject({ name: "alpha", services: ["api"] });
if (!tempDir) {
Expand Down
Loading