From f2140de4c4dc534bbdb113fec013876425f157e4 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Sat, 14 Mar 2026 04:27:33 +0000 Subject: [PATCH 1/2] feat: harden runtime session and remote beta flows --- src/commands/daemon.ts | 10 +++-- src/commands/global.ts | 55 +++++---------------------- src/daemon/status.ts | 74 +++++++++++++++++++++++++++++++++++++ src/lib/config-paths.ts | 3 +- src/mcp/agent-docs.ts | 2 +- tests/daemon-status.test.ts | 70 ++++++++++++++++++++++++++++++++++- tests/project-views.test.ts | 40 ++++++++++++++++++++ 7 files changed, 203 insertions(+), 51 deletions(-) diff --git a/src/commands/daemon.ts b/src/commands/daemon.ts index de11b9d7..8adb097d 100644 --- a/src/commands/daemon.ts +++ b/src/commands/daemon.ts @@ -20,6 +20,7 @@ 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, @@ -412,9 +413,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; diff --git a/src/commands/global.ts b/src/commands/global.ts index 57bcf7e2..993253e7 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -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 { @@ -299,7 +303,12 @@ async function ensureDockerRunning(): Promise { 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", + }) ); } @@ -326,50 +335,6 @@ async function ensureDockerRunning(): Promise { 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 { - 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; diff --git a/src/daemon/status.ts b/src/daemon/status.ts index 2b25add9..371c8b33 100644 --- a/src/daemon/status.ts +++ b/src/daemon/status.ts @@ -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, }: { @@ -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"; +} diff --git a/src/lib/config-paths.ts b/src/lib/config-paths.ts index 1de6ad3e..9d65bf79 100644 --- a/src/lib/config-paths.ts +++ b/src/lib/config-paths.ts @@ -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); } diff --git a/src/mcp/agent-docs.ts b/src/mcp/agent-docs.ts index f8cecd00..4e503169 100644 --- a/src/mcp/agent-docs.ts +++ b/src/mcp/agent-docs.ts @@ -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 `.", "", - "Workspaces (mux-managed):", + "Workspaces (mux-managed, tmux-first by default):", "- Picker: `hack session` for persistent project workspaces.", "- Reuse/create: `hack session start `", "- Force isolated agent workspace: `hack session start --new --name agent-1` (`--agent-1`).", diff --git a/tests/daemon-status.test.ts b/tests/daemon-status.test.ts index 6a584fd2..a7b4ec9b 100644 --- a/tests/daemon-status.test.ts +++ b/tests/daemon-status.test.ts @@ -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({ @@ -94,3 +97,68 @@ 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, + apiOk: 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, + apiOk: 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, + apiOk: false, + }); + + const message = buildDaemonRepairMessage({ + report, + launchdStatus: null, + dockerBackendName: "Docker Desktop", + dockerReachable: false, + }); + + expect(message).toContain("Start Docker Desktop"); + expect(message).toContain("hack daemon start"); +}); diff --git a/tests/project-views.test.ts b/tests/project-views.test.ts index 16b3b907..e37287c1 100644 --- a/tests/project-views.test.ts +++ b/tests/project-views.test.ts @@ -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) { From cf8236cf73b3fae20d5a9460f1232f46e31dda81 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Mon, 23 Mar 2026 14:20:33 -0400 Subject: [PATCH 2/2] Fix daemon status CI regressions --- src/commands/daemon.ts | 10 ++++++++++ tests/config-paths.test.ts | 3 +-- tests/daemon-status.test.ts | 9 ++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/commands/daemon.ts b/src/commands/daemon.ts index 8adb097d..7b37e7b8 100644 --- a/src/commands/daemon.ts +++ b/src/commands/daemon.ts @@ -28,6 +28,10 @@ import { 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({ @@ -310,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({ @@ -323,6 +329,8 @@ async function handleDaemonStatus({ return reportDaemonStatus({ report, launchdStatus, + dockerBackendName: dockerBackend?.name ?? null, + dockerReachable: dockerStatus.reachable, }); } @@ -387,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") { diff --git a/tests/config-paths.test.ts b/tests/config-paths.test.ts index d14a4c76..444db2c7 100644 --- a/tests/config-paths.test.ts +++ b/tests/config-paths.test.ts @@ -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"; @@ -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) ); }); diff --git a/tests/daemon-status.test.ts b/tests/daemon-status.test.ts index a7b4ec9b..da2bee00 100644 --- a/tests/daemon-status.test.ts +++ b/tests/daemon-status.test.ts @@ -104,7 +104,8 @@ test("buildDaemonRepairMessage points stale state to daemon clear", () => { processRunning: false, socketExists: true, logExists: true, - apiOk: false, + apiReachable: false, + apiCompatible: false, }); const message = buildDaemonRepairMessage({ @@ -123,7 +124,8 @@ test("buildDaemonRepairMessage calls out launchd crashes and restart guidance", processRunning: false, socketExists: false, logExists: true, - apiOk: false, + apiReachable: false, + apiCompatible: false, }); const message = buildDaemonRepairMessage({ @@ -149,7 +151,8 @@ test("buildDaemonRepairMessage tells Docker Desktop users to start Docker first" processRunning: false, socketExists: false, logExists: false, - apiOk: false, + apiReachable: false, + apiCompatible: false, }); const message = buildDaemonRepairMessage({