Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5acf346
test(session): guard HUNK_SESSION_DAEMON_VERSION with a wire fixture
elucid Sep 11, 2026
5001b24
test(session): compare the wire fixture structurally
elucid Sep 11, 2026
ea1e942
feat(session): name the rejecting parser for refused registrations
elucid Sep 11, 2026
3379419
feat(session): warn when the daemon rejects a registration after the …
elucid Sep 11, 2026
5cb9139
feat(session-broker): add a revision-tolerant daemon admin scope
elucid Sep 11, 2026
7027772
feat(ui): show which side of a daemon build skew a window is on
elucid Sep 11, 2026
c5fff4e
feat(session): return a structured daemon-build-mismatch error to agents
elucid Sep 11, 2026
1568965
feat(cli): add hunk daemon status and hunk daemon restart
elucid Sep 11, 2026
0bec458
chore: changeset and fixture follow-ups for daemon version skew
elucid Sep 11, 2026
a2d5972
test(session): keep restart coverage honest under bun test --no-orphans
elucid Sep 11, 2026
9b1e4ed
docs(session-broker): describe the admin scope
elucid Sep 11, 2026
9a2afed
test(ui): cover sticky daemon notices alongside timed status
elucid Sep 11, 2026
8a38bb4
fix(session): let a waiting window outlast the incumbent daemon
elucid Sep 11, 2026
1ff5fc3
fix(session): say what actually clears a daemon build skew
elucid Sep 11, 2026
635a240
fix(session): unify daemon build-skew guidance
elucid Sep 11, 2026
1dd412c
chore(session): fold the skew wording note into one changeset
elucid Sep 11, 2026
1511ee7
test(ui): restore the competing-notice daemon coverage on StatusLine
elucid Sep 11, 2026
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
19 changes: 19 additions & 0 deletions .changeset/daemon-version-skew.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"hunkdiff": minor
---

Make a session daemon left over from a previous Hunk build visible and replaceable.

A window the daemon refuses now shows a sticky status-bar notice naming which side is older and
what to do: run `hunk daemon restart` when the daemon is the older build, or relaunch the window
when it is the newer one. A window whose registration the daemon rejects after the handshake gets
its own notice instead of silently reconnecting.

`hunk session` commands fail with a structured `daemon-build-mismatch` error that says which side
is older, counts the attached windows, and recommends an action; under `--json` it is returned
in-band with the build details.

New `hunk daemon status` reports the daemon's version, uptime, and attached windows, and new
`hunk daemon restart` replaces the daemon with one from the current build after confirming how
many windows that disconnects. Under `HUNK_DEBUG=1` the daemon logs which parser rejected a
registration.
5 changes: 5 additions & 0 deletions .changeset/launcher-prebuilt-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

npm installs now fall back to the bundled JavaScript runtime when the prebuilt binary cannot run on the machine, report signal exits as 128 + signal, and pick the native arm64 build under Rosetta.
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer ->
`--no-extensions`. See `docs/extension-architecture.md`, `docs/extensions.md`, and
`packages/hunk/skills/hunk-extensions/SKILL.md`.
- Sidecar file order is intentional sidebar and review-stream order.
- **Session wire:** the daemon and every window exchange `HUNK_SESSION_DAEMON_VERSION`
(`packages/hunk/src/session/protocol.ts`) in the signed hello and require an exact match. Any
change to what a session registers or snapshots — under `packages/hunk/src/session/**`,
`packages/hunk/src/core/reviewDescriptor.ts`, `packages/hunk/src/app/session/registration.ts`, or
anything else that appears in `packages/hunk/src/session/broker/fixtures/session-wire.v<N>.json`
— requires a bump. `wire.snapshot.test.ts` enforces it; bump, run
`bun run generate:session-wire`, and delete the previous fixture in the same change. The
cross-revision admin scope (`status`/`stop`) is frozen separately and never grows in place.
- Derive shared rendering, navigation, scrolling, and note behavior from one planning layer. Make
shared geometry explicit, and remove obsolete paths instead of retaining parallel implementations.

Expand Down
15 changes: 15 additions & 0 deletions docs/session-broker-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,21 @@ instances remain even across executable versions.
Implementations should retain the prior broker revision for at least one minor compatibility window
when safe. Removal requires golden migration tests and release notes.

### Admin scope

The daemon may expose a revision-tolerant admin scope (`packages/session-broker/src/admin.ts`) with
exactly two actions: `status` (daemon app revision, app version, pid, start time, uptime, and
attached sessions with the app revision each producer presented) and `stop` (graceful shutdown that
first closes attached producers with `"Session daemon restarting."`). It uses the ordinary signed
caller handshake and on-disk credentials against a second authenticator whose fixed contract is
`SESSION_BROKER_ADMIN_SCOPE_VERSION` in place of the app revision, served on its own paths
(`/session-admin/challenge`, `/session-admin/proof`, `/session-admin`). Caller sessions negotiated
there are unknown to the main authenticator and cannot reach the session API. The response schema is
frozen per scope version and is extended only by adding a new version. This is what lets a client
from another app revision explain a refused hello and replace an old daemon without signalling a
pid; a daemon that predates the scope refuses the admin hello, and callers fall back to launch
metadata.

## Runtime validation

Every external boundary receives `unknown`; TypeScript generics and schema IDs are not validators.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"stage:prebuilt:release": "bun run build:npm && bun run ./scripts/packaging/stage-prebuilt-npm.ts --artifact-root ./dist/release/artifacts",
"install:bin": "bun run ./scripts/build/install-bin.ts",
"generate:skill": "bun run ./scripts/generate/generate-skill.ts",
"generate:session-wire": "bun run ./scripts/generate/generate-session-wire.ts",
"generate:theme-colors": "bun run ./scripts/generate/generate-theme-diff-colors.ts",
"generate:docs": "bun run ./scripts/generate/generate-docs.ts",
"generate:changelog": "bun run ./scripts/generate/generate-changelog.ts",
Expand Down
1 change: 1 addition & 0 deletions packages/hunk/skills/hunk-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,4 +219,5 @@ Guidelines:
- **"Specify exactly one highlight target"** -- pass `highlight add` one of `--old-line` or `--new-line`.
- **"Highlight --end must be greater than --start"** -- offsets are `[start, end)` UTF-16 code units into the line text; end is exclusive.
- **"Specify either --next-comment or --prev-comment, not both."** -- choose one comment-navigation direction.
- **"The session daemon is ..."** -- a `daemon-build-mismatch` (the `--json` error carries `daemon`, `cli`, `attachedSessions`, and `recommendedAction`). Tell the user which build is newer and how many windows are attached, then **ask** before running `hunk daemon restart --yes`; never restart unprompted. After the restart, windows that failed to register attach on their own, so re-run `hunk session list` instead of relaunching anything. When `recommendedAction` is `use-newer-hunk`, the daemon is the newer build: use that Hunk instead.
- **"Could not read the raw diff for ..."** -- the session reloaded or closed while `--include-patch` was reading it. Re-run `review`; drop `--include-patch` if you only need file and hunk structure.
39 changes: 37 additions & 2 deletions packages/hunk/src/app/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1868,12 +1868,47 @@ describe("parseCli command help text", () => {
);
});

test("parses the daemon status and restart commands", async () => {
await expect(parseCli(["bun", "hunk", "daemon", "status"])).resolves.toEqual({
kind: "daemon-status",
output: "text",
});
await expect(parseCli(["bun", "hunk", "daemon", "status", "--json"])).resolves.toEqual({
kind: "daemon-status",
output: "json",
});
await expect(parseCli(["bun", "hunk", "daemon", "restart"])).resolves.toEqual({
kind: "daemon-restart",
output: "text",
yes: false,
});
await expect(
parseCli(["bun", "hunk", "daemon", "restart", "--yes", "--json"]),
).resolves.toEqual({ kind: "daemon-restart", output: "json", yes: true });
await expect(parseCli(["bun", "hunk", "daemon", "status", "--yes"])).rejects.toThrow(
"Unknown option for `hunk daemon status`: --yes",
);
await expect(parseCli(["bun", "hunk", "daemon", "stop"])).rejects.toThrow(
"Only `hunk daemon serve`, `hunk daemon status`, and `hunk daemon restart` are supported.",
);
});

test("renders the daemon overview and the daemon serve command help", async () => {
const overview = await expectHelp(["daemon"]);
expect(overview).toContain("Usage: hunk daemon serve");
expect(overview).toContain("Usage: hunk daemon <subcommand>");
expect(overview).toContain("hunk daemon serve");
expect(overview).toContain("hunk daemon status [--json]");
expect(overview).toContain("hunk daemon restart [--yes]");
expect(overview).toContain("HUNK_MCP_PORT");
expect(overview).toBe(await expectHelp(["daemon", "--help"]));

expect(await expectHelp(["daemon", "status", "--help"])).toContain(
"Usage: hunk daemon status [--json]",
);
const restartHelp = await expectHelp(["daemon", "restart", "--help"]);
expect(restartHelp).toContain("Usage: hunk daemon restart [--yes] [--json]");
expect(restartHelp).toContain("--yes");

expect(await expectHelp(["daemon", "serve", "--help"])).toContain(
"Run the local Hunk session daemon and websocket session broker.",
);
Expand Down Expand Up @@ -2070,7 +2105,7 @@ describe("parseCli argument validation", () => {
parseCli(["bun", "hunk", "skill", "path", "hunk-review", "extra"]),
).rejects.toThrow("`hunk skill path` accepts at most one skill name.");
await expect(parseCli(["bun", "hunk", "daemon", "bogus"])).rejects.toThrow(
"Only `hunk daemon serve` is supported.",
"Only `hunk daemon serve`, `hunk daemon status`, and `hunk daemon restart` are supported.",
);
await expect(parseCli(["bun", "hunk", "stash", "bogus"])).rejects.toThrow(
"Only `hunk stash show` is supported.",
Expand Down
82 changes: 78 additions & 4 deletions packages/hunk/src/app/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type PagerCommandInput,
type ParsedCliInput,
type SelfUpdateCommandInput,
type SessionCommandOutput,
type SessionCommentListType,
type SessionCommentApplyItemInput,
} from "../core/run/commandInputs";
Expand Down Expand Up @@ -363,6 +364,32 @@ export const CLI_REFERENCE_COMMANDS = {
synopsis: ["hunk daemon serve"],
aliases: ["hunk mcp serve"],
},
"daemon-status": {
path: "daemon status",
summary: "report the running session daemon's build, uptime, and attached windows",
synopsis: ["hunk daemon status [--json]"],
details: [
"After a Hunk upgrade, a daemon from the previous build keeps running while any window holds it open, and windows or `hunk session` commands from the new build cannot attach to it. `status` shows which build the daemon is, how it compares to this CLI, and which windows are attached; attached windows are marked `(older build)` when they could not reconnect to a daemon started from this CLI.",
"A daemon from a Hunk release before this command cannot report its build; `status` then shows what its launch metadata recorded.",
],
options: [{ flag: "--json", description: "print the status as JSON" }],
},
"daemon-restart": {
path: "daemon restart",
summary: "stop the running session daemon and start one from this Hunk build",
synopsis: ["hunk daemon restart [--yes] [--json]"],
details: [
"Prints the same summary as `status`, asks for confirmation, stops the daemon, and starts a replacement from this CLI's binary. Windows from this build that could not attach register with the replacement on their own; windows from the old build are disconnected and must be relaunched, which loses their in-window notes. The daemon is never replaced automatically.",
"A daemon from a Hunk release before this command cannot be asked to stop; `restart` then asks separately before sending SIGTERM to the pid its launch metadata recorded.",
],
options: [
{
flag: "--yes",
description: "skip the confirmation prompts (required when stdin is not a terminal)",
},
{ flag: "--json", description: "print the result as JSON" },
],
},
} as const satisfies Record<string, CliReferenceCommand>;

/** Validate one requested layout mode from CLI input. */
Expand Down Expand Up @@ -591,6 +618,8 @@ function renderCliHelp() {
" hunk extension <subcommand> install and manage shared extensions",
" hunk update [version] update Hunk with the package manager that installed it",
" hunk daemon serve run the local Hunk session daemon",
" hunk daemon status report the daemon's build and attached windows",
" hunk daemon restart replace the daemon with one from this build",
"",
"Global options:",
" -h, --help show help",
Expand Down Expand Up @@ -1181,6 +1210,8 @@ function requireReloadableCliInput(input: ParsedCliInput): CliInput {
input.kind === "help" ||
input.kind === "pager" ||
input.kind === "daemon-serve" ||
input.kind === "daemon-status" ||
input.kind === "daemon-restart" ||
input.kind === "markup-render" ||
input.kind === "markup-guide" ||
input.kind === "extension-manage" ||
Expand Down Expand Up @@ -2114,17 +2145,47 @@ async function parseUpdateCommand(
};
}

/** Parse `hunk daemon serve` as the canonical local daemon entrypoint. */
/** Build the help text for one daemon control subcommand from its reference entry. */
function renderDaemonControlHelp(command: "daemon-status" | "daemon-restart") {
const reference = CLI_REFERENCE_COMMANDS[command];
return (
[
`Usage: ${reference.synopsis[0]}`,
"",
`${reference.summary[0]!.toUpperCase()}${reference.summary.slice(1)}.`,
"",
"Options:",
...reference.options.map((option) => ` ${option.flag.padEnd(30)} ${option.description}`),
].join("\n") + "\n"
);
}

/** Parse the flags shared by `hunk daemon status` and `hunk daemon restart`. */
function parseDaemonControlFlags(subcommand: "status" | "restart", tokens: string[]) {
let output: SessionCommandOutput = "text";
let yes = false;
for (const token of tokens) {
if (token === "--json") output = "json";
else if (token === "--yes" && subcommand === "restart") yes = true;
else throw new Error(`Unknown option for \`hunk daemon ${subcommand}\`: ${token}`);
}
return { output, yes };
}

/** Parse `hunk daemon serve|status|restart`. */
async function parseDaemonCommand(tokens: string[]): Promise<ParsedCliInput> {
const [subcommand, ...rest] = tokens;
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
return {
kind: "help",
text:
[
"Usage: hunk daemon serve",
"Usage: hunk daemon <subcommand>",
"",
"Run the local Hunk session daemon and websocket session broker.",
"Subcommands:",
" hunk daemon serve run the local Hunk session daemon and websocket session broker",
" hunk daemon status [--json] report the daemon's build, uptime, and attached windows",
" hunk daemon restart [--yes] replace the daemon with one from this Hunk build",
"",
"Environment:",
" HUNK_MCP_HOST bind host (default 127.0.0.1; loopback only unless explicitly overridden)",
Expand All @@ -2134,8 +2195,21 @@ async function parseDaemonCommand(tokens: string[]): Promise<ParsedCliInput> {
};
}

if (subcommand === "status" || subcommand === "restart") {
const command = subcommand === "status" ? "daemon-status" : "daemon-restart";
if (rest.includes("--help") || rest.includes("-h")) {
return { kind: "help", text: renderDaemonControlHelp(command) };
}
const flags = parseDaemonControlFlags(subcommand, rest);
return command === "daemon-status"
? { kind: "daemon-status", output: flags.output }
: { kind: "daemon-restart", output: flags.output, yes: flags.yes };
}

if (subcommand !== "serve") {
throw new Error("Only `hunk daemon serve` is supported.");
throw new Error(
"Only `hunk daemon serve`, `hunk daemon status`, and `hunk daemon restart` are supported.",
);
}

if (rest.includes("--help") || rest.includes("-h")) {
Expand Down
9 changes: 9 additions & 0 deletions packages/hunk/src/app/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import type { AppBootstrap } from "./types";
import type {
CliInput,
DaemonControlCommandInput,
ExtensionCliInvocationInput,
ExtensionManageCommandInput,
HistoryCommandInput,
Expand Down Expand Up @@ -56,6 +57,10 @@ export type StartupPlan =
| {
kind: "daemon-serve";
}
| {
kind: "daemon-control";
input: DaemonControlCommandInput;
}
| {
kind: "session-command";
input: SessionCommandInput;
Expand Down Expand Up @@ -372,6 +377,10 @@ export async function prepareStartupPlan(
});
}

if (parsedCliInput.kind === "daemon-status" || parsedCliInput.kind === "daemon-restart") {
return await finishHeadlessPlan({ kind: "daemon-control", input: parsedCliInput });
}

if (parsedCliInput.kind === "session") {
const sessionInput =
"selector" in parsedCliInput
Expand Down
17 changes: 17 additions & 0 deletions packages/hunk/src/core/run/commandInputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,22 @@ export interface DaemonServeCommandInput {
kind: "daemon-serve";
}

/** `hunk daemon status`: report the running daemon's build and attached windows. */
export interface DaemonStatusCommandInput {
kind: "daemon-status";
output: SessionCommandOutput;
}

/** `hunk daemon restart`: stop the running daemon and start one from this CLI's build. */
export interface DaemonRestartCommandInput {
kind: "daemon-restart";
output: SessionCommandOutput;
/** Skip every confirmation prompt. Required when stdin is not a terminal. */
yes: boolean;
}

export type DaemonControlCommandInput = DaemonStatusCommandInput | DaemonRestartCommandInput;

export type SessionCommandOutput = "text" | "json";

export interface SessionSelectorInput {
Expand Down Expand Up @@ -422,6 +438,7 @@ export type ParsedCliInput =
| HelpCommandInput
| PagerCommandInput
| DaemonServeCommandInput
| DaemonControlCommandInput
| SessionCommandInput
| MarkupRenderCommandInput
| MarkupGuideCommandInput
Expand Down
Loading
Loading