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

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,23 @@ 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);
189
247
  }
190
248
 
191
249
  // src/runner/spend-cap-shim.mjs
@@ -324,6 +382,52 @@ function createControlPlaneClient({
324
382
  if (!res.ok) throw new Error(`weekly-tokens failed: HTTP ${res.status}`);
325
383
  return true;
326
384
  },
385
+ /**
386
+ * Send a liveness heartbeat (M2). The control-plane upserts it under the
387
+ * authenticated operator so the web shows a TRUE "runner online" signal.
388
+ * Best-effort caller; throws on 401/non-ok so the daemon can log + retry.
389
+ */
390
+ async postHeartbeat({ runnerId, uptimeSec, activeTasks, version }) {
391
+ const body = { runner_id: runnerId };
392
+ if (typeof uptimeSec === "number") body.uptime_sec = uptimeSec;
393
+ if (typeof activeTasks === "number") body.active_tasks = activeTasks;
394
+ if (version) body.version = version;
395
+ const res = await req("POST", "/api/v1/runner/heartbeat", body);
396
+ if (res.status === 401) {
397
+ cachedFirebaseToken = null;
398
+ throw new Error("heartbeat unauthorized (401)");
399
+ }
400
+ if (!res.ok) throw new Error(`heartbeat failed: HTTP ${res.status}`);
401
+ return true;
402
+ },
403
+ /**
404
+ * Mint a short-lived (~1h), repo-scoped GitHub App installation token for
405
+ * THIS runner's operator (M3). The control-plane keys the mint on the
406
+ * authenticated operator (ctx.operator_id), so the token covers only that
407
+ * operator's installation.
408
+ *
409
+ * Returns { token, expiresAt } on success, or `null` on ANY non-success —
410
+ * 503 (App not configured / dormant), 404 (operator has no installation),
411
+ * other non-2xx, or a network error. `null` is the signal to the caller to
412
+ * fall back to the runner's own ambient `gh` auth, so a dormant or
413
+ * unmapped App NEVER blocks a PR the runner could open itself. Never throws.
414
+ *
415
+ * The minted token is only usable for push + PR if the GitHub App grants
416
+ * BOTH `Contents: write` (git push) AND `Pull requests: write` (gh pr
417
+ * create) — see docs/vo/github-app-setup-2026-06-18.md. A token missing
418
+ * either scope fails at push (→ ambient fallback) or at `gh pr create`.
419
+ */
420
+ async getInstallationToken() {
421
+ try {
422
+ const res = await req("POST", "/api/v1/github/installation-token", {});
423
+ if (!res.ok) return null;
424
+ const json = await res.json();
425
+ if (!json || !json.token) return null;
426
+ return { token: json.token, expiresAt: json.expires_at || null };
427
+ } catch {
428
+ return null;
429
+ }
430
+ },
327
431
  /**
328
432
  * Read the operator's dispatch-mode config (Fast→Ultracode effort setting).
329
433
  * Returns the mode string ('fast'|'standard'|'deep'|'ultra'|'ultracode'),
@@ -345,6 +449,39 @@ function createControlPlaneClient({
345
449
  // ../../scripts/virtual-office/code-runner/claude-runner.mjs
346
450
  import { spawn } from "node:child_process";
347
451
  import { spawnSync as spawnSync2 } from "node:child_process";
452
+
453
+ // ../../scripts/virtual-office/code-runner/anthropic-key-store.mjs
454
+ import { createRequire as createRequire2 } from "node:module";
455
+ var require2 = createRequire2(import.meta.url);
456
+ var KEY_SERVICE = "algosuite-vo";
457
+ var KEY_ACCOUNT = "anthropic-api-key";
458
+ var _entryCtor;
459
+ var _loadTried = false;
460
+ function defaultEntryCtor() {
461
+ if (_loadTried) return _entryCtor;
462
+ _loadTried = true;
463
+ try {
464
+ _entryCtor = require2("@napi-rs/keyring").Entry;
465
+ } catch {
466
+ _entryCtor = null;
467
+ }
468
+ return _entryCtor;
469
+ }
470
+ function getAnthropicKey({ EntryCtor = defaultEntryCtor() } = {}) {
471
+ if (!EntryCtor) return null;
472
+ try {
473
+ return new EntryCtor(KEY_SERVICE, KEY_ACCOUNT).getPassword() || null;
474
+ } catch {
475
+ return null;
476
+ }
477
+ }
478
+ function withAnthropicKey(baseEnv = {}, { getKey = getAnthropicKey } = {}) {
479
+ if (baseEnv.ANTHROPIC_API_KEY) return { ...baseEnv };
480
+ const key = getKey();
481
+ return key ? { ...baseEnv, ANTHROPIC_API_KEY: key } : { ...baseEnv };
482
+ }
483
+
484
+ // ../../scripts/virtual-office/code-runner/claude-runner.mjs
348
485
  var DEFAULT_PERMISSION_MODE = "acceptEdits";
349
486
  function extractText(content) {
350
487
  if (typeof content === "string") return content.trim();
@@ -396,10 +533,11 @@ function buildClaudeArgs({ permissionMode = DEFAULT_PERMISSION_MODE, maxTurns, m
396
533
  }
397
534
  return args;
398
535
  }
399
- function runClaudeTask({
536
+ function runAgentTask({
537
+ runner,
400
538
  prompt,
401
539
  cwd,
402
- claudeBin = "claude",
540
+ bin = runner.binary,
403
541
  permissionMode,
404
542
  maxTurns,
405
543
  model,
@@ -412,16 +550,20 @@ function runClaudeTask({
412
550
  spawnImpl = spawn
413
551
  }) {
414
552
  return new Promise((resolve) => {
415
- const args = buildClaudeArgs({ permissionMode, maxTurns, model });
416
- const child = spawnImpl(claudeBin, args, {
553
+ const args = runner.buildArgs({ permissionMode, maxTurns, model, prompt });
554
+ const child = spawnImpl(bin, args, {
417
555
  cwd,
418
- env: { ...env2 },
556
+ // BYO auth: each runner fills its provider's credential env var(s) from the
557
+ // OS keychain when not already set (Claude→ANTHROPIC_API_KEY via
558
+ // anthropic-key-store, Codex→OPENAI_API_KEY, …). Explicit env wins; no key
559
+ // stored → unchanged (the CLI's own login as before).
560
+ env: typeof runner.applyAuthEnv === "function" ? runner.applyAuthEnv(env2) : env2,
419
561
  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
562
+ // getSpawnOptions carries the per-runner shell/windowsHide posture. On
563
+ // Windows the CLI is a `.cmd` shim spawn needs shell:true to resolve it
564
+ // (without it: ENOENT). Safe because the prompt goes via stdin below,
565
+ // never argv, so the shell never sees untrusted input.
566
+ ...runner.getSpawnOptions()
425
567
  });
426
568
  try {
427
569
  child.stdin.write(String(prompt));
@@ -456,7 +598,7 @@ function runClaudeTask({
456
598
  while ((nl = buffer.indexOf("\n")) >= 0) {
457
599
  const line = buffer.slice(0, nl);
458
600
  buffer = buffer.slice(nl + 1);
459
- const evt = parseStreamEvent(line);
601
+ const evt = runner.parseEvent(line);
460
602
  if (!evt) continue;
461
603
  if (evt.kind === "progress") {
462
604
  try {
@@ -509,7 +651,7 @@ function runClaudeTask({
509
651
  return;
510
652
  }
511
653
  if (!result.summary && code !== 0) {
512
- result.summary = stderrTail.slice(-500) || `claude exited ${code}`;
654
+ result.summary = stderrTail.slice(-500) || `${bin} exited ${code}`;
513
655
  }
514
656
  resolve({ ...result, ok: result.ok && code === 0 });
515
657
  });
@@ -531,6 +673,14 @@ var ClaudeRunner = class {
531
673
  windowsHide: true
532
674
  };
533
675
  }
676
+ /**
677
+ * Fill ANTHROPIC_API_KEY from the OS keychain when not already set (M4 BYO),
678
+ * so a friend who ran `vo-mcp set-key` authenticates without an env var.
679
+ * Explicit env wins; no key stored → unchanged (Claude Code login as before).
680
+ */
681
+ applyAuthEnv(env2 = process.env) {
682
+ return withAnthropicKey(env2);
683
+ }
534
684
  /**
535
685
  * Best-effort auth check: is `claude` on PATH and can we verify login?
536
686
  * Never throws. If we can't cheaply detect auth, we return installed:true
@@ -574,6 +724,310 @@ var ClaudeRunner = class {
574
724
  };
575
725
  var claudeRunner = new ClaudeRunner();
576
726
 
727
+ // ../../scripts/virtual-office/code-runner/codex-runner.mjs
728
+ import { spawnSync as spawnSync3 } from "node:child_process";
729
+
730
+ // ../../scripts/virtual-office/code-runner/agent-key-store.mjs
731
+ import { createRequire as createRequire3 } from "node:module";
732
+ var require3 = createRequire3(import.meta.url);
733
+ var KEY_SERVICE2 = "algosuite-vo";
734
+ var PROVIDER_ENV = {
735
+ anthropic: ["ANTHROPIC_API_KEY"],
736
+ openai: ["OPENAI_API_KEY", "CODEX_API_KEY"],
737
+ cursor: ["CURSOR_API_KEY"]
738
+ };
739
+ var PROVIDER_ALIAS = {
740
+ claude: "anthropic",
741
+ anthropic: "anthropic",
742
+ codex: "openai",
743
+ openai: "openai",
744
+ cursor: "cursor"
745
+ };
746
+ function resolveProvider(name) {
747
+ const key = String(name || "").trim().toLowerCase();
748
+ return PROVIDER_ALIAS[key] || null;
749
+ }
750
+ function accountFor(provider) {
751
+ return `${provider}-api-key`;
752
+ }
753
+ var _entryCtor2;
754
+ var _loadTried2 = false;
755
+ function defaultEntryCtor2() {
756
+ if (_loadTried2) return _entryCtor2;
757
+ _loadTried2 = true;
758
+ try {
759
+ _entryCtor2 = require3("@napi-rs/keyring").Entry;
760
+ } catch {
761
+ _entryCtor2 = null;
762
+ }
763
+ return _entryCtor2;
764
+ }
765
+ function getAgentKey(provider, { EntryCtor = defaultEntryCtor2() } = {}) {
766
+ const p = resolveProvider(provider);
767
+ if (!p || !EntryCtor) return null;
768
+ try {
769
+ return new EntryCtor(KEY_SERVICE2, accountFor(p)).getPassword() || null;
770
+ } catch {
771
+ return null;
772
+ }
773
+ }
774
+ function withAgentKey(provider, baseEnv = {}, { getKey = getAgentKey } = {}) {
775
+ const p = resolveProvider(provider);
776
+ const vars = p && PROVIDER_ENV[p] || [];
777
+ const out = { ...baseEnv };
778
+ if (!p || vars.length === 0) return out;
779
+ if (vars.some((v) => out[v])) return out;
780
+ const key = getKey(p);
781
+ if (!key) return out;
782
+ for (const v of vars) out[v] = key;
783
+ return out;
784
+ }
785
+
786
+ // ../../scripts/virtual-office/code-runner/codex-runner.mjs
787
+ function buildCodexArgs({ model } = {}) {
788
+ const args = ["exec", "--json", "--full-auto"];
789
+ if (model) {
790
+ args.push("--model", String(model));
791
+ }
792
+ args.push("-");
793
+ return args;
794
+ }
795
+ function itemText(item) {
796
+ if (!item) return "";
797
+ if (typeof item.text === "string") return item.text;
798
+ if (typeof item.message === "string") return item.message;
799
+ if (Array.isArray(item.content)) {
800
+ return item.content.map((b) => typeof b === "string" ? b : typeof b?.text === "string" ? b.text : "").join("");
801
+ }
802
+ return "";
803
+ }
804
+ function parseCodexEvent(line) {
805
+ const trimmed = String(line || "").trim();
806
+ if (!trimmed) return null;
807
+ let evt;
808
+ try {
809
+ evt = JSON.parse(trimmed);
810
+ } catch {
811
+ return null;
812
+ }
813
+ if (!evt || typeof evt !== "object") return null;
814
+ const type = evt.type;
815
+ if (type === "item.completed" && evt.item) {
816
+ const it = evt.item.type;
817
+ if (it === "agent_message" || it === "assistant_message") {
818
+ const text = itemText(evt.item).trim();
819
+ return text ? { kind: "progress", text } : null;
820
+ }
821
+ return null;
822
+ }
823
+ if (type === "turn.completed") {
824
+ return { kind: "result", isError: false, costUsd: null, summary: "completed", numTurns: null };
825
+ }
826
+ if (type === "turn.failed" || type === "error") {
827
+ const msg = evt.error && (evt.error.message || evt.error) || evt.message || "codex run failed";
828
+ return { kind: "result", isError: true, costUsd: null, summary: String(msg), numTurns: null };
829
+ }
830
+ return null;
831
+ }
832
+ var CodexRunner = class {
833
+ get binary() {
834
+ return "codex";
835
+ }
836
+ buildArgs(opts = {}) {
837
+ return buildCodexArgs(opts);
838
+ }
839
+ parseEvent(line) {
840
+ return parseCodexEvent(line);
841
+ }
842
+ getSpawnOptions() {
843
+ return {
844
+ shell: process.platform === "win32",
845
+ windowsHide: true
846
+ };
847
+ }
848
+ /**
849
+ * Fill the OpenAI credential env var(s) (OPENAI_API_KEY / CODEX_API_KEY) from
850
+ * the OS keychain when not already set, so a BYO friend who ran
851
+ * `vo-mcp set-key --provider codex` authenticates without an env var. Explicit
852
+ * env wins; no key stored → unchanged (a prior `codex login` still works).
853
+ */
854
+ applyAuthEnv(env2 = process.env) {
855
+ return withAgentKey("openai", env2);
856
+ }
857
+ /** Best-effort: is `codex` on PATH? Never throws. */
858
+ async checkAuth() {
859
+ try {
860
+ const { status, error } = spawnSync3("codex", ["--version"], {
861
+ shell: process.platform === "win32",
862
+ windowsHide: true,
863
+ timeout: 3e3,
864
+ stdio: "ignore"
865
+ });
866
+ if (error) {
867
+ return { installed: false, authenticated: false, message: `codex not found on PATH: ${error.message}` };
868
+ }
869
+ if (status !== 0) {
870
+ return { installed: true, authenticated: false, message: "codex exists but --version failed (auth unclear)" };
871
+ }
872
+ return { installed: true, authenticated: true, message: "codex binary found (auth check is best-effort)" };
873
+ } catch (err) {
874
+ return { installed: false, authenticated: false, message: `checkAuth probe failed: ${err.message}` };
875
+ }
876
+ }
877
+ };
878
+ var codexRunner = new CodexRunner();
879
+
880
+ // ../../scripts/virtual-office/code-runner/cursor-runner.mjs
881
+ import { spawnSync as spawnSync4 } from "node:child_process";
882
+ function buildCursorArgs({ model, prompt } = {}) {
883
+ const args = ["-p", "--output-format", "stream-json", "--force"];
884
+ if (model) {
885
+ args.push("--model", String(model));
886
+ }
887
+ const p = String(prompt ?? "");
888
+ if (p.length > 0) {
889
+ args.push(p);
890
+ }
891
+ return args;
892
+ }
893
+ function messageText(message) {
894
+ if (!message) return "";
895
+ const content = message.content;
896
+ if (typeof content === "string") return content;
897
+ if (Array.isArray(content)) {
898
+ return content.map((b) => typeof b === "string" ? b : typeof b?.text === "string" ? b.text : "").join("");
899
+ }
900
+ return "";
901
+ }
902
+ function parseCursorEvent(line) {
903
+ const trimmed = String(line || "").trim();
904
+ if (!trimmed) return null;
905
+ let evt;
906
+ try {
907
+ evt = JSON.parse(trimmed);
908
+ } catch {
909
+ return null;
910
+ }
911
+ if (!evt || typeof evt !== "object") return null;
912
+ if (evt.type === "assistant") {
913
+ const text = messageText(evt.message).trim();
914
+ return text ? { kind: "progress", text } : null;
915
+ }
916
+ if (evt.type === "result") {
917
+ const isError = Boolean(evt.is_error) || evt.subtype === "error";
918
+ return {
919
+ kind: "result",
920
+ isError,
921
+ costUsd: null,
922
+ summary: typeof evt.result === "string" && evt.result.length > 0 ? evt.result : evt.subtype || (isError ? "error" : "completed"),
923
+ numTurns: null
924
+ };
925
+ }
926
+ return null;
927
+ }
928
+ var CursorRunner = class {
929
+ get binary() {
930
+ return "cursor-agent";
931
+ }
932
+ buildArgs(opts = {}) {
933
+ return buildCursorArgs(opts);
934
+ }
935
+ parseEvent(line) {
936
+ return parseCursorEvent(line);
937
+ }
938
+ getSpawnOptions() {
939
+ return {
940
+ shell: process.platform === "win32",
941
+ windowsHide: true
942
+ };
943
+ }
944
+ /**
945
+ * Fill CURSOR_API_KEY from the OS keychain when not already set, so a BYO
946
+ * friend who ran `vo-mcp set-key --provider cursor` authenticates without an
947
+ * env var. Explicit env wins; no key stored → a prior `cursor-agent login`.
948
+ */
949
+ applyAuthEnv(env2 = process.env) {
950
+ return withAgentKey("cursor", env2);
951
+ }
952
+ /** Best-effort: is `cursor-agent` on PATH? Never throws. */
953
+ async checkAuth() {
954
+ try {
955
+ const { status, error } = spawnSync4("cursor-agent", ["--version"], {
956
+ shell: process.platform === "win32",
957
+ windowsHide: true,
958
+ timeout: 3e3,
959
+ stdio: "ignore"
960
+ });
961
+ if (error) {
962
+ return { installed: false, authenticated: false, message: `cursor-agent not found on PATH: ${error.message}` };
963
+ }
964
+ if (status !== 0) {
965
+ return { installed: true, authenticated: false, message: "cursor-agent exists but --version failed (auth unclear)" };
966
+ }
967
+ return {
968
+ installed: true,
969
+ authenticated: true,
970
+ message: "cursor-agent found (EXPERIMENTAL: headless mode may need a TTY; auth check is best-effort)"
971
+ };
972
+ } catch (err) {
973
+ return { installed: false, authenticated: false, message: `checkAuth probe failed: ${err.message}` };
974
+ }
975
+ }
976
+ };
977
+ var cursorRunner = new CursorRunner();
978
+
979
+ // ../../scripts/virtual-office/code-runner/agent-runner-interface.mjs
980
+ function validateAgentRunner(runner) {
981
+ if (!runner || typeof runner !== "object") {
982
+ throw new TypeError("AgentRunner must be an object");
983
+ }
984
+ if (typeof runner.binary !== "string" || runner.binary.length === 0) {
985
+ throw new TypeError("AgentRunner.binary must be a non-empty string");
986
+ }
987
+ if (typeof runner.buildArgs !== "function") {
988
+ throw new TypeError("AgentRunner.buildArgs must be a function");
989
+ }
990
+ if (typeof runner.parseEvent !== "function") {
991
+ throw new TypeError("AgentRunner.parseEvent must be a function");
992
+ }
993
+ if (typeof runner.getSpawnOptions !== "function") {
994
+ throw new TypeError("AgentRunner.getSpawnOptions must be a function");
995
+ }
996
+ if (typeof runner.checkAuth !== "function") {
997
+ throw new TypeError("AgentRunner.checkAuth must be a function");
998
+ }
999
+ }
1000
+
1001
+ // ../../scripts/virtual-office/code-runner/resolve-runner.mjs
1002
+ var DEFAULT_AGENT = "claude";
1003
+ var RUNNERS = {
1004
+ claude: claudeRunner,
1005
+ codex: codexRunner,
1006
+ cursor: cursorRunner
1007
+ };
1008
+ function listAgents() {
1009
+ return Object.keys(RUNNERS);
1010
+ }
1011
+ function resolveRunner(env2 = process.env, { warn = () => {
1012
+ } } = {}) {
1013
+ const raw = String(env2.VO_CODE_RUNNER_AGENT || env2.VO_AGENT || DEFAULT_AGENT).trim().toLowerCase();
1014
+ let agent = raw;
1015
+ let fellBack = false;
1016
+ let runner = RUNNERS[agent];
1017
+ if (!runner) {
1018
+ try {
1019
+ warn(`unknown VO_CODE_RUNNER_AGENT "${raw}"; falling back to "${DEFAULT_AGENT}" (known: ${listAgents().join(", ")})`);
1020
+ } catch {
1021
+ }
1022
+ agent = DEFAULT_AGENT;
1023
+ runner = RUNNERS[DEFAULT_AGENT];
1024
+ fellBack = true;
1025
+ }
1026
+ validateAgentRunner(runner);
1027
+ const runnerBin = env2.VO_CODE_RUNNER_BIN || (agent === "claude" ? env2.VO_CODE_RUNNER_CLAUDE_BIN : "") || runner.binary;
1028
+ return { agent, runner, runnerBin, fellBack };
1029
+ }
1030
+
577
1031
  // ../../scripts/virtual-office/code-runner/rate-limit-resume.mjs
578
1032
  import { appendFileSync, mkdirSync as mkdirSync2 } from "node:fs";
579
1033
  import { homedir as homedir2 } from "node:os";
@@ -674,9 +1128,9 @@ function classifyFailureForResume({
674
1128
  }
675
1129
 
676
1130
  // ../../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 });
1131
+ import { spawnSync as spawnSync5 } from "node:child_process";
1132
+ function run(cmd, args, cwd, { timeout = 18e4, raw = false, env: env2 } = {}) {
1133
+ const r = spawnSync5(cmd, args, { cwd, encoding: "utf8", timeout, ...env2 ? { env: env2 } : {} });
680
1134
  if (r.error) throw r.error;
681
1135
  if (r.status !== 0) {
682
1136
  throw new Error(`${cmd} ${args[0]} failed (exit ${r.status}): ${(r.stderr || "").slice(-300)}`);
@@ -735,6 +1189,44 @@ function listCommittedFiles(cwd, base = "origin/main") {
735
1189
  function compactTitle(s, max = 100) {
736
1190
  return String(s || "").replace(/\s+/g, " ").trim().slice(0, max) || "code-task";
737
1191
  }
1192
+ function installationTokenEnv(githubToken, baseEnv = process.env) {
1193
+ if (!githubToken) return void 0;
1194
+ return { ...baseEnv, GH_TOKEN: githubToken, GITHUB_TOKEN: githubToken };
1195
+ }
1196
+ function pushArgs(branch, { withToken = false } = {}) {
1197
+ if (withToken) {
1198
+ return [
1199
+ "-c",
1200
+ "credential.helper=",
1201
+ "-c",
1202
+ "credential.helper=!gh auth git-credential",
1203
+ "push",
1204
+ "origin",
1205
+ branch
1206
+ ];
1207
+ }
1208
+ return ["push", "origin", branch];
1209
+ }
1210
+ function pushPlan(branch, githubToken) {
1211
+ if (githubToken) {
1212
+ return {
1213
+ primary: { args: pushArgs(branch, { withToken: true }), env: installationTokenEnv(githubToken), tokenUsed: true },
1214
+ fallback: { args: pushArgs(branch), env: void 0, tokenUsed: false }
1215
+ };
1216
+ }
1217
+ return { primary: { args: pushArgs(branch), env: void 0, tokenUsed: false }, fallback: null };
1218
+ }
1219
+ function pushBranch(worktreeDir, branch, githubToken, runFn = run) {
1220
+ const { primary, fallback } = pushPlan(branch, githubToken);
1221
+ try {
1222
+ runFn("git", primary.args, worktreeDir, { env: primary.env });
1223
+ return primary.tokenUsed;
1224
+ } catch (err) {
1225
+ if (!fallback) throw err;
1226
+ runFn("git", fallback.args, worktreeDir, { env: fallback.env });
1227
+ return fallback.tokenUsed;
1228
+ }
1229
+ }
738
1230
  function openCodeTaskPr(worktreeDir, files, {
739
1231
  title,
740
1232
  body,
@@ -744,7 +1236,12 @@ function openCodeTaskPr(worktreeDir, files, {
744
1236
  maxFiles = 200,
745
1237
  // Safety net: the agent already COMMITTED its work to a branch (despite the
746
1238
  // preamble). Skip add+commit; just push the existing branch and open the PR.
747
- alreadyCommitted = false
1239
+ alreadyCommitted = false,
1240
+ // Optional GitHub App installation token (M3). When present, the push + PR
1241
+ // authenticate as the App (the runner needs no repo write access of its own);
1242
+ // a push failure transparently falls back to the runner's ambient gh auth.
1243
+ // Absent → today's behavior (ambient gh) exactly.
1244
+ githubToken = null
748
1245
  } = {}) {
749
1246
  if (!Array.isArray(files) || files.length === 0) {
750
1247
  throw new Error("openCodeTaskPr: no files to commit");
@@ -761,13 +1258,14 @@ function openCodeTaskPr(worktreeDir, files, {
761
1258
  branch = run("git", ["branch", "--show-current"], worktreeDir, { timeout: 3e4 });
762
1259
  } catch {
763
1260
  }
1261
+ let tokenUsed;
764
1262
  if (alreadyCommitted) {
765
1263
  if (!branch || branch === "main" || branch === "HEAD") {
766
1264
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
767
1265
  branch = `${branchPrefix}-${stamp}`;
768
1266
  run("git", ["checkout", "-b", branch], worktreeDir);
769
1267
  }
770
- run("git", ["push", "origin", branch], worktreeDir);
1268
+ tokenUsed = pushBranch(worktreeDir, branch, githubToken);
771
1269
  } else {
772
1270
  if (!branch || branch === "main" || branch === "HEAD") {
773
1271
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -780,7 +1278,7 @@ function openCodeTaskPr(worktreeDir, files, {
780
1278
  }
781
1279
  const commitMsg = compactTitle(title, 180);
782
1280
  run("git", ["commit", "--no-verify", "-m", commitMsg], worktreeDir);
783
- run("git", ["push", "origin", branch], worktreeDir);
1281
+ tokenUsed = pushBranch(worktreeDir, branch, githubToken);
784
1282
  }
785
1283
  const out = run(
786
1284
  "gh",
@@ -796,7 +1294,8 @@ function openCodeTaskPr(worktreeDir, files, {
796
1294
  "--body",
797
1295
  String(body || "")
798
1296
  ],
799
- worktreeDir
1297
+ worktreeDir,
1298
+ { env: tokenUsed ? installationTokenEnv(githubToken) : void 0 }
800
1299
  );
801
1300
  const m = out.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/);
802
1301
  if (!m) throw new Error("gh pr create returned no parseable PR URL");
@@ -983,14 +1482,37 @@ async function forwardSessionSpool(deps) {
983
1482
  return { forwarded, pruned };
984
1483
  }
985
1484
 
1485
+ // ../../scripts/virtual-office/code-runner/loop-ticks.mjs
1486
+ var HEARTBEAT_MS = 6e4;
1487
+ function makeLoopTicks({ client, cfg, env: env2, log: log2, getActive }) {
1488
+ let lastSessionForward = 0;
1489
+ let lastHeartbeat = 0;
1490
+ return function tick() {
1491
+ const now = Date.now();
1492
+ if (cfg.sessionForwardSec > 0 && now - lastSessionForward >= cfg.sessionForwardSec * 1e3) {
1493
+ lastSessionForward = now;
1494
+ forwardSessionSpool({
1495
+ baseUrl: String(env2.VO_CONTROL_PLANE_URL || "").replace(/\/$/, ""),
1496
+ token: env2.VO_CONTROL_PLANE_ADMIN_TOKEN || "",
1497
+ operatorSeed: cfg.operatorSeed
1498
+ }).catch(() => {
1499
+ });
1500
+ }
1501
+ if (now - lastHeartbeat >= HEARTBEAT_MS) {
1502
+ lastHeartbeat = now;
1503
+ client.postHeartbeat({ runnerId: cfg.runnerId, uptimeSec: Math.floor(process.uptime()), activeTasks: getActive() }).catch((e) => log2(`heartbeat failed: ${e.message}`));
1504
+ }
1505
+ };
1506
+ }
1507
+
986
1508
  // ../../scripts/virtual-office/code-runner/pr-watcher.mjs
987
1509
  import { homedir as homedir4 } from "node:os";
988
1510
  import { join as join4 } from "node:path";
989
1511
  import { readFile as readFile2, writeFile as writeFile2, mkdir } from "node:fs/promises";
990
- import { spawnSync as spawnSync4 } from "node:child_process";
1512
+ import { spawnSync as spawnSync6 } from "node:child_process";
991
1513
  var CI_FIX_MARKER = "[VO-CI-FIX]";
992
1514
  function ghViewPr(prNumber, repo) {
993
- const r = spawnSync4(
1515
+ const r = spawnSync6(
994
1516
  "gh",
995
1517
  ["pr", "view", String(prNumber), "-R", repo, "--json", "state,statusCheckRollup,headRefName"],
996
1518
  { encoding: "utf8", timeout: 3e4 }
@@ -1628,7 +2150,8 @@ var parseList = (s) => String(s || "").split(/[\s,]+/).map((x) => x.trim()).filt
1628
2150
  function loadConfig(env2 = process.env) {
1629
2151
  return {
1630
2152
  runnerId: env2.VO_CODE_RUNNER_ID || `vo-code-runner-${os.hostname()}`,
1631
- claudeBin: env2.VO_CODE_RUNNER_CLAUDE_BIN || "claude",
2153
+ // BYO multi-agent: {agent, runner, runnerBin} — VO_CODE_RUNNER_AGENT (claude|codex).
2154
+ ...resolveRunner(env2, { warn: (m) => log(`agent-select: ${m}`) }),
1632
2155
  permissionMode: env2.VO_CODE_RUNNER_PERMISSION_MODE || "acceptEdits",
1633
2156
  maxConcurrency: Math.max(1, Number(env2.VO_CODE_TASK_MAX_CONCURRENCY || 2) || 2),
1634
2157
  pollSec: Math.max(1, Number(env2.VO_CODE_RUNNER_POLL_SEC || 5) || 5),
@@ -1699,15 +2222,16 @@ async function processOneTask(client, task, cfg) {
1699
2222
  const id = task.code_task_id;
1700
2223
  let worktreeName = "";
1701
2224
  try {
1702
- const wt = createFixWorktree("code-task", { source: id.slice(0, 8) });
2225
+ const wt = createFixWorktree("code-task", { source: id.slice(0, 8), repo: task.repo });
1703
2226
  worktreeName = wt.worktreeName;
1704
2227
  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})` });
2228
+ await safeProgress(client, id, { message: `${cfg.runnerId} spawning ${cfg.agent}:${model} (${tier}, effort ${dispatchMode})` });
1706
2229
  const cap = typeof task.max_budget_usd === "number" ? task.max_budget_usd : resolveSpendCapUsd();
1707
- const run2 = await runClaudeTask({
2230
+ const run2 = await runAgentTask({
2231
+ runner: cfg.runner,
2232
+ bin: cfg.runnerBin,
1708
2233
  prompt: effortPrompt,
1709
2234
  cwd: wt.worktreeDir,
1710
- claudeBin: cfg.claudeBin,
1711
2235
  permissionMode: effectivePermissionMode,
1712
2236
  maxTurns: effectiveMaxTurns,
1713
2237
  model,
@@ -1779,10 +2303,12 @@ async function processOneTask(client, task, cfg) {
1779
2303
  return;
1780
2304
  }
1781
2305
  await safeProgress(client, id, { message: `opening PR for ${files.length} changed file(s)` });
2306
+ const githubToken = (await client.getInstallationToken())?.token ?? null;
1782
2307
  const pr = openCodeTaskPr(wt.worktreeDir, files, {
1783
2308
  title: `code-task: ${task.prompt}`,
1784
2309
  body: buildPrBody(task, run2, files),
1785
- alreadyCommitted
2310
+ alreadyCommitted,
2311
+ githubToken
1786
2312
  });
1787
2313
  await safeProgress(client, id, {
1788
2314
  status: "pr_opened",
@@ -1836,25 +2362,17 @@ async function main({ env: env2 = process.env, once: once2 = false } = {}) {
1836
2362
  log
1837
2363
  });
1838
2364
  log(
1839
- `up as ${cfg.runnerId} \u2192 ${env2.VO_CONTROL_PLANE_URL} (concurrency ${cfg.maxConcurrency}, poll ${cfg.pollSec}s, once=${once2})`
2365
+ `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
2366
  );
1841
2367
  for (const line of describeClaimScoping(cfg, env2)) log(line);
1842
2368
  log(
1843
2369
  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
2370
  );
1845
- let lastSessionForward = 0;
1846
2371
  let lastWatchCycle = 0;
1847
2372
  const runWatch = makeWatchRunner({ client, log, maxFixAttempts: cfg.watchMaxFix });
2373
+ const loopTick = makeLoopTicks({ client, cfg, env: env2, log, getActive: () => active });
1848
2374
  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
- }
2375
+ loopTick();
1858
2376
  if (cfg.watchEnabled && Date.now() - lastWatchCycle >= cfg.watchIntervalSec * 1e3) {
1859
2377
  lastWatchCycle = Date.now();
1860
2378
  runWatch().then((r) => {