Skip to content

Commit 1823443

Browse files
committed
Add Linux systemd and Windows Scheduled Task supervisors so self-update works on all three platforms
1 parent 65b06a0 commit 1823443

8 files changed

Lines changed: 1015 additions & 150 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,7 @@ Mobile remote control for **desktop coding agents** (Claude Code, Codex, Gemini
6969
- **The EAS iOS build image is pinned** (`eas.json`) — Xcode 26.0 desyncs `react-native-keyboard-controller`'s per-frame numbers, since UIKit gates on the SDK built against. Never `auto`/`latest`; if the keyboard is off again, suspect this line first.
7070
- **Shipping an `.ipa` is `packages/app/scripts/build-ipa.sh`**`eas build --local` runs the same pipeline on this Mac in ~3 min and costs no build credits, where the cloud build is metered and queues. It leaves one `pew2-build<N>.ipa` on the Desktop for Transporter, deletes the previous one *after* a successful build, and refuses to hand over anything signed `get-task-allow=true` (a development profile uploads fine, then gets rejected by email hours later). Still needs an Expo login even offline of the cloud builder: `appVersionSource: remote` means the build number is incremented server-side, so an anonymous run dies after installing pods. `release-to-device.sh` is the other half — dev-signed, for reproducing Release behaviour on a plugged-in phone, never uploadable.
7171
- **No Expo account is committed** (`app.config.js`) — identity layers in from `EAS_OWNER`/`EAS_PROJECT_ID` or a gitignored `eas-project.json`, so push degrades to local-only banners on a fresh clone instead of breaking.
72+
- **Self-update is a swap plus an exit, and the exit is the dangerous half** (`update/`). `check.ts` reads the latest release tag, `apply.ts` downloads it, verifies the published `.sha256` and *renames* it over `process.execPath`, `scheduler.ts` exits so a supervisor relaunches on the new inode. Three rules: the staging file is written **beside the target** (rename is only atomic within one filesystem, and `os.tmpdir()` is routinely another volume); it exits **only when `daemon.busyReason()` is undefined** (never mid-turn, never with a permission on screen); and it refuses unless `isCompiled()` — under `bun run`, `process.execPath` is the developer's **`bun`** binary, and a rename would replace the runtime every other project on that machine depends on.
73+
- **A supervisor is what makes exit-to-update legal, and it is per-machine, not per-platform** (`cli/service*.ts`). All three have one — launchd `KeepAlive`, systemd `Restart=always`, and a Scheduled Task whose repeating trigger plus `IgnoreNew` policy is the same thing at one-minute granularity. Each must restart after a **clean** exit: `Restart=on-failure` and Task Scheduler's `RestartOnFailure` both ignore exit 0, which is exactly how the updater ends the daemon, so either would swap the binary and never come back. The gate is `supervisorInstalled()` — the service *file on disk*, not `process.platform`, because `pew2 serve` is a first-class command and a fresh install has no service until `pew2 setup` writes one. Windows additionally cannot unlink or replace a running `.exe`, only rename it, so the outgoing binary is parked aside and restored if the swap then fails. systemd needs `loginctl enable-linger` or the daemon dies at logout.
7274
- **`pew2 setup --json` / `doctor --json` are the agent-facing surface**`ok` is the stop condition, each problem carries a stable `id` and runnable `fix`, and missing secrets or LAN-only are `warning`, never `error`, so an agent can't loop on what it can't supply.
7375
<!-- gg:init:end -->
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/**
2+
* Keeping the daemon alive on Windows, via a Scheduled Task.
3+
*
4+
* Windows has a real service manager, and it is the wrong tool here: a true
5+
* service needs Administrator to register, runs in session 0, and would spawn
6+
* every coding agent outside the user's desktop session and away from their
7+
* environment. Task Scheduler registers per-user with no elevation, which is
8+
* what an install script run from an ordinary shell can actually do.
9+
*
10+
* ## How a task becomes a keepalive
11+
*
12+
* Task Scheduler has no `KeepAlive`. `RestartOnFailure` is not it either: that
13+
* fires when an action *fails*, and the self-updater ends the daemon with exit
14+
* 0, which is a success. Under that setting an update would swap the binary,
15+
* exit cleanly, and never come back.
16+
*
17+
* So the restart is built from two settings that do apply, and the combination
18+
* is the whole trick:
19+
*
20+
* - a **logon trigger with a repetition** of one minute, so Windows attempts to
21+
* start the task continuously, for ever; and
22+
* - **`MultipleInstancesPolicy: IgnoreNew`**, which does not start a new
23+
* instance while one is already running.
24+
*
25+
* Together they mean: while the daemon is up, every attempt is ignored; the
26+
* moment it exits, the next attempt starts it. That is a poll rather than a
27+
* supervisor, so the worst-case gap is the repetition interval — a minute,
28+
* against launchd's ten-second throttle. Acceptable for an update restart, and
29+
* the reason `update/scheduler.ts` only ever exits when the daemon is idle.
30+
*
31+
* `ExecutionTimeLimit: PT0S` disables the three-day default kill, which would
32+
* otherwise terminate a long-lived daemon on a schedule.
33+
*/
34+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
35+
import { homedir } from "node:os";
36+
import { dirname, join } from "node:path";
37+
import { logDir } from "../logs.js";
38+
import {
39+
LABEL,
40+
programArguments,
41+
type CommandResult,
42+
type InstallOptions,
43+
type ServiceStatus,
44+
} from "./service-shared.js";
45+
46+
/** Task Scheduler's own name for the job. */
47+
export const TASK_NAME = LABEL;
48+
49+
/**
50+
* Where the generated task XML is kept.
51+
*
52+
* Written to a stable path because it is also the marker `supervisorInstalled()`
53+
* reads: it is the one artefact of a Windows install that exists as a file. The
54+
* same caveat applies as to the launchd plist — someone can delete the task and
55+
* leave the file — but the file is never written unless an install ran, which is
56+
* the question being asked.
57+
*/
58+
export function taskXmlPath(home = homedir()): string {
59+
return join(home, ".pew2", "service", `${TASK_NAME}.xml`);
60+
}
61+
62+
function escapeXml(value: string): string {
63+
return value
64+
.replace(/&/g, "&amp;")
65+
.replace(/</g, "&lt;")
66+
.replace(/>/g, "&gt;")
67+
.replace(/"/g, "&quot;");
68+
}
69+
70+
/**
71+
* Quote one argument of a Windows command line.
72+
*
73+
* Task Scheduler hands `Arguments` to the process as a single string, which is
74+
* split by the same rules as any other command line, so a path containing a
75+
* space has to be quoted or it arrives as two arguments.
76+
*/
77+
function quoteArgument(value: string): string {
78+
return /[\s"]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value;
79+
}
80+
81+
export function buildTaskXml(options: InstallOptions = {}): string {
82+
const program = programArguments(options.bunPath);
83+
const logs = logDir(options.env);
84+
const port = options.port ?? Number(options.env?.PEW2_PORT ?? 8787);
85+
86+
// A task carries no environment of its own, and unlike launchd and systemd
87+
// there is no per-variable field to set. So the daemon is started through
88+
// `cmd /c` with the variables assigned inline, which is also what lets stdout
89+
// be redirected to the log files the daemon rotates.
90+
const assignments = [`set "PEW2_PORT=${port}"`];
91+
if (options.experimental) assignments.push(`set "PEW2_EXPERIMENTAL=1"`);
92+
if (options.env?.PEW2_HOME) assignments.push(`set "PEW2_HOME=${options.env.PEW2_HOME}"`);
93+
if (options.env?.PEW2_RELAY) assignments.push(`set "PEW2_RELAY=${options.env.PEW2_RELAY}"`);
94+
95+
const start = program.map(quoteArgument).join(" ");
96+
const line =
97+
`${assignments.join(" && ")} && ${start} ` +
98+
`>> ${quoteArgument(join(logs, "daemon.log"))} ` +
99+
`2>> ${quoteArgument(join(logs, "daemon.error.log"))}`;
100+
101+
// UTF-16 is what `schtasks /create /xml` expects, and the declaration has to
102+
// say so even though the file is written as UTF-8 with a BOM below.
103+
return `<?xml version="1.0" encoding="UTF-16"?>
104+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
105+
<RegistrationInfo>
106+
<Description>pew2 daemon — remote control for desktop coding agents</Description>
107+
<URI>\\${escapeXml(TASK_NAME)}</URI>
108+
</RegistrationInfo>
109+
<Triggers>
110+
<LogonTrigger>
111+
<Enabled>true</Enabled>
112+
<!-- Retried for ever, so the daemon comes back after it exits to update.
113+
Duration must exceed Interval or Windows rejects the definition. -->
114+
<Repetition>
115+
<Interval>PT1M</Interval>
116+
<Duration>P3650D</Duration>
117+
<StopAtDurationEnd>false</StopAtDurationEnd>
118+
</Repetition>
119+
</LogonTrigger>
120+
</Triggers>
121+
<Principals>
122+
<Principal id="Author">
123+
<LogonType>InteractiveToken</LogonType>
124+
<RunLevel>LeastPrivilege</RunLevel>
125+
</Principal>
126+
</Principals>
127+
<Settings>
128+
<!-- The keepalive: while the daemon runs, every retry above is ignored. -->
129+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
130+
<!-- A laptop on battery is the normal case for this, not an exception. -->
131+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
132+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
133+
<AllowHardTerminate>true</AllowHardTerminate>
134+
<StartWhenAvailable>true</StartWhenAvailable>
135+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
136+
<!-- Without this the task is killed after three days by default. -->
137+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
138+
<Enabled>true</Enabled>
139+
<Hidden>false</Hidden>
140+
<Priority>7</Priority>
141+
</Settings>
142+
<Actions Context="Author">
143+
<Exec>
144+
<Command>cmd.exe</Command>
145+
<Arguments>/c ${escapeXml(line)}</Arguments>
146+
</Exec>
147+
</Actions>
148+
</Task>
149+
`;
150+
}
151+
152+
export interface SchtasksDeps {
153+
runCommand: (command: string, args: string[]) => Promise<CommandResult>;
154+
home?: string;
155+
/** Injected so tests do not spend the startup poll in real time. */
156+
sleep?: (milliseconds: number) => Promise<void>;
157+
}
158+
159+
export async function installScheduledTask(
160+
options: InstallOptions,
161+
deps: SchtasksDeps,
162+
): Promise<ServiceStatus> {
163+
const home = options.home ?? deps.home ?? homedir();
164+
const path = taskXmlPath(home);
165+
await mkdir(dirname(path), { recursive: true });
166+
await mkdir(logDir(options.env), { recursive: true });
167+
// A BOM, because schtasks reads the file as UTF-16/Unicode and rejects a
168+
// plain UTF-8 one with a bare "The task XML is malformed".
169+
await writeFile(path, `\ufeff${buildTaskXml(options)}`, "utf8");
170+
171+
// `/f` overwrites an existing registration, so re-running install picks up a
172+
// changed definition instead of failing on a name clash.
173+
const created = await deps.runCommand("schtasks", [
174+
"/create",
175+
"/tn",
176+
TASK_NAME,
177+
"/xml",
178+
path,
179+
"/f",
180+
]);
181+
if (created.code !== 0) {
182+
return {
183+
state: "installed",
184+
servicePath: path,
185+
detail: `Written, but schtasks /create failed: ${created.stdout.trim() || `exit ${created.code}`}`,
186+
};
187+
}
188+
189+
// A logon trigger does not fire until the next logon, so the first run is
190+
// started by hand — otherwise `pew2 setup` reports success on a daemon that
191+
// will not exist until the user signs out and back in.
192+
await deps.runCommand("schtasks", ["/run", "/tn", TASK_NAME]);
193+
194+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
195+
for (let i = 0; i < 15; i++) {
196+
const status = await scheduledTaskStatus(home, deps);
197+
if (status.state === "running") return status;
198+
await sleep(200);
199+
}
200+
return scheduledTaskStatus(home, deps);
201+
}
202+
203+
export async function uninstallScheduledTask(
204+
home: string,
205+
deps: SchtasksDeps,
206+
): Promise<ServiceStatus> {
207+
const path = taskXmlPath(home);
208+
await deps.runCommand("schtasks", ["/end", "/tn", TASK_NAME]);
209+
await deps.runCommand("schtasks", ["/delete", "/tn", TASK_NAME, "/f"]);
210+
await rm(path, { force: true });
211+
return { state: "not-installed", servicePath: path };
212+
}
213+
214+
export async function scheduledTaskStatus(
215+
home: string,
216+
deps: SchtasksDeps,
217+
): Promise<ServiceStatus> {
218+
const path = taskXmlPath(home);
219+
const logPath = join(logDir(), "daemon.log");
220+
221+
try {
222+
await readFile(path, "utf8");
223+
} catch {
224+
return { state: "not-installed", servicePath: path, logPath };
225+
}
226+
227+
const queried = await deps.runCommand("schtasks", ["/query", "/tn", TASK_NAME, "/fo", "LIST"]);
228+
if (queried.code !== 0) {
229+
return { state: "installed", servicePath: path, logPath, detail: "Registered but not loaded." };
230+
}
231+
232+
// `Status: Running` is Task Scheduler's word for "an instance is executing".
233+
// Matched loosely because the label is localised on a non-English Windows,
234+
// where falling back to "installed" is the safe answer rather than a wrong one.
235+
const running = /^Status:\s*Running\s*$/im.test(queried.stdout);
236+
const lastResult = queried.stdout.match(/^Last Result:\s*(-?\d+)\s*$/im)?.[1];
237+
238+
return {
239+
state: running ? "running" : "installed",
240+
servicePath: path,
241+
logPath,
242+
lastExitCode: lastResult ? Number(lastResult) : undefined,
243+
};
244+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* What the three supervisor backends agree on.
3+
*
4+
* launchd, systemd and Task Scheduler each get their own module, because the
5+
* details do not generalise — a plist, a unit file and a task XML have nothing
6+
* in common but intent. This is the part that genuinely is shared: the label,
7+
* the result shape, and the two questions every backend has to answer the same
8+
* way ("what program should be run" and "is this a compiled binary").
9+
*
10+
* Separate from `service.ts` so the backends can import it without importing
11+
* each other through their dispatcher.
12+
*/
13+
import { resolve } from "node:path";
14+
import { fileURLToPath } from "node:url";
15+
import { spawn } from "node:child_process";
16+
17+
/** One name across all three supervisors, so a machine cannot host two. */
18+
export const LABEL = "dev.pew2.daemon";
19+
20+
export type ServiceState = "running" | "installed" | "not-installed" | "unsupported";
21+
22+
export interface ServiceStatus {
23+
state: ServiceState;
24+
/**
25+
* The file that defines the service: a launchd plist, a systemd unit, or the
26+
* task XML on Windows. Its presence is what `supervisorInstalled()` reads.
27+
*/
28+
servicePath?: string;
29+
/** Kept as an alias so existing callers and JSON output do not change shape. */
30+
plistPath?: string;
31+
/** PID when running. */
32+
pid?: number;
33+
/** Last exit code the supervisor saw. Non-zero after a crash. */
34+
lastExitCode?: number;
35+
logPath?: string;
36+
detail?: string;
37+
}
38+
39+
export interface InstallOptions {
40+
/** Absolute path to the `bun` binary. A supervisor has no PATH of its own. */
41+
bunPath?: string;
42+
port?: number;
43+
/** Surface test fixtures such as the echo agent. */
44+
experimental?: boolean;
45+
env?: NodeJS.ProcessEnv;
46+
home?: string;
47+
}
48+
49+
export interface CommandResult {
50+
code: number;
51+
stdout: string;
52+
}
53+
54+
export type RunCommand = (command: string, args: string[]) => Promise<CommandResult>;
55+
56+
/**
57+
* Is this a compiled binary rather than a source checkout?
58+
*
59+
* Bun serves a compiled binary's own modules out of a virtual filesystem rooted
60+
* at `/$bunfs/`, so `import.meta.url` says so directly. Everything downstream of
61+
* this question was wrong before it was asked.
62+
*/
63+
export function isCompiled(): boolean {
64+
return import.meta.url.includes("/$bunfs/");
65+
}
66+
67+
/**
68+
* The daemon entry point, resolved from this file.
69+
*
70+
* A supervisor has no working directory and no shell, so every path has to be
71+
* absolute. Only meaningful for a source checkout: in a compiled binary this
72+
* resolves to a path inside the executable's own virtual filesystem, which no
73+
* other process can open.
74+
*/
75+
export function serverEntry(): string {
76+
return resolve(fileURLToPath(new URL("../server.ts", import.meta.url)));
77+
}
78+
79+
/**
80+
* What the supervisor should actually execute.
81+
*
82+
* Two different programs, because there are two ways pew2 is installed: from a
83+
* checkout it is `bun run <abs path to server.ts>`, and from a released binary
84+
* it is the binary itself with `serve`.
85+
*/
86+
export function programArguments(bunPath?: string): string[] {
87+
if (isCompiled()) return [process.execPath, "serve"];
88+
return [bunPath ?? process.execPath, "run", serverEntry()];
89+
}
90+
91+
/** Run a command, treating a missing binary as a failed step rather than a throw. */
92+
export function run(command: string, args: string[]): Promise<CommandResult> {
93+
return new Promise((resolvePromise) => {
94+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
95+
let stdout = "";
96+
child.stdout.on("data", (chunk) => (stdout += chunk));
97+
child.stderr.on("data", (chunk) => (stdout += chunk));
98+
child.on("error", () => resolvePromise({ code: 127, stdout }));
99+
child.on("close", (code) => resolvePromise({ code: code ?? 1, stdout }));
100+
});
101+
}

0 commit comments

Comments
 (0)