Skip to content
Open
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
117 changes: 117 additions & 0 deletions src/mcp/daemonRecord.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import fs from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { resolveHunkMcpConfig, type ResolvedHunkMcpConfig } from "./config";

export interface HunkDaemonRecord {
pid: number;
startedAt: string;
instanceId: string;
}

function sanitizeHost(host: string) {
return host.replace(/[^a-z0-9_.:-]+/gi, "_");
}

function runtimeUserSuffix(env: NodeJS.ProcessEnv = process.env) {
if (typeof process.getuid === "function") {
return String(process.getuid());
}

return env.USER?.trim() || env.USERNAME?.trim() || "shared";
}

function resolveDaemonRuntimeDir(env: NodeJS.ProcessEnv = process.env) {
const configured = env.XDG_RUNTIME_DIR?.trim();
if (configured) {
return join(configured, "hunk");
}

return join(tmpdir(), `hunk-${runtimeUserSuffix(env)}`);
}

/** Resolve the per-user daemon record path for one host/port pair. */
export function resolveDaemonRecordPath(
config: ResolvedHunkMcpConfig = resolveHunkMcpConfig(),
env: NodeJS.ProcessEnv = process.env,
) {
return join(
resolveDaemonRuntimeDir(env),
`daemon-${sanitizeHost(config.host)}-${config.port}.json`,
);
}

function isDaemonRecord(value: unknown): value is HunkDaemonRecord {
if (!value || typeof value !== "object") {
return false;
}

const candidate = value as Record<string, unknown>;
return (
typeof candidate.pid === "number" &&
Number.isInteger(candidate.pid) &&
candidate.pid > 0 &&
typeof candidate.startedAt === "string" &&
candidate.startedAt.length > 0 &&
typeof candidate.instanceId === "string" &&
candidate.instanceId.length > 0
);
}

/** Read one previously persisted daemon record, if it looks valid. */
export function readDaemonRecord(
config: ResolvedHunkMcpConfig = resolveHunkMcpConfig(),
env: NodeJS.ProcessEnv = process.env,
) {
const path = resolveDaemonRecordPath(config, env);
if (!fs.existsSync(path)) {
return null;
}

try {
const parsed = JSON.parse(fs.readFileSync(path, "utf8")) as unknown;
return isDaemonRecord(parsed) ? parsed : null;
} catch {
return null;
}
}

/** Persist the currently running daemon's identity so sibling Hunk commands can verify it later. */
export function writeDaemonRecord(
record: HunkDaemonRecord,
config: ResolvedHunkMcpConfig = resolveHunkMcpConfig(),
env: NodeJS.ProcessEnv = process.env,
) {
const path = resolveDaemonRecordPath(config, env);
const directory = dirname(path);
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });

const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(record), { mode: 0o600 });
fs.renameSync(tempPath, path);
}

/** Remove the persisted daemon identity if it still belongs to the same daemon instance. */
export function clearDaemonRecord(
expected: Pick<HunkDaemonRecord, "pid" | "instanceId">,
config: ResolvedHunkMcpConfig = resolveHunkMcpConfig(),
env: NodeJS.ProcessEnv = process.env,
) {
const path = resolveDaemonRecordPath(config, env);
const current = readDaemonRecord(config, env);
if (!current) {
return;
}

if (current.pid !== expected.pid || current.instanceId !== expected.instanceId) {
return;
}

try {
fs.unlinkSync(path);
} catch (error) {
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
throw error;
}
}
}
17 changes: 16 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { randomUUID } from "node:crypto";
import { HUNK_SESSION_SOCKET_PATH, resolveHunkMcpConfig } from "./config";
import { clearDaemonRecord, writeDaemonRecord } from "./daemonRecord";
import { HunkDaemonState } from "./daemonState";
import type { SessionClientMessage } from "./types";
import {
Expand Down Expand Up @@ -158,6 +160,8 @@ export function serveHunkMcpServer() {
const config = resolveHunkMcpConfig();
const state = new HunkDaemonState();
const startedAt = Date.now();
const startedAtIso = new Date(startedAt).toISOString();
const instanceId = randomUUID();
let shuttingDown = false;

const sweepTimer = setInterval(() => {
Expand All @@ -178,7 +182,8 @@ export function serveHunkMcpServer() {
return Response.json({
ok: true,
pid: process.pid,
startedAt: new Date(startedAt).toISOString(),
startedAt: startedAtIso,
instanceId,
uptimeMs: Date.now() - startedAt,
sessionApi: `${config.httpOrigin}${HUNK_SESSION_API_PATH}`,
sessionCapabilities: `${config.httpOrigin}${HUNK_SESSION_CAPABILITIES_PATH}`,
Expand Down Expand Up @@ -252,6 +257,15 @@ export function serveHunkMcpServer() {
throw formatDaemonServeError(error, config.host, config.port);
}

writeDaemonRecord(
{
pid: process.pid,
startedAt: startedAtIso,
instanceId,
},
config,
);

const shutdown = () => {
if (shuttingDown) {
return;
Expand All @@ -262,6 +276,7 @@ export function serveHunkMcpServer() {
process.off("SIGINT", shutdown);
process.off("SIGTERM", shutdown);

clearDaemonRecord({ pid: process.pid, instanceId }, config);
state.shutdown();
server.stop(true);
};
Expand Down
66 changes: 57 additions & 9 deletions src/session/commands.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import type {
SessionCommandInput,
Expand All @@ -17,6 +18,7 @@ import {
waitForHunkDaemonHealth,
} from "../mcp/daemonLauncher";
import { resolveHunkMcpConfig } from "../mcp/config";
import { readDaemonRecord } from "../mcp/daemonRecord";
import type {
AppliedCommentResult,
ClearedCommentsResult,
Expand Down Expand Up @@ -214,6 +216,14 @@ class HttpHunkDaemonCliClient implements HunkDaemonCliClient {
}
}

interface HunkDaemonHealth {
ok: boolean;
pid?: number;
startedAt?: string;
instanceId?: string;
sessions?: number;
}

async function readDaemonHealth() {
const config = resolveHunkMcpConfig();

Expand All @@ -223,14 +233,52 @@ async function readDaemonHealth() {
return null;
}

return (await response.json()) as {
ok: boolean;
pid?: number;
sessions?: number;
};
return (await response.json()) as HunkDaemonHealth;
} catch {
return null;
}
}

function looksLikeHunkDaemonProcess(pid: number) {
try {
const tokens = readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean);
if (tokens.length < 2 || tokens.at(-2) !== "mcp" || tokens.at(-1) !== "serve") {
return false;
}

return tokens.some(
(token) =>
/(^|[\\/])hunk(?:\.[cm]?js)?$/i.test(token) || /(^|[\\/])src[\\/]main\.tsx$/i.test(token),
);
} catch {
return false;
}
}

function isTrustedManagedDaemon(health: HunkDaemonHealth | null) {
if (!health || typeof health.pid !== "number") {
return null;
}

const record = readDaemonRecord();
if (
record &&
record.pid === health.pid &&
record.startedAt === health.startedAt &&
record.instanceId === health.instanceId
) {
return record;
}

if (looksLikeHunkDaemonProcess(health.pid)) {
return {
pid: health.pid,
startedAt: health.startedAt ?? "",
instanceId: health.instanceId ?? "legacy-daemon",
};
}

return null;
}

async function waitForDaemonShutdown(timeoutMs = 3_000) {
Expand Down Expand Up @@ -290,16 +338,16 @@ async function restartDaemonForMissingAction(
selector?: SessionSelectorInput,
) {
const health = await readDaemonHealth();
const pid = health?.pid;
const trustedDaemon = isTrustedManagedDaemon(health);
const hadSessions = (health?.sessions ?? 0) > 0;
if (!pid || pid === process.pid) {
if (!trustedDaemon || trustedDaemon.pid === process.pid) {
throw new Error(
`The running Hunk session daemon is missing required support for ${action}. ` +
`The running Hunk session daemon is missing required support for ${action}, but Hunk could not verify that it owns the process on this port. ` +
`Restart Hunk so it can launch a fresh daemon from the current source tree.`,
);
}

process.kill(pid, "SIGTERM");
process.kill(trustedDaemon.pid, "SIGTERM");

const shutDown = await waitForDaemonShutdown();
if (!shutDown) {
Expand Down
76 changes: 76 additions & 0 deletions test/daemon-record.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
clearDaemonRecord,
readDaemonRecord,
resolveDaemonRecordPath,
writeDaemonRecord,
} from "../src/mcp/daemonRecord";
import type { ResolvedHunkMcpConfig } from "../src/mcp/config";

const originalRuntimeDir = process.env.XDG_RUNTIME_DIR;

const config: ResolvedHunkMcpConfig = {
host: "127.0.0.1",
port: 47657,
httpOrigin: "http://127.0.0.1:47657",
wsOrigin: "ws://127.0.0.1:47657",
};

afterEach(() => {
if (originalRuntimeDir === undefined) {
delete process.env.XDG_RUNTIME_DIR;
} else {
process.env.XDG_RUNTIME_DIR = originalRuntimeDir;
}
});

describe("daemon record", () => {
test("writes, reads, and clears one trusted daemon record", () => {
const runtimeDir = mkdtempSync(join(tmpdir(), "hunk-daemon-record-"));
process.env.XDG_RUNTIME_DIR = runtimeDir;

const record = {
pid: 4242,
startedAt: "2026-03-23T00:00:00.000Z",
instanceId: "instance-1",
};

try {
expect(readDaemonRecord(config)).toBeNull();

writeDaemonRecord(record, config);
expect(readDaemonRecord(config)).toEqual(record);

clearDaemonRecord({ pid: 9999, instanceId: "other-instance" }, config);
expect(readDaemonRecord(config)).toEqual(record);

clearDaemonRecord({ pid: record.pid, instanceId: record.instanceId }, config);
expect(readDaemonRecord(config)).toBeNull();
} finally {
rmSync(runtimeDir, { recursive: true, force: true });
}
});

test("separates daemon records by host and port", () => {
const runtimeDir = mkdtempSync(join(tmpdir(), "hunk-daemon-record-paths-"));
process.env.XDG_RUNTIME_DIR = runtimeDir;

try {
const first = resolveDaemonRecordPath(config);
const second = resolveDaemonRecordPath({
...config,
host: "localhost",
port: 47658,
httpOrigin: "http://localhost:47658",
wsOrigin: "ws://localhost:47658",
});

expect(first).not.toBe(second);
} finally {
rmSync(runtimeDir, { recursive: true, force: true });
}
});
});
11 changes: 10 additions & 1 deletion test/mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,23 @@ describe("Hunk session daemon server", () => {
}
});

test("exposes session capabilities and rejects the old MCP tool endpoint", async () => {
test("exposes health + session capabilities and rejects the old MCP tool endpoint", async () => {
const port = await reserveLoopbackPort();
process.env.HUNK_MCP_HOST = "127.0.0.1";
process.env.HUNK_MCP_PORT = String(port);

const server = serveHunkMcpServer();

try {
const health = await fetch(`http://127.0.0.1:${port}/health`);
expect(health.status).toBe(200);
await expect(health.json()).resolves.toMatchObject({
ok: true,
pid: process.pid,
startedAt: expect.any(String),
instanceId: expect.any(String),
});

const capabilities = await fetch(`http://127.0.0.1:${port}/session-api/capabilities`);
expect(capabilities.status).toBe(200);
await expect(capabilities.json()).resolves.toMatchObject({
Expand Down
Loading