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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.11.7] - 2026-08-23

### Added

- `POST /api/upgrade`: a remote client can trigger the hub's own staleness
check; the hub restarts onto the on-disk code only when it judges that code
newer than itself (a newer `package.json` version frozen-at-start
comparison, or any `dist/**/*.js` mtime later than process start — so a
rebuild without a version bump counts). The client never decides the
restart. The daemon re-spawns itself from disk before exiting; bridges
re-register on their next heartbeat, and a respawned hub starts after the
newest dist mtime so the check cannot loop.

## [0.11.6] - 2026-08-23

### Added
Expand Down
28 changes: 28 additions & 0 deletions docs/REMOTE-CLIENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ ACP editor ────── stdio ──────────┘
| `GET /api/instances/{id}/status` | required | Real-time per-session running status of one bridge. |
| `POST /api/instances/{id}/sessions/{sessionId}/close` | required | Retire a session from remote discovery — see [Closing a session](#closing-a-session). |
| `GET /api/quota` | required | Account-level usage stats — same payload as `account/usage_stats`, no ACP connection needed. |
| `POST /api/upgrade` | required | Trigger the hub's own staleness check — see [Hub self-upgrade](#hub-self-upgrade). |

HTTP auth: `Authorization: Bearer <token>` or `?token=<token>`.

Expand Down Expand Up @@ -291,6 +292,33 @@ Cross-instance note: if the same conversation is also registered by another
bridge of the project, the hub's dedupe re-attaches it under that instance —
close it there too.

## Hub self-upgrade

```text
POST {hub}/api/upgrade → 200 { "ok": true, "restarting": false, "reason": "up-to-date",
"runningVersion": "0.11.6", "diskVersion": "0.11.6" }
```

Lets a remote client pick up a hub that was rebuilt on the machine (e.g. code
edited and `pnpm build` run through a remote agent session). The client only
**triggers** the check — the restart decision is entirely the hub's own. The
hub restarts onto the on-disk code only when it judges that code NEWER than
itself, by either signal:

- the on-disk `package.json` version is newer than the version frozen into
the running process at start, **or**
- any `.js` under `dist/` has an mtime later than process start (a rebuild,
even without a version bump).

When `restarting` is `true`, the hub exits ~500ms after replying, re-spawns
itself from the on-disk dist, and bridges re-register on their next heartbeat
(≤10s). Poll `GET /api/health` until it answers again, then refresh
`/api/instances` and reconnect. A respawned hub starts after the newest dist
mtime, so the condition self-negates — no restart loops, and an OLDER on-disk
version never triggers anything.

Errors: `401` bad token. `GET` (or any other method) falls through to `404`.

## Session files (read-only)

Browse and download files of a session's project — served by the bridge,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zcode-acp-server",
"version": "0.11.6",
"version": "0.11.7",
"description": "Agent Client Protocol (ACP) server bridging headless ZCode to editors like Zed and JetBrains.",
"type": "module",
"license": "Apache-2.0",
Expand Down
31 changes: 30 additions & 1 deletion src/bin/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,39 @@
* hub already owns the port, which is the desired machine-singleton behaviour.
*/

import { spawn } from "node:child_process";
import process from "node:process";
import { fileURLToPath } from "node:url";

import { parseHubConfig } from "../remote/config.js";
import { startHub } from "../remote/hub-server.js";
import { warn } from "../utils.js";
import { log, warn } from "../utils.js";

/**
* Re-exec the hub from the on-disk dist (the freshest build) and exit. Runs
* after close() released the port, so the child binds cleanly; bridges racing
* to re-spawn the hub lose politely via the EADDRINUSE singleton behaviour.
* stdio is fully ignored — the parent exits immediately, and a piped stderr
* would EPIPE the child on its first log line.
*/
function respawnSelf(): void {
try {
// bin/hub.js → ../bin/hub.js is itself (this file's compiled location).
const hubJs = fileURLToPath(new URL("../bin/hub.js", import.meta.url));
const child = spawn(process.execPath, [hubJs], {
detached: true,
stdio: ["ignore", "ignore", "ignore"],
});
child.unref();
log(`hub: respawned from ${hubJs} (pid ${child.pid})`);
} catch (e) {
warn(
`hub: respawn failed (${e instanceof Error ? e.message : String(e)})` +
" — bridges will re-spawn the hub on their next heartbeat",
);
}
process.exit(0);
}

async function main(): Promise<void> {
const config = parseHubConfig();
Expand All @@ -28,6 +56,7 @@ async function main(): Promise<void> {
host: config.hubHost,
token: config.token,
onIdleExit: () => process.exit(0),
onRestart: respawnSelf,
});
process.on("SIGTERM", () => void hub.close().then(() => process.exit(0)));
process.on("SIGINT", () => void hub.close().then(() => process.exit(0)));
Expand Down
147 changes: 142 additions & 5 deletions src/remote/hub-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
* plain-HTTP conveniences that spare clients a full ACP round-trip (ADR-0005):
* a proxied per-instance /status, and an account-level /api/quota queried
* directly (quota belongs to the machine's credentials, not to any instance).
* POST /api/upgrade lets a client TRIGGER a self-decided restart: the hub
* re-checks whether the on-disk build is newer than the running process and,
* only if so, re-spawns itself onto it — the decision is never the client's.
* It holds no session state and understands no ACP — a proxied connection
* stays bound to one instance for its whole lifetime.
*
Expand All @@ -21,6 +24,8 @@
*/

import { createHash, timingSafeEqual } from "node:crypto";
import type { Dirent } from "node:fs";
import { readdir, readFile, stat } from "node:fs/promises";
import {
createServer,
get as httpGet,
Expand All @@ -30,6 +35,8 @@ import {
type ServerResponse,
} from "node:http";
import net from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { WebSocket, WebSocketServer, type RawData } from "ws";

Expand All @@ -46,6 +53,19 @@ export interface HubOptions {
idleExitMs?: number;
/** WebSocket keepalive ping interval (default 30s; tunnels drop idle links). */
pingIntervalMs?: number;
/**
* Fires when the hub decided it should restart onto newer on-disk code
* (a newer bridge registered, or POST /api/upgrade found the dist newer).
* The standalone daemon re-spawns a replacement before exiting (see
* bin/hub.ts); falls back to onIdleExit when unset.
*/
onRestart?: () => void;
/**
* Override the on-disk locations /api/upgrade checks against (tests point
* these at fixtures). Defaults: this package's package.json and the dist
* directory this module runs from.
*/
codePaths?: { packageJson: string; distDir: string };
}

export interface HubHandle {
Expand Down Expand Up @@ -197,6 +217,81 @@ function portOpen(port: number, timeoutMs: number): Promise<boolean> {
});
}

/**
* Where /api/upgrade looks for on-disk code: root package.json + dist/.
*/
function defaultCodePaths(): { packageJson: string; distDir: string } {
// dist/remote/hub-server.js → ../../package.json and dist/ (src/ in dev
// has no .js files, so the mtime signal simply stays silent there).
return {
packageJson: fileURLToPath(new URL("../../package.json", import.meta.url)),
distDir: fileURLToPath(new URL("../", import.meta.url)),
};
}

/** Newest mtime among .js files under dir (recursive); null when none found. */
async function newestJsMtime(dir: string): Promise<number | null> {
let newest: number | null = null;
const walk = async (current: string): Promise<void> => {
let entries: Dirent[];
try {
entries = await readdir(current, { withFileTypes: true });
} catch {
return; // unreadable subtree — skip it, don't fail the check
}
for (const entry of entries) {
const p = path.join(current, entry.name);
if (entry.isDirectory()) await walk(p);
else if (entry.isFile() && entry.name.endsWith(".js")) {
try {
// Floor to whole ms: APFS mtimes carry sub-ms fractions while
// Date.now() truncates, and a same-ms write/start pair would
// otherwise look "newer" than a hub started after it.
const mtime = Math.floor((await stat(p)).mtimeMs);
if (newest === null || mtime > newest) newest = mtime;
} catch {
/* raced deletion */
}
}
}
};
await walk(dir);
return newest;
}

/**
* /api/upgrade staleness check: is the code on DISK newer than this running
* process? Either signal suffices — the on-disk package.json version beats
* the version frozen into this process at start (a release upgrade), or any
* .js under dist was written after process start (a rebuild, even without a
* version bump). A respawned process starts after the newest dist mtime, so
* the condition self-negates: no restart loops.
*/
async function diskCodeIsNewer(
paths: { packageJson: string; distDir: string },
startedAt: number,
): Promise<{
newer: boolean;
reason: "version" | "mtime" | "up-to-date";
diskVersion: string | null;
}> {
let diskVersion: string | null = null;
try {
const pkg = JSON.parse(await readFile(paths.packageJson, "utf8")) as { version?: unknown };
if (typeof pkg.version === "string") diskVersion = pkg.version;
} catch {
/* unreadable package.json — the mtime signal still applies */
}
if (diskVersion && compareVersions(diskVersion, AGENT_INFO.version) > 0) {
return { newer: true, reason: "version", diskVersion };
}
const newest = await newestJsMtime(paths.distDir);
if (newest !== null && newest > startedAt) {
return { newer: true, reason: "mtime", diskVersion };
}
return { newer: false, reason: "up-to-date", diskVersion };
}

/**
* Start the hub. Resolves once listening; rejects on bind failure (including
* EADDRINUSE when another hub already owns the port).
Expand All @@ -210,12 +305,30 @@ export function startHub(options: HubOptions & { onIdleExit?: () => void }): Pro
idleExitMs = IDLE_EXIT_MS,
pingIntervalMs = PING_INTERVAL_MS,
onIdleExit,
onRestart,
codePaths = defaultCodePaths(),
} = options;

/** Frozen at hub start — the anchor the /api/upgrade signals compare to. */
const startedAt = Date.now();

const instances = new Map<string, InstanceEntry>();
const proxyPairs = new Set<{ client: WebSocket; bridge: WebSocket }>();
const timers: Array<ReturnType<typeof setInterval>> = [];

/**
* Reply first, then gracefully stop (close() releases the port) and hand
* over. `close` is declared below and hoisted — it only runs inside the
* timer, long after this scope is fully initialised.
*/
const restartSoon = (message: string): void => {
log(message);
const restart = setTimeout(() => {
void close().finally(() => (onRestart ?? onIdleExit)?.());
}, 500);
restart.unref();
};

let idleSince: number | null = null;

const wss = new WebSocketServer({ noServer: true });
Expand Down Expand Up @@ -362,6 +475,34 @@ export function startHub(options: HubOptions & { onIdleExit?: () => void }): Pro
}
return;
}
// POST /api/upgrade — a remote client may TRIGGER a staleness check but
// never decide the restart: the hub compares its frozen running version
// and process start time against the on-disk package.json and dist
// mtimes, and only restarts onto code it judged newer (diskCodeIsNewer).
if (url.pathname === "/api/upgrade" && req.method === "POST") {
if (!authorized(req, url, token)) {
res.writeHead(401, { "Content-Type": "text/plain" });
res.end("unauthorized");
return;
}
const check = await diskCodeIsNewer(codePaths, startedAt);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
ok: true,
restarting: check.newer,
reason: check.reason,
runningVersion: AGENT_INFO.version,
diskVersion: check.diskVersion,
}),
);
if (check.newer) {
restartSoon(
`hub: on-disk code is newer (${check.reason}: ${check.diskVersion ?? "rebuilt dist"}) — restarting onto it`,
);
}
return;
}
// /api/instances/{id}/fs/... and /status — byte-level proxy to the
// instance's loopback file/status endpoint (ADR-0004, ADR-0005). The hub
// routes by instance id only; sessionId, path semantics, and scope checks
Expand Down Expand Up @@ -499,13 +640,9 @@ export function startHub(options: HubOptions & { onIdleExit?: () => void }): Pro
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(stale ? { ok: true, restarting: true } : { ok: true }));
if (stale) {
log(
restartSoon(
`hub: bridge ${body.version} is newer than hub ${AGENT_INFO.version} — restarting to upgrade`,
);
const restart = setTimeout(() => {
void close().finally(() => onIdleExit?.());
}, 500);
restart.unref();
}
return;
}
Expand Down
Loading
Loading