@hank-warren/pi-loop 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The scheduled-task model: what a task is, when it next fires, and how it is
3
+ * normalized back off disk.
4
+ *
5
+ * Two task kinds, deliberately different in lifetime:
6
+ *
7
+ * - `prompt` injects a prompt into the owning session. It is **session
8
+ * scoped** — it lives in memory and dies with the session, because a prompt
9
+ * with no session to arrive in is not a task, it is a leak.
10
+ * - `run` spawns a headless `pi -p` in a working directory. It outlives the
11
+ * session, so it is the only kind persisted, and the only kind that needs
12
+ * the single-writer lease.
13
+ *
14
+ * Every task is bounded twice: `maxRuns` (default 25, `unlimited` is an
15
+ * explicit opt-in, mirroring the loop caps) and a hard 90-day expiry. A
16
+ * forgotten schedule that fires forever is the failure mode this whole family
17
+ * of features is prone to.
18
+ */
19
+
20
+ import { type CronSpec, nextCronFire, parseCron } from "./cron.js";
21
+
22
+ export const DEFAULT_MAX_RUNS = 25;
23
+ export const TASK_LIFETIME_MS = 90 * 86_400_000;
24
+ /** Interval floor, matching the loop's own 1-minute clamp. */
25
+ export const MIN_INTERVAL_MS = 60_000;
26
+
27
+ export type ScheduleSpec =
28
+ | { kind: "once"; at: number }
29
+ | { kind: "interval"; everyMs: number }
30
+ | { kind: "cron"; expression: string };
31
+
32
+ export type WakeOn = "always" | "failure" | "success" | "never";
33
+ export const WAKE_ON_VALUES: readonly WakeOn[] = ["always", "failure", "success", "never"];
34
+
35
+ export type TaskSpec =
36
+ | { kind: "prompt"; prompt: string }
37
+ | { kind: "run"; prompt: string; cwd: string; wakeOn: WakeOn };
38
+
39
+ export type TaskStatus = "active" | "paused" | "done";
40
+
41
+ export interface ScheduledTask {
42
+ id: string;
43
+ name: string;
44
+ schedule: ScheduleSpec;
45
+ task: TaskSpec;
46
+ status: TaskStatus;
47
+ createdAt: number;
48
+ expiresAt: number;
49
+ /** null means unlimited, and must be asked for explicitly. */
50
+ maxRuns: number | null;
51
+ runs: number;
52
+ lastRunAt?: number;
53
+ lastResult?: { at: number; ok: boolean; detail?: string };
54
+ }
55
+
56
+ export function isPersistentTask(task: ScheduledTask): boolean {
57
+ return task.task.kind === "run";
58
+ }
59
+
60
+ /**
61
+ * The next fire at or after `after`, or undefined when the task will never
62
+ * fire again (expired, done, paused, a one-shot already past, or a cron
63
+ * expression with no future match).
64
+ *
65
+ * A missed one-shot still returns its own time, in the past: catching up is
66
+ * the caller's decision, and it is exactly one fire, never one per missed
67
+ * occurrence.
68
+ */
69
+ export function nextFireAt(task: ScheduledTask, after: number): number | undefined {
70
+ if (task.status !== "active") return undefined;
71
+ if (after >= task.expiresAt) return undefined;
72
+ if (task.maxRuns !== null && task.runs >= task.maxRuns) return undefined;
73
+ const fire = computeNextFire(task, after);
74
+ if (fire === undefined || fire >= task.expiresAt) return undefined;
75
+ return fire;
76
+ }
77
+
78
+ function computeNextFire(task: ScheduledTask, after: number): number | undefined {
79
+ switch (task.schedule.kind) {
80
+ case "once":
81
+ // Still pending if it has never run, whether or not it is overdue.
82
+ return task.runs === 0 ? task.schedule.at : undefined;
83
+ case "interval": {
84
+ const base = task.lastRunAt ?? task.createdAt;
85
+ const elapsed = after - base;
86
+ if (elapsed < task.schedule.everyMs) return base + task.schedule.everyMs;
87
+ // Overdue by any number of intervals: one fire, now. Firing once per
88
+ // missed interval is how a laptop that slept for a weekend wakes up
89
+ // to two hundred queued turns.
90
+ return after;
91
+ }
92
+ case "cron": {
93
+ const parsed = parseCron(task.schedule.expression);
94
+ if (!parsed.ok) return undefined;
95
+ return nextCronFireForTask(parsed.spec, task, after);
96
+ }
97
+ }
98
+ }
99
+
100
+ function nextCronFireForTask(
101
+ spec: CronSpec,
102
+ task: ScheduledTask,
103
+ after: number,
104
+ ): number | undefined {
105
+ // A cron occurrence missed while nothing was running is coalesced the same
106
+ // way: the search starts from the last run, and anything already past
107
+ // collapses into a single fire at `after`.
108
+ const from = task.lastRunAt ?? task.createdAt;
109
+ const next = nextCronFire(spec, Math.max(from, task.createdAt) - 1);
110
+ if (next === undefined) return undefined;
111
+ return next <= after ? after : next;
112
+ }
113
+
114
+ export function describeSchedule(schedule: ScheduleSpec): string {
115
+ switch (schedule.kind) {
116
+ case "once":
117
+ return `once at ${new Date(schedule.at).toLocaleString()}`;
118
+ case "interval":
119
+ return `every ${formatMs(schedule.everyMs)}`;
120
+ case "cron":
121
+ return `cron ${schedule.expression}`;
122
+ }
123
+ }
124
+
125
+ function formatMs(ms: number): string {
126
+ for (const [unit, size] of [
127
+ ["d", 86_400_000],
128
+ ["h", 3_600_000],
129
+ ["m", 60_000],
130
+ ] as const) {
131
+ if (ms >= size && ms % size === 0) return `${ms / size}${unit}`;
132
+ }
133
+ return `${Math.round(ms / 1_000)}s`;
134
+ }
135
+
136
+ // --- normalization ---
137
+
138
+ const MAX_TEXT = 100_000;
139
+ const MAX_TIMESTAMP = 8_640_000_000_000_000;
140
+
141
+ export function normalizeTask(value: unknown): ScheduledTask | undefined {
142
+ const record = asRecord(value);
143
+ if (!record) return undefined;
144
+ const id = text(record.id, 200);
145
+ const name = text(record.name, 200);
146
+ if (!id || !name || /[\s/]/.test(id)) return undefined;
147
+ const schedule = normalizeSchedule(record.schedule);
148
+ const task = normalizeTaskSpec(record.task);
149
+ if (!schedule || !task) return undefined;
150
+ const status = record.status;
151
+ if (status !== "active" && status !== "paused" && status !== "done") return undefined;
152
+ if (!isTimestamp(record.createdAt) || !isTimestamp(record.expiresAt)) return undefined;
153
+ const maxRuns = record.maxRuns;
154
+ if (maxRuns !== null && !isPositiveInteger(maxRuns)) return undefined;
155
+ const runs = record.runs;
156
+ if (typeof runs !== "number" || !Number.isSafeInteger(runs) || runs < 0) return undefined;
157
+ if (record.lastRunAt !== undefined && !isTimestamp(record.lastRunAt)) return undefined;
158
+ const lastResult = normalizeResult(record.lastResult);
159
+ if (lastResult === false) return undefined;
160
+ return {
161
+ id,
162
+ name,
163
+ schedule,
164
+ task,
165
+ status,
166
+ createdAt: record.createdAt as number,
167
+ expiresAt: record.expiresAt as number,
168
+ maxRuns: maxRuns as number | null,
169
+ runs,
170
+ ...(record.lastRunAt === undefined ? {} : { lastRunAt: record.lastRunAt as number }),
171
+ ...(lastResult === undefined ? {} : { lastResult }),
172
+ };
173
+ }
174
+
175
+ function normalizeSchedule(value: unknown): ScheduleSpec | undefined {
176
+ const record = asRecord(value);
177
+ if (!record) return undefined;
178
+ if (record.kind === "once") {
179
+ return isTimestamp(record.at) ? { kind: "once", at: record.at as number } : undefined;
180
+ }
181
+ if (record.kind === "interval") {
182
+ const everyMs = record.everyMs;
183
+ return isPositiveInteger(everyMs) && everyMs >= MIN_INTERVAL_MS
184
+ ? { kind: "interval", everyMs }
185
+ : undefined;
186
+ }
187
+ if (record.kind === "cron") {
188
+ const expression = text(record.expression, 200);
189
+ if (!expression || !parseCron(expression).ok) return undefined;
190
+ return { kind: "cron", expression };
191
+ }
192
+ return undefined;
193
+ }
194
+
195
+ function normalizeTaskSpec(value: unknown): TaskSpec | undefined {
196
+ const record = asRecord(value);
197
+ if (!record) return undefined;
198
+ const prompt = text(record.prompt, MAX_TEXT);
199
+ if (!prompt) return undefined;
200
+ if (record.kind === "prompt") return { kind: "prompt", prompt };
201
+ if (record.kind !== "run") return undefined;
202
+ const cwd = text(record.cwd, 4_096);
203
+ const wakeOn = record.wakeOn;
204
+ if (!cwd || !WAKE_ON_VALUES.includes(wakeOn as WakeOn)) return undefined;
205
+ return { kind: "run", prompt, cwd, wakeOn: wakeOn as WakeOn };
206
+ }
207
+
208
+ function normalizeResult(value: unknown): ScheduledTask["lastResult"] | undefined | false {
209
+ if (value === undefined) return undefined;
210
+ const record = asRecord(value);
211
+ if (!record || !isTimestamp(record.at) || typeof record.ok !== "boolean") return false;
212
+ const detail = record.detail === undefined ? undefined : text(record.detail, 4_000);
213
+ if (record.detail !== undefined && !detail) return false;
214
+ return { at: record.at as number, ok: record.ok, ...(detail ? { detail } : {}) };
215
+ }
216
+
217
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
218
+ return value && typeof value === "object" && !Array.isArray(value)
219
+ ? (value as Record<string, unknown>)
220
+ : undefined;
221
+ }
222
+
223
+ function text(value: unknown, max: number): string | undefined {
224
+ if (typeof value !== "string") return undefined;
225
+ const trimmed = value.trim();
226
+ return trimmed && trimmed.length <= max ? trimmed : undefined;
227
+ }
228
+
229
+ function isPositiveInteger(value: unknown): value is number {
230
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
231
+ }
232
+
233
+ function isTimestamp(value: unknown): value is number {
234
+ return (
235
+ typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= MAX_TIMESTAMP
236
+ );
237
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * The scheduler runtime: one timer, two delivery paths.
3
+ *
4
+ * A `prompt` task is delivered exactly like a loop wake — only at a settled
5
+ * idle boundary, coalescing when the session is busy, because a scheduler
6
+ * that interrupts a running turn is a worse tool than no scheduler. A `run`
7
+ * task spawns a headless `pi -p` and never touches the conversation unless
8
+ * its `wakeOn` policy says to report back.
9
+ *
10
+ * The model gets no scheduling tools at all. `/schedule` is user-typed, the
11
+ * same doctrine `/loop` follows: a model that can schedule its own future
12
+ * turns can schedule its way around every limit the loop imposes.
13
+ */
14
+
15
+ import { spawn } from "node:child_process";
16
+ import { createWriteStream, mkdirSync } from "node:fs";
17
+ import { randomUUID } from "node:crypto";
18
+ import { dirname } from "node:path";
19
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+ import {
21
+ DEFAULT_MAX_RUNS,
22
+ isPersistentTask,
23
+ nextFireAt,
24
+ type ScheduledTask,
25
+ TASK_LIFETIME_MS,
26
+ type ScheduleSpec,
27
+ type TaskSpec,
28
+ } from "./model.js";
29
+ import {
30
+ acquireLease,
31
+ LEASE_HEARTBEAT_MS,
32
+ readTasks,
33
+ releaseLease,
34
+ runLogPath,
35
+ schedulePaths,
36
+ type SchedulePaths,
37
+ writeTasks,
38
+ } from "./store.js";
39
+
40
+ /** Longest a single headless run may take before it is killed. */
41
+ export const RUN_TIMEOUT_MS = 30 * 60_000;
42
+ const MAX_TIMER_MS = 2_147_483_647;
43
+
44
+ export interface SchedulerOptions {
45
+ agentDir?: string;
46
+ now?: () => number;
47
+ /** Injected for tests; defaults to spawning a real headless Pi. */
48
+ spawnRun?: (task: ScheduledTask, logPath: string) => Promise<{ ok: boolean; detail: string }>;
49
+ }
50
+
51
+ export interface CreateTaskInput {
52
+ name: string;
53
+ schedule: ScheduleSpec;
54
+ task: TaskSpec;
55
+ maxRuns?: number | null;
56
+ }
57
+
58
+ export class Scheduler {
59
+ private readonly pi: ExtensionAPI;
60
+ private readonly now: () => number;
61
+ private readonly paths: SchedulePaths;
62
+ private readonly owner = randomUUID();
63
+ private readonly spawnRun: NonNullable<SchedulerOptions["spawnRun"]>;
64
+ /** Session-scoped prompt tasks; they die with the session by design. */
65
+ private sessionTasks: ScheduledTask[] = [];
66
+ private timer: NodeJS.Timeout | undefined;
67
+ private leaseTimer: NodeJS.Timeout | undefined;
68
+ private ctx: ExtensionContext | undefined;
69
+ private pendingPrompts: string[] = [];
70
+ private started = false;
71
+
72
+ constructor(pi: ExtensionAPI, options: SchedulerOptions = {}) {
73
+ this.pi = pi;
74
+ this.now = options.now ?? Date.now;
75
+ this.paths = schedulePaths(options.agentDir);
76
+ this.spawnRun = options.spawnRun ?? spawnHeadlessRun;
77
+ }
78
+
79
+ // --- lifecycle ---
80
+
81
+ onSessionStart(ctx: ExtensionContext): void {
82
+ this.ctx = ctx;
83
+ this.sessionTasks = [];
84
+ this.pendingPrompts = [];
85
+ this.started = true;
86
+ this.renewLease();
87
+ this.reschedule();
88
+ }
89
+
90
+ onSessionShutdown(): void {
91
+ this.started = false;
92
+ this.clearTimers();
93
+ releaseLease(this.paths, this.owner);
94
+ this.ctx = undefined;
95
+ // Prompt tasks are session scoped: a prompt with no session to arrive
96
+ // in is not a task, it is a leak.
97
+ this.sessionTasks = [];
98
+ }
99
+
100
+ /** Deliver anything that came due while the session was busy. */
101
+ onAgentSettled(ctx: ExtensionContext): void {
102
+ this.ctx = ctx;
103
+ this.flushPrompts(ctx);
104
+ this.reschedule();
105
+ }
106
+
107
+ // --- task management ---
108
+
109
+ tasks(): ScheduledTask[] {
110
+ return [...this.sessionTasks, ...readTasks(this.paths)].sort(
111
+ (a, b) => (nextFireAt(a, this.now()) ?? Infinity) - (nextFireAt(b, this.now()) ?? Infinity),
112
+ );
113
+ }
114
+
115
+ find(idPrefix: string): ScheduledTask | undefined {
116
+ const matches = this.tasks().filter((task) => task.id.startsWith(idPrefix));
117
+ return matches.length === 1 ? matches[0] : undefined;
118
+ }
119
+
120
+ create(input: CreateTaskInput): { task: ScheduledTask; warning?: string } {
121
+ const now = this.now();
122
+ const task: ScheduledTask = {
123
+ id: randomUUID().slice(0, 8),
124
+ name: input.name,
125
+ schedule: input.schedule,
126
+ task: input.task,
127
+ status: "active",
128
+ createdAt: now,
129
+ expiresAt: now + TASK_LIFETIME_MS,
130
+ maxRuns: input.maxRuns === undefined ? DEFAULT_MAX_RUNS : input.maxRuns,
131
+ runs: 0,
132
+ };
133
+ const warning = this.save(task);
134
+ this.reschedule();
135
+ return { task, ...(warning ? { warning } : {}) };
136
+ }
137
+
138
+ update(task: ScheduledTask): string | undefined {
139
+ const warning = this.save(task);
140
+ this.reschedule();
141
+ return warning;
142
+ }
143
+
144
+ remove(id: string): boolean {
145
+ const before = this.sessionTasks.length;
146
+ this.sessionTasks = this.sessionTasks.filter((task) => task.id !== id);
147
+ if (this.sessionTasks.length !== before) {
148
+ this.reschedule();
149
+ return true;
150
+ }
151
+ const persisted = readTasks(this.paths);
152
+ const kept = persisted.filter((task) => task.id !== id);
153
+ if (kept.length === persisted.length) return false;
154
+ writeTasks(this.paths, kept);
155
+ this.reschedule();
156
+ return true;
157
+ }
158
+
159
+ private save(task: ScheduledTask): string | undefined {
160
+ if (!isPersistentTask(task)) {
161
+ this.sessionTasks = [...this.sessionTasks.filter((other) => other.id !== task.id), task];
162
+ return undefined;
163
+ }
164
+ const persisted = readTasks(this.paths).filter((other) => other.id !== task.id);
165
+ return writeTasks(this.paths, [...persisted, task]);
166
+ }
167
+
168
+ // --- firing ---
169
+
170
+ /** Fire everything due now. Exposed for the manager's "run now". */
171
+ runDue(): void {
172
+ if (!this.started) return;
173
+ const now = this.now();
174
+ const headlessAllowed = this.renewLease();
175
+ for (const task of this.tasks()) {
176
+ const fire = nextFireAt(task, now);
177
+ if (fire === undefined || fire > now) continue;
178
+ if (task.task.kind === "run" && !headlessAllowed) continue;
179
+ this.fire(task, now);
180
+ }
181
+ this.reschedule();
182
+ }
183
+
184
+ fireNow(task: ScheduledTask): void {
185
+ this.fire(task, this.now());
186
+ this.reschedule();
187
+ }
188
+
189
+ private fire(task: ScheduledTask, now: number): void {
190
+ // Count the run before doing it: a task that throws must not become a
191
+ // hot loop, and a one-shot must not fire twice.
192
+ const ran: ScheduledTask = { ...task, runs: task.runs + 1, lastRunAt: now };
193
+ const finished =
194
+ ran.schedule.kind === "once" || (ran.maxRuns !== null && ran.runs >= ran.maxRuns);
195
+ this.save(finished ? { ...ran, status: "done" } : ran);
196
+ if (task.task.kind === "prompt") {
197
+ this.deliverPrompt(task);
198
+ return;
199
+ }
200
+ void this.startRun(ran);
201
+ }
202
+
203
+ private deliverPrompt(task: ScheduledTask): void {
204
+ if (task.task.kind !== "prompt") return;
205
+ const message = `Scheduled task "${task.name}" fired.\n\n${task.task.prompt}`;
206
+ this.pendingPrompts.push(message);
207
+ const ctx = this.ctx;
208
+ if (ctx) this.flushPrompts(ctx);
209
+ }
210
+
211
+ /**
212
+ * Prompts deliver only at a fully idle boundary, and a queue that built up
213
+ * while the session was busy delivers in order once it settles.
214
+ */
215
+ private flushPrompts(ctx: ExtensionContext): void {
216
+ if (this.pendingPrompts.length === 0) return;
217
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
218
+ const queued = this.pendingPrompts;
219
+ this.pendingPrompts = [];
220
+ for (const message of queued) {
221
+ try {
222
+ this.pi.sendUserMessage(message);
223
+ } catch {
224
+ // Re-queue the rest for the next settle rather than dropping them.
225
+ this.pendingPrompts.push(message);
226
+ }
227
+ }
228
+ }
229
+
230
+ private async startRun(task: ScheduledTask): Promise<void> {
231
+ if (task.task.kind !== "run") return;
232
+ const at = this.now();
233
+ const logPath = runLogPath(this.paths, task.id, at);
234
+ let result: { ok: boolean; detail: string };
235
+ try {
236
+ result = await this.spawnRun(task, logPath);
237
+ } catch (error) {
238
+ result = { ok: false, detail: error instanceof Error ? error.message : String(error) };
239
+ }
240
+ const current = this.find(task.id) ?? task;
241
+ this.save({
242
+ ...current,
243
+ lastResult: { at: this.now(), ok: result.ok, detail: `${result.detail} (log: ${logPath})` },
244
+ });
245
+ this.reportRun(task, result, logPath);
246
+ }
247
+
248
+ private reportRun(
249
+ task: ScheduledTask,
250
+ result: { ok: boolean; detail: string },
251
+ logPath: string,
252
+ ): void {
253
+ if (task.task.kind !== "run") return;
254
+ const wakeOn = task.task.wakeOn;
255
+ const wanted =
256
+ wakeOn === "always" ||
257
+ (wakeOn === "failure" && !result.ok) ||
258
+ (wakeOn === "success" && result.ok);
259
+ if (!wanted) return;
260
+ const ctx = this.ctx;
261
+ if (!ctx) return;
262
+ ctx.ui.notify(
263
+ `Scheduled run "${task.name}" ${result.ok ? "succeeded" : "failed"}: ${result.detail}\nLog: ${logPath}`,
264
+ result.ok ? "info" : "warning",
265
+ );
266
+ }
267
+
268
+ // --- timers ---
269
+
270
+ private reschedule(): void {
271
+ this.clearFireTimer();
272
+ if (!this.started) return;
273
+ const now = this.now();
274
+ let earliest: number | undefined;
275
+ for (const task of this.tasks()) {
276
+ const fire = nextFireAt(task, now);
277
+ if (fire === undefined) continue;
278
+ if (earliest === undefined || fire < earliest) earliest = fire;
279
+ }
280
+ if (earliest === undefined) return;
281
+ const delay = Math.max(0, Math.min(MAX_TIMER_MS, earliest - now));
282
+ this.timer = setTimeout(() => {
283
+ this.timer = undefined;
284
+ this.runDue();
285
+ }, delay);
286
+ this.timer.unref?.();
287
+ }
288
+
289
+ /**
290
+ * Hold the headless-firing lease. Returns whether this process holds it;
291
+ * prompt tasks never consult it, since they are session-scoped and cannot
292
+ * be double-fired by another process.
293
+ */
294
+ private renewLease(): boolean {
295
+ const held = acquireLease(this.paths, this.owner, this.now());
296
+ if (!this.leaseTimer && this.started) {
297
+ this.leaseTimer = setInterval(() => {
298
+ acquireLease(this.paths, this.owner, this.now());
299
+ }, LEASE_HEARTBEAT_MS);
300
+ this.leaseTimer.unref?.();
301
+ }
302
+ return held;
303
+ }
304
+
305
+ private clearFireTimer(): void {
306
+ if (this.timer) clearTimeout(this.timer);
307
+ this.timer = undefined;
308
+ }
309
+
310
+ private clearTimers(): void {
311
+ this.clearFireTimer();
312
+ if (this.leaseTimer) clearInterval(this.leaseTimer);
313
+ this.leaseTimer = undefined;
314
+ }
315
+ }
316
+
317
+ /**
318
+ * The default headless runner: `pi -p "<prompt>"` in the task's directory,
319
+ * with stdout and stderr tee'd to the run log.
320
+ */
321
+ async function spawnHeadlessRun(
322
+ task: ScheduledTask,
323
+ logPath: string,
324
+ ): Promise<{ ok: boolean; detail: string }> {
325
+ if (task.task.kind !== "run") return { ok: false, detail: "not a run task" };
326
+ const { prompt, cwd } = task.task;
327
+ mkdirSync(dirname(logPath), { recursive: true });
328
+ const log = createWriteStream(logPath, { flags: "a" });
329
+ log.write(`# ${new Date().toISOString()} ${task.name}\n# cwd: ${cwd}\n# prompt: ${prompt}\n\n`);
330
+ return await new Promise((resolve) => {
331
+ const child = spawn("pi", ["-p", prompt], { cwd, stdio: ["ignore", "pipe", "pipe"] });
332
+ const timeout = setTimeout(() => child.kill("SIGTERM"), RUN_TIMEOUT_MS);
333
+ timeout.unref?.();
334
+ child.stdout?.pipe(log, { end: false });
335
+ child.stderr?.pipe(log, { end: false });
336
+ child.on("error", (error) => {
337
+ clearTimeout(timeout);
338
+ log.end(`\n# spawn failed: ${error.message}\n`);
339
+ resolve({ ok: false, detail: `could not start pi: ${error.message}` });
340
+ });
341
+ child.on("close", (code, signal) => {
342
+ clearTimeout(timeout);
343
+ log.end(`\n# exit ${code ?? "null"}${signal ? ` (${signal})` : ""}\n`);
344
+ resolve(
345
+ signal
346
+ ? { ok: false, detail: `killed by ${signal}` }
347
+ : { ok: code === 0, detail: `exit ${code ?? "unknown"}` },
348
+ );
349
+ });
350
+ });
351
+ }