@algosuite/vo-mcp 0.2.0-beta.0 → 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
@@ -293,20 +351,30 @@ function createControlPlaneClient({
293
351
  },
294
352
  /**
295
353
  * Report this machine's rolling-7-day Claude Code token usage (the real
296
- * weekly-capacity gauge). The daemon authenticates as admin, so the target
297
- * `operatorId` is named explicitly. Best-effort; throws on a non-2xx so the
298
- * caller can log + move on. `tokens` = { input_tokens, output_tokens,
299
- * cache_creation_tokens, cache_read_tokens }.
354
+ * weekly-capacity gauge) PLUS the operator's real Claude weekly % (when
355
+ * available). The daemon authenticates as admin, so the target `operatorId`
356
+ * is named explicitly. Best-effort; throws on a non-2xx so the caller can
357
+ * log + move on.
358
+ *
359
+ * `tokens` = { input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens }.
360
+ * Optional: `claudeWeeklyPct` (number) + `claudeWeeklyResetsAt` (ISO string | null).
300
361
  */
301
- async postWeeklyTokens({ operatorId, runnerId, tokens }) {
302
- const res = await req("POST", "/api/v1/weekly-tokens", {
362
+ async postWeeklyTokens({ operatorId, runnerId, tokens, claudeWeeklyPct, claudeWeeklyResetsAt }) {
363
+ const body = {
303
364
  operator_id: operatorId,
304
365
  runner_id: runnerId,
305
366
  input_tokens: tokens.input_tokens,
306
367
  output_tokens: tokens.output_tokens,
307
368
  cache_creation_tokens: tokens.cache_creation_tokens,
308
369
  cache_read_tokens: tokens.cache_read_tokens
309
- });
370
+ };
371
+ if (typeof claudeWeeklyPct === "number") {
372
+ body.claude_weekly_pct = claudeWeeklyPct;
373
+ }
374
+ if (claudeWeeklyResetsAt !== void 0) {
375
+ body.claude_weekly_resets_at = claudeWeeklyResetsAt;
376
+ }
377
+ const res = await req("POST", "/api/v1/weekly-tokens", body);
310
378
  if (res.status === 401) {
311
379
  cachedFirebaseToken = null;
312
380
  throw new Error("weekly-tokens unauthorized (401)");
@@ -314,6 +382,52 @@ function createControlPlaneClient({
314
382
  if (!res.ok) throw new Error(`weekly-tokens failed: HTTP ${res.status}`);
315
383
  return true;
316
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
+ },
317
431
  /**
318
432
  * Read the operator's dispatch-mode config (Fast→Ultracode effort setting).
319
433
  * Returns the mode string ('fast'|'standard'|'deep'|'ultra'|'ultracode'),
@@ -335,6 +449,39 @@ function createControlPlaneClient({
335
449
  // ../../scripts/virtual-office/code-runner/claude-runner.mjs
336
450
  import { spawn } from "node:child_process";
337
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
338
485
  var DEFAULT_PERMISSION_MODE = "acceptEdits";
339
486
  function extractText(content) {
340
487
  if (typeof content === "string") return content.trim();
@@ -386,10 +533,11 @@ function buildClaudeArgs({ permissionMode = DEFAULT_PERMISSION_MODE, maxTurns, m
386
533
  }
387
534
  return args;
388
535
  }
389
- function runClaudeTask({
536
+ function runAgentTask({
537
+ runner,
390
538
  prompt,
391
539
  cwd,
392
- claudeBin = "claude",
540
+ bin = runner.binary,
393
541
  permissionMode,
394
542
  maxTurns,
395
543
  model,
@@ -402,16 +550,20 @@ function runClaudeTask({
402
550
  spawnImpl = spawn
403
551
  }) {
404
552
  return new Promise((resolve) => {
405
- const args = buildClaudeArgs({ permissionMode, maxTurns, model });
406
- const child = spawnImpl(claudeBin, args, {
553
+ const args = runner.buildArgs({ permissionMode, maxTurns, model, prompt });
554
+ const child = spawnImpl(bin, args, {
407
555
  cwd,
408
- 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,
409
561
  stdio: ["pipe", "pipe", "pipe"],
410
- // Windows: `claude` is a `.cmd` shim → spawn needs a shell to resolve it
411
- // (without it: spawn ENOENT). Safe because the prompt goes via stdin
412
- // below, never argv, so the shell never sees untrusted input.
413
- shell: process.platform === "win32",
414
- 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()
415
567
  });
416
568
  try {
417
569
  child.stdin.write(String(prompt));
@@ -446,7 +598,7 @@ function runClaudeTask({
446
598
  while ((nl = buffer.indexOf("\n")) >= 0) {
447
599
  const line = buffer.slice(0, nl);
448
600
  buffer = buffer.slice(nl + 1);
449
- const evt = parseStreamEvent(line);
601
+ const evt = runner.parseEvent(line);
450
602
  if (!evt) continue;
451
603
  if (evt.kind === "progress") {
452
604
  try {
@@ -499,7 +651,7 @@ function runClaudeTask({
499
651
  return;
500
652
  }
501
653
  if (!result.summary && code !== 0) {
502
- result.summary = stderrTail.slice(-500) || `claude exited ${code}`;
654
+ result.summary = stderrTail.slice(-500) || `${bin} exited ${code}`;
503
655
  }
504
656
  resolve({ ...result, ok: result.ok && code === 0 });
505
657
  });
@@ -521,6 +673,14 @@ var ClaudeRunner = class {
521
673
  windowsHide: true
522
674
  };
523
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
+ }
524
684
  /**
525
685
  * Best-effort auth check: is `claude` on PATH and can we verify login?
526
686
  * Never throws. If we can't cheaply detect auth, we return installed:true
@@ -564,6 +724,310 @@ var ClaudeRunner = class {
564
724
  };
565
725
  var claudeRunner = new ClaudeRunner();
566
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
+
567
1031
  // ../../scripts/virtual-office/code-runner/rate-limit-resume.mjs
568
1032
  import { appendFileSync, mkdirSync as mkdirSync2 } from "node:fs";
569
1033
  import { homedir as homedir2 } from "node:os";
@@ -664,9 +1128,9 @@ function classifyFailureForResume({
664
1128
  }
665
1129
 
666
1130
  // ../../scripts/virtual-office/code-runner/publish.mjs
667
- import { spawnSync as spawnSync3 } from "node:child_process";
668
- function run(cmd, args, cwd, { timeout = 18e4, raw = false } = {}) {
669
- 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 } : {} });
670
1134
  if (r.error) throw r.error;
671
1135
  if (r.status !== 0) {
672
1136
  throw new Error(`${cmd} ${args[0]} failed (exit ${r.status}): ${(r.stderr || "").slice(-300)}`);
@@ -725,6 +1189,44 @@ function listCommittedFiles(cwd, base = "origin/main") {
725
1189
  function compactTitle(s, max = 100) {
726
1190
  return String(s || "").replace(/\s+/g, " ").trim().slice(0, max) || "code-task";
727
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
+ }
728
1230
  function openCodeTaskPr(worktreeDir, files, {
729
1231
  title,
730
1232
  body,
@@ -734,7 +1236,12 @@ function openCodeTaskPr(worktreeDir, files, {
734
1236
  maxFiles = 200,
735
1237
  // Safety net: the agent already COMMITTED its work to a branch (despite the
736
1238
  // preamble). Skip add+commit; just push the existing branch and open the PR.
737
- 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
738
1245
  } = {}) {
739
1246
  if (!Array.isArray(files) || files.length === 0) {
740
1247
  throw new Error("openCodeTaskPr: no files to commit");
@@ -751,13 +1258,14 @@ function openCodeTaskPr(worktreeDir, files, {
751
1258
  branch = run("git", ["branch", "--show-current"], worktreeDir, { timeout: 3e4 });
752
1259
  } catch {
753
1260
  }
1261
+ let tokenUsed;
754
1262
  if (alreadyCommitted) {
755
1263
  if (!branch || branch === "main" || branch === "HEAD") {
756
1264
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
757
1265
  branch = `${branchPrefix}-${stamp}`;
758
1266
  run("git", ["checkout", "-b", branch], worktreeDir);
759
1267
  }
760
- run("git", ["push", "origin", branch], worktreeDir);
1268
+ tokenUsed = pushBranch(worktreeDir, branch, githubToken);
761
1269
  } else {
762
1270
  if (!branch || branch === "main" || branch === "HEAD") {
763
1271
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -770,7 +1278,7 @@ function openCodeTaskPr(worktreeDir, files, {
770
1278
  }
771
1279
  const commitMsg = compactTitle(title, 180);
772
1280
  run("git", ["commit", "--no-verify", "-m", commitMsg], worktreeDir);
773
- run("git", ["push", "origin", branch], worktreeDir);
1281
+ tokenUsed = pushBranch(worktreeDir, branch, githubToken);
774
1282
  }
775
1283
  const out = run(
776
1284
  "gh",
@@ -786,7 +1294,8 @@ function openCodeTaskPr(worktreeDir, files, {
786
1294
  "--body",
787
1295
  String(body || "")
788
1296
  ],
789
- worktreeDir
1297
+ worktreeDir,
1298
+ { env: tokenUsed ? installationTokenEnv(githubToken) : void 0 }
790
1299
  );
791
1300
  const m = out.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/);
792
1301
  if (!m) throw new Error("gh pr create returned no parseable PR URL");
@@ -973,14 +1482,37 @@ async function forwardSessionSpool(deps) {
973
1482
  return { forwarded, pruned };
974
1483
  }
975
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
+
976
1508
  // ../../scripts/virtual-office/code-runner/pr-watcher.mjs
977
1509
  import { homedir as homedir4 } from "node:os";
978
1510
  import { join as join4 } from "node:path";
979
1511
  import { readFile as readFile2, writeFile as writeFile2, mkdir } from "node:fs/promises";
980
- import { spawnSync as spawnSync4 } from "node:child_process";
1512
+ import { spawnSync as spawnSync6 } from "node:child_process";
981
1513
  var CI_FIX_MARKER = "[VO-CI-FIX]";
982
1514
  function ghViewPr(prNumber, repo) {
983
- const r = spawnSync4(
1515
+ const r = spawnSync6(
984
1516
  "gh",
985
1517
  ["pr", "view", String(prNumber), "-R", repo, "--json", "state,statusCheckRollup,headRefName"],
986
1518
  { encoding: "utf8", timeout: 3e4 }
@@ -1618,7 +2150,8 @@ var parseList = (s) => String(s || "").split(/[\s,]+/).map((x) => x.trim()).filt
1618
2150
  function loadConfig(env2 = process.env) {
1619
2151
  return {
1620
2152
  runnerId: env2.VO_CODE_RUNNER_ID || `vo-code-runner-${os.hostname()}`,
1621
- 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}`) }),
1622
2155
  permissionMode: env2.VO_CODE_RUNNER_PERMISSION_MODE || "acceptEdits",
1623
2156
  maxConcurrency: Math.max(1, Number(env2.VO_CODE_TASK_MAX_CONCURRENCY || 2) || 2),
1624
2157
  pollSec: Math.max(1, Number(env2.VO_CODE_RUNNER_POLL_SEC || 5) || 5),
@@ -1689,15 +2222,16 @@ async function processOneTask(client, task, cfg) {
1689
2222
  const id = task.code_task_id;
1690
2223
  let worktreeName = "";
1691
2224
  try {
1692
- const wt = createFixWorktree("code-task", { source: id.slice(0, 8) });
2225
+ const wt = createFixWorktree("code-task", { source: id.slice(0, 8), repo: task.repo });
1693
2226
  worktreeName = wt.worktreeName;
1694
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 }) });
1695
- 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})` });
1696
2229
  const cap = typeof task.max_budget_usd === "number" ? task.max_budget_usd : resolveSpendCapUsd();
1697
- const run2 = await runClaudeTask({
2230
+ const run2 = await runAgentTask({
2231
+ runner: cfg.runner,
2232
+ bin: cfg.runnerBin,
1698
2233
  prompt: effortPrompt,
1699
2234
  cwd: wt.worktreeDir,
1700
- claudeBin: cfg.claudeBin,
1701
2235
  permissionMode: effectivePermissionMode,
1702
2236
  maxTurns: effectiveMaxTurns,
1703
2237
  model,
@@ -1769,10 +2303,12 @@ async function processOneTask(client, task, cfg) {
1769
2303
  return;
1770
2304
  }
1771
2305
  await safeProgress(client, id, { message: `opening PR for ${files.length} changed file(s)` });
2306
+ const githubToken = (await client.getInstallationToken())?.token ?? null;
1772
2307
  const pr = openCodeTaskPr(wt.worktreeDir, files, {
1773
2308
  title: `code-task: ${task.prompt}`,
1774
2309
  body: buildPrBody(task, run2, files),
1775
- alreadyCommitted
2310
+ alreadyCommitted,
2311
+ githubToken
1776
2312
  });
1777
2313
  await safeProgress(client, id, {
1778
2314
  status: "pr_opened",
@@ -1826,25 +2362,17 @@ async function main({ env: env2 = process.env, once: once2 = false } = {}) {
1826
2362
  log
1827
2363
  });
1828
2364
  log(
1829
- `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})`
1830
2366
  );
1831
2367
  for (const line of describeClaimScoping(cfg, env2)) log(line);
1832
2368
  log(
1833
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)"
1834
2370
  );
1835
- let lastSessionForward = 0;
1836
2371
  let lastWatchCycle = 0;
1837
2372
  const runWatch = makeWatchRunner({ client, log, maxFixAttempts: cfg.watchMaxFix });
2373
+ const loopTick = makeLoopTicks({ client, cfg, env: env2, log, getActive: () => active });
1838
2374
  while (!stopping) {
1839
- if (cfg.sessionForwardSec > 0 && Date.now() - lastSessionForward >= cfg.sessionForwardSec * 1e3) {
1840
- lastSessionForward = Date.now();
1841
- forwardSessionSpool({
1842
- baseUrl: String(env2.VO_CONTROL_PLANE_URL || "").replace(/\/$/, ""),
1843
- token: env2.VO_CONTROL_PLANE_ADMIN_TOKEN || "",
1844
- operatorSeed: cfg.operatorSeed
1845
- }).catch(() => {
1846
- });
1847
- }
2375
+ loopTick();
1848
2376
  if (cfg.watchEnabled && Date.now() - lastWatchCycle >= cfg.watchIntervalSec * 1e3) {
1849
2377
  lastWatchCycle = Date.now();
1850
2378
  runWatch().then((r) => {
@@ -1888,7 +2416,11 @@ async function main({ env: env2 = process.env, once: once2 = false } = {}) {
1888
2416
  if (controlServer) controlServer.close();
1889
2417
  log("stopped");
1890
2418
  }
1891
- var invokedDirectly = process.argv[1] && fileURLToPath2(import.meta.url) === process.argv[1];
2419
+ 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
2420
+ // module is inlined into a bundle (e.g. @algosuite/vo-mcp's dist/runner-cli.js,
2421
+ // which calls main() itself — without this, `node dist/runner-cli.js` would
2422
+ // start a SECOND daemon loop and double-claim tasks).
2423
+ import.meta.url.endsWith("code-runner-daemon.mjs");
1892
2424
  if (invokedDirectly) {
1893
2425
  const once2 = process.argv.includes("--once");
1894
2426
  main({ once: once2 }).catch((err) => {