orbit-runner 0.1.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.
Files changed (3) hide show
  1. package/README.md +69 -0
  2. package/dist/index.js +1445 -0
  3. package/package.json +37 -0
package/dist/index.js ADDED
@@ -0,0 +1,1445 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { cac } from "cac";
5
+ import pc5 from "picocolors";
6
+
7
+ // src/commands/doctor.ts
8
+ import { existsSync as existsSync2 } from "node:fs";
9
+ import { join as join3 } from "node:path";
10
+ import pc from "picocolors";
11
+
12
+ // src/claude.ts
13
+ import { execFileSync } from "node:child_process";
14
+
15
+ // src/constants.ts
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+ var CLI_VERSION = "0.1.0";
19
+ var DEFAULT_API_URL = "https://api.orbitagents.dev";
20
+ var resolveApiUrl = (configApiUrl) => process.env.ORBIT_API_URL ?? configApiUrl ?? DEFAULT_API_URL;
21
+ var CONFIG_DIR = join(homedir(), ".config", "orbit-runner");
22
+ var CONFIG_PATH = join(CONFIG_DIR, "config.json");
23
+ var DEFAULT_WORK_DIR = join(homedir(), ".orbit-runner");
24
+ var PLUGIN_MARKETPLACE_SOURCE = "rafaelszago/orbit-plugin";
25
+ var PLUGIN_MARKETPLACE_NAME = "orbit-marketplace";
26
+ var PLUGIN_ID = "orbit@orbit-marketplace";
27
+ var MCP_SERVER_NAME = "orbit";
28
+
29
+ // src/claude.ts
30
+ var stripSlash = (u) => u.replace(/\/+$/, "");
31
+ function run(bin, args) {
32
+ return execFileSync(bin, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
33
+ }
34
+ function tryRun(bin, args) {
35
+ try {
36
+ return { ok: true, out: run(bin, args) };
37
+ } catch (err) {
38
+ const e = err;
39
+ return { ok: false, out: `${e.stdout ?? ""}${e.stderr ?? ""}`.trim() || e.message };
40
+ }
41
+ }
42
+ function claudeVersion(bin = "claude") {
43
+ const r = tryRun(bin, ["--version"]);
44
+ return r.ok ? r.out.trim() : null;
45
+ }
46
+ var claudeAvailable = (bin = "claude") => claudeVersion(bin) !== null;
47
+ function mcpServerRegistered(name = MCP_SERVER_NAME, bin = "claude") {
48
+ const r = tryRun(bin, ["mcp", "list"]);
49
+ return r.ok && new RegExp(`(^|\\n)\\s*${name}\\b`, "m").test(r.out);
50
+ }
51
+ function addMcpServer(opts) {
52
+ const name = opts.name ?? MCP_SERVER_NAME;
53
+ const bin = opts.bin ?? "claude";
54
+ if (mcpServerRegistered(name, bin))
55
+ return "exists";
56
+ run(bin, [
57
+ "mcp",
58
+ "add",
59
+ name,
60
+ `${stripSlash(opts.url)}/mcp`,
61
+ "-t",
62
+ "http",
63
+ "-H",
64
+ `x-api-key: ${opts.key}`,
65
+ "-s",
66
+ "user"
67
+ ]);
68
+ return "added";
69
+ }
70
+ function marketplaceRegistered(bin = "claude") {
71
+ const r = tryRun(bin, ["plugin", "marketplace", "list"]);
72
+ return r.ok && (r.out.includes(PLUGIN_MARKETPLACE_SOURCE) || r.out.includes(PLUGIN_MARKETPLACE_NAME));
73
+ }
74
+ function addMarketplace(bin = "claude") {
75
+ if (marketplaceRegistered(bin))
76
+ return "exists";
77
+ run(bin, ["plugin", "marketplace", "add", PLUGIN_MARKETPLACE_SOURCE]);
78
+ return "added";
79
+ }
80
+ function pluginInstalled(bin = "claude") {
81
+ const r = tryRun(bin, ["plugin", "list"]);
82
+ return r.ok && /(^|\n)\s*orbit\b/m.test(r.out);
83
+ }
84
+ function installPlugin(bin = "claude") {
85
+ if (pluginInstalled(bin))
86
+ return "exists";
87
+ run(bin, ["plugin", "install", PLUGIN_ID, "-s", "user"]);
88
+ return "installed";
89
+ }
90
+
91
+ // src/config.ts
92
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
93
+ import { homedir as homedir2 } from "node:os";
94
+ import { join as join2 } from "node:path";
95
+ import { z } from "zod";
96
+ var expandHome = (p) => p === "~" || p.startsWith("~/") ? join2(homedir2(), p.slice(1)) : p;
97
+ var projectSchema = z.object({
98
+ repo: z.string(),
99
+ repos: z.record(z.string()).optional(),
100
+ model: z.string().nullable().optional(),
101
+ effort: z.string().nullable().optional()
102
+ });
103
+ var configSchema = z.object({
104
+ api_key: z.string().min(1, "api_key is required — create one in Orbit → Settings → Agents"),
105
+ api_url: z.string().url().optional(),
106
+ poll_interval_seconds: z.number().int().positive().default(30),
107
+ max_slots: z.number().int().positive().default(2),
108
+ rate_limit_pause_minutes: z.number().int().positive().default(30),
109
+ task_retry_cooldown_minutes: z.number().int().positive().default(10),
110
+ work_dir: z.string().default(DEFAULT_WORK_DIR),
111
+ claude_bin: z.string().default("claude"),
112
+ claude_args: z.array(z.string()).default([]),
113
+ model: z.string().nullable().default(null),
114
+ effort: z.string().nullable().default(null),
115
+ projects: z.record(projectSchema).default({})
116
+ });
117
+ var configExists = () => existsSync(CONFIG_PATH);
118
+ function loadConfig() {
119
+ let raw;
120
+ try {
121
+ raw = readFileSync(CONFIG_PATH, "utf8");
122
+ } catch {
123
+ throw new Error(`No config at ${CONFIG_PATH}. Run \`orbit-runner init\` first.`);
124
+ }
125
+ let json;
126
+ try {
127
+ json = JSON.parse(raw);
128
+ } catch (err) {
129
+ throw new Error(`Config at ${CONFIG_PATH} is not valid JSON: ${err.message}`);
130
+ }
131
+ const parsed = configSchema.safeParse(json);
132
+ if (!parsed.success) {
133
+ const issues = parsed.error.issues.map((i) => ` • ${i.path.join(".") || "(root)"}: ${i.message}`).join(`
134
+ `);
135
+ throw new Error(`Config at ${CONFIG_PATH} is invalid:
136
+ ${issues}`);
137
+ }
138
+ return parsed.data;
139
+ }
140
+ function tryLoadConfig() {
141
+ try {
142
+ return loadConfig();
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+ function saveConfig(config) {
148
+ mkdirSync(CONFIG_DIR, { recursive: true });
149
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}
150
+ `, { mode: 384 });
151
+ }
152
+
153
+ // src/orbit.ts
154
+ var stripSlash2 = (u) => u.replace(/\/+$/, "");
155
+ var sessionMcpConfig = (apiUrl, apiKey) => ({
156
+ mcpServers: {
157
+ [MCP_SERVER_NAME]: {
158
+ type: "http",
159
+ url: `${stripSlash2(apiUrl)}/mcp`,
160
+ headers: { "x-api-key": apiKey }
161
+ }
162
+ }
163
+ });
164
+ function parseMcp(raw) {
165
+ const trimmed = raw.trim();
166
+ if (trimmed.startsWith("{"))
167
+ return JSON.parse(trimmed);
168
+ let last = null;
169
+ for (const line of trimmed.split(`
170
+ `)) {
171
+ if (line.startsWith("data:"))
172
+ last = line.slice("data:".length).trim();
173
+ }
174
+ if (last === null)
175
+ throw new Error(`unrecognized MCP response: ${trimmed.slice(0, 200)}`);
176
+ return JSON.parse(last);
177
+ }
178
+
179
+ class OrbitClient {
180
+ apiKey;
181
+ id = 0;
182
+ base;
183
+ constructor(apiUrl, apiKey) {
184
+ this.apiKey = apiKey;
185
+ this.base = stripSlash2(apiUrl);
186
+ }
187
+ headers() {
188
+ return {
189
+ "x-api-key": this.apiKey,
190
+ "content-type": "application/json",
191
+ accept: "application/json, text/event-stream"
192
+ };
193
+ }
194
+ async callTool(name, args = {}) {
195
+ this.id += 1;
196
+ let res;
197
+ try {
198
+ res = await fetch(`${this.base}/mcp`, {
199
+ method: "POST",
200
+ headers: this.headers(),
201
+ body: JSON.stringify({
202
+ jsonrpc: "2.0",
203
+ id: this.id,
204
+ method: "tools/call",
205
+ params: { name, arguments: args }
206
+ })
207
+ });
208
+ } catch (err) {
209
+ throw new Error(`Orbit API unreachable: ${err.message}`);
210
+ }
211
+ const msg = parseMcp(await res.text());
212
+ if (msg.error)
213
+ throw new Error(`${name}: ${msg.error.message ?? JSON.stringify(msg.error)}`);
214
+ const text = msg.result?.content?.find((c) => c.type === "text")?.text ?? "";
215
+ let payload;
216
+ try {
217
+ payload = JSON.parse(text);
218
+ } catch {
219
+ payload = { text };
220
+ }
221
+ if (msg.result?.isError) {
222
+ const detail = payload?.error ?? text;
223
+ throw new Error(`${name}: ${detail}`);
224
+ }
225
+ return payload;
226
+ }
227
+ async verifyKey() {
228
+ await this.callTool("list_projects");
229
+ }
230
+ async heartbeat(input) {
231
+ const res = await fetch(`${this.base}/api/runner/heartbeat`, {
232
+ method: "POST",
233
+ headers: this.headers(),
234
+ body: JSON.stringify(input)
235
+ });
236
+ if (!res.ok)
237
+ throw new Error(`heartbeat failed: ${res.status} ${res.statusText}`);
238
+ }
239
+ }
240
+
241
+ // src/commands/doctor.ts
242
+ var ICON = {
243
+ ok: pc.green("✔"),
244
+ warn: pc.yellow("⚠"),
245
+ fail: pc.red("✗")
246
+ };
247
+ var formatCheck = (c) => `${ICON[c.status]} ${c.label}${c.detail ? pc.dim(` — ${c.detail}`) : ""}`;
248
+ async function runChecks() {
249
+ const checks = [];
250
+ const version = claudeVersion();
251
+ checks.push(version ? { label: "Claude Code CLI", status: "ok", detail: version } : {
252
+ label: "Claude Code CLI",
253
+ status: "fail",
254
+ detail: "`claude` not on PATH — install Claude Code"
255
+ });
256
+ const config = tryLoadConfig();
257
+ if (!config) {
258
+ checks.push({
259
+ label: "Runner config",
260
+ status: "fail",
261
+ detail: "missing or invalid — run `orbit-runner init`"
262
+ });
263
+ return checks;
264
+ }
265
+ checks.push({
266
+ label: "Runner config",
267
+ status: "ok",
268
+ detail: "~/.config/orbit-runner/config.json"
269
+ });
270
+ const apiUrl = resolveApiUrl(config.api_url);
271
+ try {
272
+ await new OrbitClient(apiUrl, config.api_key).verifyKey();
273
+ checks.push({ label: "Orbit API + key", status: "ok", detail: apiUrl });
274
+ } catch (err) {
275
+ checks.push({ label: "Orbit API + key", status: "fail", detail: err.message });
276
+ }
277
+ const keys = Object.keys(config.projects);
278
+ if (keys.length === 0) {
279
+ checks.push({
280
+ label: "Project mapping",
281
+ status: "warn",
282
+ detail: "no projects mapped — the runner has nothing to poll"
283
+ });
284
+ }
285
+ for (const key of keys) {
286
+ const project = config.projects[key];
287
+ if (!project)
288
+ continue;
289
+ const repo = expandHome(project.repo);
290
+ if (!existsSync2(repo)) {
291
+ checks.push({
292
+ label: `Project ${key} repo`,
293
+ status: "fail",
294
+ detail: `${repo} does not exist`
295
+ });
296
+ continue;
297
+ }
298
+ if (!existsSync2(join3(repo, ".git"))) {
299
+ checks.push({
300
+ label: `Project ${key} repo`,
301
+ status: "fail",
302
+ detail: `${repo} is not a git repo`
303
+ });
304
+ continue;
305
+ }
306
+ const hasAllowlist = existsSync2(join3(repo, ".claude", "settings.json"));
307
+ checks.push(hasAllowlist ? { label: `Project ${key} repo`, status: "ok", detail: repo } : {
308
+ label: `Project ${key} repo`,
309
+ status: "warn",
310
+ detail: `${repo} — no .claude/settings.json allowlist; headless sessions get every tool denied`
311
+ });
312
+ }
313
+ if (version) {
314
+ checks.push(mcpServerRegistered() ? { label: "Orbit MCP (interactive Claude)", status: "ok" } : {
315
+ label: "Orbit MCP (interactive Claude)",
316
+ status: "warn",
317
+ detail: "not registered — run `orbit-runner init` (or ignore if you used --no-plugin)"
318
+ });
319
+ checks.push(pluginInstalled() ? { label: "Orbit plugin (interactive Claude)", status: "ok" } : {
320
+ label: "Orbit plugin (interactive Claude)",
321
+ status: "warn",
322
+ detail: "not installed — optional; enables /orbit:tasks and the workflow skill"
323
+ });
324
+ }
325
+ return checks;
326
+ }
327
+ async function doctorCommand() {
328
+ const checks = await runChecks();
329
+ console.log(pc.bold("orbit-runner doctor"));
330
+ for (const c of checks)
331
+ console.log(` ${formatCheck(c)}`);
332
+ if (checks.some((c) => c.status === "fail"))
333
+ process.exitCode = 1;
334
+ }
335
+
336
+ // src/commands/init.ts
337
+ import { existsSync as existsSync3 } from "node:fs";
338
+ import { join as join4 } from "node:path";
339
+ import * as p from "@clack/prompts";
340
+ import pc2 from "picocolors";
341
+ async function initCommand(opts) {
342
+ p.intro(pc2.bgCyan(pc2.black(" orbit-runner init ")));
343
+ const existing = configExists() ? tryLoadConfig() : null;
344
+ if (existing)
345
+ p.log.info("Found an existing config — press enter to keep current values.");
346
+ let apiKey = opts.key ?? process.env.ORBIT_API_KEY ?? existing?.api_key;
347
+ if (!apiKey) {
348
+ const answer = await p.password({
349
+ message: "Orbit agent API key",
350
+ validate: (v) => v && v.length > 0 ? undefined : "Required — Orbit → Settings → Agents"
351
+ });
352
+ if (p.isCancel(answer))
353
+ return cancelled();
354
+ apiKey = answer;
355
+ }
356
+ const apiUrl = resolveApiUrl(existing?.api_url);
357
+ let projects = [];
358
+ const spin = p.spinner();
359
+ spin.start("Verifying key against Orbit");
360
+ try {
361
+ const client = new OrbitClient(apiUrl, apiKey);
362
+ await client.verifyKey();
363
+ projects = await fetchProjects(client);
364
+ spin.stop(`${pc2.green("Key verified")}${pc2.dim(` · ${apiUrl}`)}`);
365
+ } catch (err) {
366
+ spin.stop(pc2.yellow(`Could not verify key: ${err.message}`));
367
+ const proceed = await p.confirm({ message: "Save anyway and continue?", initialValue: false });
368
+ if (p.isCancel(proceed) || !proceed)
369
+ return cancelled();
370
+ }
371
+ const mapped = { ...existing?.projects ?? {} };
372
+ const chosen = await chooseProjects(projects, Object.keys(mapped));
373
+ if (chosen === null)
374
+ return cancelled();
375
+ for (const key of chosen) {
376
+ const guess = mapped[key]?.repo ?? detectRepo();
377
+ const repo = await p.text({
378
+ message: `Local git repo for ${pc2.cyan(key)}`,
379
+ initialValue: guess ?? "",
380
+ validate: (v) => v && v.length > 0 ? undefined : "Required"
381
+ });
382
+ if (p.isCancel(repo))
383
+ return cancelled();
384
+ mapped[key] = { ...mapped[key], repo };
385
+ }
386
+ const config = configSchema.parse({
387
+ ...existing ?? {},
388
+ api_key: apiKey,
389
+ projects: mapped,
390
+ ...apiUrl !== DEFAULT_API_URL ? { api_url: apiUrl } : {}
391
+ });
392
+ saveConfig(config);
393
+ p.log.success(`Saved ${pc2.cyan("~/.config/orbit-runner/config.json")}`);
394
+ if (opts.plugin) {
395
+ await setupClaude(apiUrl, apiKey);
396
+ } else {
397
+ p.log.info(pc2.dim("Skipped plugin/MCP setup (--no-plugin). The runner still supplies MCP to sessions it spawns."));
398
+ }
399
+ const checks = await runChecks();
400
+ p.note(checks.map(formatCheck).join(`
401
+ `), "Doctor");
402
+ const failed = checks.some((c) => c.status === "fail");
403
+ p.outro(failed ? pc2.yellow("Saved with warnings above — fix the ✗ items, then run `orbit-runner start`.") : `${pc2.green("Ready.")} Flag a task agent-ready in Orbit, then run ${pc2.cyan("orbit-runner start")}.`);
404
+ }
405
+ async function chooseProjects(projects, preselected) {
406
+ if (projects.length === 0) {
407
+ const answer = await p.text({
408
+ message: "Project key to map (e.g. ORB)",
409
+ initialValue: preselected[0] ?? ""
410
+ });
411
+ if (p.isCancel(answer))
412
+ return null;
413
+ return answer ? [answer] : [];
414
+ }
415
+ const selection = await p.multiselect({
416
+ message: "Which projects should this runner work?",
417
+ options: projects.map((pr) => ({
418
+ value: pr.key,
419
+ label: `${pr.key} — ${pr.name}`,
420
+ hint: preselected.includes(pr.key) ? "mapped" : undefined
421
+ })),
422
+ initialValues: preselected,
423
+ required: false
424
+ });
425
+ if (p.isCancel(selection))
426
+ return null;
427
+ return selection;
428
+ }
429
+ async function setupClaude(apiUrl, apiKey) {
430
+ if (!claudeAvailable()) {
431
+ p.log.warn("`claude` CLI not found — skipping plugin/MCP setup. Install Claude Code, then re-run `orbit-runner init`.");
432
+ return;
433
+ }
434
+ const spin = p.spinner();
435
+ spin.start("Setting up Orbit in Claude Code (user scope)");
436
+ try {
437
+ const mcp = addMcpServer({ url: apiUrl, key: apiKey });
438
+ const marketplace = addMarketplace();
439
+ const plugin = installPlugin();
440
+ spin.stop(pc2.green("Claude Code configured"));
441
+ p.log.step(`MCP server — ${outcome(mcp === "added", "registered", "already registered")}`);
442
+ p.log.step(`Marketplace — ${outcome(marketplace === "added", "added", "already added")}`);
443
+ p.log.step(`Plugin — ${outcome(plugin === "installed", "installed", "already installed")}`);
444
+ } catch (err) {
445
+ spin.stop(pc2.yellow("Plugin/MCP setup hit a snag"));
446
+ p.log.warn(err.message);
447
+ p.log.info("Your config is saved — you can finish this step manually (see the README) or re-run `init`.");
448
+ }
449
+ }
450
+ var outcome = (changed, did, already) => changed ? pc2.green(did) : pc2.dim(already);
451
+ async function fetchProjects(client) {
452
+ try {
453
+ const res = await client.callTool("list_projects");
454
+ const arr = Array.isArray(res) ? res : res?.projects ?? [];
455
+ return arr.map((x) => x).filter((x) => typeof x.key === "string").map((x) => ({ key: String(x.key), name: String(x.name ?? x.key) }));
456
+ } catch {
457
+ return [];
458
+ }
459
+ }
460
+ function detectRepo() {
461
+ const cwd = process.cwd();
462
+ return existsSync3(join4(cwd, ".git")) ? cwd : undefined;
463
+ }
464
+ function cancelled() {
465
+ p.cancel("Setup cancelled.");
466
+ }
467
+
468
+ // src/commands/logs.ts
469
+ import {
470
+ closeSync,
471
+ existsSync as existsSync4,
472
+ openSync,
473
+ readdirSync as readdirSync2,
474
+ readFileSync as readFileSync3,
475
+ readSync,
476
+ statSync
477
+ } from "node:fs";
478
+ import { basename as basename2, join as join6, resolve } from "node:path";
479
+ import pc3 from "picocolors";
480
+
481
+ // src/paths.ts
482
+ import { readdirSync, readFileSync as readFileSync2, unlinkSync } from "node:fs";
483
+ import { basename, join as join5 } from "node:path";
484
+ var suffix = (project) => project ? `-${project.toLowerCase()}` : "";
485
+ var pidfilePath = (workDir, project) => join5(workDir, `runner${suffix(project)}.pid`);
486
+ var statePath = (workDir, project) => join5(workDir, `runner${suffix(project)}.state.json`);
487
+ var runnerLogPath = (workDir, project) => join5(workDir, `runner${suffix(project)}.log`);
488
+ var worktreesDir = (workDir) => join5(workDir, "worktrees");
489
+ var logsDir = (workDir) => join5(workDir, "logs");
490
+ function readPid(pidFile) {
491
+ try {
492
+ const n = Number.parseInt(readFileSync2(pidFile, "utf8").trim(), 10);
493
+ return Number.isFinite(n) ? n : null;
494
+ } catch {
495
+ return null;
496
+ }
497
+ }
498
+ function pidAlive(pid) {
499
+ if (!pid)
500
+ return false;
501
+ try {
502
+ process.kill(pid, 0);
503
+ return true;
504
+ } catch (err) {
505
+ return err.code === "EPERM";
506
+ }
507
+ }
508
+ var readLivePid = (pidFile) => {
509
+ const pid = readPid(pidFile);
510
+ return pidAlive(pid) ? pid : null;
511
+ };
512
+ function loadState(path) {
513
+ try {
514
+ return JSON.parse(readFileSync2(path, "utf8"));
515
+ } catch {
516
+ return {};
517
+ }
518
+ }
519
+ function discoverInstances(workDir, scope) {
520
+ let names;
521
+ try {
522
+ names = readdirSync(workDir).filter((f) => /^runner(-[^.]+)?\.pid$/.test(f));
523
+ } catch {
524
+ return [];
525
+ }
526
+ const out = [];
527
+ for (const name of names.sort()) {
528
+ const m = /^runner(?:-([^.]+))?\.pid$/.exec(name);
529
+ const project = m?.[1] ? m[1].toUpperCase() : null;
530
+ if (scope != null && project !== scope)
531
+ continue;
532
+ out.push({
533
+ project,
534
+ pidFile: join5(workDir, name),
535
+ state: loadState(statePath(workDir, project))
536
+ });
537
+ }
538
+ return out;
539
+ }
540
+ function fmtDuration(seconds) {
541
+ const s = Math.max(0, Math.floor(seconds));
542
+ const h = Math.floor(s / 3600);
543
+ const m = Math.floor(s % 3600 / 60);
544
+ const sec = s % 60;
545
+ if (h)
546
+ return `${h}h${m}m`;
547
+ if (m)
548
+ return `${m}m${sec}s`;
549
+ return `${sec}s`;
550
+ }
551
+ var taskFromLog = (path) => basename(path).replace(/-\d+\.log$/, "").toUpperCase();
552
+ function safeRemove(...paths) {
553
+ for (const p2 of paths) {
554
+ try {
555
+ unlinkSync(p2);
556
+ } catch {}
557
+ }
558
+ }
559
+
560
+ // src/util.ts
561
+ var delay = (ms) => new Promise((r) => setTimeout(r, ms));
562
+ var pad = (n) => String(n).padStart(2, "0");
563
+ function fmtDateTime(ms) {
564
+ const d = new Date(ms);
565
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
566
+ }
567
+
568
+ // src/commands/logs.ts
569
+ async function logsCommand(task, opts) {
570
+ const config = tryLoadConfig();
571
+ const workDir = resolve(expandHome(config?.work_dir ?? DEFAULT_WORK_DIR));
572
+ const dir = logsDir(workDir);
573
+ const collect = () => {
574
+ if (task) {
575
+ if (!existsSync4(dir))
576
+ return [];
577
+ const matches = readdirSync2(dir).filter((f) => f.toLowerCase().startsWith(`${task.toLowerCase()}-`) && f.endsWith(".log")).sort();
578
+ const last = matches[matches.length - 1];
579
+ return last ? [join6(dir, last)] : [];
580
+ }
581
+ const files2 = [];
582
+ for (const { state } of discoverInstances(workDir)) {
583
+ for (const s of state.slots ?? []) {
584
+ if (s.log_path && existsSync4(s.log_path) && !files2.includes(s.log_path))
585
+ files2.push(s.log_path);
586
+ }
587
+ }
588
+ return files2;
589
+ };
590
+ const files = collect();
591
+ if (files.length === 0 && !opts.follow) {
592
+ if (task)
593
+ throw new Error(`no logs for ${task} under ${dir}`);
594
+ console.log("orbit-runner: no active sessions");
595
+ return;
596
+ }
597
+ if (!opts.follow) {
598
+ files.forEach((fp, i) => {
599
+ if (i)
600
+ console.log();
601
+ console.log(pc3.dim(`==> ${basename2(fp)} <==`));
602
+ console.log(tailText(fp, 200));
603
+ });
604
+ return;
605
+ }
606
+ await followLogs(collect, task === undefined);
607
+ }
608
+ function tailText(path, lines) {
609
+ try {
610
+ const arr = readFileSync3(path, "utf8").split(`
611
+ `);
612
+ if (arr.at(-1) === "")
613
+ arr.pop();
614
+ return arr.slice(-lines).join(`
615
+ `);
616
+ } catch {
617
+ return "";
618
+ }
619
+ }
620
+ async function followLogs(collect, multiplex) {
621
+ console.log(pc3.dim("Following… (Ctrl-C to stop)"));
622
+ let stop = false;
623
+ process.on("SIGINT", () => {
624
+ stop = true;
625
+ });
626
+ const offsets = new Map;
627
+ for (const f of collect())
628
+ offsets.set(f, fileSize(f));
629
+ while (!stop) {
630
+ for (const f of collect()) {
631
+ const { text: text2, next } = readSince(f, offsets.get(f) ?? 0);
632
+ offsets.set(f, next);
633
+ if (!text2)
634
+ continue;
635
+ const prefix = multiplex ? `[${taskFromLog(f)}] ` : "";
636
+ for (const line of text2.split(`
637
+ `)) {
638
+ if (line)
639
+ process.stdout.write(`${prefix}${line}
640
+ `);
641
+ }
642
+ }
643
+ await delay(500);
644
+ }
645
+ }
646
+ function fileSize(path) {
647
+ try {
648
+ return statSync(path).size;
649
+ } catch {
650
+ return 0;
651
+ }
652
+ }
653
+ function readSince(path, offset) {
654
+ let fd;
655
+ try {
656
+ fd = openSync(path, "r");
657
+ } catch {
658
+ return { text: "", next: offset };
659
+ }
660
+ try {
661
+ const buf = Buffer.alloc(65536);
662
+ let pos = offset;
663
+ let out = "";
664
+ for (;; ) {
665
+ const n = readSync(fd, buf, 0, buf.length, pos);
666
+ if (n <= 0)
667
+ break;
668
+ out += buf.toString("utf8", 0, n);
669
+ pos += n;
670
+ }
671
+ return { text: out, next: pos };
672
+ } finally {
673
+ closeSync(fd);
674
+ }
675
+ }
676
+
677
+ // src/commands/start.ts
678
+ import { mkdirSync as mkdirSync3 } from "node:fs";
679
+ import { resolve as resolve4 } from "node:path";
680
+
681
+ // src/daemon.ts
682
+ import { spawn } from "node:child_process";
683
+ import { closeSync as closeSync3, mkdirSync as mkdirSync2, openSync as openSync3, renameSync, writeFileSync as writeFileSync2 } from "node:fs";
684
+ import { join as join8, resolve as resolve3 } from "node:path";
685
+
686
+ // ../../packages/shared/src/constants.ts
687
+ var STATUS_CATEGORIES = ["backlog", "todo", "in_progress", "done", "canceled"];
688
+ var ACTIVITY_TYPES = [
689
+ "task.created",
690
+ "task.updated",
691
+ "task.status_changed",
692
+ "task.assigned",
693
+ "task.commented",
694
+ "task.deleted",
695
+ "task.dependency_added",
696
+ "task.dependency_removed",
697
+ "project.created",
698
+ "project.updated",
699
+ "agent.created",
700
+ "agent.session_started",
701
+ "agent.session_progress",
702
+ "agent.session_completed",
703
+ "agent.session_abandoned",
704
+ "digest.standup",
705
+ "github.branch_created",
706
+ "github.pr_opened",
707
+ "github.pr_merged",
708
+ "github.pr_closed",
709
+ "github.pr_changes_requested"
710
+ ];
711
+ var WEBHOOK_FORMATS = ["json", "slack"];
712
+ var AGENT_KINDS = ["claude-code", "custom"];
713
+ var COMMENT_KINDS = ["comment", "progress", "summary"];
714
+ var RUNNER_STATUSES = ["active", "paused_rate_limit"];
715
+ var RUNNER_HEARTBEAT_INTERVAL_SECONDS = 30;
716
+ var RUNNER_OFFLINE_AFTER_MISSED_BEATS = 2;
717
+ var RUNNER_OFFLINE_THRESHOLD_MS = RUNNER_HEARTBEAT_INTERVAL_SECONDS * (RUNNER_OFFLINE_AFTER_MISSED_BEATS + 1) * 1000;
718
+ var RUNNER_REALTIME_TYPES = ["runner.heartbeat"];
719
+ var REALTIME_EVENT_TYPES = [...ACTIVITY_TYPES, ...RUNNER_REALTIME_TYPES];
720
+ // ../../packages/shared/src/schemas.ts
721
+ import { z as z2 } from "zod";
722
+ var prioritySchema = z2.number().int().min(0).max(4);
723
+ var createProjectSchema = z2.object({
724
+ name: z2.string().min(1).max(120),
725
+ key: z2.string().min(2).max(6).regex(/^[A-Z][A-Z0-9]*$/, "Key must be uppercase letters/digits"),
726
+ description: z2.string().max(4000).optional()
727
+ });
728
+ var createTaskSchema = z2.object({
729
+ projectId: z2.string(),
730
+ title: z2.string().min(1).max(300),
731
+ description: z2.string().max(50000).optional(),
732
+ statusId: z2.string().optional(),
733
+ priority: prioritySchema.optional(),
734
+ agentReady: z2.boolean().optional(),
735
+ assigneeUserId: z2.string().nullish(),
736
+ labelIds: z2.array(z2.string()).optional(),
737
+ estimate: z2.number().int().min(0).max(100).nullish(),
738
+ parentTask: z2.string().nullish()
739
+ });
740
+ var updateTaskSchema = createTaskSchema.omit({ projectId: true }).partial();
741
+ var addDependencySchema = z2.object({
742
+ dependsOn: z2.string().min(1)
743
+ });
744
+ var createCommentSchema = z2.object({
745
+ body: z2.string().min(1).max(50000),
746
+ kind: z2.enum(COMMENT_KINDS).default("comment")
747
+ });
748
+ var createLabelSchema = z2.object({
749
+ name: z2.string().min(1).max(60),
750
+ color: z2.string().regex(/^#[0-9a-fA-F]{6}$/)
751
+ });
752
+ var createAgentSchema = z2.object({
753
+ name: z2.string().min(1).max(120),
754
+ kind: z2.enum(AGENT_KINDS).default("claude-code")
755
+ });
756
+ var createWebhookSchema = z2.object({
757
+ url: z2.string().url(),
758
+ events: z2.array(z2.enum(ACTIVITY_TYPES)).min(1),
759
+ format: z2.enum(WEBHOOK_FORMATS).default("json")
760
+ });
761
+ var createStatusSchema = z2.object({
762
+ name: z2.string().min(1).max(60),
763
+ category: z2.enum(STATUS_CATEGORIES),
764
+ color: z2.string().regex(/^#[0-9a-fA-F]{6}$/)
765
+ });
766
+ var runnerHeartbeatSchema = z2.object({
767
+ slotsTotal: z2.number().int().min(0).max(64),
768
+ slotsBusy: z2.number().int().min(0).max(64),
769
+ taskIds: z2.array(z2.string().min(1).max(64)).max(64).default([]),
770
+ status: z2.enum(RUNNER_STATUSES).default("active"),
771
+ rateLimitResetsAt: z2.string().datetime().nullish()
772
+ }).refine((v) => v.slotsBusy <= v.slotsTotal, {
773
+ message: "slotsBusy cannot exceed slotsTotal",
774
+ path: ["slotsBusy"]
775
+ });
776
+ var realtimeEventSchema = z2.object({
777
+ type: z2.enum(REALTIME_EVENT_TYPES),
778
+ orgId: z2.string(),
779
+ taskId: z2.string().nullable(),
780
+ projectId: z2.string().nullable(),
781
+ actorType: z2.enum(["user", "agent", "system"]),
782
+ actorId: z2.string(),
783
+ actorName: z2.string(),
784
+ payload: z2.record(z2.unknown()),
785
+ createdAt: z2.string()
786
+ });
787
+ // src/ratelimit.ts
788
+ var EPOCH_LIMIT_RE = /usage limit reached\|(\d{10,13})/;
789
+ var TEXT_LIMIT_RE = /usage limit reached|reached your usage limit|hit your usage limit|limit reached ∙ resets|out of usage/i;
790
+ var RESETS_AT_RE = /resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i;
791
+ function parseResetTime(output, fallbackPauseMinutes, now = Date.now()) {
792
+ const epochMatch = EPOCH_LIMIT_RE.exec(output);
793
+ if (epochMatch?.[1]) {
794
+ let epoch = Number.parseInt(epochMatch[1], 10);
795
+ if (epoch < 1000000000000)
796
+ epoch *= 1000;
797
+ return Math.max(epoch, now + 60000);
798
+ }
799
+ if (!TEXT_LIMIT_RE.test(output))
800
+ return null;
801
+ const at = RESETS_AT_RE.exec(output);
802
+ if (at?.[1]) {
803
+ const hour12 = Number.parseInt(at[1], 10);
804
+ const minute = at[2] ? Number.parseInt(at[2], 10) : 0;
805
+ const pm = at[3]?.toLowerCase() === "pm";
806
+ const hour = hour12 % 12 + (pm ? 12 : 0);
807
+ const reset = new Date(now);
808
+ reset.setHours(hour, minute, 0, 0);
809
+ if (reset.getTime() <= now)
810
+ reset.setDate(reset.getDate() + 1);
811
+ return reset.getTime();
812
+ }
813
+ return now + fallbackPauseMinutes * 60000;
814
+ }
815
+
816
+ // src/repo.ts
817
+ import { execFileSync as execFileSync2 } from "node:child_process";
818
+ import { existsSync as existsSync5 } from "node:fs";
819
+ import { join as join7, resolve as resolve2 } from "node:path";
820
+ var isGitRepo = (path) => existsSync5(join7(path, ".git"));
821
+ var REPO_HINT_RE = /^\s*repo:\s*(\S+)\s*$/im;
822
+ function resolveModelEffort(config, projectKey, cli) {
823
+ const proj = config.projects[projectKey];
824
+ return {
825
+ model: cli.model ?? proj?.model ?? config.model ?? null,
826
+ effort: cli.effort ?? proj?.effort ?? config.effort ?? null
827
+ };
828
+ }
829
+ var parseRepoHint = (description) => REPO_HINT_RE.exec(description)?.[1] ?? null;
830
+ function resolveRepo(config, projectKey, description) {
831
+ const proj = config.projects[projectKey];
832
+ if (!proj)
833
+ throw new Error(`no repo mapping for project ${projectKey}`);
834
+ const def = resolve2(expandHome(proj.repo));
835
+ const hint = parseRepoHint(description);
836
+ if (!hint)
837
+ return { repo: def, hint: null };
838
+ const named = proj.repos?.[hint];
839
+ if (named)
840
+ return { repo: resolve2(expandHome(named)), hint };
841
+ const other = config.projects[hint];
842
+ if (other)
843
+ return { repo: resolve2(expandHome(other.repo)), hint };
844
+ const path = resolve2(expandHome(hint));
845
+ if (isGitRepo(path))
846
+ return { repo: path, hint };
847
+ return { repo: def, hint: `!${hint}` };
848
+ }
849
+ function git(repo, args) {
850
+ return execFileSync2("git", ["-C", repo, ...args], { encoding: "utf8" });
851
+ }
852
+ function gitQuiet(repo, args) {
853
+ try {
854
+ return {
855
+ code: 0,
856
+ out: execFileSync2("git", ["-C", repo, ...args], {
857
+ encoding: "utf8",
858
+ stdio: ["ignore", "pipe", "pipe"]
859
+ })
860
+ };
861
+ } catch (err) {
862
+ return { code: err.status ?? 1, out: "" };
863
+ }
864
+ }
865
+ function makeWorktree(repo, ident, workDir) {
866
+ git(repo, ["fetch", "origin", "--quiet"]);
867
+ const head = gitQuiet(repo, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
868
+ const base = head.out.trim() || "origin/main";
869
+ const low = ident.toLowerCase();
870
+ let branch = `orbit/${low}`;
871
+ let path = join7(worktreesDir(workDir), low);
872
+ let n = 1;
873
+ while (existsSync5(path) || gitQuiet(repo, ["rev-parse", "--verify", "--quiet", branch]).code === 0) {
874
+ n += 1;
875
+ branch = `orbit/${low}-${n}`;
876
+ path = join7(worktreesDir(workDir), `${low}-${n}`);
877
+ }
878
+ git(repo, ["worktree", "add", "-b", branch, path, base]);
879
+ return { worktree: path, branch };
880
+ }
881
+
882
+ // src/session.ts
883
+ import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2 } from "node:fs";
884
+
885
+ class Session {
886
+ task;
887
+ child;
888
+ logPath;
889
+ worktree;
890
+ branch;
891
+ running = true;
892
+ exitCode = null;
893
+ started = Date.now();
894
+ scanOffset = 0;
895
+ followOffset = 0;
896
+ constructor(task, child, logPath, worktree, branch) {
897
+ this.task = task;
898
+ this.child = child;
899
+ this.logPath = logPath;
900
+ this.worktree = worktree;
901
+ this.branch = branch;
902
+ child.on("exit", (code) => {
903
+ this.exitCode = code ?? 0;
904
+ this.running = false;
905
+ });
906
+ }
907
+ readFrom(which) {
908
+ let fd;
909
+ try {
910
+ fd = openSync2(this.logPath, "r");
911
+ } catch {
912
+ return "";
913
+ }
914
+ try {
915
+ let pos = which === "scan" ? this.scanOffset : this.followOffset;
916
+ const buf = Buffer.alloc(65536);
917
+ let out = "";
918
+ for (;; ) {
919
+ const n = readSync2(fd, buf, 0, buf.length, pos);
920
+ if (n <= 0)
921
+ break;
922
+ out += buf.toString("utf8", 0, n);
923
+ pos += n;
924
+ }
925
+ if (which === "scan")
926
+ this.scanOffset = pos;
927
+ else
928
+ this.followOffset = pos;
929
+ return out;
930
+ } finally {
931
+ closeSync2(fd);
932
+ }
933
+ }
934
+ readNewOutput() {
935
+ return this.readFrom("scan");
936
+ }
937
+ readFollow() {
938
+ return this.readFrom("follow");
939
+ }
940
+ asState() {
941
+ return {
942
+ task: this.task,
943
+ pid: this.child.pid,
944
+ log_path: this.logPath,
945
+ branch: this.branch,
946
+ worktree: this.worktree,
947
+ started: Math.floor(this.started / 1000)
948
+ };
949
+ }
950
+ }
951
+
952
+ // src/daemon.ts
953
+ var stamp = () => new Date().toTimeString().slice(0, 8);
954
+ var dlog = (msg) => console.log(`[${stamp()}] ${msg}`);
955
+
956
+ class Runner {
957
+ config;
958
+ apiUrl;
959
+ stopping = false;
960
+ orbit;
961
+ workDir;
962
+ scope;
963
+ pollKeys;
964
+ pidFile;
965
+ stateFile;
966
+ mcpConfigPath;
967
+ follow;
968
+ cliModel;
969
+ cliEffort;
970
+ sessions = [];
971
+ pausedUntil = 0;
972
+ cooldowns = new Map;
973
+ rr = 0;
974
+ startedAt = Date.now();
975
+ lastHeartbeat = 0;
976
+ constructor(config, apiUrl, opts) {
977
+ this.config = config;
978
+ this.apiUrl = apiUrl;
979
+ this.orbit = new OrbitClient(apiUrl, config.api_key);
980
+ this.workDir = resolve3(expandHome(config.work_dir));
981
+ this.scope = opts.project ?? null;
982
+ this.follow = Boolean(opts.follow);
983
+ this.cliModel = opts.model;
984
+ this.cliEffort = opts.effort;
985
+ this.pollKeys = this.scope ? [this.scope] : Object.keys(config.projects);
986
+ this.pidFile = pidfilePath(this.workDir, this.scope);
987
+ this.stateFile = statePath(this.workDir, this.scope);
988
+ this.mcpConfigPath = join8(this.workDir, `mcp${suffix(this.scope)}.json`);
989
+ mkdirSync2(logsDir(this.workDir), { recursive: true });
990
+ mkdirSync2(worktreesDir(this.workDir), { recursive: true });
991
+ }
992
+ async run() {
993
+ this.writeMcpConfig();
994
+ this.writePidfile();
995
+ this.installSignalHandlers();
996
+ const model = this.cliModel ?? this.config.model ?? "default";
997
+ const effort = this.cliEffort ?? this.config.effort ?? "default";
998
+ dlog(`orbit-runner up: scope=${this.scope ?? "all"}, ${this.pollKeys.length} project(s), ` + `${this.config.max_slots} slot(s), polling every ${this.config.poll_interval_seconds}s, ` + `model=${model}, effort=${effort}`);
999
+ try {
1000
+ this.writeState();
1001
+ while (!this.stopping) {
1002
+ try {
1003
+ await this.tick();
1004
+ } catch (err) {
1005
+ dlog(`tick failed (will retry): ${err.message}`);
1006
+ }
1007
+ await this.sleep(this.config.poll_interval_seconds * 1000);
1008
+ }
1009
+ await this.shutdown();
1010
+ } finally {
1011
+ this.cleanupFiles();
1012
+ }
1013
+ }
1014
+ async sleep(ms) {
1015
+ const deadline = Date.now() + ms;
1016
+ while (!this.stopping && Date.now() < deadline) {
1017
+ await delay(Math.min(1000, Math.max(0, deadline - Date.now())));
1018
+ if (this.follow)
1019
+ this.pumpFollow(this.sessions);
1020
+ await this.heartbeat();
1021
+ if (this.sessions.some((s) => !s.running))
1022
+ return;
1023
+ }
1024
+ }
1025
+ async tick() {
1026
+ this.scanRunningSessions();
1027
+ await this.reapFinishedSessions();
1028
+ await this.heartbeat(true);
1029
+ if (Date.now() < this.pausedUntil) {
1030
+ this.writeState();
1031
+ return;
1032
+ }
1033
+ while (this.sessions.length < this.config.max_slots && !this.stopping) {
1034
+ const task = await this.nextTask();
1035
+ if (!task)
1036
+ break;
1037
+ if (!await this.spawn(task))
1038
+ break;
1039
+ }
1040
+ this.writeState();
1041
+ }
1042
+ async nextTask() {
1043
+ const keys = this.pollKeys;
1044
+ if (keys.length === 0)
1045
+ return null;
1046
+ const inFlight = new Set(this.sessions.map((s) => s.task));
1047
+ for (let i = 0;i < keys.length; i++) {
1048
+ const key = keys[(this.rr + i) % keys.length];
1049
+ if (!key)
1050
+ continue;
1051
+ const payload = await this.orbit.callTool("get_next_task", {
1052
+ project_key: key
1053
+ });
1054
+ const task = payload.task;
1055
+ if (!task)
1056
+ continue;
1057
+ if (inFlight.has(task.identifier))
1058
+ continue;
1059
+ if (Date.now() < (this.cooldowns.get(task.identifier) ?? 0))
1060
+ continue;
1061
+ this.rr = (this.rr + i + 1) % keys.length;
1062
+ return task;
1063
+ }
1064
+ return null;
1065
+ }
1066
+ async spawn(task) {
1067
+ const ident = task.identifier;
1068
+ const projectKey = ident.slice(0, ident.lastIndexOf("-"));
1069
+ if (!this.config.projects[projectKey]) {
1070
+ dlog(`${ident}: no repo mapping for project ${projectKey}, skipping`);
1071
+ return false;
1072
+ }
1073
+ const description = await this.fetchDescription(ident);
1074
+ const { repo, hint } = resolveRepo(this.config, projectKey, description);
1075
+ if (hint?.startsWith("!"))
1076
+ dlog(`${ident}: unknown repo hint ${JSON.stringify(hint.slice(1))}; using project default`);
1077
+ else if (hint)
1078
+ dlog(`${ident}: repo hint ${JSON.stringify(hint)} -> ${repo}`);
1079
+ let worktree;
1080
+ let branch;
1081
+ try {
1082
+ ({ worktree, branch } = makeWorktree(repo, ident, this.workDir));
1083
+ } catch (err) {
1084
+ dlog(`${ident}: worktree setup failed, skipping this poll: ${err.message}`);
1085
+ return false;
1086
+ }
1087
+ const logPath = join8(logsDir(this.workDir), `${ident.toLowerCase()}-${Math.floor(Date.now() / 1000)}.log`);
1088
+ const prompt = `Work on ${ident} using the orbit workflow: claim it with start_task ` + `(branch ${branch}), implement it, open a PR, and complete the task with the PR link.`;
1089
+ const { model, effort } = resolveModelEffort(this.config, projectKey, {
1090
+ model: this.cliModel,
1091
+ effort: this.cliEffort
1092
+ });
1093
+ const args = ["-p", prompt, "--mcp-config", this.mcpConfigPath, "--strict-mcp-config"];
1094
+ if (model)
1095
+ args.push("--model", model);
1096
+ if (effort)
1097
+ args.push("--effort", effort);
1098
+ args.push(...this.config.claude_args);
1099
+ const fd = openSync3(logPath, "a");
1100
+ let child;
1101
+ try {
1102
+ child = spawn(this.config.claude_bin, args, {
1103
+ cwd: worktree,
1104
+ stdio: ["ignore", fd, fd],
1105
+ detached: true
1106
+ });
1107
+ } finally {
1108
+ closeSync3(fd);
1109
+ }
1110
+ this.sessions.push(new Session(ident, child, logPath, worktree, branch));
1111
+ const extras = [model ? `model=${model}` : "", effort ? `effort=${effort}` : ""].filter(Boolean).join(" ");
1112
+ dlog(`${ident}: spawned claude (pid ${child.pid}) in ${worktree} [${branch}]${extras ? ` (${extras})` : ""}`);
1113
+ await this.heartbeat(true);
1114
+ return true;
1115
+ }
1116
+ async fetchDescription(ident) {
1117
+ try {
1118
+ const full = await this.orbit.callTool("get_task", { task: ident });
1119
+ return full.description ?? "";
1120
+ } catch (err) {
1121
+ dlog(`${ident}: could not fetch task detail for repo hint: ${err.message}`);
1122
+ return "";
1123
+ }
1124
+ }
1125
+ scanRunningSessions() {
1126
+ for (const s of this.sessions)
1127
+ this.checkForRateLimit(s, s.readNewOutput());
1128
+ }
1129
+ async reapFinishedSessions() {
1130
+ for (const s of this.sessions.filter((x) => !x.running)) {
1131
+ if (this.follow)
1132
+ this.pumpFollow([s]);
1133
+ this.sessions = this.sessions.filter((x) => x !== s);
1134
+ this.checkForRateLimit(s, s.readNewOutput());
1135
+ dlog(`${s.task}: session exited with code ${s.exitCode} after ` + `${Math.floor((Date.now() - s.started) / 1000)}s (log: ${s.logPath})`);
1136
+ try {
1137
+ await this.closeOut(s);
1138
+ } catch (err) {
1139
+ dlog(`${s.task}: could not check/annotate task state: ${err.message}`);
1140
+ }
1141
+ }
1142
+ }
1143
+ async closeOut(session) {
1144
+ const task = await this.orbit.callTool("get_task", { task: session.task });
1145
+ const done = task.statusCategory === "done";
1146
+ const prOpened = Boolean(task.github);
1147
+ if (done || prOpened) {
1148
+ dlog(`${session.task}: completed (${done ? "done" : "PR opened"})`);
1149
+ return;
1150
+ }
1151
+ const cooldownMs = this.config.task_retry_cooldown_minutes * 60000;
1152
+ this.cooldowns.set(session.task, Date.now() + cooldownMs);
1153
+ dlog(`${session.task}: cooling down for ${Math.floor(cooldownMs / 60000)}m before re-dispatch`);
1154
+ const note2 = `runner: session exited without completing (exit code ${session.exitCode}); ` + "releasing the slot. The stale-session sweep will free the claim.";
1155
+ await this.orbit.callTool("update_progress", { task: session.task, message: note2 });
1156
+ dlog(`${session.task}: posted exit-without-completing note`);
1157
+ }
1158
+ pumpFollow(sessions) {
1159
+ for (const s of sessions) {
1160
+ const chunk = s.readFollow();
1161
+ if (!chunk)
1162
+ continue;
1163
+ for (const line of chunk.split(`
1164
+ `)) {
1165
+ if (line)
1166
+ process.stdout.write(`[${s.task}] ${line}
1167
+ `);
1168
+ }
1169
+ }
1170
+ }
1171
+ checkForRateLimit(session, output) {
1172
+ if (!output || Date.now() < this.pausedUntil)
1173
+ return;
1174
+ const reset = parseResetTime(output, this.config.rate_limit_pause_minutes);
1175
+ if (reset === null)
1176
+ return;
1177
+ this.pausedUntil = reset;
1178
+ const until = fmtDateTime(reset);
1179
+ dlog(`usage limit reported by ${session.task}; pausing all dispatch until ${until}`);
1180
+ this.orbit.callTool("update_progress", {
1181
+ task: session.task,
1182
+ message: `runner: Claude Code usage limit hit; pausing dispatch of new sessions until ${until}.`
1183
+ }).catch((err) => dlog(`could not post pause note to Orbit: ${err.message}`));
1184
+ }
1185
+ async heartbeat(force = false) {
1186
+ const now = Date.now();
1187
+ if (!force && now - this.lastHeartbeat < RUNNER_HEARTBEAT_INTERVAL_SECONDS * 1000)
1188
+ return;
1189
+ this.lastHeartbeat = now;
1190
+ const paused = now < this.pausedUntil;
1191
+ try {
1192
+ await this.orbit.heartbeat({
1193
+ slotsTotal: this.config.max_slots,
1194
+ slotsBusy: this.sessions.length,
1195
+ taskIds: this.sessions.map((s) => s.task),
1196
+ status: paused ? "paused_rate_limit" : "active",
1197
+ rateLimitResetsAt: paused ? new Date(this.pausedUntil).toISOString() : null
1198
+ });
1199
+ } catch (err) {
1200
+ dlog(`heartbeat failed (non-fatal): ${err.message}`);
1201
+ }
1202
+ }
1203
+ writeMcpConfig() {
1204
+ writeFileSync2(this.mcpConfigPath, JSON.stringify(sessionMcpConfig(this.apiUrl, this.config.api_key)), {
1205
+ mode: 384
1206
+ });
1207
+ }
1208
+ writePidfile() {
1209
+ writeFileSync2(this.pidFile, String(process.pid));
1210
+ }
1211
+ writeState() {
1212
+ const state = {
1213
+ pid: process.pid,
1214
+ project: this.scope,
1215
+ started: Math.floor(this.startedAt / 1000),
1216
+ poll_interval_seconds: this.config.poll_interval_seconds,
1217
+ max_slots: this.config.max_slots,
1218
+ model: this.cliModel ?? this.config.model ?? null,
1219
+ effort: this.cliEffort ?? this.config.effort ?? null,
1220
+ paused_until: Math.floor(this.pausedUntil / 1000),
1221
+ slots: this.sessions.map((s) => s.asState())
1222
+ };
1223
+ const tmp = `${this.stateFile}.tmp`;
1224
+ try {
1225
+ writeFileSync2(tmp, JSON.stringify(state));
1226
+ renameSync(tmp, this.stateFile);
1227
+ } catch (err) {
1228
+ dlog(`could not write state file: ${err.message}`);
1229
+ }
1230
+ }
1231
+ cleanupFiles() {
1232
+ safeRemove(this.pidFile, this.stateFile, this.mcpConfigPath);
1233
+ }
1234
+ installSignalHandlers() {
1235
+ const stop = (sig) => {
1236
+ dlog(`received ${sig}, shutting down`);
1237
+ this.stopping = true;
1238
+ };
1239
+ process.on("SIGINT", () => stop("SIGINT"));
1240
+ process.on("SIGTERM", () => stop("SIGTERM"));
1241
+ }
1242
+ async shutdown() {
1243
+ for (const s of this.sessions) {
1244
+ if (s.running && s.child.pid) {
1245
+ dlog(`${s.task}: terminating session (pid ${s.child.pid})`);
1246
+ killGroup(s.child.pid, "SIGTERM");
1247
+ }
1248
+ }
1249
+ const deadline = Date.now() + 1e4;
1250
+ while (this.sessions.some((s) => s.running) && Date.now() < deadline)
1251
+ await delay(200);
1252
+ for (const s of this.sessions) {
1253
+ if (s.running && s.child.pid)
1254
+ killGroup(s.child.pid, "SIGKILL");
1255
+ }
1256
+ await this.heartbeat(true).catch(() => {});
1257
+ dlog("orbit-runner stopped; Orbit's stale-session sweep frees any held claims");
1258
+ }
1259
+ }
1260
+ function killGroup(pid, signal) {
1261
+ try {
1262
+ process.kill(-pid, signal);
1263
+ } catch {}
1264
+ }
1265
+ function validateForStart(config, scope) {
1266
+ if (!config.api_key)
1267
+ throw new Error("no API key. Run `orbit-runner init`.");
1268
+ if (Object.keys(config.projects).length === 0) {
1269
+ throw new Error('config needs at least one entry under "projects". Run `orbit-runner init`.');
1270
+ }
1271
+ for (const [key, proj] of Object.entries(config.projects)) {
1272
+ const repo = resolve3(expandHome(proj.repo));
1273
+ if (!isGitRepo(repo))
1274
+ throw new Error(`projects.${key}.repo is not a git repo: ${repo}`);
1275
+ for (const [name, alt] of Object.entries(proj.repos ?? {})) {
1276
+ if (!isGitRepo(resolve3(expandHome(alt)))) {
1277
+ throw new Error(`projects.${key}.repos.${name} is not a git repo: ${alt}`);
1278
+ }
1279
+ }
1280
+ }
1281
+ if (scope && !config.projects[scope]) {
1282
+ const known = Object.keys(config.projects).join(", ") || "none";
1283
+ throw new Error(`--project ${scope} is not in the config's projects (${known}).`);
1284
+ }
1285
+ if (!claudeAvailable(config.claude_bin)) {
1286
+ throw new Error(`claude CLI not found (${config.claude_bin})`);
1287
+ }
1288
+ }
1289
+ async function runDaemon(config, apiUrl, opts) {
1290
+ await new Runner(config, apiUrl, opts).run();
1291
+ }
1292
+ async function startBackground(workDir, scope, childArgs) {
1293
+ mkdirSync2(workDir, { recursive: true });
1294
+ const logPath = runnerLogPath(workDir, scope);
1295
+ const fd = openSync3(logPath, "a");
1296
+ try {
1297
+ const child = spawn(process.execPath, [process.argv[1] ?? "", "start", "--foreground", ...childArgs], {
1298
+ detached: true,
1299
+ stdio: ["ignore", fd, fd]
1300
+ });
1301
+ child.unref();
1302
+ } finally {
1303
+ closeSync3(fd);
1304
+ }
1305
+ const pidFile = pidfilePath(workDir, scope);
1306
+ const deadline = Date.now() + 5000;
1307
+ while (Date.now() < deadline) {
1308
+ const live = readLivePid(pidFile);
1309
+ if (live) {
1310
+ console.log(`orbit-runner: started (pid ${live}); logs at ${logPath}`);
1311
+ return;
1312
+ }
1313
+ await delay(100);
1314
+ }
1315
+ console.error(`orbit-runner: started, but the pidfile did not appear in time — check ${logPath}`);
1316
+ }
1317
+
1318
+ // src/commands/start.ts
1319
+ async function startCommand(opts) {
1320
+ const config = loadConfig();
1321
+ const apiUrl = resolveApiUrl(config.api_url);
1322
+ const scope = opts.project ? opts.project.toUpperCase() : null;
1323
+ validateForStart(config, scope);
1324
+ const workDir = resolve4(expandHome(config.work_dir));
1325
+ const existing = readLivePid(pidfilePath(workDir, scope));
1326
+ if (existing) {
1327
+ throw new Error(`already running for ${scope ?? "all projects"} (pid ${existing}); pidfile ${pidfilePath(workDir, scope)}`);
1328
+ }
1329
+ const foreground = Boolean(opts.foreground || opts.follow);
1330
+ if (foreground) {
1331
+ mkdirSync3(workDir, { recursive: true });
1332
+ await runDaemon(config, apiUrl, {
1333
+ project: scope,
1334
+ model: opts.model,
1335
+ effort: opts.effort,
1336
+ follow: opts.follow
1337
+ });
1338
+ return;
1339
+ }
1340
+ const childArgs = [];
1341
+ if (scope)
1342
+ childArgs.push("--project", scope);
1343
+ if (opts.model)
1344
+ childArgs.push("--model", opts.model);
1345
+ if (opts.effort)
1346
+ childArgs.push("--effort", opts.effort);
1347
+ await startBackground(workDir, scope, childArgs);
1348
+ }
1349
+
1350
+ // src/commands/status.ts
1351
+ import { resolve as resolve5 } from "node:path";
1352
+ import pc4 from "picocolors";
1353
+ async function statusCommand(opts) {
1354
+ const config = tryLoadConfig();
1355
+ const workDir = resolve5(expandHome(config?.work_dir ?? DEFAULT_WORK_DIR));
1356
+ const scope = opts.project ? opts.project.toUpperCase() : null;
1357
+ const instances = discoverInstances(workDir, scope);
1358
+ if (instances.length === 0) {
1359
+ console.log(`orbit-runner: no runner instances${scope ? ` for ${scope}` : ""} (not running)`);
1360
+ return;
1361
+ }
1362
+ const nowSec = Date.now() / 1000;
1363
+ for (const { project, pidFile, state } of instances) {
1364
+ const label = (project ?? "all").padEnd(8);
1365
+ const pid = readPid(pidFile);
1366
+ if (!pidAlive(pid)) {
1367
+ console.log(`${pc4.dim("●")} ${label} stale pidfile points to dead pid ${pid}`);
1368
+ continue;
1369
+ }
1370
+ const up = fmtDuration(nowSec - (state.started ?? nowSec));
1371
+ const model = state.model ?? "default";
1372
+ const effort = state.effort ?? "default";
1373
+ const slots = state.slots ?? [];
1374
+ const pausedMs = (state.paused_until ?? 0) * 1000;
1375
+ const pauseNote = pausedMs > Date.now() ? pc4.yellow(` PAUSED until ${fmtDateTime(pausedMs)}`) : "";
1376
+ console.log(`${pc4.green("●")} ${label} running pid ${pid} up ${up} ` + `model=${model} effort=${effort} slots ${slots.length}/${state.max_slots ?? "?"}${pauseNote}`);
1377
+ for (const s of slots) {
1378
+ const sUp = fmtDuration(nowSec - (s.started ?? nowSec));
1379
+ console.log(` ${(s.task ?? "").padEnd(10)} pid ${String(s.pid ?? "?").padEnd(7)} up ${sUp.padEnd(7)} ${s.branch ?? ""}`);
1380
+ }
1381
+ }
1382
+ }
1383
+
1384
+ // src/commands/stop.ts
1385
+ import { resolve as resolve6 } from "node:path";
1386
+ async function stopCommand(opts) {
1387
+ const config = tryLoadConfig();
1388
+ const workDir = resolve6(expandHome(config?.work_dir ?? DEFAULT_WORK_DIR));
1389
+ const scope = opts.project ? opts.project.toUpperCase() : null;
1390
+ const instances = discoverInstances(workDir, scope);
1391
+ if (instances.length === 0) {
1392
+ console.log(`orbit-runner: no runner${scope ? ` for ${scope}` : ""} is running`);
1393
+ return;
1394
+ }
1395
+ for (const { project, pidFile } of instances) {
1396
+ const label = project ?? "all";
1397
+ const pid = readPid(pidFile);
1398
+ if (!pidAlive(pid)) {
1399
+ safeRemove(pidFile, statePath(workDir, project));
1400
+ console.log(`${label}: not running (removed stale pidfile)`);
1401
+ continue;
1402
+ }
1403
+ try {
1404
+ process.kill(pid, "SIGTERM");
1405
+ } catch {
1406
+ safeRemove(pidFile, statePath(workDir, project));
1407
+ console.log(`${label}: not running (removed stale pidfile)`);
1408
+ continue;
1409
+ }
1410
+ const deadline = Date.now() + 12000;
1411
+ while (pidAlive(pid) && Date.now() < deadline)
1412
+ await delay(200);
1413
+ console.log(pidAlive(pid) ? `${label}: SIGTERM sent to pid ${pid} (still shutting down)` : `${label}: stopped (pid ${pid})`);
1414
+ }
1415
+ }
1416
+
1417
+ // src/index.ts
1418
+ var cli = cac("orbit-runner");
1419
+ cli.command("init", "Configure the runner and set up the Claude Code plugin + MCP").option("--key <key>", "Orbit agent API key (else prompted, or $ORBIT_API_KEY)").option("--no-plugin", "Don't touch your interactive Claude (skip the plugin + MCP install)").action((opts) => initCommand({ key: opts.key, plugin: opts.plugin !== false }));
1420
+ cli.command("start", "Start the runner daemon").option("-p, --project <key>", "Scope to a single project").option("-F, --foreground", "Run inline instead of daemonizing").option("-f, --follow", "Stream live session logs (implies --foreground)").option("-m, --model <model>", "Override the model for spawned sessions").option("-e, --effort <effort>", "Override the effort for spawned sessions").action((opts) => startCommand({
1421
+ project: opts.project,
1422
+ foreground: opts.foreground,
1423
+ follow: opts.follow,
1424
+ model: opts.model,
1425
+ effort: opts.effort
1426
+ }));
1427
+ cli.command("stop", "Stop the runner daemon").option("-p, --project <key>", "Target a single project's instance").action((opts) => stopCommand({ project: opts.project }));
1428
+ cli.command("status", "Show the configured runner and daemon state").option("-p, --project <key>", "Target a single project's instance").action((opts) => statusCommand({ project: opts.project }));
1429
+ cli.command("logs [task]", "Tail per-session logs").option("-f, --follow", "Follow live output").action((task, opts) => logsCommand(task, { follow: opts.follow }));
1430
+ cli.command("doctor", "Check that the environment is ready to run").action(() => doctorCommand());
1431
+ cli.help();
1432
+ cli.version(CLI_VERSION);
1433
+ async function main() {
1434
+ const parsed = cli.parse(process.argv, { run: false });
1435
+ if (!cli.matchedCommand) {
1436
+ if (!parsed.options.help && !parsed.options.version)
1437
+ cli.outputHelp();
1438
+ return;
1439
+ }
1440
+ await cli.runMatchedCommand();
1441
+ }
1442
+ main().catch((err) => {
1443
+ console.error(pc5.red("✗"), err instanceof Error ? err.message : String(err));
1444
+ process.exitCode = 1;
1445
+ });