hub-runner 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Runs a hub workspace's Mac bots on this Mac. It runs under launchd, so no app has to be open.
4
4
 
5
5
  ```
6
- npm i -g hub-runner
6
+ npm i -g hub-runner --min-release-age=0
7
7
  hub-runner login
8
8
  hub-runner start
9
9
  ```
@@ -22,11 +22,13 @@ hub-runner start
22
22
  ## Update
23
23
 
24
24
  ```
25
- npm i -g hub-runner@latest && hub-runner restart
25
+ npm i -g hub-runner@latest --min-release-age=0 && hub-runner restart
26
26
  ```
27
27
 
28
28
  `restart` lets the turns open now finish, so no bot is cut off.
29
29
 
30
+ `--min-release-age=0` lets the newest release install when your npm config holds packages back for a few days (`min-release-age`). It applies to this one install, and hub-runner has no dependencies, so nothing else skips the wait.
31
+
30
32
  ## Notes
31
33
 
32
34
  - Node 24 or later, and `pnpm` on your PATH when you run `start`: the runner uses both to set the bots up.
@@ -12,7 +12,7 @@ const USAGE = `hub-runner login | start [--take-over] | stop | restart | status
12
12
  login sign in with Vercel and choose the workspace this Mac runs bots for
13
13
  start start the runner under launchd (it starts again at every login); --take-over claims it from another Mac
14
14
  stop stop the runner
15
- restart let the open turns finish, then start the runner again (after \`npm i -g hub-runner@latest\`)
15
+ restart let the open turns finish, then start the runner again (after \`npm i -g hub-runner@latest --min-release-age=0\`)
16
16
  status what the runner is doing; exits 1 when it isn't running
17
17
  logs the runner's log; -f follows it
18
18
  logout stop the runner and forget the sign-in`;
@@ -48,6 +48,7 @@ function logs(follow) {
48
48
 
49
49
  const [command, ...args] = process.argv.slice(2);
50
50
  try {
51
+ if (process.getuid() === 0) throw new Error('run hub-runner without sudo: the runner is a launchd job of your own user, and root has none');
51
52
  switch (command) {
52
53
  case 'login': await login(); break;
53
54
  case 'start': start({ takeOver: args.includes('--take-over') }); console.log('started: see `hub-runner status`'); break;
package/lib/launchd.mjs CHANGED
@@ -17,6 +17,20 @@ const launchctl = (args, { check = true } = {}) => {
17
17
 
18
18
  export const loaded = () => launchctl(['print', `${domain()}/${LABEL}`], { check: false });
19
19
 
20
+ const EXIT_TIMEOUT_S = 60;
21
+
22
+ // `bootout` returns while the old runner may still be draining (up to ExitTimeOut), and a `bootstrap` before launchd
23
+ // drops the job fails with error 5. So wait for the job to leave the domain.
24
+ function bootout() {
25
+ launchctl(['bootout', `${domain()}/${LABEL}`]);
26
+ const until = Date.now() + (EXIT_TIMEOUT_S + 5) * 1000;
27
+ const tick = new Int32Array(new SharedArrayBuffer(4));
28
+ while (loaded()) {
29
+ if (Date.now() > until) throw new Error(`launchd still holds ${LABEL} ${EXIT_TIMEOUT_S + 5}s after bootout: see \`hub-runner logs\``);
30
+ Atomics.wait(tick, 0, 0, 250);
31
+ }
32
+ }
33
+
20
34
  export function plist({ takeOver }) {
21
35
  const pnpm = execFileSync('/bin/sh', ['-c', 'command -v pnpm'], { encoding: 'utf8' }).trim();
22
36
  const env = { HUB_PNPM: pnpm, PATH: process.env.PATH, HUB_RUNNER_DIR: runnerDir(), HUB_RUNNER_BUILD: version(), HUB_DEVICE_NAME: deviceName(), ...(takeOver ? { HUB_RUNNER_TAKE_OVER: '1' } : {}) };
@@ -40,7 +54,7 @@ ${Object.entries(env).map(([k, v]) => ` <key>${k}</key>\n <string>${xml(v)
40
54
  <key>RunAtLoad</key>
41
55
  <true/>
42
56
  <key>ExitTimeOut</key>
43
- <integer>60</integer>
57
+ <integer>${EXIT_TIMEOUT_S}</integer>
44
58
  <key>StandardOutPath</key>
45
59
  <string>${xml(logFile())}</string>
46
60
  <key>StandardErrorPath</key>
@@ -55,12 +69,12 @@ export function start({ takeOver = false } = {}) {
55
69
  mkdirSync(runnerDir(), { recursive: true });
56
70
  mkdirSync(dirname(plistFile()), { recursive: true });
57
71
  writeFileSync(plistFile(), plist({ takeOver }));
58
- if (loaded()) launchctl(['bootout', `${domain()}/${LABEL}`]);
72
+ if (loaded()) bootout();
59
73
  launchctl(['bootstrap', domain(), plistFile()]);
60
74
  }
61
75
 
62
76
  export function stop() {
63
- if (loaded()) launchctl(['bootout', `${domain()}/${LABEL}`]);
77
+ if (loaded()) bootout();
64
78
  }
65
79
 
66
80
  /** Starts the loaded job again, or loads it from the plist the last `start` wrote. */
package/main.mjs CHANGED
@@ -2463,6 +2463,7 @@ function cancelTurn(fetchFn, eve, sessionId, turnId) {
2463
2463
  );
2464
2464
  }
2465
2465
  var ATTEMPT_MS = 15e3;
2466
+ var NOT_READY_WAITS_MS = [250, 500, 1e3, 2e3];
2466
2467
  var COMPACT_MS = 3e4;
2467
2468
  async function compactSession(fetchFn, eve, sessionId) {
2468
2469
  try {
@@ -2487,32 +2488,58 @@ function sessionEnded(status2, body) {
2487
2488
  return false;
2488
2489
  }
2489
2490
  }
2491
+ var CreateRefused = class extends Error {
2492
+ };
2493
+ async function createSession(fetchFn, eve, operationId) {
2494
+ const response = await fetchFn(`${eve.url}/v1/session`, { method: "POST", headers: eve.headers, signal: AbortSignal.timeout(ATTEMPT_MS), body: JSON.stringify({ operationId }) });
2495
+ const text = await response.text();
2496
+ const body = (() => {
2497
+ try {
2498
+ return JSON.parse(text);
2499
+ } catch {
2500
+ return {};
2501
+ }
2502
+ })(), { sessionId } = body;
2503
+ if (definitive(response.status)) throw new CreateRefused(`${response.status} ${body.error ?? body.code ?? text.slice(0, 200)}`);
2504
+ if (!response.ok || !sessionId) throw new Error(`create ${operationId}: HTTP ${response.status} ${text.slice(0, 200)}`);
2505
+ return sessionId;
2506
+ }
2490
2507
  async function sendToBot(fetchFn, eve, d3, context, loadImage) {
2491
- const first = !d3.sessionStarted;
2492
2508
  try {
2509
+ const { eveSessionId } = d3;
2493
2510
  const render = (notes) => [context, messageToYou(d3, notes)].filter(Boolean).join("\n\n");
2494
2511
  const prompt = render("");
2495
- const fields = { deliveryId: d3.id, turnPolicy: !d3.steer && (d3.authorKind === "agent" || queued(d3.text) !== null) ? "queue" : "steer", ...first ? { sessionId: d3.sessionId } : {} };
2512
+ const fields = { deliveryId: d3.id, turnPolicy: !d3.steer && (d3.authorKind === "agent" || queued(d3.text) !== null) ? "queue" : "steer" };
2496
2513
  const fit = d3.fit ? fitImages(Buffer.byteLength(JSON.stringify({ message: [{ type: "text", text: prompt }], ...fields })), d3.messageId, d3.fit.own, d3.fit.earlier) : void 0;
2497
2514
  const message = await messageWithImages(render, fit ? fit.attachments : d3.attachments, loadImage, fit?.more);
2498
- const response = await fetchFn(`${eve.url}/v1/session${first ? "" : `/${encodeURIComponent(d3.sessionId)}`}`, {
2499
- method: "POST",
2500
- headers: eve.headers,
2501
- signal: AbortSignal.timeout(ATTEMPT_MS),
2502
- body: JSON.stringify({ message, ...fields })
2503
- });
2504
- const text = await response.text();
2505
- const body = (() => {
2506
- try {
2507
- return JSON.parse(text);
2508
- } catch {
2509
- return {};
2510
- }
2511
- })();
2515
+ const deadline = Date.now() + ATTEMPT_MS, signal = AbortSignal.timeout(ATTEMPT_MS);
2516
+ const send = async () => {
2517
+ const response2 = await fetchFn(`${eve.url}/v1/session/${encodeURIComponent(eveSessionId)}`, {
2518
+ method: "POST",
2519
+ headers: eve.headers,
2520
+ signal,
2521
+ body: JSON.stringify({ message, ...fields })
2522
+ });
2523
+ const text2 = await response2.text();
2524
+ const body2 = (() => {
2525
+ try {
2526
+ return JSON.parse(text2);
2527
+ } catch {
2528
+ return {};
2529
+ }
2530
+ })();
2531
+ return { response: response2, text: text2, body: body2 };
2532
+ };
2533
+ let { response, text, body } = await send();
2534
+ for (const wait of NOT_READY_WAITS_MS) {
2535
+ if (response.status !== 409 || body.code !== "session_not_ready" || Date.now() + wait >= deadline) break;
2536
+ await new Promise((resolve6) => setTimeout(resolve6, wait));
2537
+ ({ response, text, body } = await send());
2538
+ }
2512
2539
  const answer = (kind, detail) => ({ answer: kind, status: response.status, ...detail ? { detail } : {} });
2513
- if (response.ok) return body.sessionId === d3.sessionId ? answer({ kind: "accepted" }) : answer({ kind: "unknown" }, `2xx named session ${body.sessionId ?? "none"}, not ${d3.sessionId}`);
2540
+ if (response.ok) return body.sessionId === eveSessionId ? answer({ kind: "accepted" }) : answer({ kind: "unknown" }, `2xx named session ${body.sessionId ?? "none"}, not ${eveSessionId}`);
2514
2541
  console.error("[hosted-hub] bot", response.status, d3.id, text.slice(0, 300));
2515
- if (sessionEnded(response.status, text) && !first) return answer({ kind: "ended" });
2542
+ if (sessionEnded(response.status, text)) return answer({ kind: "ended" });
2516
2543
  if (response.status === 409 && body.code === "session_not_ready") return answer({ kind: "unknown", notTaken: true }, "session_not_ready");
2517
2544
  if (definitive(response.status))
2518
2545
  return answer({ kind: "refused", error: `${response.status} ${body.error ?? body.code ?? text.slice(0, 200)}` });
@@ -15315,10 +15342,10 @@ async function run3(config, bots = realBots) {
15315
15342
  async function retireDead(name, work) {
15316
15343
  const open = work.sessions.filter((s3) => s3.bot === name);
15317
15344
  for (const s3 of open) {
15318
- const res = await cancelTurn(net, eve(name, work.human), s3.sessionId);
15345
+ const res = await cancelTurn(net, eve(name, work.human), s3.eveSessionId);
15319
15346
  if (!res.ok) throw new Error(`cancel ${s3.sessionId}: HTTP ${res.status}`);
15320
15347
  }
15321
- for (const s3 of open) await upload(s3.sessionId, name, work.human, s3.foldedIndex + 1, { toTail: true });
15348
+ for (const s3 of open) await upload(s3, name, work.human, s3.foldedIndex + 1, { toTail: true });
15322
15349
  const sessionIds = open.map((s3) => s3.sessionId);
15323
15350
  if (sessionIds.length) {
15324
15351
  const { retired } = await hubJson("/api/local-runner/lost", { method: "POST", body: JSON.stringify({ sessionIds }) });
@@ -15368,21 +15395,30 @@ async function run3(config, bots = realBots) {
15368
15395
  return { bytes: new Uint8Array(await res.arrayBuffer()), mediaType: res.headers.get("content-type") ?? "" };
15369
15396
  };
15370
15397
  async function deliver([d3], work) {
15371
- await worktrees.ensure(d3);
15372
15398
  unsettled.add(d3.id);
15373
- const { answer } = await sendToBot(net, eve(d3.bot, work.human), { ...d3, status: d3.status ?? null }, d3.workspace, loadImage);
15399
+ const created = d3.eveSessionId ?? await createSession(net, eve(d3.bot, work.human), d3.sessionId).catch((error) => {
15400
+ if (error instanceof CreateRefused) return error;
15401
+ throw error;
15402
+ });
15403
+ if (created instanceof CreateRefused) return answered(d3, { kind: "refused", error: created.message });
15404
+ const eveSessionId = d3.eveSessionId ?? (await hubJson(`/api/sessions/${encodeURIComponent(d3.sessionId)}/eve`, { method: "POST", body: JSON.stringify({ eveSessionId: created }) })).eveSessionId;
15405
+ await worktrees.ensure({ ...d3, sessionId: eveSessionId });
15406
+ const { answer } = await sendToBot(net, eve(d3.bot, work.human), { ...d3, status: d3.status ?? null, eveSessionId }, d3.workspace, loadImage);
15374
15407
  if (answer.kind === "ended") {
15375
- await upload(d3.sessionId, d3.bot, work.human, 0, { toTail: true });
15408
+ await upload({ sessionId: d3.sessionId, eveSessionId }, d3.bot, work.human, 0, { toTail: true });
15376
15409
  }
15377
- const { stood } = await hubJson(`/api/deliveries/${encodeURIComponent(d3.id)}/answer`, { method: "POST", body: JSON.stringify({ sessionId: d3.sessionId, answer }) });
15410
+ await answered(d3, answer);
15411
+ }
15412
+ async function answered(d3, answer) {
15413
+ const { stood } = await hubJson(`/api/deliveries/${encodeURIComponent(d3.id)}/answer`, { method: "POST", body: JSON.stringify({ sessionId: d3.sessionId, answer: { kind: answer.kind, ...answer.kind === "refused" ? { error: answer.error } : {} } }) });
15378
15414
  log("delivery", d3.id, answer.kind, stood);
15379
15415
  if (stood === "pending") throw new Error(`delivery ${d3.id}: eve's answer settled nothing`);
15380
15416
  unsettled.delete(d3.id);
15381
15417
  wake();
15382
15418
  }
15383
- async function upload(sessionId, bot, human, from, { toTail = false, signal } = {}) {
15419
+ async function upload({ sessionId, eveSessionId }, bot, human, from, { toTail = false, signal } = {}) {
15384
15420
  const { url, headers } = eve(bot, human);
15385
- const session = new Client({ host: new URL(url).origin, headers }).sessions.attach(sessionId);
15421
+ const session = new Client({ host: new URL(url).origin, headers }).sessions.attach(eveSessionId);
15386
15422
  let next = from, pending = [], posting = Promise.resolve(), failure, dropped = false;
15387
15423
  const halt = new AbortController();
15388
15424
  const post = (expired = false) => posting = posting.then(async () => {
@@ -15428,7 +15464,7 @@ async function run3(config, bots = realBots) {
15428
15464
  if (following.has(s3.sessionId)) return;
15429
15465
  const abort = new AbortController();
15430
15466
  following.set(s3.sessionId, abort);
15431
- void upload(s3.sessionId, s3.bot, human, s3.foldedIndex + 1, { signal: abort.signal }).then(
15467
+ void upload({ sessionId: s3.sessionId, eveSessionId: s3.eveSessionId ?? s3.sessionId }, s3.bot, human, s3.foldedIndex + 1, { signal: abort.signal }).then(
15432
15468
  // eve's stream reached a boundary: the next pass re-reads what is still owed.
15433
15469
  () => {
15434
15470
  following.delete(s3.sessionId);
@@ -15442,7 +15478,7 @@ async function run3(config, bots = realBots) {
15442
15478
  }
15443
15479
  async function cancel(s3, human) {
15444
15480
  for (const turnId of s3.cancel) {
15445
- const res = await cancelTurn(net, eve(s3.bot, human), s3.sessionId, turnId);
15481
+ const res = await cancelTurn(net, eve(s3.bot, human), s3.eveSessionId, turnId);
15446
15482
  if (!res.ok) throw new Error(`cancel ${s3.sessionId}: HTTP ${res.status}`);
15447
15483
  }
15448
15484
  }
@@ -15478,10 +15514,10 @@ async function run3(config, bots = realBots) {
15478
15514
  }
15479
15515
  const compacting = /* @__PURE__ */ new Map();
15480
15516
  const compacted = /* @__PURE__ */ new Map();
15481
- async function compactOne(sessionId, bot, requestedAt, human) {
15517
+ async function compactOne({ sessionId, eveSessionId, bot, requestedAt }, human) {
15482
15518
  if (compacted.get(sessionId)?.requestedAt !== requestedAt) {
15483
15519
  const agent = running.get(bot);
15484
- const answer = agent ? await compactSession(net, eve(bot, human), sessionId).then((r2) => ({ status: r2.status, body: typeof r2.body === "string" ? r2.body : JSON.stringify(r2.body) })) : { status: 503, body: "the bot is not running on this Mac" };
15520
+ const answer = agent ? await compactSession(net, eve(bot, human), eveSessionId).then((r2) => ({ status: r2.status, body: typeof r2.body === "string" ? r2.body : JSON.stringify(r2.body) })) : { status: 503, body: "the bot is not running on this Mac" };
15485
15521
  compacted.set(sessionId, { requestedAt, answer, reported: false });
15486
15522
  }
15487
15523
  const entry2 = compacted.get(sessionId);
@@ -15490,9 +15526,9 @@ async function run3(config, bots = realBots) {
15490
15526
  }
15491
15527
  function compact(work) {
15492
15528
  for (const id2 of compacted.keys()) if (!work.compact.some((c3) => c3.sessionId === id2)) compacted.delete(id2);
15493
- for (const { sessionId, bot, requestedAt } of work.compact) {
15494
- if (compacting.has(sessionId) || compacted.get(sessionId)?.requestedAt === requestedAt && compacted.get(sessionId).reported) continue;
15495
- compacting.set(sessionId, compactOne(sessionId, bot, requestedAt, work.human).then(wake, (e9) => failed("compact", e9)).finally(() => compacting.delete(sessionId)));
15529
+ for (const c3 of work.compact) {
15530
+ if (compacting.has(c3.sessionId) || compacted.get(c3.sessionId)?.requestedAt === c3.requestedAt && compacted.get(c3.sessionId).reported) continue;
15531
+ compacting.set(c3.sessionId, compactOne(c3, work.human).then(wake, (e9) => failed("compact", e9)).finally(() => compacting.delete(c3.sessionId)));
15496
15532
  }
15497
15533
  }
15498
15534
  let syncing = null, again = false, drained = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hub-runner",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Runs a hub workspace's Mac bots on this Mac under launchd, with no app needed.",
6
6
  "type": "module",