@algosuite/vo-mcp 0.2.0-beta.1 → 0.2.0-beta.4

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.
@@ -155,12 +155,65 @@ import fs from "node:fs";
155
155
  function repoRoot() {
156
156
  return process.env.VO_CODE_RUNNER_REPO || process.cwd();
157
157
  }
158
+ function clonesRoot() {
159
+ return process.env.VO_CODE_RUNNER_CLONES_ROOT || "";
160
+ }
161
+ var VALID_REPO_SLUG = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
162
+ var TRACKED_WORKTREES = /* @__PURE__ */ new Map();
158
163
  function sanitize(value, fallback) {
159
164
  const cleaned = String(value || "").trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
160
165
  return cleaned || fallback;
161
166
  }
167
+ function cloneDirForSlug(repoSlug, clonesRootDir) {
168
+ if (!clonesRootDir || !repoSlug || !VALID_REPO_SLUG.test(String(repoSlug))) return null;
169
+ const [owner, name] = String(repoSlug).split("/");
170
+ if (owner === "." || owner === ".." || name === "." || name === "..") return null;
171
+ if (owner.startsWith("-") || name.startsWith("-")) return null;
172
+ return path.join(clonesRootDir, `${sanitize(owner, "owner")}__${sanitize(name, "repo")}`);
173
+ }
174
+ function resolveTaskRoot(repoSlug) {
175
+ const root = clonesRoot();
176
+ if (root && !path.isAbsolute(root)) {
177
+ throw new Error(
178
+ `[vo-mcp runner] VO_CODE_RUNNER_CLONES_ROOT must be an absolute path (got '${root}')`
179
+ );
180
+ }
181
+ const dir = cloneDirForSlug(repoSlug, root);
182
+ if (!dir) return { root: repoRoot(), multiRepo: false };
183
+ if (!fs.existsSync(path.join(dir, ".git"))) {
184
+ fs.mkdirSync(clonesRoot(), { recursive: true });
185
+ const [owner, name] = String(repoSlug).split("/");
186
+ const tmpDir = `${dir}.tmp-${process.pid}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
187
+ const cl = spawnSync(
188
+ "git",
189
+ ["clone", "--no-tags", `https://github.com/${owner}/${name}.git`, tmpDir],
190
+ { encoding: "utf8", timeout: 6e5 }
191
+ );
192
+ if (cl.status !== 0 || !fs.existsSync(path.join(tmpDir, ".git"))) {
193
+ try {
194
+ fs.rmSync(tmpDir, { recursive: true, force: true });
195
+ } catch {
196
+ }
197
+ throw new Error(
198
+ `[vo-mcp runner] clone failed for ${repoSlug}: ${String(cl.stderr || cl.error || "").slice(0, 200)}`
199
+ );
200
+ }
201
+ try {
202
+ fs.renameSync(tmpDir, dir);
203
+ } catch {
204
+ try {
205
+ fs.rmSync(tmpDir, { recursive: true, force: true });
206
+ } catch {
207
+ }
208
+ if (!fs.existsSync(path.join(dir, ".git"))) {
209
+ throw new Error(`[vo-mcp runner] clone race left no usable clone for ${repoSlug}`);
210
+ }
211
+ }
212
+ }
213
+ return { root: dir, multiRepo: true };
214
+ }
162
215
  function createFixWorktree(kind, error = {}) {
163
- const root = repoRoot();
216
+ const { root, multiRepo } = resolveTaskRoot(error.repo);
164
217
  const safeKind = sanitize(kind, "task");
165
218
  const safeTarget = sanitize(error.source || error.tester || "run", "run").slice(0, 24);
166
219
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -174,18 +227,56 @@ function createFixWorktree(kind, error = {}) {
174
227
  { cwd: root, encoding: "utf8", timeout: 12e4 }
175
228
  );
176
229
  if (add.status === 0 && fs.existsSync(worktreeDir)) {
230
+ TRACKED_WORKTREES.set(worktreeName, { worktreeDir, root });
177
231
  return { worktreeDir, worktreeName };
178
232
  }
179
- console.error(
180
- `[vo-mcp runner] worktree isolation unavailable: ${String(add.stderr || add.error || "").slice(0, 200)}`
181
- );
233
+ const detail = String(add.stderr || add.error || "").slice(0, 200);
234
+ if (multiRepo) {
235
+ throw new Error(`[vo-mcp runner] worktree create failed for ${error.repo}: ${detail}`);
236
+ }
237
+ console.error(`[vo-mcp runner] worktree isolation unavailable: ${detail}`);
182
238
  return { worktreeDir: root, worktreeName: "" };
183
239
  }
184
240
  function cleanupFixWorktree(worktreeName) {
185
241
  if (!worktreeName) return;
186
- const root = repoRoot();
187
- const worktreeDir = path.join(root, ".agent-worktrees", worktreeName);
242
+ const tracked = TRACKED_WORKTREES.get(worktreeName);
243
+ const root = tracked ? tracked.root : repoRoot();
244
+ const worktreeDir = tracked ? tracked.worktreeDir : path.join(root, ".agent-worktrees", worktreeName);
188
245
  spawnSync("git", ["worktree", "remove", "--force", worktreeDir], { cwd: root, timeout: 12e4 });
246
+ TRACKED_WORKTREES.delete(worktreeName);
247
+ }
248
+ function finalizeWorktree(worktreeName, meta = {}) {
249
+ if (!worktreeName) return;
250
+ if (meta.preserveReason) {
251
+ preserveFailedWorktree(worktreeName, { ...meta, reason: meta.preserveReason });
252
+ } else {
253
+ cleanupFixWorktree(worktreeName);
254
+ }
255
+ }
256
+ function preserveFailedWorktree(worktreeName, meta = {}) {
257
+ if (!worktreeName) return null;
258
+ const tracked = TRACKED_WORKTREES.get(worktreeName);
259
+ const root = tracked ? tracked.root : repoRoot();
260
+ const worktreeDir = tracked ? tracked.worktreeDir : path.join(root, ".agent-worktrees", worktreeName);
261
+ const entry = {
262
+ at: (/* @__PURE__ */ new Date()).toISOString(),
263
+ worktreeName,
264
+ worktreeDir,
265
+ branch: meta.branch || `vo/${worktreeName}`,
266
+ taskId: meta.taskId || null,
267
+ repo: meta.repo || null,
268
+ prompt: String(meta.prompt || "").slice(0, 300),
269
+ reason: String(meta.reason || "task failed").slice(0, 300)
270
+ };
271
+ try {
272
+ const ledger = path.join(root, ".agent-worktrees", "recovery-ledger.jsonl");
273
+ fs.mkdirSync(path.dirname(ledger), { recursive: true });
274
+ fs.appendFileSync(ledger, JSON.stringify(entry) + "\n", "utf8");
275
+ console.error(`[vo-mcp runner] Preserved worktree ${worktreeName} (reason: ${entry.reason})`);
276
+ } catch (err) {
277
+ console.error(`[vo-mcp runner] Failed to write recovery ledger: ${err.message}`);
278
+ }
279
+ return entry;
189
280
  }
190
281
 
191
282
  // src/runner/spend-cap-shim.mjs
@@ -324,6 +415,57 @@ function createControlPlaneClient({
324
415
  if (!res.ok) throw new Error(`weekly-tokens failed: HTTP ${res.status}`);
325
416
  return true;
326
417
  },
418
+ /**
419
+ * Send a liveness heartbeat (M2). The control-plane upserts it under the
420
+ * authenticated operator so the web shows a TRUE "runner online" signal.
421
+ * Best-effort caller; throws on 401/non-ok so the daemon can log + retry.
422
+ */
423
+ async postHeartbeat({ runnerId, operatorId, uptimeSec, activeTasks, version, servedRepos, servedOperators }) {
424
+ const body = { runner_id: runnerId };
425
+ if (operatorId) body.operator_id = operatorId;
426
+ if (typeof uptimeSec === "number") body.uptime_sec = uptimeSec;
427
+ if (typeof activeTasks === "number") body.active_tasks = activeTasks;
428
+ if (version) body.version = version;
429
+ if (Array.isArray(servedRepos) && servedRepos.length > 0) body.served_repos = servedRepos;
430
+ if (Array.isArray(servedOperators) && servedOperators.length > 0) {
431
+ body.served_operator_ids = servedOperators;
432
+ }
433
+ const res = await req("POST", "/api/v1/runner/heartbeat", body);
434
+ if (res.status === 401) {
435
+ cachedFirebaseToken = null;
436
+ throw new Error("heartbeat unauthorized (401)");
437
+ }
438
+ if (!res.ok) throw new Error(`heartbeat failed: HTTP ${res.status}`);
439
+ return true;
440
+ },
441
+ /**
442
+ * Mint a short-lived (~1h), repo-scoped GitHub App installation token for
443
+ * THIS runner's operator (M3). The control-plane keys the mint on the
444
+ * authenticated operator (ctx.operator_id), so the token covers only that
445
+ * operator's installation.
446
+ *
447
+ * Returns { token, expiresAt } on success, or `null` on ANY non-success —
448
+ * 503 (App not configured / dormant), 404 (operator has no installation),
449
+ * other non-2xx, or a network error. `null` is the signal to the caller to
450
+ * fall back to the runner's own ambient `gh` auth, so a dormant or
451
+ * unmapped App NEVER blocks a PR the runner could open itself. Never throws.
452
+ *
453
+ * The minted token is only usable for push + PR if the GitHub App grants
454
+ * BOTH `Contents: write` (git push) AND `Pull requests: write` (gh pr
455
+ * create) — see docs/vo/github-app-setup-2026-06-18.md. A token missing
456
+ * either scope fails at push (→ ambient fallback) or at `gh pr create`.
457
+ */
458
+ async getInstallationToken() {
459
+ try {
460
+ const res = await req("POST", "/api/v1/github/installation-token", {});
461
+ if (!res.ok) return null;
462
+ const json = await res.json();
463
+ if (!json || !json.token) return null;
464
+ return { token: json.token, expiresAt: json.expires_at || null };
465
+ } catch {
466
+ return null;
467
+ }
468
+ },
327
469
  /**
328
470
  * Read the operator's dispatch-mode config (Fast→Ultracode effort setting).
329
471
  * Returns the mode string ('fast'|'standard'|'deep'|'ultra'|'ultracode'),
@@ -344,7 +486,152 @@ function createControlPlaneClient({
344
486
 
345
487
  // ../../scripts/virtual-office/code-runner/claude-runner.mjs
346
488
  import { spawn } from "node:child_process";
489
+ import { spawnSync as spawnSync4 } from "node:child_process";
490
+
491
+ // ../../scripts/virtual-office/code-runner/anthropic-key-store.mjs
492
+ import { createRequire as createRequire2 } from "node:module";
347
493
  import { spawnSync as spawnSync2 } from "node:child_process";
494
+ var require2 = createRequire2(import.meta.url);
495
+ var KEY_SERVICE = "algosuite-vo";
496
+ var KEY_ACCOUNT = "anthropic-api-key";
497
+ var _entryCtor;
498
+ var _loadTried = false;
499
+ function defaultEntryCtor() {
500
+ if (_loadTried) return _entryCtor;
501
+ _loadTried = true;
502
+ try {
503
+ _entryCtor = require2("@napi-rs/keyring").Entry;
504
+ } catch {
505
+ _entryCtor = null;
506
+ }
507
+ return _entryCtor;
508
+ }
509
+ function getAnthropicKey({ EntryCtor = defaultEntryCtor() } = {}) {
510
+ if (!EntryCtor) return null;
511
+ try {
512
+ return new EntryCtor(KEY_SERVICE, KEY_ACCOUNT).getPassword() || null;
513
+ } catch {
514
+ return null;
515
+ }
516
+ }
517
+ var PREFER_LOGIN_ENV = "VO_RUNNER_PREFER_LOGIN";
518
+ function isTruthyFlag(v) {
519
+ const s = String(v ?? "").trim().toLowerCase();
520
+ return s === "1" || s === "true" || s === "yes" || s === "on";
521
+ }
522
+ function withAnthropicKey(baseEnv = {}, { getKey = getAnthropicKey } = {}) {
523
+ if (isTruthyFlag(baseEnv[PREFER_LOGIN_ENV])) {
524
+ const next = { ...baseEnv };
525
+ delete next.ANTHROPIC_API_KEY;
526
+ return next;
527
+ }
528
+ if (baseEnv.ANTHROPIC_API_KEY) return { ...baseEnv };
529
+ const key = getKey();
530
+ return key ? { ...baseEnv, ANTHROPIC_API_KEY: key } : { ...baseEnv };
531
+ }
532
+ function describeAnthropicAuthSource(baseEnv = {}, { getKey = getAnthropicKey } = {}) {
533
+ if (isTruthyFlag(baseEnv[PREFER_LOGIN_ENV])) {
534
+ return "claude auth login (VO_RUNNER_PREFER_LOGIN set \u2014 any API key ignored)";
535
+ }
536
+ if (baseEnv.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY from environment";
537
+ if (getKey()) return "ANTHROPIC_API_KEY from OS keychain";
538
+ return "claude auth login session (no API key set)";
539
+ }
540
+ var AUTH_ERROR_RE = /\b401\b|invalid[^.]{0,24}(authentication|credential)|authentication_error|unauthorized|not[ _-]?authenticated/i;
541
+ function augmentAuthError(summary) {
542
+ const s = String(summary ?? "");
543
+ if (!AUTH_ERROR_RE.test(s)) return s;
544
+ return `${s}
545
+ \u21B3 Anthropic auth failed on the runner. The \`claude\` CLI is a SEPARATE install/login from the Claude Desktop app and the Claude Code IDE extension \u2014 signing into those does NOT authenticate it. Fix: run \`claude auth login\` (Claude subscription) on the runner machine, or clear any stale ANTHROPIC_API_KEY (env / OS keychain / .env.local) and set VO_RUNNER_PREFER_LOGIN=1 \u2014 then restart the runner. Verify with \`claude -p "say hi"\`.`;
546
+ }
547
+ function probeClaudeLoginState({ spawn: spawn2 = spawnSync2 } = {}) {
548
+ try {
549
+ const st = spawn2("claude", ["auth", "status"], {
550
+ shell: process.platform === "win32",
551
+ windowsHide: true,
552
+ timeout: 5e3,
553
+ encoding: "utf8"
554
+ });
555
+ const parsed = JSON.parse(String(st.stdout || "").trim() || "{}");
556
+ return typeof parsed.loggedIn === "boolean" ? parsed.loggedIn : null;
557
+ } catch {
558
+ return null;
559
+ }
560
+ }
561
+
562
+ // ../../scripts/virtual-office/code-runner/sandbox/sandbox-docker.mjs
563
+ import { spawnSync as spawnSync3 } from "node:child_process";
564
+ var DEFAULT_SANDBOX_IMAGE = "vo-agent-sandbox";
565
+ function buildDockerArgs({
566
+ worktreeDir,
567
+ image = DEFAULT_SANDBOX_IMAGE,
568
+ agentBin = "claude",
569
+ agentArgs = [],
570
+ passEnv = ["ANTHROPIC_API_KEY"],
571
+ network = "bridge",
572
+ memory = "4g",
573
+ cpus = "2",
574
+ pids = "512",
575
+ user,
576
+ shadowGit = true,
577
+ readOnlyWork = false,
578
+ extraDockerArgs = []
579
+ } = {}) {
580
+ if (!worktreeDir) throw new Error("buildDockerArgs: worktreeDir is required");
581
+ const args = [
582
+ "run",
583
+ "--rm",
584
+ "-i",
585
+ // keep stdin open so the runner can feed the prompt (injection-safe)
586
+ "--network",
587
+ String(network),
588
+ "--cap-drop",
589
+ "ALL",
590
+ "--security-opt",
591
+ "no-new-privileges",
592
+ "--memory",
593
+ String(memory),
594
+ "--cpus",
595
+ String(cpus),
596
+ "--pids-limit",
597
+ String(pids),
598
+ // Read-only root + tmpfs scratch: the ONLY persistent writable path is the
599
+ // host-backed /work mount, so the agent can't tamper with the image or
600
+ // stash anything off-worktree.
601
+ "--read-only",
602
+ "--tmpfs",
603
+ "/tmp:rw,nosuid,nodev",
604
+ "-e",
605
+ "HOME=/tmp/agent-home"
606
+ ];
607
+ if (shadowGit) args.push("--tmpfs", "/work/.git:rw,nosuid,nodev,size=2m");
608
+ if (user) args.push("--user", String(user));
609
+ for (const k of passEnv) {
610
+ if (k && /^[A-Z_][A-Z0-9_]*$/i.test(k)) args.push("-e", k);
611
+ }
612
+ args.push("-v", `${worktreeDir}:/work${readOnlyWork ? ":ro" : ""}`, "-w", "/work");
613
+ args.push(...extraDockerArgs);
614
+ args.push(image, agentBin, ...agentArgs);
615
+ return args;
616
+ }
617
+
618
+ // ../../scripts/virtual-office/code-runner/context7-mcp.mjs
619
+ var CONTEXT7_URL = "https://mcp.context7.com/mcp";
620
+ function context7McpConfig(env2 = process.env) {
621
+ if (env2.VO_ENABLE_CONTEXT7 !== "1") return null;
622
+ const url = env2.VO_CONTEXT7_URL && env2.VO_CONTEXT7_URL.trim() || CONTEXT7_URL;
623
+ const server = { type: "http", url };
624
+ if (env2.CONTEXT7_API_KEY && env2.CONTEXT7_API_KEY.trim()) {
625
+ server.headers = { CONTEXT7_API_KEY: env2.CONTEXT7_API_KEY.trim() };
626
+ }
627
+ return { mcpServers: { context7: server } };
628
+ }
629
+ function context7McpArgs(env2 = process.env) {
630
+ const cfg = context7McpConfig(env2);
631
+ return cfg ? ["--mcp-config", JSON.stringify(cfg)] : [];
632
+ }
633
+
634
+ // ../../scripts/virtual-office/code-runner/claude-runner.mjs
348
635
  var DEFAULT_PERMISSION_MODE = "acceptEdits";
349
636
  function extractText(content) {
350
637
  if (typeof content === "string") return content.trim();
@@ -379,7 +666,7 @@ function parseStreamEvent(line) {
379
666
  }
380
667
  return null;
381
668
  }
382
- function buildClaudeArgs({ permissionMode = DEFAULT_PERMISSION_MODE, maxTurns, model } = {}) {
669
+ function buildClaudeArgs({ permissionMode = DEFAULT_PERMISSION_MODE, maxTurns, model, env: env2 = process.env } = {}) {
383
670
  const args = [
384
671
  "-p",
385
672
  "--output-format",
@@ -394,12 +681,14 @@ function buildClaudeArgs({ permissionMode = DEFAULT_PERMISSION_MODE, maxTurns, m
394
681
  if (model) {
395
682
  args.push("--model", String(model));
396
683
  }
684
+ args.push(...context7McpArgs(env2));
397
685
  return args;
398
686
  }
399
- function runClaudeTask({
687
+ function runAgentTask({
688
+ runner,
400
689
  prompt,
401
690
  cwd,
402
- claudeBin = "claude",
691
+ bin = runner.binary,
403
692
  permissionMode,
404
693
  maxTurns,
405
694
  model,
@@ -409,19 +698,39 @@ function runClaudeTask({
409
698
  shouldCancel = async () => false,
410
699
  cancelPollMs = 5e3,
411
700
  maxWallClockMs = 0,
412
- spawnImpl = spawn
701
+ spawnImpl = spawn,
702
+ sandbox = null
413
703
  }) {
414
- return new Promise((resolve) => {
415
- const args = buildClaudeArgs({ permissionMode, maxTurns, model });
416
- const child = spawnImpl(claudeBin, args, {
704
+ return new Promise((resolve2) => {
705
+ const args = runner.buildArgs({ permissionMode, maxTurns, model, prompt });
706
+ const spawnEnv = typeof runner.applyAuthEnv === "function" ? runner.applyAuthEnv(env2) : env2;
707
+ if (typeof runner.describeAuth === "function") {
708
+ try {
709
+ console.error(`[runner] agent auth: ${runner.describeAuth(spawnEnv)}`);
710
+ } catch {
711
+ }
712
+ }
713
+ let spawnBin = bin;
714
+ let spawnArgs = args;
715
+ let spawnOpts = runner.getSpawnOptions({ bin: spawnBin });
716
+ if (sandbox && sandbox.mode === "docker") {
717
+ spawnArgs = buildDockerArgs({
718
+ worktreeDir: cwd,
719
+ image: sandbox.image,
720
+ agentBin: bin,
721
+ agentArgs: args,
722
+ network: sandbox.network,
723
+ user: sandbox.user,
724
+ ...sandbox.passEnv ? { passEnv: sandbox.passEnv } : {}
725
+ });
726
+ spawnBin = sandbox.dockerBin || "docker";
727
+ spawnOpts = { windowsHide: true };
728
+ }
729
+ const child = spawnImpl(spawnBin, spawnArgs, {
417
730
  cwd,
418
- env: { ...env2 },
731
+ env: spawnEnv,
419
732
  stdio: ["pipe", "pipe", "pipe"],
420
- // Windows: `claude` is a `.cmd` shim → spawn needs a shell to resolve it
421
- // (without it: spawn ENOENT). Safe because the prompt goes via stdin
422
- // below, never argv, so the shell never sees untrusted input.
423
- shell: process.platform === "win32",
424
- windowsHide: true
733
+ ...spawnOpts
425
734
  });
426
735
  try {
427
736
  child.stdin.write(String(prompt));
@@ -456,7 +765,7 @@ function runClaudeTask({
456
765
  while ((nl = buffer.indexOf("\n")) >= 0) {
457
766
  const line = buffer.slice(0, nl);
458
767
  buffer = buffer.slice(nl + 1);
459
- const evt = parseStreamEvent(line);
768
+ const evt = runner.parseEvent(line);
460
769
  if (!evt) continue;
461
770
  if (evt.kind === "progress") {
462
771
  try {
@@ -480,7 +789,7 @@ function runClaudeTask({
480
789
  child.on("error", (err) => {
481
790
  clearInterval(poll);
482
791
  if (wallTimer) clearTimeout(wallTimer);
483
- resolve({ ...result, ok: false, summary: `spawn error: ${err.message}` });
792
+ resolve2({ ...result, ok: false, summary: `spawn error: ${err.message}` });
484
793
  });
485
794
  const poll = setInterval(() => {
486
795
  Promise.resolve().then(() => shouldCancel()).then((cancel) => {
@@ -496,7 +805,7 @@ function runClaudeTask({
496
805
  clearInterval(poll);
497
806
  if (wallTimer) clearTimeout(wallTimer);
498
807
  if (timedOut) {
499
- resolve({
808
+ resolve2({
500
809
  ...result,
501
810
  ok: false,
502
811
  timedOut: true,
@@ -505,13 +814,13 @@ function runClaudeTask({
505
814
  return;
506
815
  }
507
816
  if (killed) {
508
- resolve({ ...result, ok: false, killed: true, summary: "cancelled by operator" });
817
+ resolve2({ ...result, ok: false, killed: true, summary: "cancelled by operator" });
509
818
  return;
510
819
  }
511
820
  if (!result.summary && code !== 0) {
512
- result.summary = stderrTail.slice(-500) || `claude exited ${code}`;
821
+ result.summary = stderrTail.slice(-500) || `${bin} exited ${code}`;
513
822
  }
514
- resolve({ ...result, ok: result.ok && code === 0 });
823
+ resolve2({ ...result, ok: result.ok && code === 0, summary: augmentAuthError(result.summary) });
515
824
  });
516
825
  });
517
826
  }
@@ -531,6 +840,18 @@ var ClaudeRunner = class {
531
840
  windowsHide: true
532
841
  };
533
842
  }
843
+ /**
844
+ * Fill ANTHROPIC_API_KEY from the OS keychain when not already set (M4 BYO),
845
+ * so a friend who ran `vo-mcp set-key` authenticates without an env var.
846
+ * Explicit env wins; no key stored → unchanged (Claude Code login as before).
847
+ */
848
+ applyAuthEnv(env2 = process.env) {
849
+ return withAnthropicKey(env2);
850
+ }
851
+ /** Describe which Anthropic auth source the spawn will use (for runner logs). */
852
+ describeAuth(env2 = process.env) {
853
+ return describeAnthropicAuthSource(env2);
854
+ }
534
855
  /**
535
856
  * Best-effort auth check: is `claude` on PATH and can we verify login?
536
857
  * Never throws. If we can't cheaply detect auth, we return installed:true
@@ -538,30 +859,34 @@ var ClaudeRunner = class {
538
859
  */
539
860
  async checkAuth() {
540
861
  try {
541
- const { status, error } = spawnSync2("claude", ["--version"], {
862
+ const probe = spawnSync4("claude", ["--version"], {
542
863
  shell: process.platform === "win32",
543
864
  windowsHide: true,
544
865
  timeout: 3e3,
545
866
  stdio: "ignore"
546
867
  });
547
- if (error) {
868
+ if (probe.error) {
548
869
  return {
549
870
  installed: false,
550
871
  authenticated: false,
551
- message: `claude not found on PATH: ${error.message}`
872
+ message: "claude CLI not found on PATH \u2014 it is a SEPARATE install from the Claude Desktop app and the Claude Code IDE extension. Install: npm install -g @anthropic-ai/claude-code, then sign in: claude auth login."
552
873
  };
553
874
  }
554
- if (status !== 0) {
875
+ if (probe.status !== 0) {
876
+ return { installed: true, authenticated: false, message: "claude binary exists but --version failed (auth unclear)" };
877
+ }
878
+ const loggedIn = probeClaudeLoginState();
879
+ if (loggedIn === false) {
555
880
  return {
556
881
  installed: true,
557
882
  authenticated: false,
558
- message: "claude binary exists but --version failed (auth unclear)"
883
+ message: "claude CLI is installed but NOT logged in \u2014 its login is SEPARATE from the Claude Desktop app and the Claude Code IDE extension. Run: claude auth login (Claude subscription), then restart the runner."
559
884
  };
560
885
  }
561
886
  return {
562
887
  installed: true,
563
888
  authenticated: true,
564
- message: "claude binary found (auth check is best-effort)"
889
+ message: loggedIn === true ? "claude CLI installed and logged in (claude auth status)" : "claude binary found (login state unknown \u2014 auth check is best-effort)"
565
890
  };
566
891
  } catch (err) {
567
892
  return {
@@ -574,10 +899,368 @@ var ClaudeRunner = class {
574
899
  };
575
900
  var claudeRunner = new ClaudeRunner();
576
901
 
902
+ // ../../scripts/virtual-office/code-runner/codex-runner.mjs
903
+ import { spawnSync as spawnSync5 } from "node:child_process";
904
+ import { existsSync as existsSync2 } from "node:fs";
905
+ import { join as join2 } from "node:path";
906
+
907
+ // ../../scripts/virtual-office/code-runner/agent-key-store.mjs
908
+ import { createRequire as createRequire3 } from "node:module";
909
+ var require3 = createRequire3(import.meta.url);
910
+ var KEY_SERVICE2 = "algosuite-vo";
911
+ var PROVIDER_ENV = {
912
+ anthropic: ["ANTHROPIC_API_KEY"],
913
+ openai: ["OPENAI_API_KEY", "CODEX_API_KEY"],
914
+ cursor: ["CURSOR_API_KEY"]
915
+ };
916
+ var PROVIDER_ALIAS = {
917
+ claude: "anthropic",
918
+ anthropic: "anthropic",
919
+ codex: "openai",
920
+ openai: "openai",
921
+ cursor: "cursor"
922
+ };
923
+ function resolveProvider(name) {
924
+ const key = String(name || "").trim().toLowerCase();
925
+ return PROVIDER_ALIAS[key] || null;
926
+ }
927
+ function accountFor(provider) {
928
+ return `${provider}-api-key`;
929
+ }
930
+ var _entryCtor2;
931
+ var _loadTried2 = false;
932
+ function defaultEntryCtor2() {
933
+ if (_loadTried2) return _entryCtor2;
934
+ _loadTried2 = true;
935
+ try {
936
+ _entryCtor2 = require3("@napi-rs/keyring").Entry;
937
+ } catch {
938
+ _entryCtor2 = null;
939
+ }
940
+ return _entryCtor2;
941
+ }
942
+ function getAgentKey(provider, { EntryCtor = defaultEntryCtor2() } = {}) {
943
+ const p = resolveProvider(provider);
944
+ if (!p || !EntryCtor) return null;
945
+ try {
946
+ return new EntryCtor(KEY_SERVICE2, accountFor(p)).getPassword() || null;
947
+ } catch {
948
+ return null;
949
+ }
950
+ }
951
+ function withAgentKey(provider, baseEnv = {}, { getKey = getAgentKey } = {}) {
952
+ const p = resolveProvider(provider);
953
+ const vars = p && PROVIDER_ENV[p] || [];
954
+ const out = { ...baseEnv };
955
+ if (!p || vars.length === 0) return out;
956
+ if (vars.some((v) => out[v])) return out;
957
+ const key = getKey(p);
958
+ if (!key) return out;
959
+ for (const v of vars) out[v] = key;
960
+ return out;
961
+ }
962
+
963
+ // ../../scripts/virtual-office/code-runner/codex-runner.mjs
964
+ function resolveCodexBinary({
965
+ env: env2 = process.env,
966
+ platform = process.platform,
967
+ exists = existsSync2
968
+ } = {}) {
969
+ if (platform !== "win32") return "codex";
970
+ const appData = String(env2.APPDATA || "").trim();
971
+ if (appData) {
972
+ const npmVendorBinary = join2(
973
+ appData,
974
+ "npm",
975
+ "node_modules",
976
+ "@openai",
977
+ "codex",
978
+ "node_modules",
979
+ "@openai",
980
+ "codex-win32-x64",
981
+ "vendor",
982
+ "x86_64-pc-windows-msvc",
983
+ "bin",
984
+ "codex.exe"
985
+ );
986
+ if (exists(npmVendorBinary)) return npmVendorBinary;
987
+ }
988
+ return "codex";
989
+ }
990
+ function buildCodexArgs({ model } = {}) {
991
+ const args = ["exec", "--json", "-c", 'approval_policy="never"', "--sandbox", "danger-full-access"];
992
+ if (model) {
993
+ args.push("--model", String(model));
994
+ }
995
+ args.push("-");
996
+ return args;
997
+ }
998
+ function itemText(item) {
999
+ if (!item) return "";
1000
+ if (typeof item.text === "string") return item.text;
1001
+ if (typeof item.message === "string") return item.message;
1002
+ if (Array.isArray(item.content)) {
1003
+ return item.content.map((b) => typeof b === "string" ? b : typeof b?.text === "string" ? b.text : "").join("");
1004
+ }
1005
+ return "";
1006
+ }
1007
+ function parseCodexEvent(line) {
1008
+ const trimmed = String(line || "").trim();
1009
+ if (!trimmed) return null;
1010
+ let evt;
1011
+ try {
1012
+ evt = JSON.parse(trimmed);
1013
+ } catch {
1014
+ return null;
1015
+ }
1016
+ if (!evt || typeof evt !== "object") return null;
1017
+ const type = evt.type;
1018
+ if (type === "item.completed" && evt.item) {
1019
+ const it = evt.item.type;
1020
+ if (it === "agent_message" || it === "assistant_message") {
1021
+ const text = itemText(evt.item).trim();
1022
+ return text ? { kind: "progress", text } : null;
1023
+ }
1024
+ return null;
1025
+ }
1026
+ if (type === "turn.completed") {
1027
+ return { kind: "result", isError: false, costUsd: null, summary: "completed", numTurns: null };
1028
+ }
1029
+ if (type === "turn.failed" || type === "error") {
1030
+ const msg = evt.error && (evt.error.message || evt.error) || evt.message || "codex run failed";
1031
+ return { kind: "result", isError: true, costUsd: null, summary: String(msg), numTurns: null };
1032
+ }
1033
+ return null;
1034
+ }
1035
+ var CodexRunner = class {
1036
+ get binary() {
1037
+ return resolveCodexBinary();
1038
+ }
1039
+ buildArgs(opts = {}) {
1040
+ return buildCodexArgs(opts);
1041
+ }
1042
+ parseEvent(line) {
1043
+ return parseCodexEvent(line);
1044
+ }
1045
+ getSpawnOptions({ bin } = {}) {
1046
+ const effectiveBin = String(bin || this.binary || "");
1047
+ return {
1048
+ shell: process.platform === "win32" && !/\.exe$/i.test(effectiveBin),
1049
+ windowsHide: true
1050
+ };
1051
+ }
1052
+ /**
1053
+ * Fill the OpenAI credential env var(s) (OPENAI_API_KEY / CODEX_API_KEY) from
1054
+ * the OS keychain when not already set, so a BYO friend who ran
1055
+ * `vo-mcp set-key --provider codex` authenticates without an env var. Explicit
1056
+ * env wins; no key stored → unchanged (a prior `codex login` still works).
1057
+ */
1058
+ applyAuthEnv(env2 = process.env) {
1059
+ return withAgentKey("openai", env2);
1060
+ }
1061
+ /** Best-effort: is `codex` on PATH? Never throws. */
1062
+ async checkAuth() {
1063
+ try {
1064
+ const bin = this.binary;
1065
+ const { status, error } = spawnSync5(bin, ["--version"], {
1066
+ ...this.getSpawnOptions({ bin }),
1067
+ windowsHide: true,
1068
+ timeout: 3e3,
1069
+ stdio: "ignore"
1070
+ });
1071
+ if (error) {
1072
+ return { installed: false, authenticated: false, message: `codex not found on PATH: ${error.message}` };
1073
+ }
1074
+ if (status !== 0) {
1075
+ return { installed: true, authenticated: false, message: "codex exists but --version failed (auth unclear)" };
1076
+ }
1077
+ return { installed: true, authenticated: true, message: "codex binary found (auth check is best-effort)" };
1078
+ } catch (err) {
1079
+ return { installed: false, authenticated: false, message: `checkAuth probe failed: ${err.message}` };
1080
+ }
1081
+ }
1082
+ };
1083
+ var codexRunner = new CodexRunner();
1084
+
1085
+ // ../../scripts/virtual-office/code-runner/cursor-runner.mjs
1086
+ import { spawnSync as spawnSync6 } from "node:child_process";
1087
+ function buildCursorArgs({ model, prompt } = {}) {
1088
+ const args = ["-p", "--output-format", "stream-json", "--force"];
1089
+ if (model) {
1090
+ args.push("--model", String(model));
1091
+ }
1092
+ const p = String(prompt ?? "");
1093
+ if (p.length > 0) {
1094
+ args.push(p);
1095
+ }
1096
+ return args;
1097
+ }
1098
+ function messageText(message) {
1099
+ if (!message) return "";
1100
+ const content = message.content;
1101
+ if (typeof content === "string") return content;
1102
+ if (Array.isArray(content)) {
1103
+ return content.map((b) => typeof b === "string" ? b : typeof b?.text === "string" ? b.text : "").join("");
1104
+ }
1105
+ return "";
1106
+ }
1107
+ function parseCursorEvent(line) {
1108
+ const trimmed = String(line || "").trim();
1109
+ if (!trimmed) return null;
1110
+ let evt;
1111
+ try {
1112
+ evt = JSON.parse(trimmed);
1113
+ } catch {
1114
+ return null;
1115
+ }
1116
+ if (!evt || typeof evt !== "object") return null;
1117
+ if (evt.type === "assistant") {
1118
+ const text = messageText(evt.message).trim();
1119
+ return text ? { kind: "progress", text } : null;
1120
+ }
1121
+ if (evt.type === "result") {
1122
+ const isError = Boolean(evt.is_error) || evt.subtype === "error";
1123
+ return {
1124
+ kind: "result",
1125
+ isError,
1126
+ costUsd: null,
1127
+ summary: typeof evt.result === "string" && evt.result.length > 0 ? evt.result : evt.subtype || (isError ? "error" : "completed"),
1128
+ numTurns: null
1129
+ };
1130
+ }
1131
+ return null;
1132
+ }
1133
+ var CursorRunner = class {
1134
+ get binary() {
1135
+ return "cursor-agent";
1136
+ }
1137
+ buildArgs(opts = {}) {
1138
+ return buildCursorArgs(opts);
1139
+ }
1140
+ parseEvent(line) {
1141
+ return parseCursorEvent(line);
1142
+ }
1143
+ getSpawnOptions() {
1144
+ return {
1145
+ shell: process.platform === "win32",
1146
+ windowsHide: true
1147
+ };
1148
+ }
1149
+ /**
1150
+ * Fill CURSOR_API_KEY from the OS keychain when not already set, so a BYO
1151
+ * friend who ran `vo-mcp set-key --provider cursor` authenticates without an
1152
+ * env var. Explicit env wins; no key stored → a prior `cursor-agent login`.
1153
+ */
1154
+ applyAuthEnv(env2 = process.env) {
1155
+ return withAgentKey("cursor", env2);
1156
+ }
1157
+ /** Best-effort: is `cursor-agent` on PATH? Never throws. */
1158
+ async checkAuth() {
1159
+ try {
1160
+ const { status, error } = spawnSync6("cursor-agent", ["--version"], {
1161
+ shell: process.platform === "win32",
1162
+ windowsHide: true,
1163
+ timeout: 3e3,
1164
+ stdio: "ignore"
1165
+ });
1166
+ if (error) {
1167
+ return { installed: false, authenticated: false, message: `cursor-agent not found on PATH: ${error.message}` };
1168
+ }
1169
+ if (status !== 0) {
1170
+ return { installed: true, authenticated: false, message: "cursor-agent exists but --version failed (auth unclear)" };
1171
+ }
1172
+ return {
1173
+ installed: true,
1174
+ authenticated: true,
1175
+ message: "cursor-agent found (EXPERIMENTAL: headless mode may need a TTY; auth check is best-effort)"
1176
+ };
1177
+ } catch (err) {
1178
+ return { installed: false, authenticated: false, message: `checkAuth probe failed: ${err.message}` };
1179
+ }
1180
+ }
1181
+ };
1182
+ var cursorRunner = new CursorRunner();
1183
+
1184
+ // ../../scripts/virtual-office/code-runner/agent-runner-interface.mjs
1185
+ function validateAgentRunner(runner) {
1186
+ if (!runner || typeof runner !== "object") {
1187
+ throw new TypeError("AgentRunner must be an object");
1188
+ }
1189
+ if (typeof runner.binary !== "string" || runner.binary.length === 0) {
1190
+ throw new TypeError("AgentRunner.binary must be a non-empty string");
1191
+ }
1192
+ if (typeof runner.buildArgs !== "function") {
1193
+ throw new TypeError("AgentRunner.buildArgs must be a function");
1194
+ }
1195
+ if (typeof runner.parseEvent !== "function") {
1196
+ throw new TypeError("AgentRunner.parseEvent must be a function");
1197
+ }
1198
+ if (typeof runner.getSpawnOptions !== "function") {
1199
+ throw new TypeError("AgentRunner.getSpawnOptions must be a function");
1200
+ }
1201
+ if (typeof runner.checkAuth !== "function") {
1202
+ throw new TypeError("AgentRunner.checkAuth must be a function");
1203
+ }
1204
+ }
1205
+
1206
+ // ../../scripts/virtual-office/code-runner/resolve-runner.mjs
1207
+ var DEFAULT_AGENT = "claude";
1208
+ var RUNNERS = {
1209
+ claude: claudeRunner,
1210
+ codex: codexRunner,
1211
+ cursor: cursorRunner
1212
+ };
1213
+ function listAgents() {
1214
+ return Object.keys(RUNNERS);
1215
+ }
1216
+ function inferAgentFromBin(bin) {
1217
+ const raw = String(bin || "").trim().toLowerCase();
1218
+ if (!raw) return null;
1219
+ const base = raw.replace(/\\/g, "/").split("/").pop() || raw;
1220
+ if (base.includes("codex")) return "codex";
1221
+ if (base.includes("cursor-agent") || base === "cursor" || base.startsWith("cursor.")) return "cursor";
1222
+ if (base.includes("claude")) return "claude";
1223
+ return null;
1224
+ }
1225
+ function inferAgentFromEnvBin(env2) {
1226
+ for (const bin of [env2.VO_CODE_RUNNER_BIN, env2.VO_CODE_RUNNER_CLAUDE_BIN]) {
1227
+ const agent = inferAgentFromBin(bin);
1228
+ if (agent) return { agent, bin };
1229
+ }
1230
+ return null;
1231
+ }
1232
+ function resolveRunner(env2 = process.env, { warn = () => {
1233
+ } } = {}) {
1234
+ const explicitAgent = String(env2.VO_CODE_RUNNER_AGENT || env2.VO_AGENT || "").trim();
1235
+ const inferred = explicitAgent ? null : inferAgentFromEnvBin(env2);
1236
+ const raw = String(explicitAgent || inferred?.agent || DEFAULT_AGENT).trim().toLowerCase();
1237
+ let agent = raw;
1238
+ let fellBack = false;
1239
+ let runner = RUNNERS[agent];
1240
+ if (inferred && runner) {
1241
+ try {
1242
+ warn(`VO_CODE_RUNNER_AGENT not set; inferred "${agent}" from runner binary "${inferred.bin}"`);
1243
+ } catch {
1244
+ }
1245
+ }
1246
+ if (!runner) {
1247
+ try {
1248
+ warn(`unknown VO_CODE_RUNNER_AGENT "${raw}"; falling back to "${DEFAULT_AGENT}" (known: ${listAgents().join(", ")})`);
1249
+ } catch {
1250
+ }
1251
+ agent = DEFAULT_AGENT;
1252
+ runner = RUNNERS[DEFAULT_AGENT];
1253
+ fellBack = true;
1254
+ }
1255
+ validateAgentRunner(runner);
1256
+ const runnerBin = env2.VO_CODE_RUNNER_BIN || (inferred && inferred.agent === agent ? inferred.bin : "") || (agent === "claude" ? env2.VO_CODE_RUNNER_CLAUDE_BIN : "") || runner.binary;
1257
+ return { agent, runner, runnerBin, fellBack };
1258
+ }
1259
+
577
1260
  // ../../scripts/virtual-office/code-runner/rate-limit-resume.mjs
578
1261
  import { appendFileSync, mkdirSync as mkdirSync2 } from "node:fs";
579
1262
  import { homedir as homedir2 } from "node:os";
580
- import { join as join2, dirname as dirname2 } from "node:path";
1263
+ import { join as join3, dirname as dirname2 } from "node:path";
581
1264
 
582
1265
  // ../../scripts/ci/rate-limit-detector-core.mjs
583
1266
  var RATE_LIMIT_RE = /\b(?:usage limit reached|usage limit|rate[ _-]?limit(?:ed|_error)?|too many requests|\b429\b|limit (?:will )?reset)/i;
@@ -612,7 +1295,7 @@ function detectRateLimit(text, { now = null } = {}) {
612
1295
 
613
1296
  // ../../scripts/virtual-office/code-runner/rate-limit-resume.mjs
614
1297
  function resumeQueuePath() {
615
- return join2(homedir2(), ".claude", "resume-queue.jsonl");
1298
+ return join3(homedir2(), ".claude", "resume-queue.jsonl");
616
1299
  }
617
1300
  function buildResumeEntry({ task = {}, resumeAfter = null, summary = "", at } = {}) {
618
1301
  return {
@@ -674,9 +1357,65 @@ function classifyFailureForResume({
674
1357
  }
675
1358
 
676
1359
  // ../../scripts/virtual-office/code-runner/publish.mjs
677
- import { spawnSync as spawnSync3 } from "node:child_process";
678
- function run(cmd, args, cwd, { timeout = 18e4, raw = false } = {}) {
679
- const r = spawnSync3(cmd, args, { cwd, encoding: "utf8", timeout });
1360
+ import { spawnSync as spawnSync7 } from "node:child_process";
1361
+
1362
+ // ../../scripts/virtual-office/code-runner/git-resilience.mjs
1363
+ var TRANSIENT_CODES = /* @__PURE__ */ new Set([
1364
+ "ETIMEDOUT",
1365
+ "ECONNRESET",
1366
+ "ECONNREFUSED",
1367
+ "ENOTFOUND",
1368
+ "EAI_AGAIN",
1369
+ "ENETUNREACH",
1370
+ "EHOSTUNREACH",
1371
+ "EPIPE"
1372
+ ]);
1373
+ var TRANSIENT_RE = /\b(?:ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|EPIPE)\b|\b50[234]\b|timed?[ _-]?out|connection (?:reset|refused|closed|timed out)|could not resolve host|couldn't resolve host|failed to connect|unable to access|temporary failure|remote end hung up|early eof|rpc failed|the remote end hung up unexpectedly|operation timed out|gnutls_handshake|ssl_read|recv failure/i;
1374
+ function isTransientGitError(err) {
1375
+ if (!err) return false;
1376
+ if (err.code && TRANSIENT_CODES.has(err.code)) return true;
1377
+ const msg = String(err.message || err);
1378
+ if (/non-fast-forward|fast[- ]forward|\(fetch first\)|permission denied|authentication failed|\b40[134]\b|merge conflict|nothing to commit|did not match any/i.test(msg)) {
1379
+ return false;
1380
+ }
1381
+ return TRANSIENT_RE.test(msg);
1382
+ }
1383
+ function computeGitBackoffMs(attempt, { baseMs = 5e3, capMs = 3e4, rng = Math.random } = {}) {
1384
+ const exp = Math.min(capMs, baseMs * Math.pow(2, Math.max(0, attempt)));
1385
+ return Math.floor(exp / 2 + rng() * (exp / 2));
1386
+ }
1387
+ function sleepSync(ms) {
1388
+ if (!(ms > 0)) return;
1389
+ try {
1390
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1391
+ } catch {
1392
+ }
1393
+ }
1394
+ function retryTransient(fn, { attempts = 3, baseMs = 5e3, capMs = 3e4, sleep: sleep2 = sleepSync, rng = Math.random, onRetry } = {}) {
1395
+ let lastErr;
1396
+ for (let i = 0; i < attempts; i += 1) {
1397
+ try {
1398
+ return fn(i);
1399
+ } catch (err) {
1400
+ lastErr = err;
1401
+ if (i >= attempts - 1 || !isTransientGitError(err)) throw err;
1402
+ const delayMs = computeGitBackoffMs(i, { baseMs, capMs, rng });
1403
+ if (typeof onRetry === "function") onRetry({ err, attempt: i + 1, delayMs });
1404
+ sleep2(delayMs);
1405
+ }
1406
+ }
1407
+ throw lastErr;
1408
+ }
1409
+
1410
+ // ../../scripts/virtual-office/code-runner/publish.mjs
1411
+ function gitRetryLog(op) {
1412
+ return ({ attempt, delayMs, err }) => {
1413
+ const why = String(err && err.message || err).replace(/\s+/g, " ").slice(0, 120);
1414
+ console.error(`[publish] transient ${op} failure (attempt ${attempt}): ${why} \u2014 retrying in ${Math.round(delayMs / 1e3)}s`);
1415
+ };
1416
+ }
1417
+ function run(cmd, args, cwd, { timeout = 18e4, raw = false, env: env2 } = {}) {
1418
+ const r = spawnSync7(cmd, args, { cwd, encoding: "utf8", timeout, ...env2 ? { env: env2 } : {} });
680
1419
  if (r.error) throw r.error;
681
1420
  if (r.status !== 0) {
682
1421
  throw new Error(`${cmd} ${args[0]} failed (exit ${r.status}): ${(r.stderr || "").slice(-300)}`);
@@ -735,6 +1474,84 @@ function listCommittedFiles(cwd, base = "origin/main") {
735
1474
  function compactTitle(s, max = 100) {
736
1475
  return String(s || "").replace(/\s+/g, " ").trim().slice(0, max) || "code-task";
737
1476
  }
1477
+ function installationTokenEnv(githubToken, baseEnv = process.env) {
1478
+ if (!githubToken) return void 0;
1479
+ return { ...baseEnv, GH_TOKEN: githubToken, GITHUB_TOKEN: githubToken };
1480
+ }
1481
+ function pushArgs(branch, { withToken = false } = {}) {
1482
+ if (withToken) {
1483
+ return [
1484
+ "-c",
1485
+ "credential.helper=",
1486
+ "-c",
1487
+ "credential.helper=!gh auth git-credential",
1488
+ "push",
1489
+ "origin",
1490
+ branch
1491
+ ];
1492
+ }
1493
+ return ["push", "origin", branch];
1494
+ }
1495
+ function pushPlan(branch, githubToken) {
1496
+ if (githubToken) {
1497
+ return {
1498
+ primary: { args: pushArgs(branch, { withToken: true }), env: installationTokenEnv(githubToken), tokenUsed: true },
1499
+ fallback: { args: pushArgs(branch), env: void 0, tokenUsed: false }
1500
+ };
1501
+ }
1502
+ return { primary: { args: pushArgs(branch), env: void 0, tokenUsed: false }, fallback: null };
1503
+ }
1504
+ function pushBranch(worktreeDir, branch, githubToken, runFn = run) {
1505
+ const { primary, fallback } = pushPlan(branch, githubToken);
1506
+ try {
1507
+ runFn("git", primary.args, worktreeDir, { env: primary.env });
1508
+ return primary.tokenUsed;
1509
+ } catch (err) {
1510
+ if (!fallback) throw err;
1511
+ runFn("git", fallback.args, worktreeDir, { env: fallback.env });
1512
+ return fallback.tokenUsed;
1513
+ }
1514
+ }
1515
+ function resolveOrCreateBranch(worktreeDir, branchPrefix, runFn = run) {
1516
+ let branch = "";
1517
+ try {
1518
+ branch = runFn("git", ["branch", "--show-current"], worktreeDir, { timeout: 3e4 });
1519
+ } catch {
1520
+ }
1521
+ if (!branch || branch === "main" || branch === "HEAD") {
1522
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1523
+ branch = `${branchPrefix}-${stamp}`;
1524
+ runFn("git", ["checkout", "-b", branch], worktreeDir);
1525
+ }
1526
+ return branch;
1527
+ }
1528
+ function commitWorkLocally(worktreeDir, files, { title, branchPrefix = "vo/code-task", botName = "vo-code-runner", botEmail = "vo-code-runner@algosuite.ai", maxFiles = 200, runFn = run } = {}) {
1529
+ const cleaned = (files || []).filter((f) => !isAgentScratch(f));
1530
+ if (cleaned.length === 0) throw new Error("commitWorkLocally: only scratch files, nothing to commit");
1531
+ const toAdd = cleaned.slice(0, maxFiles);
1532
+ runFn("git", ["config", "user.name", botName], worktreeDir);
1533
+ runFn("git", ["config", "user.email", botEmail], worktreeDir);
1534
+ const branch = resolveOrCreateBranch(worktreeDir, branchPrefix, runFn);
1535
+ for (let i = 0; i < toAdd.length; i += 100) {
1536
+ runFn("git", ["add", "--", ...toAdd.slice(i, i + 100)], worktreeDir, { timeout: 12e4 });
1537
+ }
1538
+ runFn("git", ["commit", "--no-verify", "-m", compactTitle(title, 180)], worktreeDir);
1539
+ return { branch, truncated: cleaned.length > maxFiles };
1540
+ }
1541
+ function existingPrUrl(worktreeDir, branch, githubToken = null, runFn = run) {
1542
+ try {
1543
+ const out = runFn(
1544
+ "gh",
1545
+ ["pr", "list", "--head", branch, "--state", "open", "--json", "url,number", "--limit", "1"],
1546
+ worktreeDir,
1547
+ { env: githubToken ? installationTokenEnv(githubToken) : void 0 }
1548
+ );
1549
+ const arr = JSON.parse(out || "[]");
1550
+ if (Array.isArray(arr) && arr[0] && arr[0].url) return { url: String(arr[0].url), number: Number(arr[0].number) };
1551
+ } catch {
1552
+ }
1553
+ return null;
1554
+ }
738
1555
  function openCodeTaskPr(worktreeDir, files, {
739
1556
  title,
740
1557
  body,
@@ -744,63 +1561,43 @@ function openCodeTaskPr(worktreeDir, files, {
744
1561
  maxFiles = 200,
745
1562
  // Safety net: the agent already COMMITTED its work to a branch (despite the
746
1563
  // preamble). Skip add+commit; just push the existing branch and open the PR.
747
- alreadyCommitted = false
1564
+ alreadyCommitted = false,
1565
+ // Optional GitHub App installation token (M3). When present, push + PR
1566
+ // authenticate as the App; a push failure transparently falls back to the
1567
+ // runner's ambient gh auth. Absent → ambient gh exactly.
1568
+ githubToken = null,
1569
+ // Open as DRAFT — used to auto-publish partial/timed-out work for recovery.
1570
+ draft = false
748
1571
  } = {}) {
749
1572
  if (!Array.isArray(files) || files.length === 0) {
750
1573
  throw new Error("openCodeTaskPr: no files to commit");
751
1574
  }
752
- const cleaned = files.filter((f) => !isAgentScratch(f));
753
- if (!alreadyCommitted && cleaned.length === 0) {
754
- throw new Error("openCodeTaskPr: only scratch files, nothing to commit");
755
- }
756
- const toAdd = cleaned.slice(0, maxFiles);
757
- run("git", ["config", "user.name", botName], worktreeDir);
758
- run("git", ["config", "user.email", botEmail], worktreeDir);
759
- let branch = "";
760
- try {
761
- branch = run("git", ["branch", "--show-current"], worktreeDir, { timeout: 3e4 });
762
- } catch {
763
- }
1575
+ let branch;
1576
+ let truncated = false;
764
1577
  if (alreadyCommitted) {
765
- if (!branch || branch === "main" || branch === "HEAD") {
766
- const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
767
- branch = `${branchPrefix}-${stamp}`;
768
- run("git", ["checkout", "-b", branch], worktreeDir);
769
- }
770
- run("git", ["push", "origin", branch], worktreeDir);
1578
+ branch = resolveOrCreateBranch(worktreeDir, branchPrefix);
771
1579
  } else {
772
- if (!branch || branch === "main" || branch === "HEAD") {
773
- const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
774
- branch = `${branchPrefix}-${stamp}`;
775
- run("git", ["fetch", "origin", "main"], worktreeDir, { timeout: 6e4 });
776
- run("git", ["checkout", "-b", branch, "origin/main"], worktreeDir);
777
- }
778
- for (const f of toAdd) {
779
- run("git", ["add", "--", f], worktreeDir, { timeout: 6e4 });
780
- }
781
- const commitMsg = compactTitle(title, 180);
782
- run("git", ["commit", "--no-verify", "-m", commitMsg], worktreeDir);
783
- run("git", ["push", "origin", branch], worktreeDir);
1580
+ const committed = commitWorkLocally(worktreeDir, files, { title, branchPrefix, botName, botEmail, maxFiles });
1581
+ branch = committed.branch;
1582
+ truncated = committed.truncated;
784
1583
  }
785
- const out = run(
786
- "gh",
787
- [
788
- "pr",
789
- "create",
790
- "--base",
791
- "main",
792
- "--head",
793
- branch,
794
- "--title",
795
- compactTitle(title),
796
- "--body",
797
- String(body || "")
798
- ],
799
- worktreeDir
1584
+ const tokenUsed = retryTransient(() => pushBranch(worktreeDir, branch, githubToken), {
1585
+ onRetry: gitRetryLog("git push")
1586
+ });
1587
+ const existing = existingPrUrl(worktreeDir, branch, tokenUsed ? githubToken : null);
1588
+ if (existing) return { prUrl: existing.url, prNumber: existing.number, branch, truncated, resumed: true };
1589
+ const out = retryTransient(
1590
+ () => run(
1591
+ "gh",
1592
+ ["pr", "create", "--base", "main", "--head", branch, "--title", compactTitle(title), "--body", String(body || ""), ...draft ? ["--draft"] : []],
1593
+ worktreeDir,
1594
+ { env: tokenUsed ? installationTokenEnv(githubToken) : void 0 }
1595
+ ),
1596
+ { onRetry: gitRetryLog("gh pr create") }
800
1597
  );
801
1598
  const m = out.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/);
802
1599
  if (!m) throw new Error("gh pr create returned no parseable PR URL");
803
- return { prUrl: m[0], prNumber: Number(m[1]), branch, truncated: cleaned.length > maxFiles };
1600
+ return { prUrl: m[0], prNumber: Number(m[1]), branch, truncated };
804
1601
  }
805
1602
 
806
1603
  // ../../scripts/virtual-office/code-runner/dispatch-onboarding.mjs
@@ -866,11 +1663,11 @@ ${String(taskPrompt ?? "").trim()}
866
1663
 
867
1664
  // ../../scripts/virtual-office/code-runner/session-spool-forwarder.mjs
868
1665
  import { homedir as homedir3 } from "node:os";
869
- import { join as join3 } from "node:path";
1666
+ import { join as join4 } from "node:path";
870
1667
  import { readdir, readFile, unlink, writeFile } from "node:fs/promises";
871
1668
  import { createHash } from "node:crypto";
872
- var SPOOL_DIR = join3(homedir3(), ".vo", "session-spool");
873
- var CLOUD_MAP_FILE = join3(homedir3(), ".vo", "session-cloud-map.json");
1669
+ var SPOOL_DIR = join4(homedir3(), ".vo", "session-spool");
1670
+ var CLOUD_MAP_FILE = join4(homedir3(), ".vo", "session-cloud-map.json");
874
1671
  var STALE_MS = 60 * 60 * 1e3;
875
1672
  var ACTIVE_SILENCE_MS = 10 * 60 * 1e3;
876
1673
  function deriveUuid(seed) {
@@ -902,9 +1699,9 @@ async function readSpool(spoolDir = SPOOL_DIR) {
902
1699
  for (const f of files) {
903
1700
  if (!f.endsWith(".json")) continue;
904
1701
  try {
905
- const record = JSON.parse(await readFile(join3(spoolDir, f), "utf8"));
1702
+ const record = JSON.parse(await readFile(join4(spoolDir, f), "utf8"));
906
1703
  if (record && typeof record.session_key === "string") {
907
- out.push({ full: join3(spoolDir, f), record });
1704
+ out.push({ full: join4(spoolDir, f), record });
908
1705
  }
909
1706
  } catch {
910
1707
  }
@@ -983,14 +1780,266 @@ async function forwardSessionSpool(deps) {
983
1780
  return { forwarded, pruned };
984
1781
  }
985
1782
 
1783
+ // ../../scripts/virtual-office/code-runner/rate-limit-resume-scheduler.mjs
1784
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync3 } from "node:fs";
1785
+ import { dirname as dirname3, join as join5, resolve } from "node:path";
1786
+
1787
+ // ../../scripts/virtual-office/code-runner/rate-limit-resume-scheduler-core.mjs
1788
+ var MAX_ATTEMPTS = 5;
1789
+ var MAX_DISPATCH_PER_RUN = 10;
1790
+ var NULL_RESUME_AFTER_BACKOFF_MS = 15 * 60 * 1e3;
1791
+ function stableTaskKey(entry) {
1792
+ return `${entry && entry.repo || ""}\0${entry && entry.prompt || ""}`;
1793
+ }
1794
+ function selectDueEntries({ entries = [], now, alreadyDispatched = /* @__PURE__ */ new Set(), attemptsByKey = {} } = {}) {
1795
+ if (!now) throw new Error("selectDueEntries: now is required");
1796
+ const nowMs = new Date(now).getTime();
1797
+ if (!Number.isFinite(nowMs)) throw new Error("selectDueEntries: invalid now timestamp");
1798
+ const due = [];
1799
+ const exhausted = [];
1800
+ const seen = new Set(alreadyDispatched);
1801
+ for (const e of entries) {
1802
+ const { code_task_id, resume_after, at, attempts } = e || {};
1803
+ if (!code_task_id) continue;
1804
+ if (seen.has(code_task_id)) continue;
1805
+ const stableAttempts = Number(attemptsByKey[stableTaskKey(e)] || 0);
1806
+ if (Math.max(typeof attempts === "number" ? attempts : 0, stableAttempts) >= MAX_ATTEMPTS) {
1807
+ exhausted.push(e);
1808
+ continue;
1809
+ }
1810
+ let isDue = false;
1811
+ if (resume_after === null || resume_after === void 0) {
1812
+ const entryAtMs = new Date(at).getTime();
1813
+ if (Number.isFinite(entryAtMs)) {
1814
+ const attemptCount = typeof attempts === "number" ? attempts : 0;
1815
+ const backoffMs = NULL_RESUME_AFTER_BACKOFF_MS * Math.pow(2, attemptCount);
1816
+ const dueAtMs = entryAtMs + backoffMs;
1817
+ isDue = nowMs >= dueAtMs;
1818
+ }
1819
+ } else {
1820
+ const resumeMs = new Date(resume_after).getTime();
1821
+ if (Number.isFinite(resumeMs)) {
1822
+ isDue = nowMs >= resumeMs;
1823
+ }
1824
+ }
1825
+ if (isDue) {
1826
+ due.push(e);
1827
+ seen.add(code_task_id);
1828
+ if (due.length >= MAX_DISPATCH_PER_RUN) break;
1829
+ }
1830
+ }
1831
+ return { due, exhausted };
1832
+ }
1833
+ function reconcileQueue({ entries = [], dispatchedIds = /* @__PURE__ */ new Set(), exhaustedIds = /* @__PURE__ */ new Set() } = {}) {
1834
+ return entries.filter((e) => {
1835
+ const { code_task_id } = e || {};
1836
+ if (!code_task_id) return false;
1837
+ if (dispatchedIds.has(code_task_id)) return false;
1838
+ if (exhaustedIds.has(code_task_id)) return false;
1839
+ return true;
1840
+ });
1841
+ }
1842
+
1843
+ // ../../scripts/virtual-office/code-runner/rate-limit-resume-scheduler.mjs
1844
+ var ATTEMPTS_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
1845
+ function log(msg) {
1846
+ console.log(`[rate-limit-scheduler ${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
1847
+ }
1848
+ function readQueue(queuePath) {
1849
+ if (!existsSync3(queuePath)) return [];
1850
+ const content = readFileSync2(queuePath, "utf-8");
1851
+ const lines = content.split("\n").filter((l) => l.trim());
1852
+ const entries = [];
1853
+ for (const line of lines) {
1854
+ try {
1855
+ entries.push(JSON.parse(line));
1856
+ } catch {
1857
+ log(`warn: malformed queue line: ${line.slice(0, 100)}`);
1858
+ }
1859
+ }
1860
+ return entries;
1861
+ }
1862
+ function writeQueue(queuePath, entries) {
1863
+ mkdirSync3(dirname3(queuePath), { recursive: true });
1864
+ const lines = entries.map((e) => JSON.stringify(e)).join("\n");
1865
+ writeFileSync2(queuePath, lines + (entries.length > 0 ? "\n" : ""), "utf-8");
1866
+ }
1867
+ function attemptsStorePath() {
1868
+ return join5(dirname3(resumeQueuePath()), "resume-attempts.json");
1869
+ }
1870
+ function readAttemptsStore() {
1871
+ const p = attemptsStorePath();
1872
+ if (!existsSync3(p)) return {};
1873
+ try {
1874
+ const parsed = JSON.parse(readFileSync2(p, "utf-8"));
1875
+ return parsed && typeof parsed === "object" ? parsed : {};
1876
+ } catch {
1877
+ return {};
1878
+ }
1879
+ }
1880
+ function writeAttemptsStore(store) {
1881
+ const p = attemptsStorePath();
1882
+ mkdirSync3(dirname3(p), { recursive: true });
1883
+ writeFileSync2(p, JSON.stringify(store, null, 2), "utf-8");
1884
+ }
1885
+ function countsFromStore(store) {
1886
+ const counts = {};
1887
+ for (const [k, v] of Object.entries(store)) {
1888
+ counts[k] = v && typeof v.count === "number" ? v.count : 0;
1889
+ }
1890
+ return counts;
1891
+ }
1892
+ function bumpAttempts(store, key, nowIso) {
1893
+ const prev = store[key] && typeof store[key].count === "number" ? store[key].count : 0;
1894
+ store[key] = { count: prev + 1, lastSeen: nowIso };
1895
+ }
1896
+ function pruneAttemptsStore(store, nowIso) {
1897
+ const nowMs = new Date(nowIso).getTime();
1898
+ const out = {};
1899
+ for (const [k, v] of Object.entries(store)) {
1900
+ const t = v && v.lastSeen ? new Date(v.lastSeen).getTime() : 0;
1901
+ if (Number.isFinite(t) && nowMs - t < ATTEMPTS_TTL_MS) out[k] = v;
1902
+ }
1903
+ return out;
1904
+ }
1905
+ async function runScheduler({ env: env2 = process.env } = {}) {
1906
+ const enabled = env2.VO_RATE_LIMIT_RESUME === "1";
1907
+ if (!enabled) {
1908
+ log("VO_RATE_LIMIT_RESUME not enabled; no-op");
1909
+ return { dispatched: 0, exhausted: 0, kept: 0 };
1910
+ }
1911
+ const queuePath = resumeQueuePath();
1912
+ const entries = readQueue(queuePath);
1913
+ if (entries.length === 0) {
1914
+ log("queue empty; nothing to do");
1915
+ return { dispatched: 0, exhausted: 0, kept: 0 };
1916
+ }
1917
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1918
+ const alreadyDispatched = /* @__PURE__ */ new Set();
1919
+ const attemptsStore = readAttemptsStore();
1920
+ const attemptsByKey = countsFromStore(attemptsStore);
1921
+ const { due, exhausted } = selectDueEntries({ entries, now, alreadyDispatched, attemptsByKey });
1922
+ log(`queue: ${entries.length} total, ${due.length} due, ${exhausted.length} exhausted`);
1923
+ if (due.length === 0 && exhausted.length === 0) {
1924
+ log("no due or exhausted entries; queue unchanged");
1925
+ writeAttemptsStore(pruneAttemptsStore(attemptsStore, now));
1926
+ return { dispatched: 0, exhausted: 0, kept: entries.length };
1927
+ }
1928
+ const client = createControlPlaneClient({ env: env2 });
1929
+ const dispatchedIds = /* @__PURE__ */ new Set();
1930
+ const exhaustedIds = new Set(exhausted.map((e) => e.code_task_id));
1931
+ for (const entry of due) {
1932
+ const { code_task_id, repo, prompt, attempts } = entry;
1933
+ try {
1934
+ const task = {
1935
+ repo,
1936
+ prompt,
1937
+ _resume_attempts: (typeof attempts === "number" ? attempts : 0) + 1
1938
+ };
1939
+ await client.enqueueCodeTask(task);
1940
+ bumpAttempts(attemptsStore, stableTaskKey(entry), now);
1941
+ log(`dispatched: ${code_task_id} (stable attempts ${attemptsStore[stableTaskKey(entry)].count})`);
1942
+ dispatchedIds.add(code_task_id);
1943
+ alreadyDispatched.add(code_task_id);
1944
+ } catch (err) {
1945
+ log(`dispatch failed for ${code_task_id}: ${err.message}`);
1946
+ }
1947
+ }
1948
+ const kept = reconcileQueue({ entries, dispatchedIds, exhaustedIds });
1949
+ writeQueue(queuePath, kept);
1950
+ writeAttemptsStore(pruneAttemptsStore(attemptsStore, now));
1951
+ if (exhausted.length > 0) {
1952
+ log(`WARN: ${exhausted.length} task(s) gave up after ${MAX_ATTEMPTS} rate-limit retries and were DROPPED from the queue: ${exhausted.map((e) => e.code_task_id).join(", ")}`);
1953
+ }
1954
+ if (due.length >= MAX_DISPATCH_PER_RUN) {
1955
+ log(`WARN: hit the per-run dispatch cap (${MAX_DISPATCH_PER_RUN}); more rate-limited tasks remain queued and will resume on the next run`);
1956
+ }
1957
+ log(`done: dispatched ${dispatchedIds.size}, exhausted ${exhaustedIds.size}, kept ${kept.length}`);
1958
+ return {
1959
+ dispatched: dispatchedIds.size,
1960
+ exhausted: exhaustedIds.size,
1961
+ kept: kept.length
1962
+ };
1963
+ }
1964
+ var isMainModule = (() => {
1965
+ try {
1966
+ const argv1 = process.argv[1] ? resolve(process.argv[1]) : "";
1967
+ const here = new URL(import.meta.url).pathname.replace(/^\/([a-zA-Z]):\//, "$1:/");
1968
+ return resolve(here) === argv1;
1969
+ } catch {
1970
+ return false;
1971
+ }
1972
+ })();
1973
+ if (isMainModule) {
1974
+ runScheduler().catch((err) => {
1975
+ console.error("[rate-limit-scheduler] fatal:", err);
1976
+ process.exit(1);
1977
+ });
1978
+ }
1979
+
1980
+ // ../../scripts/virtual-office/code-runner/loop-ticks.mjs
1981
+ var HEARTBEAT_MS = 6e4;
1982
+ var DEFAULT_RESUME_SCHEDULE_SEC = 300;
1983
+ function makeLoopTicks({
1984
+ client,
1985
+ cfg,
1986
+ env: env2,
1987
+ log: log3,
1988
+ getActive,
1989
+ // Injectable for tests; default to the real scheduler + wall clock.
1990
+ runResumeScheduler = runScheduler,
1991
+ now: nowFn = () => Date.now()
1992
+ }) {
1993
+ let lastSessionForward = 0;
1994
+ let lastHeartbeat = 0;
1995
+ let lastResumeSchedule = 0;
1996
+ let resumeRunning = false;
1997
+ return function tick() {
1998
+ const now = nowFn();
1999
+ if (cfg.sessionForwardSec > 0 && now - lastSessionForward >= cfg.sessionForwardSec * 1e3) {
2000
+ lastSessionForward = now;
2001
+ forwardSessionSpool({
2002
+ baseUrl: String(env2.VO_CONTROL_PLANE_URL || "").replace(/\/$/, ""),
2003
+ token: env2.VO_CONTROL_PLANE_ADMIN_TOKEN || "",
2004
+ operatorSeed: cfg.operatorSeed
2005
+ }).catch(() => {
2006
+ });
2007
+ }
2008
+ if (now - lastHeartbeat >= HEARTBEAT_MS) {
2009
+ lastHeartbeat = now;
2010
+ const servedRepos = Array.isArray(cfg.servedRepos) ? cfg.servedRepos.slice(0, 100) : [];
2011
+ const servedOperators = Array.isArray(cfg.servedOperators) ? cfg.servedOperators.slice(0, 100) : [];
2012
+ const baseHeartbeat = {
2013
+ runnerId: cfg.runnerId,
2014
+ ...servedRepos.length > 0 ? { servedRepos } : {},
2015
+ ...servedOperators.length > 0 ? { servedOperators } : {},
2016
+ uptimeSec: Math.floor(process.uptime()),
2017
+ activeTasks: getActive()
2018
+ };
2019
+ const operatorIds = servedOperators.length > 0 ? servedOperators : [void 0];
2020
+ for (const operatorId of operatorIds) {
2021
+ client.postHeartbeat({ ...baseHeartbeat, ...operatorId ? { operatorId } : {} }).catch((e) => log3(`heartbeat failed: ${e.message}`));
2022
+ }
2023
+ }
2024
+ const resumeSec = Number(env2.VO_RESUME_SCHEDULE_SEC) > 0 ? Number(env2.VO_RESUME_SCHEDULE_SEC) : DEFAULT_RESUME_SCHEDULE_SEC;
2025
+ if (!resumeRunning && now - lastResumeSchedule >= resumeSec * 1e3) {
2026
+ lastResumeSchedule = now;
2027
+ resumeRunning = true;
2028
+ Promise.resolve(runResumeScheduler({ env: env2 })).catch((e) => log3(`resume-scheduler tick failed: ${e.message}`)).finally(() => {
2029
+ resumeRunning = false;
2030
+ });
2031
+ }
2032
+ };
2033
+ }
2034
+
986
2035
  // ../../scripts/virtual-office/code-runner/pr-watcher.mjs
987
2036
  import { homedir as homedir4 } from "node:os";
988
- import { join as join4 } from "node:path";
2037
+ import { join as join6 } from "node:path";
989
2038
  import { readFile as readFile2, writeFile as writeFile2, mkdir } from "node:fs/promises";
990
- import { spawnSync as spawnSync4 } from "node:child_process";
2039
+ import { spawnSync as spawnSync8 } from "node:child_process";
991
2040
  var CI_FIX_MARKER = "[VO-CI-FIX]";
992
2041
  function ghViewPr(prNumber, repo) {
993
- const r = spawnSync4(
2042
+ const r = spawnSync8(
994
2043
  "gh",
995
2044
  ["pr", "view", String(prNumber), "-R", repo, "--json", "state,statusCheckRollup,headRefName"],
996
2045
  { encoding: "utf8", timeout: 3e4 }
@@ -998,7 +2047,7 @@ function ghViewPr(prNumber, repo) {
998
2047
  if (r.status !== 0) throw new Error((r.stderr || "gh pr view failed").slice(-200));
999
2048
  return JSON.parse(r.stdout || "{}");
1000
2049
  }
1001
- var DEFAULT_STATE_FILE = join4(homedir4(), ".vo", "dispatched-prs.json");
2050
+ var DEFAULT_STATE_FILE = join6(homedir4(), ".vo", "dispatched-prs.json");
1002
2051
  var FAIL_CONCLUSIONS = /* @__PURE__ */ new Set([
1003
2052
  "FAILURE",
1004
2053
  "TIMED_OUT",
@@ -1061,7 +2110,7 @@ async function readState(stateFile) {
1061
2110
  }
1062
2111
  async function writeState(stateFile, state) {
1063
2112
  try {
1064
- await mkdir(join4(stateFile, ".."), { recursive: true });
2113
+ await mkdir(join6(stateFile, ".."), { recursive: true });
1065
2114
  await writeFile2(stateFile, JSON.stringify(state, null, 2), "utf8");
1066
2115
  } catch {
1067
2116
  }
@@ -1078,7 +2127,7 @@ async function trackDispatchedPr({ prNumber, repo, branch, taskId }, { stateFile
1078
2127
  };
1079
2128
  await writeState(stateFile, state);
1080
2129
  }
1081
- async function runWatchCycle({ viewPr, enqueueFix, log: log2 = () => {
2130
+ async function runWatchCycle({ viewPr, enqueueFix, log: log3 = () => {
1082
2131
  }, now = () => Date.now(), maxFixAttempts = 1, stateFile = DEFAULT_STATE_FILE }) {
1083
2132
  const state = await readState(stateFile);
1084
2133
  const prNumbers = Object.keys(state);
@@ -1091,7 +2140,7 @@ async function runWatchCycle({ viewPr, enqueueFix, log: log2 = () => {
1091
2140
  try {
1092
2141
  view = await viewPr(prNumber, entry.repo);
1093
2142
  } catch (err) {
1094
- log2(`watch: pr #${prNumber} view failed: ${err.message}`);
2143
+ log3(`watch: pr #${prNumber} view failed: ${err.message}`);
1095
2144
  continue;
1096
2145
  }
1097
2146
  checked += 1;
@@ -1101,21 +2150,21 @@ async function runWatchCycle({ viewPr, enqueueFix, log: log2 = () => {
1101
2150
  if (action === "untrack") {
1102
2151
  delete state[prNumber];
1103
2152
  untracked += 1;
1104
- log2(`watch: pr #${prNumber} is ${pr.state} \u2014 untracked`);
2153
+ log3(`watch: pr #${prNumber} is ${pr.state} \u2014 untracked`);
1105
2154
  } else if (action === "fix") {
1106
2155
  entry.fixAttempts = (entry.fixAttempts || 0) + 1;
1107
2156
  entry.lastCheckedAt = now();
1108
2157
  try {
1109
2158
  await enqueueFix({ prNumber: Number(prNumber), repo: entry.repo, branch: pr.branch || entry.branch, failedChecks: pr.failedChecks });
1110
2159
  fixed += 1;
1111
- log2(`watch: pr #${prNumber} CI failing (${pr.failedChecks.join(", ") || "unknown"}) \u2014 dispatched fix ${entry.fixAttempts}/${maxFixAttempts}`);
2160
+ log3(`watch: pr #${prNumber} CI failing (${pr.failedChecks.join(", ") || "unknown"}) \u2014 dispatched fix ${entry.fixAttempts}/${maxFixAttempts}`);
1112
2161
  } catch (err) {
1113
2162
  entry.enqueueErrors = (entry.enqueueErrors || 0) + 1;
1114
2163
  if (entry.enqueueErrors >= MAX_ENQUEUE_ERRORS) {
1115
- log2(`watch: pr #${prNumber} fix enqueue failed ${entry.enqueueErrors}x \u2014 giving up: ${err.message}`);
2164
+ log3(`watch: pr #${prNumber} fix enqueue failed ${entry.enqueueErrors}x \u2014 giving up: ${err.message}`);
1116
2165
  } else {
1117
2166
  entry.fixAttempts = Math.max(0, (entry.fixAttempts || 1) - 1);
1118
- log2(`watch: pr #${prNumber} fix enqueue failed (${entry.enqueueErrors}/${MAX_ENQUEUE_ERRORS}): ${err.message}`);
2167
+ log3(`watch: pr #${prNumber} fix enqueue failed (${entry.enqueueErrors}/${MAX_ENQUEUE_ERRORS}): ${err.message}`);
1119
2168
  }
1120
2169
  }
1121
2170
  } else {
@@ -1127,17 +2176,17 @@ async function runWatchCycle({ viewPr, enqueueFix, log: log2 = () => {
1127
2176
  if (cappedFailing && e.trackedAt && now() - e.trackedAt > STALE_MS2) {
1128
2177
  delete state[n];
1129
2178
  untracked += 1;
1130
- log2(`watch: pr #${n} capped + failing + tracked >24h ago \u2014 pruned from watch state`);
2179
+ log3(`watch: pr #${n} capped + failing + tracked >24h ago \u2014 pruned from watch state`);
1131
2180
  }
1132
2181
  }
1133
2182
  await writeState(stateFile, state);
1134
2183
  return { checked, fixed, untracked };
1135
2184
  }
1136
- function makeWatchRunner({ client, viewPr = ghViewPr, log: log2, maxFixAttempts }) {
2185
+ function makeWatchRunner({ client, viewPr = ghViewPr, log: log3, maxFixAttempts }) {
1137
2186
  return () => runWatchCycle({
1138
2187
  viewPr,
1139
2188
  enqueueFix: ({ prNumber, repo, branch, failedChecks }) => client.enqueueCodeTask({ repo, prompt: buildCiFixPrompt({ prNumber, repo, branch, failedChecks }) }),
1140
- log: log2,
2189
+ log: log3,
1141
2190
  maxFixAttempts
1142
2191
  });
1143
2192
  }
@@ -1198,15 +2247,15 @@ function buildControlHandler({ getStatus, requestStop, allowedOrigin }) {
1198
2247
  res.end(JSON.stringify({ ok: false, error: "not_found" }));
1199
2248
  };
1200
2249
  }
1201
- function startControlServer({ port, getStatus, requestStop, allowedOrigin, log: log2 = () => {
2250
+ function startControlServer({ port, getStatus, requestStop, allowedOrigin, log: log3 = () => {
1202
2251
  } }) {
1203
2252
  const server = createServer(buildControlHandler({ getStatus, requestStop, allowedOrigin }));
1204
- server.on("error", (e) => log2(`control server error: ${e.message} (in-product runner control disabled)`));
1205
- server.listen(port, "127.0.0.1", () => log2(`control server on http://127.0.0.1:${port} (allow ${allowedOrigin})`));
2253
+ server.on("error", (e) => log3(`control server error: ${e.message} (in-product runner control disabled)`));
2254
+ server.listen(port, "127.0.0.1", () => log3(`control server on http://127.0.0.1:${port} (allow ${allowedOrigin})`));
1206
2255
  server.unref?.();
1207
2256
  return server;
1208
2257
  }
1209
- function startDaemonControl({ cfg, requestStop, getActiveCount, isRunning, startedAt, log: log2 = () => {
2258
+ function startDaemonControl({ cfg, requestStop, getActiveCount, isRunning, startedAt, log: log3 = () => {
1210
2259
  } }) {
1211
2260
  if (!cfg.controlEnabled) return null;
1212
2261
  return startControlServer({
@@ -1218,12 +2267,13 @@ function startDaemonControl({ cfg, requestStop, getActiveCount, isRunning, start
1218
2267
  pid: process.pid,
1219
2268
  runnerId: cfg.runnerId,
1220
2269
  servedRepos: cfg.servedRepos,
2270
+ servedOperators: cfg.servedOperators,
1221
2271
  watchEnabled: cfg.watchEnabled,
1222
2272
  activeTasks: getActiveCount(),
1223
2273
  startedAt: new Date(startedAt).toISOString(),
1224
2274
  uptimeSec: Math.round((Date.now() - startedAt) / 1e3)
1225
2275
  }),
1226
- log: log2
2276
+ log: log3
1227
2277
  });
1228
2278
  }
1229
2279
 
@@ -1527,6 +2577,59 @@ async function resolveModelFamily(family, options = {}) {
1527
2577
  }
1528
2578
 
1529
2579
  // ../../scripts/virtual-office/code-runner/model-router.mjs
2580
+ var TASK_MODEL_AGENTS = ["claude", "codex", "cursor"];
2581
+ var DEFAULT_AGENT2 = "claude";
2582
+ var AGENT_TIER_FAMILIES = {
2583
+ claude: {
2584
+ cheap: "anthropic-balanced",
2585
+ mid: "anthropic-flagship",
2586
+ best: "anthropic-flagship"
2587
+ },
2588
+ codex: {
2589
+ // Codex model names are account/runtime dependent. A ChatGPT-account Codex
2590
+ // CLI rejects some registry/fallback ids, so let the CLI choose its default.
2591
+ cheap: null,
2592
+ mid: null,
2593
+ best: null
2594
+ },
2595
+ // Cursor's supported remote model ids are account/runtime dependent. Let the
2596
+ // CLI pick its own default unless the operator overrides it elsewhere.
2597
+ cursor: {
2598
+ cheap: null,
2599
+ mid: null,
2600
+ best: null
2601
+ }
2602
+ };
2603
+ var AGENT_TIER_FALLBACKS = {
2604
+ claude: {
2605
+ cheap: "claude-sonnet-4-6",
2606
+ mid: "claude-opus-4-7",
2607
+ best: "claude-opus-4-8"
2608
+ },
2609
+ codex: {
2610
+ cheap: null,
2611
+ mid: null,
2612
+ best: null
2613
+ },
2614
+ cursor: {
2615
+ cheap: null,
2616
+ mid: null,
2617
+ best: null
2618
+ }
2619
+ };
2620
+ var AGENT_MODEL_COMPATIBILITY = {
2621
+ claude: (model) => /^claude-/i.test(String(model || "")),
2622
+ codex: (model) => /^(?:gpt-|o\d|codex)/i.test(String(model || "")),
2623
+ cursor: () => true
2624
+ };
2625
+ function normalizeAgent(agent = DEFAULT_AGENT2) {
2626
+ const normalized = String(agent || DEFAULT_AGENT2).trim().toLowerCase();
2627
+ return TASK_MODEL_AGENTS.includes(normalized) ? normalized : DEFAULT_AGENT2;
2628
+ }
2629
+ function modelCompatibleWithAgent(agent, model) {
2630
+ if (!model) return true;
2631
+ return AGENT_MODEL_COMPATIBILITY[agent]?.(model) ?? false;
2632
+ }
1530
2633
  function classifyTier(prompt) {
1531
2634
  const text = String(prompt || "").trim();
1532
2635
  if (!text) return "mid";
@@ -1543,30 +2646,34 @@ function classifyTier(prompt) {
1543
2646
  }
1544
2647
  return "mid";
1545
2648
  }
1546
- async function resolveModelForTier(tier, { resolveModelFamily: resolver = resolveModelFamily } = {}) {
2649
+ async function resolveModelForTier(tier, { agent = DEFAULT_AGENT2, resolveModelFamily: resolver = resolveModelFamily } = {}) {
1547
2650
  const t = String(tier || "mid").trim();
1548
- if (t === "cheap") {
1549
- const resolved2 = await resolver("anthropic-balanced");
1550
- return resolved2 || "claude-sonnet-4-6";
1551
- }
1552
- if (t === "best") {
1553
- const resolved2 = await resolver("anthropic-flagship");
1554
- return resolved2 || "claude-opus-4-8";
1555
- }
1556
- const resolved = await resolver("anthropic-flagship");
1557
- return resolved || "claude-opus-4-7";
1558
- }
1559
- async function resolveTaskModel(task) {
2651
+ const normalizedAgent = normalizeAgent(agent);
2652
+ const families = AGENT_TIER_FAMILIES[normalizedAgent];
2653
+ const fallbacks = AGENT_TIER_FALLBACKS[normalizedAgent];
2654
+ const effectiveTier = t === "cheap" || t === "best" ? t : "mid";
2655
+ const family = families[effectiveTier];
2656
+ if (!family) {
2657
+ return fallbacks[effectiveTier];
2658
+ }
2659
+ const resolved = await resolver(family);
2660
+ if (resolved && modelCompatibleWithAgent(normalizedAgent, resolved)) return resolved;
2661
+ return fallbacks[effectiveTier];
2662
+ }
2663
+ async function resolveTaskModel(task, { agent = DEFAULT_AGENT2 } = {}) {
1560
2664
  const tier = task.tier && task.tier !== "auto" ? task.tier : classifyTier(task.prompt);
1561
- const model = await resolveModelForTier(tier);
2665
+ const model = await resolveModelForTier(tier, { agent });
1562
2666
  return { tier, model };
1563
2667
  }
1564
2668
 
1565
2669
  // ../../scripts/virtual-office/code-runner/apply-effort-mode.mjs
1566
- async function resolveEffortDispatch({ client, task, env: env2, basePrompt, resolveModel = resolveTaskModel }) {
2670
+ async function resolveEffortDispatch({ client, task, agent = "claude", env: env2, basePrompt, resolveModel = resolveTaskModel }) {
1567
2671
  const dispatchMode = await client.getDispatchMode().catch(() => "standard");
1568
2672
  const effortConfig = resolveEffortMode(dispatchMode);
1569
- const { tier, model } = await resolveModel({ ...task, tier: task.tier ?? effortConfig.tier });
2673
+ const { tier, model } = await resolveModel(
2674
+ { ...task, tier: task.tier ?? effortConfig.tier },
2675
+ { agent }
2676
+ );
1570
2677
  return {
1571
2678
  dispatchMode,
1572
2679
  tier,
@@ -1619,8 +2726,73 @@ function describeClaimScoping(cfg = {}, env2 = {}) {
1619
2726
  return lines;
1620
2727
  }
1621
2728
 
2729
+ // ../../scripts/virtual-office/code-runner/reconnect-backoff.mjs
2730
+ function makeReconnectBackoff({
2731
+ baseMs = 5e3,
2732
+ capMs = 6e4,
2733
+ jitter = 0.2,
2734
+ log: log3 = () => {
2735
+ },
2736
+ random = Math.random
2737
+ } = {}) {
2738
+ let consecutiveFailures = 0;
2739
+ return {
2740
+ /**
2741
+ * Record a failed poll. Logs once as an outage begins, then quieter retry
2742
+ * lines. Returns the delay (ms) the caller should sleep before retrying.
2743
+ * @param {unknown} err
2744
+ * @returns {number} delayMs
2745
+ */
2746
+ onFailure(err) {
2747
+ consecutiveFailures += 1;
2748
+ const exp = Math.min(capMs, baseMs * 2 ** (consecutiveFailures - 1));
2749
+ const delta = exp * jitter * (random() * 2 - 1);
2750
+ const delayMs = Math.round(Math.min(capMs, Math.max(baseMs, exp + delta)));
2751
+ const reason = err && err.message ? err.message : String(err);
2752
+ const secs = Math.max(1, Math.round(delayMs / 1e3));
2753
+ log3(
2754
+ consecutiveFailures === 1 ? `\u26A0 lost connection to control-plane \u2014 retrying in ${secs}s: ${reason}` : `\u26A0 still offline (${consecutiveFailures} consecutive) \u2014 retrying in ${secs}s: ${reason}`
2755
+ );
2756
+ return delayMs;
2757
+ },
2758
+ /**
2759
+ * Record a successful poll. On the FIRST success after an outage, logs
2760
+ * "✓ reconnected" and resets the backoff. Returns true iff it was a recovery.
2761
+ * @returns {boolean} reconnected
2762
+ */
2763
+ onSuccess() {
2764
+ if (consecutiveFailures === 0) return false;
2765
+ const prior = consecutiveFailures;
2766
+ consecutiveFailures = 0;
2767
+ log3(`\u2713 reconnected to control-plane after ${prior} failed attempt(s)`);
2768
+ return true;
2769
+ },
2770
+ /** Current consecutive-failure count (0 ⇒ healthy). */
2771
+ get failures() {
2772
+ return consecutiveFailures;
2773
+ },
2774
+ /** True while the connection is considered degraded/offline. */
2775
+ get degraded() {
2776
+ return consecutiveFailures > 0;
2777
+ }
2778
+ };
2779
+ }
2780
+ function installProcessSafetyNet({ log: log3 = () => {
2781
+ }, proc = process } = {}) {
2782
+ if (proc.__voRunnerSafetyNet) return false;
2783
+ proc.__voRunnerSafetyNet = true;
2784
+ const describe = (e) => e && e.stack ? e.stack : e && e.message ? e.message : String(e);
2785
+ proc.on("unhandledRejection", (reason) => {
2786
+ log3(`unhandledRejection (kept alive): ${describe(reason)}`);
2787
+ });
2788
+ proc.on("uncaughtException", (err) => {
2789
+ log3(`uncaughtException (kept alive): ${describe(err)}`);
2790
+ });
2791
+ return true;
2792
+ }
2793
+
1622
2794
  // ../../scripts/virtual-office/code-runner-daemon.mjs
1623
- function log(msg) {
2795
+ function log2(msg) {
1624
2796
  console.log(`[code-runner ${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
1625
2797
  }
1626
2798
  var RATE_LIMIT_RESUME_ENABLED = process.env.VO_RATE_LIMIT_RESUME === "1";
@@ -1628,7 +2800,8 @@ var parseList = (s) => String(s || "").split(/[\s,]+/).map((x) => x.trim()).filt
1628
2800
  function loadConfig(env2 = process.env) {
1629
2801
  return {
1630
2802
  runnerId: env2.VO_CODE_RUNNER_ID || `vo-code-runner-${os.hostname()}`,
1631
- claudeBin: env2.VO_CODE_RUNNER_CLAUDE_BIN || "claude",
2803
+ // BYO multi-agent: {agent, runner, runnerBin} — VO_CODE_RUNNER_AGENT (claude|codex).
2804
+ ...resolveRunner(env2, { warn: (m) => log2(`agent-select: ${m}`) }),
1632
2805
  permissionMode: env2.VO_CODE_RUNNER_PERMISSION_MODE || "acceptEdits",
1633
2806
  maxConcurrency: Math.max(1, Number(env2.VO_CODE_TASK_MAX_CONCURRENCY || 2) || 2),
1634
2807
  pollSec: Math.max(1, Number(env2.VO_CODE_RUNNER_POLL_SEC || 5) || 5),
@@ -1643,9 +2816,8 @@ function loadConfig(env2 = process.env) {
1643
2816
  sessionForwardSec: Math.max(0, Number(env2.VO_SESSION_FORWARD_SEC ?? 30) || 0),
1644
2817
  operatorSeed: env2.VO_LOCAL_OPERATOR_SEED || env2.VO_CODE_RUNNER_ID || `local-${os.hostname()}`,
1645
2818
  cancelPollMs: Math.max(1e3, Number(env2.VO_CODE_RUNNER_CANCEL_POLL_MS || 2500) || 2500),
1646
- // HARD enforced spend bound (max_budget_usd can only be checked post-hoc).
1647
- // Default 30 min; set 0 to disable.
1648
- maxWallClockMs: Math.max(0, Number(env2.VO_CODE_RUNNER_MAX_WALL_CLOCK_MS ?? 18e5) || 18e5),
2819
+ // Hard cap OFF by default (0=no timer; work preserved via #7218 draft-PR). Set ms>0 to enforce; invalid→0.
2820
+ maxWallClockMs: ((n) => Number.isFinite(n) && n >= 0 ? n : 0)(Number(env2.VO_CODE_RUNNER_MAX_WALL_CLOCK_MS ?? NaN)),
1649
2821
  // Active PR watcher: monitor each dispatched PR's CI + auto-dispatch ONE fix
1650
2822
  // on failure (never auto-merges). Off: VO_CODE_RUNNER_WATCH=0; cap/interval below.
1651
2823
  watchEnabled: env2.VO_CODE_RUNNER_WATCH !== "0",
@@ -1663,10 +2835,10 @@ var numOrUndef = (x) => typeof x === "number" ? x : void 0;
1663
2835
  async function safeProgress(client, id, patch) {
1664
2836
  try {
1665
2837
  const r = await client.postProgress(id, patch);
1666
- if (r && r.terminal) log(`task ${id} is terminal server-side; stopping updates`);
2838
+ if (r && r.terminal) log2(`task ${id} is terminal server-side; stopping updates`);
1667
2839
  return r;
1668
2840
  } catch (err) {
1669
- log(`progress post failed for ${id}: ${err.message}`);
2841
+ log2(`progress post failed for ${id}: ${err.message}`);
1670
2842
  return null;
1671
2843
  }
1672
2844
  }
@@ -1698,16 +2870,19 @@ function buildPrBody(task, run2, files) {
1698
2870
  async function processOneTask(client, task, cfg) {
1699
2871
  const id = task.code_task_id;
1700
2872
  let worktreeName = "";
2873
+ let preserveReason = null;
1701
2874
  try {
1702
- const wt = createFixWorktree("code-task", { source: id.slice(0, 8) });
2875
+ const wt = createFixWorktree("code-task", { source: id.slice(0, 8), repo: task.repo });
1703
2876
  worktreeName = wt.worktreeName;
1704
- const { dispatchMode, tier, model, permissionMode: effectivePermissionMode, maxTurns: effectiveMaxTurns, prompt: effortPrompt } = await resolveEffortDispatch({ client, task, env: process.env, basePrompt: composeDispatchPrompt(task.prompt, { repo: task.repo }) });
1705
- await safeProgress(client, id, { message: `${cfg.runnerId} spawning ${model} (${tier}, effort ${dispatchMode})` });
2877
+ if (!worktreeName || !wt.worktreeDir) throw new Error("worktree isolation failure \u2014 refusing to run in the main tree");
2878
+ const { dispatchMode, tier, model, permissionMode: effectivePermissionMode, maxTurns: effectiveMaxTurns, prompt: effortPrompt } = await resolveEffortDispatch({ client, task, agent: cfg.agent, env: process.env, basePrompt: composeDispatchPrompt(task.prompt, { repo: task.repo }) });
2879
+ await safeProgress(client, id, { message: `${cfg.runnerId} spawning ${cfg.agent}:${model || "default"} (${tier}, effort ${dispatchMode})` });
1706
2880
  const cap = typeof task.max_budget_usd === "number" ? task.max_budget_usd : resolveSpendCapUsd();
1707
- const run2 = await runClaudeTask({
2881
+ const run2 = await runAgentTask({
2882
+ runner: cfg.runner,
2883
+ bin: cfg.runnerBin,
1708
2884
  prompt: effortPrompt,
1709
2885
  cwd: wt.worktreeDir,
1710
- claudeBin: cfg.claudeBin,
1711
2886
  permissionMode: effectivePermissionMode,
1712
2887
  maxTurns: effectiveMaxTurns,
1713
2888
  model,
@@ -1723,10 +2898,12 @@ async function processOneTask(client, task, cfg) {
1723
2898
  maxWallClockMs: cfg.maxWallClockMs
1724
2899
  });
1725
2900
  if (run2.killed) {
1726
- log(`task ${id} cancelled by operator`);
2901
+ preserveReason = "cancelled by operator \u2014 work preserved for recovery";
2902
+ log2(`task ${id} cancelled by operator`);
1727
2903
  return;
1728
2904
  }
1729
2905
  if (run2.timedOut) {
2906
+ preserveReason = "wall-clock timeout \u2014 partial work preserved for recovery";
1730
2907
  await safeProgress(client, id, {
1731
2908
  status: "failed",
1732
2909
  message: run2.summary,
@@ -1736,18 +2913,22 @@ async function processOneTask(client, task, cfg) {
1736
2913
  return;
1737
2914
  }
1738
2915
  if (typeof task.max_turns === "number" && typeof run2.numTurns === "number" && run2.numTurns > task.max_turns) {
1739
- log(`task ${id} WARNING: agent ran ${run2.numTurns} turns > max_turns ${task.max_turns}`);
2916
+ log2(`task ${id} WARNING: agent ran ${run2.numTurns} turns > max_turns ${task.max_turns}`);
1740
2917
  }
1741
2918
  if (typeof run2.costUsd === "number" && cap > 0 && run2.costUsd > cap) {
1742
- log(
1743
- `task ${id}: usage ~$${run2.costUsd.toFixed(2)} (est, API-equivalent \u2014 not billed on a subscription) exceeded soft cap $${cap}; publishing the agent's work anyway`
1744
- );
2919
+ log2(`task ${id}: usage ~$${run2.costUsd.toFixed(2)} (est, API-equivalent, not billed on a subscription) exceeded soft cap $${cap}; publishing anyway`);
1745
2920
  }
2921
+ let partial = false;
1746
2922
  if (!run2.ok) {
1747
2923
  const v = classifyFailureForResume({ enabled: RATE_LIMIT_RESUME_ENABLED, run: run2, task });
1748
- if (v.rateLimited) log(`task ${id}: RATE_LIMITED (resumeAfter=${v.resumeAfter || "backoff"}); queued (${v.recorded ? "ok" : "queue-write-failed"})`);
1749
- await safeProgress(client, id, { ...v.progress, cost_usd: numOrUndef(run2.costUsd) });
1750
- return;
2924
+ if (v.rateLimited) {
2925
+ log2(`task ${id}: RATE_LIMITED (resumeAfter=${v.resumeAfter || "backoff"}); queued (${v.recorded ? "ok" : "queue-write-failed"})`);
2926
+ await safeProgress(client, id, { ...v.progress, cost_usd: numOrUndef(run2.costUsd) });
2927
+ return;
2928
+ }
2929
+ partial = true;
2930
+ preserveReason = `${run2.summary || "incomplete"} \u2014 partial work preserved`;
2931
+ log2(`task ${id}: ${run2.summary || "failed"} \u2014 publishing partial work as a draft PR`);
1751
2932
  }
1752
2933
  let files = listChangedFiles(wt.worktreeDir);
1753
2934
  let alreadyCommitted = false;
@@ -1756,33 +2937,37 @@ async function processOneTask(client, task, cfg) {
1756
2937
  if (committed.length > 0) {
1757
2938
  files = committed;
1758
2939
  alreadyCommitted = true;
1759
- log(`task ${id}: agent committed ${committed.length} file(s) to a branch; recovering`);
2940
+ log2(`task ${id}: agent committed ${committed.length} file(s) to a branch; recovering`);
1760
2941
  }
1761
2942
  }
1762
2943
  const scratch = files.filter(isAgentScratch);
1763
2944
  if (scratch.length > 0) {
1764
2945
  files = files.filter((f) => !isAgentScratch(f));
1765
- log(`task ${id}: dropped ${scratch.length} scratch file(s): ${scratch.join(", ")}`);
2946
+ log2(`task ${id}: dropped ${scratch.length} scratch file(s): ${scratch.join(", ")}`);
1766
2947
  }
1767
2948
  if (files.length === 0) {
1768
2949
  await safeProgress(client, id, {
1769
- status: "failed",
1770
- message: "agent made no file changes",
1771
- result: "no_changes",
2950
+ status: partial ? "failed" : "no_changes_needed",
2951
+ message: partial ? "agent made no file changes" : "agent completed \u2014 no change needed (already fixed / nothing to do)",
2952
+ result: partial ? "no_changes" : String(run2.summary || "no_changes_needed").slice(0, 2e3),
1772
2953
  cost_usd: numOrUndef(run2.costUsd)
1773
2954
  });
2955
+ if (!partial) log2(`task ${id}: agent completed successfully with no changes (already fixed)`);
1774
2956
  return;
1775
2957
  }
1776
2958
  const fresh = await client.getTask(id).catch(() => null);
1777
2959
  if (fresh && fresh.status === "cancelled") {
1778
- log(`task ${id} cancelled before PR open; discarding changes`);
2960
+ log2(`task ${id} cancelled before PR open; discarding changes`);
1779
2961
  return;
1780
2962
  }
1781
2963
  await safeProgress(client, id, { message: `opening PR for ${files.length} changed file(s)` });
2964
+ const githubToken = (await client.getInstallationToken())?.token ?? null;
1782
2965
  const pr = openCodeTaskPr(wt.worktreeDir, files, {
1783
- title: `code-task: ${task.prompt}`,
2966
+ title: `${partial ? "\u26A0 PARTIAL (timed out) \u2014 " : ""}code-task: ${task.prompt}`,
1784
2967
  body: buildPrBody(task, run2, files),
1785
- alreadyCommitted
2968
+ alreadyCommitted,
2969
+ githubToken,
2970
+ draft: partial
1786
2971
  });
1787
2972
  await safeProgress(client, id, {
1788
2973
  status: "pr_opened",
@@ -1792,18 +2977,19 @@ async function processOneTask(client, task, cfg) {
1792
2977
  result: String(run2.summary).slice(0, 2e3),
1793
2978
  cost_usd: numOrUndef(run2.costUsd)
1794
2979
  });
1795
- log(`task ${id} \u2192 PR ${pr.prUrl}`);
1796
- if (cfg.watchEnabled && !String(task.prompt || "").includes(CI_FIX_MARKER)) {
2980
+ log2(`task ${id} \u2192 PR ${pr.prUrl}`);
2981
+ if (cfg.watchEnabled && !partial && !String(task.prompt || "").includes(CI_FIX_MARKER)) {
1797
2982
  await trackDispatchedPr({
1798
2983
  prNumber: pr.prNumber,
1799
2984
  repo: task.repo,
1800
2985
  branch: pr.branch,
1801
2986
  taskId: id
1802
- }).catch((e) => log(`watch: track failed for #${pr.prNumber}: ${e.message}`));
2987
+ }).catch((e) => log2(`watch: track failed for #${pr.prNumber}: ${e.message}`));
1803
2988
  }
1804
2989
  } catch (err) {
1805
2990
  const msg = err && err.message ? err.message : String(err);
1806
- log(`task ${id} error: ${msg}`);
2991
+ log2(`task ${id} error: ${msg}`);
2992
+ preserveReason = `runner error: ${msg}`.slice(0, 280);
1807
2993
  await safeProgress(client, id, {
1808
2994
  status: "failed",
1809
2995
  message: `runner error: ${msg}`.slice(0, 1500),
@@ -1811,7 +2997,7 @@ async function processOneTask(client, task, cfg) {
1811
2997
  }).catch(() => {
1812
2998
  });
1813
2999
  } finally {
1814
- if (worktreeName) cleanupFixWorktree(worktreeName);
3000
+ if (worktreeName) finalizeWorktree(worktreeName, { preserveReason, taskId: id, repo: task.repo, prompt: task.prompt });
1815
3001
  }
1816
3002
  }
1817
3003
  async function main({ env: env2 = process.env, once: once2 = false } = {}) {
@@ -1822,10 +3008,11 @@ async function main({ env: env2 = process.env, once: once2 = false } = {}) {
1822
3008
  const stop = (sig) => {
1823
3009
  if (stopping) return;
1824
3010
  stopping = true;
1825
- log(`${sig} received \u2014 draining ${active} active task(s), no new claims`);
3011
+ log2(`${sig} received \u2014 draining ${active} active task(s), no new claims`);
1826
3012
  };
1827
3013
  process.on("SIGINT", () => stop("SIGINT"));
1828
3014
  process.on("SIGTERM", () => stop("SIGTERM"));
3015
+ installProcessSafetyNet({ log: log2 });
1829
3016
  const startedAt = Date.now();
1830
3017
  const controlServer = startDaemonControl({
1831
3018
  cfg,
@@ -1833,33 +3020,26 @@ async function main({ env: env2 = process.env, once: once2 = false } = {}) {
1833
3020
  getActiveCount: () => active,
1834
3021
  isRunning: () => !stopping,
1835
3022
  startedAt,
1836
- log
3023
+ log: log2
1837
3024
  });
1838
- log(
1839
- `up as ${cfg.runnerId} \u2192 ${env2.VO_CONTROL_PLANE_URL} (concurrency ${cfg.maxConcurrency}, poll ${cfg.pollSec}s, once=${once2})`
3025
+ log2(
3026
+ `up as ${cfg.runnerId} \u2192 ${env2.VO_CONTROL_PLANE_URL} (agent ${cfg.agent} [${cfg.runnerBin}], concurrency ${cfg.maxConcurrency}, poll ${cfg.pollSec}s, once=${once2})`
1840
3027
  );
1841
- for (const line of describeClaimScoping(cfg, env2)) log(line);
1842
- log(
3028
+ for (const line of describeClaimScoping(cfg, env2)) log2(line);
3029
+ log2(
1843
3030
  cfg.watchEnabled ? `PR watcher ON \u2014 auto-fix ${cfg.watchMaxFix}/PR on CI failure, never auto-merges, every ${cfg.watchIntervalSec}s (VO_CODE_RUNNER_WATCH=0 to disable)` : "PR watcher OFF (VO_CODE_RUNNER_WATCH=0)"
1844
3031
  );
1845
- let lastSessionForward = 0;
1846
3032
  let lastWatchCycle = 0;
1847
- const runWatch = makeWatchRunner({ client, log, maxFixAttempts: cfg.watchMaxFix });
3033
+ const runWatch = makeWatchRunner({ client, log: log2, maxFixAttempts: cfg.watchMaxFix });
3034
+ const loopTick = makeLoopTicks({ client, cfg, env: env2, log: log2, getActive: () => active });
3035
+ const backoff = makeReconnectBackoff({ baseMs: cfg.pollSec * 1e3, log: log2 });
1848
3036
  while (!stopping) {
1849
- if (cfg.sessionForwardSec > 0 && Date.now() - lastSessionForward >= cfg.sessionForwardSec * 1e3) {
1850
- lastSessionForward = Date.now();
1851
- forwardSessionSpool({
1852
- baseUrl: String(env2.VO_CONTROL_PLANE_URL || "").replace(/\/$/, ""),
1853
- token: env2.VO_CONTROL_PLANE_ADMIN_TOKEN || "",
1854
- operatorSeed: cfg.operatorSeed
1855
- }).catch(() => {
1856
- });
1857
- }
3037
+ loopTick();
1858
3038
  if (cfg.watchEnabled && Date.now() - lastWatchCycle >= cfg.watchIntervalSec * 1e3) {
1859
3039
  lastWatchCycle = Date.now();
1860
3040
  runWatch().then((r) => {
1861
- if (r.checked > 0) log(`watch: ${r.checked} PR(s) checked, ${r.fixed} fix(es), ${r.untracked} untracked`);
1862
- }).catch((e) => log(`watch cycle error: ${e.message}`));
3041
+ if (r.checked > 0) log2(`watch: ${r.checked} PR(s) checked, ${r.fixed} fix(es), ${r.untracked} untracked`);
3042
+ }).catch((e) => log2(`watch cycle error: ${e.message}`));
1863
3043
  }
1864
3044
  if (active >= cfg.maxConcurrency) {
1865
3045
  await sleep(cfg.pollSec * 1e3);
@@ -1868,21 +3048,24 @@ async function main({ env: env2 = process.env, once: once2 = false } = {}) {
1868
3048
  let task;
1869
3049
  try {
1870
3050
  task = await client.claim(cfg.runnerId, cfg.servedRepos, cfg.servedOperators);
3051
+ backoff.onSuccess();
1871
3052
  } catch (err) {
1872
- log(`claim error: ${err.message}`);
1873
- if (once2) break;
1874
- await sleep(cfg.pollSec * 1e3);
3053
+ if (once2) {
3054
+ log2(`claim error: ${err.message}`);
3055
+ break;
3056
+ }
3057
+ await sleep(backoff.onFailure(err));
1875
3058
  continue;
1876
3059
  }
1877
3060
  if (!task) {
1878
3061
  if (once2) {
1879
- log("no pending task; --once exiting");
3062
+ log2("no pending task; --once exiting");
1880
3063
  break;
1881
3064
  }
1882
3065
  await sleep(cfg.pollSec * 1e3);
1883
3066
  continue;
1884
3067
  }
1885
- log(`claimed task ${task.code_task_id} (${task.repo})`);
3068
+ log2(`claimed task ${task.code_task_id} (${task.repo})`);
1886
3069
  active += 1;
1887
3070
  const done = processOneTask(client, task, cfg).finally(() => {
1888
3071
  active -= 1;
@@ -1896,12 +3079,9 @@ async function main({ env: env2 = process.env, once: once2 = false } = {}) {
1896
3079
  await sleep(500);
1897
3080
  }
1898
3081
  if (controlServer) controlServer.close();
1899
- log("stopped");
3082
+ log2("stopped");
1900
3083
  }
1901
- var invokedDirectly = process.argv[1] && fileURLToPath2(import.meta.url) === process.argv[1] && // Bundle-safe: only self-start when THIS file is the real entry, not when the
1902
- // module is inlined into a bundle (e.g. @algosuite/vo-mcp's dist/runner-cli.js,
1903
- // which calls main() itself — without this, `node dist/runner-cli.js` would
1904
- // start a SECOND daemon loop and double-claim tasks).
3084
+ var invokedDirectly = process.argv[1] && fileURLToPath2(import.meta.url) === process.argv[1] && // Bundle-safe: self-start only when THIS file is the real entry (not inlined into vo-mcp's runner-cli.js ⇒ double-claim).
1905
3085
  import.meta.url.endsWith("code-runner-daemon.mjs");
1906
3086
  if (invokedDirectly) {
1907
3087
  const once2 = process.argv.includes("--once");