premanmcp 0.9.0 → 0.10.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.
package/bin/runner.js ADDED
@@ -0,0 +1,880 @@
1
+ /**
2
+ * `preman runner` — execute PreMan's queued coding-agent jobs on this machine.
3
+ *
4
+ * The backend has had a complete local-runner protocol for a while
5
+ * (`docs/LOCAL_AGENT_RUNNER.md`): pair once, hold an outbound SSE stream, get
6
+ * leased jobs, report started/progress/complete under a fencing token. Only the
7
+ * desktop app spoke it, which meant a terminal-first user could be fully
8
+ * connected and still have PreMan unable to *do* anything — every fix was a
9
+ * suggestion waiting for someone to paste it into an agent.
10
+ *
11
+ * This is the same protocol from the CLI, so `preman connect` can leave behind a
12
+ * machine that runs work instead of one that only reports it. The desktop app
13
+ * stays a valid second runner; the server picks whichever device is online.
14
+ *
15
+ * Non-negotiables carried over from the desktop implementation:
16
+ * - The stream is outbound. No inbound port, tunnel, or editor plugin.
17
+ * - A job is refused if its agent or project path is not the one this runner
18
+ * paired with. The server chooses the path; a queued job cannot widen it.
19
+ * - Process exit never resolves a job. Only a completion callback holding the
20
+ * current lease does.
21
+ * - The runner token is separate from the pm_live_ key, stored 0600, and never
22
+ * placed in a child process's environment.
23
+ */
24
+
25
+ import { spawn, spawnSync } from "node:child_process";
26
+ import { createHash } from "node:crypto";
27
+ import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
28
+ import os from "node:os";
29
+ import path from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ import {
33
+ CREDENTIALS_DIR,
34
+ backendUrl,
35
+ callBackendJson,
36
+ cliInvocation,
37
+ makeArgs,
38
+ resolveApiKey,
39
+ } from "./shared.js";
40
+
41
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
42
+
43
+ export const RUNNER_STATE_FILE = path.join(CREDENTIALS_DIR, "runner.json");
44
+ export const RUNNER_PID_FILE = path.join(CREDENTIALS_DIR, "runner.pid");
45
+ export const RUNNER_LOG_FILE = path.join(CREDENTIALS_DIR, "runner.log");
46
+
47
+ // Matches docs/LOCAL_AGENT_RUNNER.md — the backend's TTLs are the contract, not
48
+ // a preference. Runner online TTL is 45s, unstarted lease 60s, running lease a
49
+ // renewable 90s, so heartbeat and renewal both have to beat those windows.
50
+ const HEARTBEAT_MS = 20_000;
51
+ const LEASE_RENEW_MS = 45_000;
52
+ const PROGRESS_MS = 20_000;
53
+ const RECONNECT_MIN_MS = 1_000;
54
+ const RECONNECT_MAX_MS = 30_000;
55
+ const AGENT_TIMEOUT_MS = Number(process.env.PREMAN_RUNNER_TIMEOUT_MS) || 30 * 60_000;
56
+ const AGENT_TIMEOUT_CAP_MS = 60 * 60_000;
57
+ const MAX_OUTPUT_BYTES = 1024 * 1024;
58
+ const SUMMARY_CAP = 20_000;
59
+ const ERROR_CAP = 4_000;
60
+
61
+ /**
62
+ * What this runner promises the job matcher.
63
+ *
64
+ * `find_online_runner` filters on these keys, so a runner that does not advertise
65
+ * the telemetry and manifest capabilities is skipped with `runner_update_required`
66
+ * — it is not a hint, it is the routing condition.
67
+ */
68
+ export const RUNNER_CAPABILITIES = {
69
+ tool_call_telemetry_v1: true,
70
+ tool_fact_manifest_v1: true,
71
+ transport: "cli",
72
+ client: "premanmcp",
73
+ };
74
+
75
+ export class RunnerError extends Error {
76
+ constructor(message, exitCode = 1) {
77
+ super(message);
78
+ this.exitCode = exitCode;
79
+ }
80
+ }
81
+
82
+ const AGENT_IDS = new Set(["claude_code", "cursor", "codex"]);
83
+
84
+ export function normalizeAgentId(value) {
85
+ const raw = String(value || "").trim().toLowerCase().replace(/[\s-]+/g, "_");
86
+ if (raw === "claude" || raw === "claudecode") return "claude_code";
87
+ if (raw === "openai_codex") return "codex";
88
+ return AGENT_IDS.has(raw) ? raw : "";
89
+ }
90
+
91
+ // ── Local state ─────────────────────────────────────────────────────────
92
+
93
+ function ensureDir() {
94
+ mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
95
+ }
96
+
97
+ export function readRunnerState() {
98
+ try {
99
+ const raw = readFileSync(RUNNER_STATE_FILE, "utf8").trim();
100
+ if (!raw) return null;
101
+ const state = JSON.parse(raw);
102
+ return state && typeof state.runner_token === "string" ? state : null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ export function saveRunnerState(state) {
109
+ ensureDir();
110
+ writeFileSync(RUNNER_STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
111
+ chmodSync(RUNNER_STATE_FILE, 0o600);
112
+ return state;
113
+ }
114
+
115
+ export function clearRunnerState() {
116
+ rmSync(RUNNER_STATE_FILE, { force: true });
117
+ }
118
+
119
+ /**
120
+ * A device identity that survives restarts but not a different project.
121
+ *
122
+ * Registration replaces the previous runner with the same device_id, so reusing
123
+ * one per (host, project) keeps repeated `preman connect` runs from piling up
124
+ * dead runner rows the matcher still has to walk.
125
+ */
126
+ export function deviceId(projectPath) {
127
+ const seed = `${os.hostname()}::${path.resolve(projectPath)}`;
128
+ return `cli-${createHash("sha256").update(seed).digest("hex").slice(0, 32)}`;
129
+ }
130
+
131
+ export function packageVersion() {
132
+ try {
133
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
134
+ return String(pkg.version || "0.0.0");
135
+ } catch {
136
+ return "0.0.0";
137
+ }
138
+ }
139
+
140
+ function readPid() {
141
+ try {
142
+ const pid = Number(readFileSync(RUNNER_PID_FILE, "utf8").trim());
143
+ return Number.isInteger(pid) && pid > 0 ? pid : 0;
144
+ } catch {
145
+ return 0;
146
+ }
147
+ }
148
+
149
+ export function runnerIsAlive(pid = readPid()) {
150
+ if (!pid) return false;
151
+ try {
152
+ process.kill(pid, 0);
153
+ return true;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ // ── Registration ────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Pair and register, returning the saved runner state.
163
+ *
164
+ * The pair code is single-use and shared with the MCP check-in path, so this
165
+ * mints its own rather than reusing whatever `connect` put in the agent's config
166
+ * — that one is spent the moment the agent calls `preman_status`.
167
+ */
168
+ export async function registerRunner(args, { agent, projectPath, label = "", apiKey = "" } = {}) {
169
+ const agentId = normalizeAgentId(agent);
170
+ if (!agentId) throw new RunnerError(`unknown agent: ${agent}`, 2);
171
+ const token = apiKey || resolveApiKey(args);
172
+ if (!token) {
173
+ throw new RunnerError(`No PreMan credentials. Run '${cliInvocation()} connect' first.`, 2);
174
+ }
175
+ const project = path.resolve(projectPath || process.cwd());
176
+
177
+ const pairing = await callBackendJson(args, "PUT", "/workbench/coding-agent", {
178
+ token,
179
+ json: { agent: agentId, project_path: project, start_pairing: true },
180
+ });
181
+ if (!pairing.ok) {
182
+ throw new RunnerError(
183
+ `could not start pairing (${pairing.status_code} ${pairing.detail || ""})`.trim()
184
+ );
185
+ }
186
+ const pairCode = String(pairing.pair_code || "");
187
+ if (!pairCode) throw new RunnerError("PreMan did not return a pair code");
188
+
189
+ const registered = await callBackendJson(
190
+ args,
191
+ "POST",
192
+ "/workbench/coding-agent/local-runners/register",
193
+ {
194
+ json: {
195
+ pair_code: pairCode,
196
+ device_id: deviceId(project),
197
+ label: label || `${os.hostname()} (${path.basename(project)})`,
198
+ agent: agentId,
199
+ project_path: project,
200
+ project_name: path.basename(project),
201
+ platform: `${process.platform}-${process.arch}`,
202
+ app_version: packageVersion(),
203
+ capabilities: RUNNER_CAPABILITIES,
204
+ },
205
+ }
206
+ );
207
+ if (!registered.ok) {
208
+ throw new RunnerError(
209
+ `could not register the runner (${registered.status_code} ${registered.detail || ""})`.trim()
210
+ );
211
+ }
212
+
213
+ return saveRunnerState({
214
+ runner_id: String(registered.runner?.id || ""),
215
+ runner_token: String(registered.runner_token || ""),
216
+ agent: agentId,
217
+ project_path: project,
218
+ project_name: path.basename(project),
219
+ backend_url: backendUrl(args),
220
+ device_id: deviceId(project),
221
+ workspace_id: String(registered.runner?.workspace_id || ""),
222
+ registered_at: new Date().toISOString(),
223
+ });
224
+ }
225
+
226
+ // ── Agent invocation ────────────────────────────────────────────────────
227
+
228
+ /**
229
+ * How to run one job with each agent, non-interactively.
230
+ *
231
+ * Permissions are the only interesting choice here. A fix task exists to change
232
+ * files, so edits are accepted; everything beyond that stays behind
233
+ * `PREMAN_RUNNER_FULL_ACCESS=1`, because "PreMan can run arbitrary commands on my
234
+ * laptop unattended" has to be a decision someone made on purpose.
235
+ */
236
+ export function agentCommand(agent, prompt, { fullAccess = false } = {}) {
237
+ if (agent === "claude_code") {
238
+ const args = ["-p", prompt, "--output-format", "stream-json", "--verbose"];
239
+ if (fullAccess) args.push("--dangerously-skip-permissions");
240
+ else args.push("--permission-mode", "acceptEdits");
241
+ return { bin: "claude", args, stream: "claude-json" };
242
+ }
243
+ if (agent === "cursor") {
244
+ return { bin: "cursor-agent", args: ["-p", prompt], stream: "text" };
245
+ }
246
+ if (agent === "codex") {
247
+ const args = ["exec"];
248
+ if (fullAccess) args.push("--full-auto");
249
+ args.push(prompt);
250
+ return { bin: "codex", args, stream: "text" };
251
+ }
252
+ return null;
253
+ }
254
+
255
+ export function onPath(binary) {
256
+ const probe = spawnSync(process.platform === "win32" ? "where" : "which", [binary], {
257
+ stdio: "ignore",
258
+ });
259
+ return probe.status === 0;
260
+ }
261
+
262
+ function sanitizeId(value, cap) {
263
+ return String(value || "")
264
+ .replace(/[^A-Za-z0-9_.:-]/g, "_")
265
+ .slice(0, cap);
266
+ }
267
+
268
+ /**
269
+ * Collect the attempt's tool identities from Claude Code's stream-json output.
270
+ *
271
+ * The completion manifest is what the backend meters, and it must be the unique
272
+ * set for this attempt — including identities already acknowledged mid-run — so
273
+ * this keys by tool_use id and upgrades a `started` to `completed` rather than
274
+ * emitting the same identity twice.
275
+ */
276
+ export function createToolEventCollector() {
277
+ const events = new Map();
278
+ let sessionId = "";
279
+
280
+ function note(id, name, phase) {
281
+ const eventId = sanitizeId(id, 500);
282
+ if (!eventId) return;
283
+ const existing = events.get(eventId);
284
+ if (existing) {
285
+ if (phase === "completed") existing.phase = "completed";
286
+ if (name && !existing.tool_name) existing.tool_name = sanitizeId(name, 100);
287
+ return;
288
+ }
289
+ const event = { event_id: eventId, phase };
290
+ const toolName = sanitizeId(name, 100);
291
+ if (toolName) event.tool_name = toolName;
292
+ events.set(eventId, event);
293
+ }
294
+
295
+ return {
296
+ ingestLine(line) {
297
+ const trimmed = String(line || "").trim();
298
+ if (!trimmed.startsWith("{")) return;
299
+ let parsed;
300
+ try {
301
+ parsed = JSON.parse(trimmed);
302
+ } catch {
303
+ return;
304
+ }
305
+ if (!sessionId && typeof parsed.session_id === "string") sessionId = parsed.session_id;
306
+ const content = parsed?.message?.content;
307
+ if (!Array.isArray(content)) return;
308
+ for (const block of content) {
309
+ if (block?.type === "tool_use") note(block.id, block.name, "started");
310
+ else if (block?.type === "tool_result") note(block.tool_use_id, "", "completed");
311
+ }
312
+ },
313
+ manifest() {
314
+ return [...events.values()];
315
+ },
316
+ count() {
317
+ return events.size;
318
+ },
319
+ sessionId() {
320
+ return sessionId;
321
+ },
322
+ };
323
+ }
324
+
325
+ /** Frame `text/event-stream` bytes into (event, data) pairs. */
326
+ export function createSseParser(onEvent) {
327
+ let buffer = "";
328
+ return function push(chunk) {
329
+ buffer += chunk;
330
+ let boundary = buffer.indexOf("\n\n");
331
+ while (boundary !== -1) {
332
+ const frame = buffer.slice(0, boundary);
333
+ buffer = buffer.slice(boundary + 2);
334
+ let event = "message";
335
+ const data = [];
336
+ for (const line of frame.split("\n")) {
337
+ if (line.startsWith("event:")) event = line.slice(6).trim();
338
+ else if (line.startsWith("data:")) data.push(line.slice(5).trim());
339
+ }
340
+ if (data.length) {
341
+ let payload;
342
+ try {
343
+ payload = JSON.parse(data.join("\n"));
344
+ } catch {
345
+ payload = { raw: data.join("\n") };
346
+ }
347
+ onEvent(event, payload);
348
+ }
349
+ boundary = buffer.indexOf("\n\n");
350
+ }
351
+ };
352
+ }
353
+
354
+ /**
355
+ * Why this runner must not touch a job.
356
+ *
357
+ * Empty string means "run it". The server picked the path at pairing time; a job
358
+ * naming a different one is either stale or an attempt to widen filesystem
359
+ * access, and both get the same answer.
360
+ */
361
+ export function jobRefusal(state, job) {
362
+ const jobAgent = normalizeAgentId(job?.agent);
363
+ if (jobAgent && jobAgent !== state.agent) {
364
+ return `job targets ${jobAgent}, this runner is registered for ${state.agent}`;
365
+ }
366
+ const wanted = job?.payload?.project_path;
367
+ if (wanted && path.resolve(String(wanted)) !== path.resolve(state.project_path)) {
368
+ return `job targets ${wanted}, this runner is registered for ${state.project_path}`;
369
+ }
370
+ if (!String(job?.prompt || "").trim()) return "job carries no prompt";
371
+ return "";
372
+ }
373
+
374
+ function currentBranch(cwd) {
375
+ const probe = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
376
+ cwd,
377
+ encoding: "utf8",
378
+ stdio: ["ignore", "pipe", "ignore"],
379
+ });
380
+ const branch = probe.status === 0 ? probe.stdout.trim() : "";
381
+ return branch && branch !== "HEAD" ? branch.slice(0, 500) : "";
382
+ }
383
+
384
+ /**
385
+ * The child's environment: inherited minus our own secrets.
386
+ *
387
+ * The runner token and the pm_live_ key both authorize far more than a coding
388
+ * agent needs, and an agent that can read them can leak them into a commit.
389
+ */
390
+ function childEnv() {
391
+ const env = { ...process.env };
392
+ for (const key of ["PREMAN_API_KEY", "PREMAN_RUNNER_TOKEN", "PREMAN_PAIR_CODE"]) {
393
+ delete env[key];
394
+ }
395
+ return env;
396
+ }
397
+
398
+ // ── Job execution ───────────────────────────────────────────────────────
399
+
400
+ function nowMs() {
401
+ return Number(process.hrtime.bigint() / 1000000n);
402
+ }
403
+
404
+ /** POST a runner callback. Never throws — a lost callback is retried or fenced. */
405
+ async function callback(args, state, route, body, log) {
406
+ try {
407
+ const result = await callBackendJson(args, "POST", route, {
408
+ token: state.runner_token,
409
+ json: body,
410
+ });
411
+ if (!result.ok) log(`callback ${route} -> ${result.status_code} ${result.detail || ""}`);
412
+ return result;
413
+ } catch (error) {
414
+ log(`callback ${route} failed: ${error.message}`);
415
+ return { ok: false, status_code: 0 };
416
+ }
417
+ }
418
+
419
+ export async function executeJob(args, state, job, { log = () => {}, fullAccess = false } = {}) {
420
+ const lease = String(job.lease_token || "");
421
+ const jobRoute = `/workbench/coding-agent/local-runner/jobs/${job.id}`;
422
+ if (!lease) {
423
+ log(`job ${job.id} arrived without a lease token; ignoring`);
424
+ return { ok: false, reason: "no_lease" };
425
+ }
426
+
427
+ const refusal = jobRefusal(state, job);
428
+ if (refusal) {
429
+ log(`refusing job ${job.id}: ${refusal}`);
430
+ await callback(
431
+ args,
432
+ state,
433
+ `${jobRoute}/complete`,
434
+ {
435
+ lease_token: lease,
436
+ ok: false,
437
+ error: refusal.slice(0, ERROR_CAP),
438
+ attempt_count: Math.max(1, Number(job.attempt_count) || 1),
439
+ tool_events: [],
440
+ tool_call_count: 0,
441
+ metadata: { transport: "cli", refused: true },
442
+ },
443
+ log
444
+ );
445
+ return { ok: false, reason: "refused" };
446
+ }
447
+
448
+ const spec = agentCommand(state.agent, String(job.prompt), { fullAccess });
449
+ if (!spec || !onPath(spec.bin)) {
450
+ const detail = `${spec ? spec.bin : state.agent} is not installed on this machine`;
451
+ log(`cannot run job ${job.id}: ${detail}`);
452
+ await callback(
453
+ args,
454
+ state,
455
+ `${jobRoute}/complete`,
456
+ {
457
+ lease_token: lease,
458
+ ok: false,
459
+ error: detail,
460
+ attempt_count: Math.max(1, Number(job.attempt_count) || 1),
461
+ tool_events: [],
462
+ tool_call_count: 0,
463
+ metadata: { transport: "cli", agent_missing: true },
464
+ },
465
+ log
466
+ );
467
+ return { ok: false, reason: "agent_missing" };
468
+ }
469
+
470
+ const started = await callback(args, state, `${jobRoute}/started`, { lease_token: lease }, log);
471
+ if (!started.ok) return { ok: false, reason: "not_leased" };
472
+ // The server increments the attempt on `started`, and the manifest is scoped to
473
+ // that attempt, so its number has to come from the response we just got.
474
+ const attemptCount = Math.max(1, Number(started.job?.attempt_count) || Number(job.attempt_count) || 1);
475
+
476
+ const collector = createToolEventCollector();
477
+ const timeoutMs = Math.min(AGENT_TIMEOUT_MS, AGENT_TIMEOUT_CAP_MS);
478
+ const t0 = nowMs();
479
+ let bytes = 0;
480
+ let truncated = false;
481
+ const chunks = [];
482
+ let pending = "";
483
+
484
+ const child = spawn(spec.bin, spec.args, {
485
+ cwd: state.project_path,
486
+ env: childEnv(),
487
+ shell: false,
488
+ stdio: ["ignore", "pipe", "pipe"],
489
+ });
490
+
491
+ function absorb(text) {
492
+ if (bytes < MAX_OUTPUT_BYTES) {
493
+ const room = MAX_OUTPUT_BYTES - bytes;
494
+ const slice = text.length > room ? text.slice(0, room) : text;
495
+ chunks.push(slice);
496
+ bytes += slice.length;
497
+ if (slice.length < text.length) truncated = true;
498
+ } else {
499
+ truncated = true;
500
+ }
501
+ if (spec.stream !== "claude-json") return;
502
+ pending += text;
503
+ let newline = pending.indexOf("\n");
504
+ while (newline !== -1) {
505
+ collector.ingestLine(pending.slice(0, newline));
506
+ pending = pending.slice(newline + 1);
507
+ newline = pending.indexOf("\n");
508
+ }
509
+ }
510
+
511
+ child.stdout?.setEncoding("utf8");
512
+ child.stderr?.setEncoding("utf8");
513
+ child.stdout?.on("data", absorb);
514
+ child.stderr?.on("data", absorb);
515
+
516
+ const renew = setInterval(() => {
517
+ callback(args, state, `${jobRoute}/lease`, { lease_token: lease }, log);
518
+ }, LEASE_RENEW_MS);
519
+ const progress = setInterval(() => {
520
+ callback(
521
+ args,
522
+ state,
523
+ `${jobRoute}/progress`,
524
+ {
525
+ lease_token: lease,
526
+ stage: "running",
527
+ message: `${spec.bin} running for ${Math.round((nowMs() - t0) / 1000)}s`,
528
+ activity: [
529
+ {
530
+ id: "agent",
531
+ label: `${spec.bin} (${collector.count()} tool calls)`.slice(0, 160),
532
+ state: "active",
533
+ elapsed_ms: nowMs() - t0,
534
+ },
535
+ ],
536
+ },
537
+ log
538
+ );
539
+ }, PROGRESS_MS);
540
+
541
+ let timedOut = false;
542
+ const timer = setTimeout(() => {
543
+ timedOut = true;
544
+ child.kill("SIGTERM");
545
+ setTimeout(() => child.kill("SIGKILL"), 5_000).unref?.();
546
+ }, timeoutMs);
547
+
548
+ const exit = await new Promise((resolve) => {
549
+ child.on("error", (error) => resolve({ code: null, error }));
550
+ child.on("close", (code, signal) => resolve({ code, signal }));
551
+ });
552
+
553
+ clearInterval(renew);
554
+ clearInterval(progress);
555
+ clearTimeout(timer);
556
+ if (pending) collector.ingestLine(pending);
557
+
558
+ const output = chunks.join("");
559
+ const ok = !timedOut && !exit.error && exit.code === 0;
560
+ const summary = output.slice(-SUMMARY_CAP) || undefined;
561
+ const error = ok
562
+ ? undefined
563
+ : (timedOut
564
+ ? `agent timed out after ${Math.round(timeoutMs / 1000)}s`
565
+ : exit.error
566
+ ? exit.error.message
567
+ : `${spec.bin} exited with code ${exit.code}${exit.signal ? ` (${exit.signal})` : ""}`
568
+ ).slice(0, ERROR_CAP);
569
+
570
+ const sessionId = collector.sessionId();
571
+ const complete = await callback(
572
+ args,
573
+ state,
574
+ `${jobRoute}/complete`,
575
+ {
576
+ lease_token: lease,
577
+ ok,
578
+ summary,
579
+ error,
580
+ exit_code: typeof exit.code === "number" ? exit.code : undefined,
581
+ branch: currentBranch(state.project_path) || undefined,
582
+ agent_session_id: sessionId || undefined,
583
+ attempt_count: attemptCount,
584
+ tool_events: collector.manifest(),
585
+ tool_call_count: collector.count(),
586
+ metadata: {
587
+ transport: "cli",
588
+ agent_bin: spec.bin,
589
+ duration_ms: nowMs() - t0,
590
+ output_truncated: truncated,
591
+ },
592
+ },
593
+ log
594
+ );
595
+
596
+ log(`job ${job.id} ${ok ? "completed" : "failed"} in ${Math.round((nowMs() - t0) / 1000)}s`);
597
+ return { ok, delivered: complete.ok, reason: ok ? "" : error };
598
+ }
599
+
600
+ // ── Event loop ──────────────────────────────────────────────────────────
601
+
602
+ function defaultLog(message) {
603
+ process.stdout.write(`[preman runner] ${new Date().toISOString()} ${message}\n`);
604
+ }
605
+
606
+ /**
607
+ * Hold the stream and run what it leases, until stopped.
608
+ *
609
+ * `once` and `stopWhen` are how a bounded run ends; a real one ends on a signal.
610
+ * Every terminal SSE reason has its own handling because they mean different
611
+ * things: `replaced` and `revoked` must not reconnect, `reconnect` must.
612
+ */
613
+ export async function runnerLoop(
614
+ args,
615
+ state,
616
+ { log = defaultLog, once = false, stopWhen = null, fullAccess = false } = {}
617
+ ) {
618
+ let stopped = false;
619
+ let busy = false;
620
+ let jobsRun = 0;
621
+ let backoff = RECONNECT_MIN_MS;
622
+ const eventsUrl = new URL(
623
+ "workbench/coding-agent/local-runner/events",
624
+ `${state.backend_url || backendUrl(args)}/`
625
+ );
626
+
627
+ const heartbeat = setInterval(async () => {
628
+ // Reported honestly because the matcher prefers idle runners: a busy device
629
+ // claiming to be idle wins work it will only sit on until the lease expires.
630
+ const result = await callBackendJson(args, "POST", "/workbench/coding-agent/local-runner/heartbeat", {
631
+ token: state.runner_token,
632
+ json: { state: busy ? "busy" : "idle" },
633
+ });
634
+ if (result.status_code === 401) {
635
+ log("runner registration is no longer active; stopping");
636
+ stopped = true;
637
+ }
638
+ }, HEARTBEAT_MS);
639
+
640
+ try {
641
+ while (!stopped && !(stopWhen && stopWhen())) {
642
+ let response;
643
+ try {
644
+ response = await fetch(eventsUrl, {
645
+ headers: { Authorization: `Bearer ${state.runner_token}`, Accept: "text/event-stream" },
646
+ });
647
+ } catch (error) {
648
+ log(`stream failed: ${error.message}; retrying in ${Math.round(backoff / 1000)}s`);
649
+ await sleep(backoff);
650
+ backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
651
+ continue;
652
+ }
653
+
654
+ if (response.status === 401) {
655
+ log("runner token was revoked; re-pair with `preman runner register`");
656
+ clearRunnerState();
657
+ return { stopped: true, jobsRun, reason: "revoked" };
658
+ }
659
+ if (!response.ok || !response.body) {
660
+ log(`stream answered ${response.status}; retrying in ${Math.round(backoff / 1000)}s`);
661
+ await sleep(backoff);
662
+ backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
663
+ continue;
664
+ }
665
+
666
+ backoff = RECONNECT_MIN_MS;
667
+ const queue = [];
668
+ let terminal = "";
669
+ const push = createSseParser((event, data) => {
670
+ if (event === "connected") log(`connected as runner ${data.runner_id || state.runner_id}`);
671
+ else if (event === "job") queue.push(data);
672
+ else if (event === "revoked" || event === "replaced") terminal = event;
673
+ else if (event === "reconnect") terminal = "reconnect";
674
+ });
675
+
676
+ const decoder = new TextDecoder();
677
+ try {
678
+ for await (const chunk of response.body) {
679
+ push(decoder.decode(chunk, { stream: true }));
680
+ while (queue.length) {
681
+ const job = queue.shift();
682
+ log(`leased job ${job.id}`);
683
+ busy = true;
684
+ try {
685
+ await executeJob(args, state, job, { log, fullAccess });
686
+ } finally {
687
+ busy = false;
688
+ }
689
+ jobsRun += 1;
690
+ if (once || (typeof stopWhen === "function" && stopWhen())) {
691
+ terminal = "done";
692
+ break;
693
+ }
694
+ }
695
+ if (terminal) break;
696
+ }
697
+ } catch (error) {
698
+ log(`stream dropped: ${error.message}`);
699
+ }
700
+
701
+ if (terminal === "revoked") {
702
+ log("runner was revoked; re-pair with `preman runner register`");
703
+ clearRunnerState();
704
+ return { stopped: true, jobsRun, reason: "revoked" };
705
+ }
706
+ if (terminal === "replaced") {
707
+ log("another runner replaced this one; stopping");
708
+ return { stopped: true, jobsRun, reason: "replaced" };
709
+ }
710
+ if (terminal === "done") return { stopped: true, jobsRun, reason: "done" };
711
+ if (!stopped) await sleep(RECONNECT_MIN_MS);
712
+ }
713
+ } finally {
714
+ clearInterval(heartbeat);
715
+ }
716
+
717
+ return { stopped: true, jobsRun, reason: "stopped" };
718
+ }
719
+
720
+ function sleep(ms) {
721
+ return new Promise((resolve) => setTimeout(resolve, ms));
722
+ }
723
+
724
+ async function goOffline(args, state, log = defaultLog) {
725
+ await callback(args, state, "/workbench/coding-agent/local-runner/offline", {}, log);
726
+ }
727
+
728
+ // ── Command ─────────────────────────────────────────────────────────────
729
+
730
+ export const RUNNER_HELP = `
731
+ Runner options:
732
+ runner start [--background] Hold the job stream and run queued work here
733
+ runner register [--agent a] Pair this machine as a runner (connect does this)
734
+ runner status Report whether the local runner is running
735
+ runner stop Stop the background runner and go offline
736
+ --agent <name> claude-code | cursor | codex
737
+ --path <dir> Project the runner is bound to. Defaults to cwd
738
+ --once Run one job, then exit
739
+ --full-access Let the agent run commands, not just edit files
740
+ `;
741
+
742
+ /** Start the daemon detached, so a terminal can be closed without killing it. */
743
+ export function startBackground(commandArgs) {
744
+ ensureDir();
745
+ // 0600 like everything else under ~/.preman: nothing in here is a credential
746
+ // today, and that is not a property to leave depending on future log lines.
747
+ const log = openSync(RUNNER_LOG_FILE, "a", 0o600);
748
+ const passthrough = commandArgs.filter((value) => value !== "--background");
749
+ const child = spawn(
750
+ process.execPath,
751
+ [path.join(__dirname, "cli.js"), "runner", "start", ...passthrough],
752
+ { detached: true, stdio: ["ignore", log, log] }
753
+ );
754
+ child.unref();
755
+ writeFileSync(RUNNER_PID_FILE, `${child.pid}\n`, { mode: 0o600 });
756
+ return { pid: child.pid, log: RUNNER_LOG_FILE };
757
+ }
758
+
759
+ async function startForeground(args, commandArgs) {
760
+ let state = readRunnerState();
761
+ if (!state) {
762
+ const agent = normalizeAgentId(args.value("--agent", "")) || "";
763
+ if (!agent) {
764
+ throw new RunnerError(
765
+ `This machine is not paired as a runner yet. Run '${cliInvocation()} runner register --agent claude-code'.`,
766
+ 2
767
+ );
768
+ }
769
+ state = await registerRunner(args, { agent, projectPath: args.value("--path", process.cwd()) });
770
+ }
771
+
772
+ const log = defaultLog;
773
+ log(`runner ${state.runner_id} · ${state.agent} · ${state.project_path}`);
774
+ ensureDir();
775
+ writeFileSync(RUNNER_PID_FILE, `${process.pid}\n`, { mode: 0o600 });
776
+
777
+ let shuttingDown = false;
778
+ const shutdown = async (signal) => {
779
+ if (shuttingDown) return;
780
+ shuttingDown = true;
781
+ log(`${signal}: going offline`);
782
+ await goOffline(args, state, log);
783
+ rmSync(RUNNER_PID_FILE, { force: true });
784
+ process.exit(0);
785
+ };
786
+ process.on("SIGINT", () => void shutdown("SIGINT"));
787
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
788
+
789
+ try {
790
+ const result = await runnerLoop(args, state, {
791
+ log,
792
+ once: args.has("--once"),
793
+ fullAccess: args.has("--full-access") || process.env.PREMAN_RUNNER_FULL_ACCESS === "1",
794
+ });
795
+ log(`stopped after ${result.jobsRun} job(s): ${result.reason}`);
796
+ } finally {
797
+ if (!shuttingDown) {
798
+ await goOffline(args, state, log);
799
+ rmSync(RUNNER_PID_FILE, { force: true });
800
+ }
801
+ }
802
+ }
803
+
804
+ export async function runnerCommand(commandArgs = []) {
805
+ const sub = commandArgs.find((value) => !value.startsWith("-")) || "status";
806
+ const args = makeArgs(commandArgs);
807
+
808
+ if (sub === "register") {
809
+ const agent = normalizeAgentId(args.value("--agent", ""));
810
+ if (!agent) throw new RunnerError("pass --agent claude-code|cursor|codex", 2);
811
+ const state = await registerRunner(args, {
812
+ agent,
813
+ projectPath: args.value("--path", process.cwd()),
814
+ });
815
+ process.stdout.write(
816
+ `Runner paired: ${state.runner_id}\n` +
817
+ ` agent: ${state.agent}\n project: ${state.project_path}\n\n` +
818
+ `Start it: ${cliInvocation()} runner start --background\n`
819
+ );
820
+ return state;
821
+ }
822
+
823
+ if (sub === "start") {
824
+ if (args.has("--background")) {
825
+ if (runnerIsAlive()) {
826
+ process.stdout.write(`Runner already running (pid ${readPid()}).\n`);
827
+ return { state: "running", pid: readPid() };
828
+ }
829
+ const started = startBackground(commandArgs.filter((value) => value !== "start"));
830
+ process.stdout.write(
831
+ `Runner started in the background (pid ${started.pid}).\n log: ${started.log}\n`
832
+ );
833
+ return started;
834
+ }
835
+ await startForeground(args, commandArgs);
836
+ return { state: "stopped" };
837
+ }
838
+
839
+ if (sub === "status") {
840
+ const state = readRunnerState();
841
+ const pid = readPid();
842
+ const alive = runnerIsAlive(pid);
843
+ if (!state) {
844
+ process.stdout.write(
845
+ `Local runner: not paired\n Pair it: ${cliInvocation()} runner register --agent claude-code\n`
846
+ );
847
+ return { state: "unpaired" };
848
+ }
849
+ process.stdout.write(
850
+ `Local runner: ${alive ? `running (pid ${pid})` : "paired, not running"}\n` +
851
+ ` agent: ${state.agent}\n` +
852
+ ` project: ${state.project_path}\n` +
853
+ ` runner id: ${state.runner_id}\n` +
854
+ (alive ? ` log: ${RUNNER_LOG_FILE}\n` : ` Start it: ${cliInvocation()} runner start --background\n`)
855
+ );
856
+ return { state: alive ? "running" : "paired", pid };
857
+ }
858
+
859
+ if (sub === "stop") {
860
+ const pid = readPid();
861
+ const state = readRunnerState();
862
+ if (!runnerIsAlive(pid)) {
863
+ if (state) await goOffline(args, state, () => {});
864
+ rmSync(RUNNER_PID_FILE, { force: true });
865
+ process.stdout.write("Runner was not running.\n");
866
+ return { state: "stopped" };
867
+ }
868
+ process.kill(pid, "SIGTERM");
869
+ // The daemon reports itself offline on SIGTERM; this only covers the case
870
+ // where it dies before that callback lands.
871
+ for (let i = 0; i < 20 && runnerIsAlive(pid); i += 1) await sleep(250);
872
+ if (runnerIsAlive(pid)) process.kill(pid, "SIGKILL");
873
+ if (state) await goOffline(args, state, () => {});
874
+ rmSync(RUNNER_PID_FILE, { force: true });
875
+ process.stdout.write(`Runner stopped (pid ${pid}).\n`);
876
+ return { state: "stopped", pid };
877
+ }
878
+
879
+ throw new RunnerError(`unknown runner subcommand: ${sub}${RUNNER_HELP}`, 2);
880
+ }