cubefarm 0.3.6 → 0.3.8

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.
@@ -1,7 +1,7 @@
1
1
  // server/index.ts
2
2
  import http2 from "node:http";
3
- import path13 from "node:path";
4
- import fs11 from "node:fs";
3
+ import path14 from "node:path";
4
+ import fs12 from "node:fs";
5
5
  import express from "express";
6
6
  import { WebSocketServer } from "ws";
7
7
 
@@ -17,6 +17,7 @@ var DEMO = process.argv.includes("--demo") || process.env.SWARM_DEMO === "1" ||
17
17
  var STATE_FILE = path.join(HOME_DIR, DEMO ? "demo-state.json" : "state.json");
18
18
  var SYNC_INTERVAL_MS = 45e3;
19
19
  var SCHEDULER_INTERVAL_MS = 8e3;
20
+ var DESK_SWEEP_INTERVAL_MS = 30 * 6e4;
20
21
  var LOG_BUFFER = 600;
21
22
  function defaultProjectsDir(appDir, home = os.homedir()) {
22
23
  if (!appDir.split(/[\\/]/).includes("node_modules")) return path.resolve(appDir, "..");
@@ -162,6 +163,9 @@ async function issueState(fullName, number) {
162
163
  return null;
163
164
  }
164
165
  }
166
+ async function closeIssue(fullName, number, comment) {
167
+ await gh(["issue", "close", String(number), "-R", fullName, "--reason", "completed", "--comment", comment]);
168
+ }
165
169
  async function editIssue(fullName, number, edit) {
166
170
  const args = ["issue", "edit", String(number), "-R", fullName];
167
171
  if (edit.body !== void 0) args.push("--body-file", "-");
@@ -296,6 +300,33 @@ function npmInstall(dir) {
296
300
  installs.set(dir, next);
297
301
  return next;
298
302
  }
303
+ function porcelainPaths(porcelain) {
304
+ return porcelain.split(/\r?\n/).map((line) => /^([ MTADRCU?!]{1,2}) (.+)$/.exec(line)).filter((m) => m !== null).map(([, xy, file]) => /[RC]/.test(xy) ? /^(?:"(?:[^"\\]|\\.)*"|.*?) -> (.+)$/.exec(file)?.[1] ?? file : file);
305
+ }
306
+ function overwrittenPaths(stderr) {
307
+ const lines = stderr.split(/\r?\n/);
308
+ const start = lines.findIndex((l) => /would be overwritten by/.test(l));
309
+ if (start < 0) return [];
310
+ const files = [];
311
+ for (const line of lines.slice(start + 1)) {
312
+ if (!line.startsWith(" ")) break;
313
+ files.push(line.trim());
314
+ }
315
+ return files.filter(Boolean);
316
+ }
317
+ function fileList(files, max = 3, maxChars = 120) {
318
+ const shown = [];
319
+ let length = 0;
320
+ for (const file of files.slice(0, max)) {
321
+ const name = file.length > maxChars ? `\u2026${file.slice(-(maxChars - 1))}` : file;
322
+ const add = name.length + (shown.length ? 2 : 0);
323
+ if (shown.length && length + add > maxChars) break;
324
+ shown.push(name);
325
+ length += add;
326
+ }
327
+ const more = files.length - shown.length;
328
+ return `${shown.join(", ")}${more ? ` +${more} more` : ""}`;
329
+ }
299
330
  async function syncMain(fullName, defaultBranch, opts) {
300
331
  const dir = mainDir(fullName);
301
332
  const result = await withRepoLock(fullName, async () => {
@@ -313,12 +344,17 @@ async function syncMain(fullName, defaultBranch, opts) {
313
344
  if (behind === 0) return stays("in sync");
314
345
  if (Number(await g(["rev-list", "--count", `${target}..HEAD`])) > 0) return stays(`diverged: local commits, and ${commits} to pull`);
315
346
  if (!opts.touch) return { status: `update ready (${commits})`, behind, updatable: true };
316
- if (await g(["status", "--porcelain", "--untracked-files=no"])) return { status: `${behind} behind: local changes`, behind, updatable: true };
347
+ const dirty = await g(["status", "--porcelain", "--untracked-files=no"]);
348
+ if (dirty) {
349
+ const files = porcelainPaths(dirty);
350
+ return { status: `${behind} behind: local changes${files.length ? ` in ${fileList(files)}` : ""}`, behind, updatable: true };
351
+ }
317
352
  const before = await g(["rev-parse", "HEAD"]);
318
353
  try {
319
354
  await g(["merge", "--ff-only", target], 12e4);
320
- } catch {
321
- return { status: `${behind} behind: local files are in the way`, behind, updatable: true };
355
+ } catch (err) {
356
+ const files = overwrittenPaths(err.stderr ?? "");
357
+ return { status: `${behind} behind: local files are in the way${files.length ? `: ${fileList(files)}` : ""}`, behind, updatable: true };
322
358
  }
323
359
  const changed = (await g(["diff", "--name-only", before, "HEAD"])).split(/\r?\n/);
324
360
  const install = changed.some((f) => f === "package.json" || f === "package-lock.json") && await exists(path2.join(dir, "package.json"));
@@ -463,9 +499,8 @@ function prepareDesk(fullName, base, agentSlug, branch) {
463
499
  return wt;
464
500
  });
465
501
  }
466
- function removeDesk(fullName, agentSlug) {
502
+ function removeDesk(fullName, agentSlug, main = mainDir(fullName)) {
467
503
  return withRepoLock(fullName, async () => {
468
- const main = mainDir(fullName);
469
504
  const wt = deskDir(fullName, agentSlug);
470
505
  if (!await exists(wt)) return;
471
506
  await git(["worktree", "remove", "--force", wt], { cwd: main }).catch(() => void 0);
@@ -473,6 +508,121 @@ function removeDesk(fullName, agentSlug) {
473
508
  await git(["worktree", "prune"], { cwd: main }).catch(() => void 0);
474
509
  });
475
510
  }
511
+ var OLD_HOMES = [".office-swarm"];
512
+ var OFFICE_BRANCH = /^(swarm\/issue-|qa\/pr-)/;
513
+ function parseWorktrees(porcelain) {
514
+ const out = [];
515
+ for (const line of porcelain.split(/\r?\n/)) {
516
+ if (line.startsWith("worktree ")) out.push({ path: path2.resolve(line.slice(9)), branch: null, locked: false });
517
+ else if (!out.length) continue;
518
+ else if (line.startsWith("branch ")) out[out.length - 1].branch = line.slice(7).replace(/^refs\/heads\//, "");
519
+ else if (line === "locked" || line.startsWith("locked ")) out[out.length - 1].locked = true;
520
+ }
521
+ return out;
522
+ }
523
+ var samePath = (a, b) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
524
+ function isOfficeDesk(p, fullName, root) {
525
+ const desks = path2.dirname(p);
526
+ const repo = path2.dirname(desks);
527
+ const workspaces = path2.dirname(repo);
528
+ if (!samePath(path2.basename(desks), "desks") || !samePath(path2.basename(repo), fullName.replace("/", "__"))) return false;
529
+ if (samePath(workspaces, path2.resolve(root))) return true;
530
+ return path2.basename(workspaces) === "workspaces" && OLD_HOMES.some((h) => samePath(path2.basename(path2.dirname(workspaces)), h));
531
+ }
532
+ function planSweep(input, keep) {
533
+ const deskRoot = path2.join(path2.resolve(input.root), input.fullName.replace("/", "__"), "desks");
534
+ const kept = (p) => keep.desks.some((slug) => samePath(p, path2.join(deskRoot, slug)));
535
+ const worktrees = input.worktrees.slice(1).filter((w) => !w.locked && isOfficeDesk(w.path, input.fullName, input.root) && !kept(w.path));
536
+ const stays = input.worktrees.filter((w) => !worktrees.includes(w));
537
+ const folders = input.folders.filter((name) => {
538
+ const p = path2.join(deskRoot, name);
539
+ return !kept(p) && !input.worktrees.some((w) => samePath(w.path, p));
540
+ });
541
+ const branches = input.branches.filter((b) => OFFICE_BRANCH.test(b) && !keep.branches.includes(b) && !stays.some((w) => w.branch === b));
542
+ return { worktrees, folders, branches };
543
+ }
544
+ async function inUse(dir) {
545
+ const probe = `${dir}.sweep`;
546
+ try {
547
+ await fs2.rename(dir, probe);
548
+ } catch (err) {
549
+ return err.code !== "ENOENT";
550
+ }
551
+ await fs2.rename(probe, dir);
552
+ return false;
553
+ }
554
+ var stamp = (d) => `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
555
+ async function saveLeftovers(fullName, desk) {
556
+ const g = (args) => git(args, { cwd: desk, timeoutMs: 6e4 });
557
+ if (!await g(["rev-parse", "--verify", "-q", "HEAD"]).catch(() => "")) return null;
558
+ const dirty = await g(["status", "--porcelain", "--untracked-files=no"]);
559
+ const commits = Number(await g(["rev-list", "--count", "HEAD", "--not", "--remotes"]));
560
+ if (!dirty && !commits) return null;
561
+ const dir = path2.join(HOME_DIR, "leftovers", fullName.replace("/", "__"));
562
+ await fs2.mkdir(dir, { recursive: true });
563
+ const tmp = await fs2.mkdtemp(path2.join(dir, ".tmp-"));
564
+ try {
565
+ if (commits) await g(["format-patch", "-q", "-o", tmp, "HEAD", "--not", "--remotes"]);
566
+ if (dirty) await g(["diff", "HEAD", "--binary", `--output=${path2.join(tmp, "zz-uncommitted.patch")}`]);
567
+ const parts = (await fs2.readdir(tmp)).sort();
568
+ const base = `${path2.basename(desk)}-${stamp(/* @__PURE__ */ new Date())}`;
569
+ let file = path2.join(dir, `${base}.patch`);
570
+ for (let n = 2; await exists(file); n++) file = path2.join(dir, `${base}-${n}.patch`);
571
+ await fs2.writeFile(file, Buffer.concat(await Promise.all(parts.map((p) => fs2.readFile(path2.join(tmp, p))))));
572
+ return file;
573
+ } finally {
574
+ await removeDir(tmp).catch(() => void 0);
575
+ }
576
+ }
577
+ function sweepDesks(fullName, keep) {
578
+ return withRepoLock(fullName, async () => {
579
+ const result = { desks: 0, folders: 0, branches: 0, patches: [], skipped: [] };
580
+ const main = mainDir(fullName);
581
+ if (!await exists(path2.join(main, ".git"))) return result;
582
+ const g = (args) => git(args, { cwd: main, timeoutMs: 6e4 });
583
+ await g(["worktree", "prune"]);
584
+ const real = (p) => fs2.realpath(p).catch(() => p);
585
+ const worktrees = await Promise.all(parseWorktrees(await g(["worktree", "list", "--porcelain"])).map(async (w) => ({ ...w, path: await real(w.path) })));
586
+ const refs = await g(["for-each-ref", "--format=%(refname)", "refs/heads/swarm", "refs/heads/qa"]);
587
+ const branches = refs.split(/\r?\n/).filter(Boolean).map((r) => r.replace(/^refs\/heads\//, ""));
588
+ const root = await real(WORKSPACE_ROOT);
589
+ const deskRoot = path2.join(root, fullName.replace("/", "__"), "desks");
590
+ const folders = (await fs2.readdir(deskRoot, { withFileTypes: true }).catch(() => [])).filter((e) => e.isDirectory()).map((e) => e.name);
591
+ const plan = planSweep({ fullName, root, worktrees, branches, folders }, keep);
592
+ const held = /* @__PURE__ */ new Set();
593
+ for (const wt of plan.worktrees) {
594
+ try {
595
+ if (await inUse(wt.path)) throw new Error("in use");
596
+ const patch = await saveLeftovers(fullName, wt.path);
597
+ if (patch) result.patches.push(patch);
598
+ await g(["worktree", "remove", "--force", wt.path]).catch(() => void 0);
599
+ await removeDir(wt.path);
600
+ result.desks++;
601
+ } catch {
602
+ result.skipped.push(wt.path);
603
+ if (wt.branch) held.add(wt.branch);
604
+ }
605
+ }
606
+ await g(["worktree", "prune"]).catch(() => void 0);
607
+ for (const name of plan.folders) {
608
+ const dir = path2.join(deskRoot, name);
609
+ try {
610
+ if (await inUse(dir)) throw new Error("in use");
611
+ await removeDir(dir);
612
+ result.folders++;
613
+ } catch {
614
+ result.skipped.push(dir);
615
+ }
616
+ }
617
+ const doomed = plan.branches.filter((b) => !held.has(b));
618
+ for (let i = 0; i < doomed.length; i += 40) await g(["branch", "-D", ...doomed.slice(i, i + 40)]).catch(() => void 0);
619
+ if (doomed.length) {
620
+ const left = new Set((await g(["for-each-ref", "--format=%(refname)", "refs/heads/swarm", "refs/heads/qa"])).split(/\r?\n/));
621
+ result.branches = doomed.filter((b) => !left.has(`refs/heads/${b}`)).length;
622
+ }
623
+ return result;
624
+ });
625
+ }
476
626
  var LAUNCHERS = ["node.exe", "cmd.exe", "bash.exe", "sh.exe", "conhost.exe", "python.exe", "npm.exe", "npx.exe", "bun.exe", "deno.exe"];
477
627
  var pathForms = (p) => [.../* @__PURE__ */ new Set([p, p.replaceAll("\\", "/"), p.replaceAll("\\", "\\\\")])];
478
628
  function leftoversInDesk(listing, desk, keep) {
@@ -537,9 +687,46 @@ Write-Output $kill.Count`;
537
687
  }
538
688
 
539
689
  // server/agentRunner.ts
690
+ import fs4 from "node:fs";
691
+ import path4 from "node:path";
692
+ import { query } from "@anthropic-ai/claude-agent-sdk";
693
+
694
+ // server/browser.ts
540
695
  import fs3 from "node:fs";
696
+ import { createRequire } from "node:module";
697
+ import os2 from "node:os";
541
698
  import path3 from "node:path";
542
- import { query } from "@anthropic-ai/claude-agent-sdk";
699
+ function playwrightMcpCli() {
700
+ try {
701
+ const pkgFile = createRequire(import.meta.url).resolve("@playwright/mcp/package.json");
702
+ const bin = JSON.parse(fs3.readFileSync(pkgFile, "utf8")).bin?.["playwright-mcp"] ?? "cli.js";
703
+ return path3.join(path3.dirname(pkgFile), bin);
704
+ } catch {
705
+ return null;
706
+ }
707
+ }
708
+ function playwrightMcpCommand(cli, initScript, outputDir, node = process.execPath) {
709
+ return { command: node, args: [cli, "--headless", "--isolated", ...outputDir ? ["--output-dir", outputDir] : [], "--init-script", initScript] };
710
+ }
711
+ function playwrightMcp(outputDir) {
712
+ const cli = playwrightMcpCli();
713
+ return cli ? playwrightMcpCommand(cli, path3.join(import.meta.dirname, "browser-init.js"), outputDir) : null;
714
+ }
715
+ function chromePaths(platform = process.platform, env = process.env, home = os2.homedir()) {
716
+ if (platform === "win32") {
717
+ return [env.LOCALAPPDATA, env.PROGRAMFILES, env["PROGRAMFILES(X86)"]].filter((d) => !!d).map((d) => path3.win32.join(d, "Google", "Chrome", "Application", "chrome.exe"));
718
+ }
719
+ if (platform === "darwin") return ["/Applications/Google Chrome.app", path3.posix.join(home, "Applications", "Google Chrome.app")];
720
+ return ["/opt/google/chrome/chrome", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable"];
721
+ }
722
+ var hasChrome = () => chromePaths().some((p) => fs3.existsSync(p));
723
+ function browserProblem(mcp, chrome = hasChrome()) {
724
+ if (!mcp) return "\u26A0 The browser tool (@playwright/mcp) is not installed with cubefarm: reinstall it, or run npx cubefarm doctor";
725
+ if (!chrome) return "\u26A0 Google Chrome not found, so this session cannot test in a browser: install it, or run npx cubefarm doctor";
726
+ return null;
727
+ }
728
+
729
+ // server/agentRunner.ts
543
730
  var InputQueue = class {
544
731
  items = [];
545
732
  waiters = [];
@@ -571,7 +758,7 @@ function screenshotFile(cwd, text) {
571
758
  const file = text.match(/\]\(([^)\s]+\.(png|jpe?g))\)/i);
572
759
  if (!file) return null;
573
760
  try {
574
- return { data: fs3.readFileSync(path3.resolve(cwd, file[1])), mime: /png$/i.test(file[2]) ? "image/png" : "image/jpeg" };
761
+ return { data: fs4.readFileSync(path4.resolve(cwd, file[1])), mime: /png$/i.test(file[2]) ? "image/png" : "image/jpeg" };
575
762
  } catch {
576
763
  return null;
577
764
  }
@@ -580,17 +767,17 @@ var IMAGE_MIME = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jp
580
767
  function newScreenshots(dir, seen, now2 = Date.now()) {
581
768
  let names;
582
769
  try {
583
- names = fs3.readdirSync(dir);
770
+ names = fs4.readdirSync(dir);
584
771
  } catch {
585
772
  return [];
586
773
  }
587
774
  const found2 = [];
588
775
  for (const name of names.sort()) {
589
- const mime = IMAGE_MIME[path3.extname(name).toLowerCase()];
590
- const file = path3.join(dir, name);
776
+ const mime = IMAGE_MIME[path4.extname(name).toLowerCase()];
777
+ const file = path4.join(dir, name);
591
778
  if (!mime || seen.has(name)) continue;
592
779
  try {
593
- if (now2 - fs3.statSync(file).mtimeMs < 1e3) continue;
780
+ if (now2 - fs4.statSync(file).mtimeMs < 1e3) continue;
594
781
  } catch {
595
782
  continue;
596
783
  }
@@ -602,7 +789,7 @@ var clip = (s, n) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
602
789
  function rel(cwd, p) {
603
790
  const s = String(p ?? "");
604
791
  if (!s) return "";
605
- const r = path3.relative(cwd, path3.resolve(cwd, s));
792
+ const r = path4.relative(cwd, path4.resolve(cwd, s));
606
793
  return r && !r.startsWith("..") ? r.replaceAll("\\", "/") : s;
607
794
  }
608
795
  var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -729,8 +916,10 @@ function startSession(opts, callbacks) {
729
916
  env.CLAUDE_AGENT_SDK_CLIENT_APP = `cubefarm/${VERSION}`;
730
917
  const mcpServers = {};
731
918
  if (opts.browserTesting) {
732
- const args = ["-y", "@playwright/mcp@latest", "--headless", "--isolated", "--init-script", path3.join(import.meta.dirname, "browser-init.js")];
733
- mcpServers.playwright = process.platform === "win32" ? { command: "cmd", args: ["/c", "npx", ...args] } : { command: "npx", args };
919
+ const browser = playwrightMcp();
920
+ if (browser) mcpServers.playwright = browser;
921
+ const problem = browserProblem(browser);
922
+ if (problem) cb.log([{ kind: "error", text: problem }]);
734
923
  }
735
924
  if (opts.office) mcpServers.office = opts.office.server;
736
925
  const disallowedTools = ["AskUserQuestion", "EnterPlanMode", "ExitPlanMode"];
@@ -903,15 +1092,15 @@ function startSession(opts, callbacks) {
903
1092
 
904
1093
  // server/cliRunner.ts
905
1094
  import crypto3 from "node:crypto";
906
- import fs6 from "node:fs";
907
- import path7 from "node:path";
1095
+ import fs7 from "node:fs";
1096
+ import path8 from "node:path";
908
1097
  import { pathToFileURL } from "node:url";
909
1098
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
910
1099
 
911
1100
  // server/clis.ts
912
- import fs4 from "node:fs";
913
- import { createRequire } from "node:module";
914
- import path4 from "node:path";
1101
+ import fs5 from "node:fs";
1102
+ import { createRequire as createRequire2 } from "node:module";
1103
+ import path5 from "node:path";
915
1104
  var CLIS = [
916
1105
  { id: "claude", label: "Claude Code", command: "claude", integrated: true },
917
1106
  { id: "codex", label: "Codex", command: "codex", integrated: false },
@@ -922,13 +1111,13 @@ var cliLabel = (id) => CLIS.find((c) => c.id === id)?.label ?? id;
922
1111
  var WIN = process.platform === "win32";
923
1112
  function resolveCommand(name, env = process.env) {
924
1113
  const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH";
925
- const dirs = (env[key] ?? "").split(path4.delimiter).filter(Boolean);
1114
+ const dirs = (env[key] ?? "").split(path5.delimiter).filter(Boolean);
926
1115
  const exts = WIN ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((e) => /^\.(exe|cmd|bat|com)$/i.test(e)) : [""];
927
1116
  for (const dir of dirs) {
928
1117
  for (const ext of exts) {
929
- const file = path4.join(dir, name + ext.toLowerCase());
1118
+ const file = path5.join(dir, name + ext.toLowerCase());
930
1119
  try {
931
- const st = fs4.statSync(file);
1120
+ const st = fs5.statSync(file);
932
1121
  if (st.isFile() && (WIN || (st.mode & 73) !== 0)) return file;
933
1122
  } catch {
934
1123
  }
@@ -939,16 +1128,16 @@ function resolveCommand(name, env = process.env) {
939
1128
  function unwrapCmdShim(shimPath, text) {
940
1129
  const line = text.split(/\r?\n/).find((l) => /%\*\s*$/.test(l));
941
1130
  if (!line) return null;
942
- const dir = path4.dirname(shimPath);
943
- const targets = [...line.matchAll(/"%dp0%\\([^"]+)"/g)].map((m) => path4.join(dir, m[1]));
1131
+ const dir = path5.dirname(shimPath);
1132
+ const targets = [...line.matchAll(/"%dp0%\\([^"]+)"/g)].map((m) => path5.join(dir, m[1]));
944
1133
  const target = targets[targets.length - 1];
945
1134
  if (!target) return null;
946
1135
  if (/\.exe$/i.test(target)) return { file: target, args: [] };
947
1136
  if (!/\.[cm]?js$/i.test(target)) return null;
948
1137
  const between = line.slice(line.indexOf('"%_prog%"') + '"%_prog%"'.length, line.lastIndexOf(`"%dp0%\\`));
949
1138
  const nodeFlags = line.includes('"%_prog%"') ? between.trim().split(/\s+/).filter((f) => f.startsWith("--")) : [];
950
- const localNode = path4.join(dir, "node.exe");
951
- return { file: fs4.existsSync(localNode) ? localNode : process.execPath, args: [...nodeFlags, target] };
1139
+ const localNode = path5.join(dir, "node.exe");
1140
+ return { file: fs5.existsSync(localNode) ? localNode : process.execPath, args: [...nodeFlags, target] };
952
1141
  }
953
1142
  var bundled;
954
1143
  function bundledClaude() {
@@ -956,7 +1145,7 @@ function bundledClaude() {
956
1145
  bundled = null;
957
1146
  let sdk;
958
1147
  try {
959
- sdk = createRequire(import.meta.url).resolve("@anthropic-ai/claude-agent-sdk");
1148
+ sdk = createRequire2(import.meta.url).resolve("@anthropic-ai/claude-agent-sdk");
960
1149
  } catch {
961
1150
  return null;
962
1151
  }
@@ -966,7 +1155,7 @@ function bundledClaude() {
966
1155
  const targets = platform === "linux" ? musl ? [`linux-${arch}-musl`, `linux-${arch}`] : [`linux-${arch}`, `linux-${arch}-musl`] : [`${platform}-${arch}`];
967
1156
  for (const target of targets) {
968
1157
  try {
969
- bundled = createRequire(sdk).resolve(`@anthropic-ai/claude-agent-sdk-${target}/claude${WIN ? ".exe" : ""}`);
1158
+ bundled = createRequire2(sdk).resolve(`@anthropic-ai/claude-agent-sdk-${target}/claude${WIN ? ".exe" : ""}`);
970
1159
  return bundled;
971
1160
  } catch {
972
1161
  }
@@ -981,7 +1170,7 @@ function commandFor(id) {
981
1170
  if (!found2) return null;
982
1171
  if (WIN && /\.(cmd|bat)$/i.test(found2)) {
983
1172
  try {
984
- return unwrapCmdShim(found2, fs4.readFileSync(found2, "utf8")) ?? { file: found2, args: [] };
1173
+ return unwrapCmdShim(found2, fs5.readFileSync(found2, "utf8")) ?? { file: found2, args: [] };
985
1174
  } catch {
986
1175
  return { file: found2, args: [] };
987
1176
  }
@@ -1183,18 +1372,18 @@ export default async function CubefarmPlugin({ client } = {}) {
1183
1372
 
1184
1373
  // server/ptyClient.ts
1185
1374
  import crypto2 from "node:crypto";
1186
- import fs5 from "node:fs";
1375
+ import fs6 from "node:fs";
1187
1376
  import net from "node:net";
1188
- import path6 from "node:path";
1377
+ import path7 from "node:path";
1189
1378
  import { spawn } from "node:child_process";
1190
1379
 
1191
1380
  // server/ptyProtocol.ts
1192
1381
  import crypto from "node:crypto";
1193
- import path5 from "node:path";
1382
+ import path6 from "node:path";
1194
1383
  var PTY_PROTOCOL = 1;
1195
1384
  function hostSocket(home, platform = process.platform) {
1196
- if (platform !== "win32") return path5.join(home, "pty.sock");
1197
- const tag = crypto.createHash("sha1").update(path5.resolve(home).toLowerCase()).digest("hex").slice(0, 12);
1385
+ if (platform !== "win32") return path6.join(home, "pty.sock");
1386
+ const tag = crypto.createHash("sha1").update(path6.resolve(home).toLowerCase()).digest("hex").slice(0, 12);
1198
1387
  return `\\\\.\\pipe\\cubefarm-pty-${tag}`;
1199
1388
  }
1200
1389
  var encode = (m) => `${JSON.stringify(m)}
@@ -1325,7 +1514,7 @@ function connect(socket, secret) {
1325
1514
  }
1326
1515
  var runtimeFlags = () => process.execArgv.filter((a, i, all) => !/^--(inspect|watch|debug)/.test(a) && !/^--(inspect|watch|debug)/.test(all[i - 1] ?? ""));
1327
1516
  function launch(socket, secretFile) {
1328
- const entry = path6.join(import.meta.dirname, `ptyHost${path6.extname(import.meta.filename)}`);
1517
+ const entry = path7.join(import.meta.dirname, `ptyHost${path7.extname(import.meta.filename)}`);
1329
1518
  spawn(process.execPath, [...runtimeFlags(), entry, "launch", socket, secretFile], { detached: true, stdio: "ignore", windowsHide: true }).once("error", () => void 0).unref();
1330
1519
  }
1331
1520
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -1333,17 +1522,17 @@ async function startKeeper(home, onHook) {
1333
1522
  hookHandler = onHook;
1334
1523
  if (!nodePty) return [];
1335
1524
  const socket = hostSocket(home);
1336
- const secretFile = path6.join(home, "pty.secret");
1525
+ const secretFile = path7.join(home, "pty.secret");
1337
1526
  try {
1338
- if (!fs5.existsSync(secretFile)) {
1339
- fs5.mkdirSync(home, { recursive: true });
1340
- fs5.writeFileSync(secretFile, crypto2.randomBytes(24).toString("hex"), { mode: 384 });
1527
+ if (!fs6.existsSync(secretFile)) {
1528
+ fs6.mkdirSync(home, { recursive: true });
1529
+ fs6.writeFileSync(secretFile, crypto2.randomBytes(24).toString("hex"), { mode: 384 });
1341
1530
  }
1342
1531
  } catch (err) {
1343
1532
  console.warn(` terminal keeper unavailable: ${err.message}`);
1344
1533
  return [];
1345
1534
  }
1346
- const secret = fs5.readFileSync(secretFile, "utf8").trim();
1535
+ const secret = fs6.readFileSync(secretFile, "utf8").trim();
1347
1536
  let hello = await connect(socket, secret);
1348
1537
  if (hello && hello.ready.version !== PTY_PROTOCOL) {
1349
1538
  console.log(` replacing the terminal keeper (v${hello.ready.version}); the ${hello.ready.ptys.length} terminal(s) it held are stopped`);
@@ -1423,8 +1612,8 @@ function killTree(proc) {
1423
1612
  }
1424
1613
 
1425
1614
  // server/cliRunner.ts
1426
- var SESSIONS_DIR = path7.join(HOME_DIR, "sessions");
1427
- var BIN_DIR = path7.join(HOME_DIR, "bin");
1615
+ var SESSIONS_DIR = path8.join(HOME_DIR, "sessions");
1616
+ var BIN_DIR = path8.join(HOME_DIR, "bin");
1428
1617
  var FINISH_GRACE_MS = 3e3;
1429
1618
  var BOOT_MS = 6e4;
1430
1619
  var USAGE_WARN_PCT = 90;
@@ -1462,19 +1651,15 @@ async function handleMcp(token, req, res) {
1462
1651
  var helpersWritten = false;
1463
1652
  function writeHelpers() {
1464
1653
  if (helpersWritten) return;
1465
- fs6.mkdirSync(BIN_DIR, { recursive: true });
1466
- fs6.writeFileSync(path7.join(BIN_DIR, "statusline.cjs"), STATUSLINE_SOURCE);
1467
- fs6.writeFileSync(path7.join(BIN_DIR, "notify.cjs"), NOTIFY_SOURCE);
1468
- fs6.writeFileSync(path7.join(BIN_DIR, "codex-hook.cjs"), CODEX_HOOK_SOURCE);
1469
- fs6.writeFileSync(path7.join(BIN_DIR, "opencode-plugin.mjs"), OPENCODE_PLUGIN_SOURCE);
1654
+ fs7.mkdirSync(BIN_DIR, { recursive: true });
1655
+ fs7.writeFileSync(path8.join(BIN_DIR, "statusline.cjs"), STATUSLINE_SOURCE);
1656
+ fs7.writeFileSync(path8.join(BIN_DIR, "notify.cjs"), NOTIFY_SOURCE);
1657
+ fs7.writeFileSync(path8.join(BIN_DIR, "codex-hook.cjs"), CODEX_HOOK_SOURCE);
1658
+ fs7.writeFileSync(path8.join(BIN_DIR, "opencode-plugin.mjs"), OPENCODE_PLUGIN_SOURCE);
1470
1659
  helpersWritten = true;
1471
1660
  }
1472
1661
  var shellArg = (p) => `"${p.replaceAll("\\", "/")}"`;
1473
1662
  var HOOK_EVENTS = ["UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "Stop", "StopFailure", "Notification"];
1474
- function playwrightServer(outputDir) {
1475
- const args = ["-y", "@playwright/mcp@latest", "--headless", "--isolated", "--output-dir", outputDir, "--init-script", path7.join(import.meta.dirname, "browser-init.js")];
1476
- return process.platform === "win32" ? { command: "cmd", args: ["/c", "npx", ...args] } : { command: "npx", args };
1477
- }
1478
1663
  function toolOutput(response) {
1479
1664
  const images = [];
1480
1665
  const blocks = Array.isArray(response) ? response : Array.isArray(response?.content) ? response.content : null;
@@ -1521,7 +1706,7 @@ function endLive(live) {
1521
1706
  live.proc?.kill();
1522
1707
  live.proc = null;
1523
1708
  live.term.note(`\u2500\u2500 ${cliLabel(live.cli)} session ended \u2500\u2500`);
1524
- fs6.rm(live.dir, { recursive: true, force: true, maxRetries: 5 }, () => void 0);
1709
+ fs7.rm(live.dir, { recursive: true, force: true, maxRetries: 5 }, () => void 0);
1525
1710
  return live.cli === "codex" && live.resumeId ? codexThread("archive", live.resumeId, gone) : Promise.resolve();
1526
1711
  }
1527
1712
  var metaOf = (l) => ({ agentId: l.agentId, cli: l.cli, token: l.token, dir: l.dir, resumeId: l.resumeId, busy: !!l.session });
@@ -1605,7 +1790,7 @@ function startCliSession(opts, callbacks) {
1605
1790
  const label = cliLabel(cli);
1606
1791
  const token = crypto3.randomUUID();
1607
1792
  const sessionId = opts.resumeSessionId ?? crypto3.randomUUID();
1608
- const dir = path7.join(SESSIONS_DIR, token);
1793
+ const dir = path8.join(SESSIONS_DIR, token);
1609
1794
  const started = Date.now();
1610
1795
  const statusLine = `\u{1F3E2} cubefarm${opts.label ? ` \xB7 ${opts.label}` : ""}`;
1611
1796
  const toolNames = /* @__PURE__ */ new Map();
@@ -1633,10 +1818,10 @@ function startCliSession(opts, callbacks) {
1633
1818
  const collectShots = (all = false) => {
1634
1819
  const l2 = live;
1635
1820
  if (!l2 || cli === "claude" || !opts.browserTesting) return;
1636
- for (const shot of newScreenshots(path7.join(l2.dir, "browser"), l2.shots, all ? Infinity : Date.now())) {
1821
+ for (const shot of newScreenshots(path8.join(l2.dir, "browser"), l2.shots, all ? Infinity : Date.now())) {
1637
1822
  l2.shots.add(shot.name);
1638
1823
  try {
1639
- cb.screenshot(fs6.readFileSync(shot.file), shot.mime);
1824
+ cb.screenshot(fs7.readFileSync(shot.file), shot.mime);
1640
1825
  } catch {
1641
1826
  }
1642
1827
  }
@@ -1707,7 +1892,7 @@ function startCliSession(opts, callbacks) {
1707
1892
  const event = String(b.hook_event_name ?? "");
1708
1893
  const sub = typeof b.agent_id === "string" && b.agent_id !== "";
1709
1894
  if (cli === "claude" && !sub && b.session_id !== kept) {
1710
- const id = resumableSession(b, fs6.existsSync);
1895
+ const id = resumableSession(b, fs7.existsSync);
1711
1896
  if (id) {
1712
1897
  kept = id;
1713
1898
  cb.sessionId(id);
@@ -1859,7 +2044,9 @@ function startCliSession(opts, callbacks) {
1859
2044
  }
1860
2045
  const keeper = keeperHookUrl();
1861
2046
  const hookUrl = `${keeper ?? officeUrl}/api/hooks/${token}`;
1862
- const browser = opts.browserTesting ? playwrightServer(path7.join(dir, "browser")) : null;
2047
+ const browser = opts.browserTesting ? playwrightMcp(path8.join(dir, "browser")) : null;
2048
+ const browserIssue = opts.browserTesting ? browserProblem(browser) : null;
2049
+ if (browserIssue) log([{ kind: "error", text: browserIssue }]);
1863
2050
  let prompt = opts.prompt;
1864
2051
  if (opts.outputSchema) {
1865
2052
  prompt += `
@@ -1870,21 +2057,21 @@ ${JSON.stringify(opts.outputSchema)}`;
1870
2057
  let files;
1871
2058
  try {
1872
2059
  writeHelpers();
1873
- fs6.mkdirSync(dir, { recursive: true });
2060
+ fs7.mkdirSync(dir, { recursive: true });
1874
2061
  const settings = {
1875
2062
  hooks: Object.fromEntries(HOOK_EVENTS.map((e) => [e, [{ hooks: [{ type: "http", url: hookUrl, timeout: keeper ? 120 : 30 }] }]])),
1876
- statusLine: { type: "command", command: `${shellArg(process.execPath)} ${shellArg(path7.join(BIN_DIR, "statusline.cjs"))} ${shellArg(hookUrl)}`, padding: 0 }
2063
+ statusLine: { type: "command", command: `${shellArg(process.execPath)} ${shellArg(path8.join(BIN_DIR, "statusline.cjs"))} ${shellArg(hookUrl)}`, padding: 0 }
1877
2064
  };
1878
2065
  const servers = {};
1879
2066
  if (browser) servers.playwright = browser;
1880
2067
  if (opts.office) servers.office = { type: "http", url: `${officeUrl}/api/mcp/${token}` };
1881
- files = { settings: path7.join(dir, "settings.json"), mcp: Object.keys(servers).length ? path7.join(dir, "mcp.json") : null, system: path7.join(dir, "instructions.md") };
1882
- fs6.writeFileSync(files.settings, JSON.stringify(settings, null, 2));
1883
- if (files.mcp) fs6.writeFileSync(files.mcp, JSON.stringify({ mcpServers: servers }, null, 2));
1884
- fs6.writeFileSync(files.system, opts.systemAppend);
2068
+ files = { settings: path8.join(dir, "settings.json"), mcp: Object.keys(servers).length ? path8.join(dir, "mcp.json") : null, system: path8.join(dir, "instructions.md") };
2069
+ fs7.writeFileSync(files.settings, JSON.stringify(settings, null, 2));
2070
+ if (files.mcp) fs7.writeFileSync(files.mcp, JSON.stringify({ mcpServers: servers }, null, 2));
2071
+ fs7.writeFileSync(files.system, opts.systemAppend);
1885
2072
  if (prompt.length > INLINE_PROMPT_MAX) {
1886
- const file = path7.join(dir, "task.md");
1887
- fs6.writeFileSync(file, prompt);
2073
+ const file = path8.join(dir, "task.md");
2074
+ fs7.writeFileSync(file, prompt);
1888
2075
  prompt = `Your task is in ${file}. Read all of it first, then do what it says.`;
1889
2076
  }
1890
2077
  } catch (err) {
@@ -1904,9 +2091,9 @@ ${JSON.stringify(opts.outputSchema)}`;
1904
2091
  role: opts.role,
1905
2092
  additionalDirectories: opts.additionalDirectories,
1906
2093
  files,
1907
- notify: { script: path7.join(BIN_DIR, "notify.cjs"), url: hookUrl },
1908
- codexHook: path7.join(BIN_DIR, "codex-hook.cjs"),
1909
- plugin: pathToFileURL(path7.join(BIN_DIR, "opencode-plugin.mjs")).href,
2094
+ notify: { script: path8.join(BIN_DIR, "notify.cjs"), url: hookUrl },
2095
+ codexHook: path8.join(BIN_DIR, "codex-hook.cjs"),
2096
+ plugin: pathToFileURL(path8.join(BIN_DIR, "opencode-plugin.mjs")).href,
1910
2097
  browser
1911
2098
  });
1912
2099
  const env = {};
@@ -1972,9 +2159,9 @@ ${JSON.stringify(opts.outputSchema)}`;
1972
2159
  // server/previewRunner.ts
1973
2160
  import { spawn as spawn2 } from "node:child_process";
1974
2161
  import crypto4 from "node:crypto";
1975
- import fs7 from "node:fs/promises";
2162
+ import fs8 from "node:fs/promises";
1976
2163
  import net2 from "node:net";
1977
- import path8 from "node:path";
2164
+ import path9 from "node:path";
1978
2165
  var PREVIEW_SLUG = "preview";
1979
2166
  var PREVIEW_BRANCH = "swarm-preview";
1980
2167
  var PREVIEW_TMP = ".preview-tmp";
@@ -2010,7 +2197,7 @@ async function portOpen(port) {
2010
2197
  var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
2011
2198
  async function readJson(file) {
2012
2199
  try {
2013
- return JSON.parse(await fs7.readFile(file, "utf8"));
2200
+ return JSON.parse(await fs8.readFile(file, "utf8"));
2014
2201
  } catch {
2015
2202
  return null;
2016
2203
  }
@@ -2071,9 +2258,9 @@ var RealPreview = class {
2071
2258
  this.check();
2072
2259
  await ensureLocalExcludes(job.fullName).catch(() => void 0);
2073
2260
  cb.commit(await git(["rev-parse", "--short", "HEAD"], { cwd: wt }).catch(() => ""));
2074
- const tmp = path8.join(wt, PREVIEW_TMP);
2075
- await fs7.mkdir(tmp, { recursive: true });
2076
- const pkg = await readJson(path8.join(wt, "package.json"));
2261
+ const tmp = path9.join(wt, PREVIEW_TMP);
2262
+ await fs8.mkdir(tmp, { recursive: true });
2263
+ const pkg = await readJson(path9.join(wt, "package.json"));
2077
2264
  const command = job.command ?? defaultCommand(pkg);
2078
2265
  if (!command) {
2079
2266
  this.stopped = true;
@@ -2112,11 +2299,11 @@ var RealPreview = class {
2112
2299
  }
2113
2300
  /** npm ci (or npm install without a lockfile), skipped when package.json and the lockfile haven't changed. */
2114
2301
  async install(wt, env) {
2115
- const lockFile = path8.join(wt, "package-lock.json");
2116
- const lock = await fs7.readFile(lockFile, "utf8").catch(() => null);
2117
- const hash = crypto4.createHash("sha256").update(await fs7.readFile(path8.join(wt, "package.json"), "utf8")).update("\0").update(lock ?? "").digest("hex");
2118
- const marker = path8.join(wt, "node_modules", INSTALL_MARKER);
2119
- if (await fs7.readFile(marker, "utf8").catch(() => "") === hash) {
2302
+ const lockFile = path9.join(wt, "package-lock.json");
2303
+ const lock = await fs8.readFile(lockFile, "utf8").catch(() => null);
2304
+ const hash = crypto4.createHash("sha256").update(await fs8.readFile(path9.join(wt, "package.json"), "utf8")).update("\0").update(lock ?? "").digest("hex");
2305
+ const marker = path9.join(wt, "node_modules", INSTALL_MARKER);
2306
+ if (await fs8.readFile(marker, "utf8").catch(() => "") === hash) {
2120
2307
  this.cb.log(["Dependencies unchanged since the last install; skipping it."]);
2121
2308
  return;
2122
2309
  }
@@ -2130,7 +2317,7 @@ var RealPreview = class {
2130
2317
  }
2131
2318
  if (code !== 0) throw new Error(`${cmd} failed (code ${code}).`);
2132
2319
  this.child = null;
2133
- await fs7.writeFile(marker, hash).catch(() => void 0);
2320
+ await fs8.writeFile(marker, hash).catch(() => void 0);
2134
2321
  }
2135
2322
  /** Run a command line through the platform shell; resolves with its exit code. */
2136
2323
  spawn(cmd, cwd, env) {
@@ -2161,12 +2348,12 @@ var RealPreview = class {
2161
2348
  };
2162
2349
  var realPreviews = {
2163
2350
  start: (job, cb) => new RealPreview(job, cb),
2164
- hasDefault: async (fullName) => fs7.access(path8.join(mainDir(fullName), "package.json")).then(() => true).catch(() => false)
2351
+ hasDefault: async (fullName) => fs8.access(path9.join(mainDir(fullName), "package.json")).then(() => true).catch(() => false)
2165
2352
  };
2166
2353
 
2167
2354
  // server/officeUpdate.ts
2168
- import fs8 from "node:fs/promises";
2169
- import path9 from "node:path";
2355
+ import fs9 from "node:fs/promises";
2356
+ import path10 from "node:path";
2170
2357
  var DRAIN_TIMEOUT_MS = 20 * 6e4;
2171
2358
  var POSTPONE_MS = 2 * 60 * 6e4;
2172
2359
  function underLauncher(proc = process) {
@@ -2203,10 +2390,10 @@ function parseLastUpdate(raw) {
2203
2390
  }
2204
2391
  }
2205
2392
  async function takeLastUpdate(home) {
2206
- const file = path9.join(home, "last-update.json");
2207
- const raw = await fs8.readFile(file, "utf8").catch(() => null);
2393
+ const file = path10.join(home, "last-update.json");
2394
+ const raw = await fs9.readFile(file, "utf8").catch(() => null);
2208
2395
  if (raw === null) return null;
2209
- await fs8.rm(file, { force: true, maxRetries: 3 }).catch(() => void 0);
2396
+ await fs9.rm(file, { force: true, maxRetries: 3 }).catch(() => void 0);
2210
2397
  return parseLastUpdate(raw);
2211
2398
  }
2212
2399
  function lastUpdateMessage(u, commits) {
@@ -2214,8 +2401,8 @@ function lastUpdateMessage(u, commits) {
2214
2401
  const n = commits === null ? "" : ` (${commits} commit${commits === 1 ? "" : "s"})`;
2215
2402
  return `\u2B06\uFE0F Updated the office from ${u.from.slice(0, 7)} to ${u.to.slice(0, 7)}${n}`;
2216
2403
  }
2217
- var OFFICE_DIR = path9.resolve(import.meta.dirname, "..");
2218
- var isSamePath = (a, b) => process.platform === "win32" ? path9.resolve(a).toLowerCase() === path9.resolve(b).toLowerCase() : path9.resolve(a) === path9.resolve(b);
2404
+ var OFFICE_DIR = path10.resolve(import.meta.dirname, "..");
2405
+ var isSamePath = (a, b) => process.platform === "win32" ? path10.resolve(a).toLowerCase() === path10.resolve(b).toLowerCase() : path10.resolve(a) === path10.resolve(b);
2219
2406
  var realOffice = {
2220
2407
  launcher: underLauncher(),
2221
2408
  head: () => git(["rev-parse", "HEAD"], { cwd: OFFICE_DIR }).catch(() => null),
@@ -2240,6 +2427,7 @@ var realBackend = {
2240
2427
  createIssue,
2241
2428
  issueState,
2242
2429
  editIssue,
2430
+ closeIssue,
2243
2431
  mergePull,
2244
2432
  updateBranch,
2245
2433
  closePull,
@@ -2259,6 +2447,7 @@ var realBackend = {
2259
2447
  deskDir,
2260
2448
  prepareDesk,
2261
2449
  removeDesk,
2450
+ sweepDesks,
2262
2451
  releaseDesk: (fullName, agentSlug, port) => releaseDesk(fullName, agentSlug, port, officeProcesses()),
2263
2452
  startSession: (opts, cb) => opts.terminal ? startCliSession(opts, cb) : startSession(opts, cb),
2264
2453
  terminals: terminalsAvailable,
@@ -2273,7 +2462,7 @@ var realBackend = {
2273
2462
  // server/demo.ts
2274
2463
  import crypto5 from "node:crypto";
2275
2464
  import http from "node:http";
2276
- import path10 from "node:path";
2465
+ import path11 from "node:path";
2277
2466
  var now = () => (/* @__PURE__ */ new Date()).toISOString();
2278
2467
  function issue(n, title, body, fullName, labels = []) {
2279
2468
  return { number: n, title, body, url: `https://github.com/${fullName}/issues/${n}`, labels, createdAt: now() };
@@ -2326,7 +2515,7 @@ function screenshotSvg(title, url, hue) {
2326
2515
  </svg>`;
2327
2516
  }
2328
2517
  function devScript(opts, cb, issueNumber, issueTitle) {
2329
- const branch = path10.basename(opts.cwd);
2518
+ const branch = path11.basename(opts.cwd);
2330
2519
  const hue = issueNumber * 67 % 360;
2331
2520
  const file = ["src/App.tsx", "src/components/TodoList.tsx", "src/api/routes.ts", "src/styles.css"][issueNumber % 4];
2332
2521
  const port = 5200 + issueNumber % 50;
@@ -2465,7 +2654,7 @@ function fakeSession(opts, cb, fullName) {
2465
2654
  number: n,
2466
2655
  title,
2467
2656
  url,
2468
- headRefName: `swarm/issue-${number}-${path10.basename(opts.cwd).replace(/-[0-9a-f]{4}$/, "")}`,
2657
+ headRefName: `swarm/issue-${number}-${path11.basename(opts.cwd).replace(/-[0-9a-f]{4}$/, "")}`,
2469
2658
  state: "OPEN",
2470
2659
  isDraft: false,
2471
2660
  mergeable: "MERGEABLE",
@@ -2660,8 +2849,16 @@ function createDemoBackend() {
2660
2849
  mergedSinceSync.set(fullName, (mergedSinceSync.get(fullName) ?? 0) + 1);
2661
2850
  pr.state = "MERGED";
2662
2851
  pr.mergedAt = now();
2663
- for (const n of pr.closesIssues) closedIssues.add(`${fullName}#${n}`);
2664
- r.issues = r.issues.filter((i) => !pr.closesIssues.includes(i.number));
2852
+ setTimeout(() => {
2853
+ for (const n of pr.closesIssues) closedIssues.add(`${fullName}#${n}`);
2854
+ r.issues = r.issues.filter((i) => !pr.closesIssues.includes(i.number));
2855
+ }, 2e4).unref();
2856
+ },
2857
+ closeIssue: async (fullName, number) => {
2858
+ const r = repos.get(fullName);
2859
+ if (!r?.issues.some((i) => i.number === number)) throw new Error(`Issue #${number} is not open`);
2860
+ closedIssues.add(`${fullName}#${number}`);
2861
+ r.issues = r.issues.filter((i) => i.number !== number);
2665
2862
  },
2666
2863
  updateBranch: async (fullName, number) => {
2667
2864
  const pr = repos.get(fullName)?.pulls.find((p) => p.number === number);
@@ -2716,6 +2913,7 @@ Closes #${pr.closesIssues[0] ?? "?"}`,
2716
2913
  return dir;
2717
2914
  },
2718
2915
  removeDesk: async () => void 0,
2916
+ sweepDesks: async () => ({ desks: 0, folders: 0, branches: 0, patches: [], skipped: [] }),
2719
2917
  releaseDesk: async () => void 0,
2720
2918
  startSession: (opts, cb) => {
2721
2919
  if (++sessionsStarted === USAGE_WARNING_AT) fakeUsageWarning(cb);
@@ -2912,7 +3110,8 @@ function ceoSession(opts, cb) {
2912
3110
  const s = JSON.parse(await office.call("company_status", {}));
2913
3111
  const people = s.floors.reduce((n, f) => n + f.team.length, 0);
2914
3112
  const issues = s.floors.reduce((n, f) => n + f.backlog.length, 0);
2915
- cb.log([{ kind: "result", text: ` \u23BF ${s.floors.length} floors \xB7 ${people} people \xB7 ${issues} open issues \xB7 ${s.pendingProposals.length} proposals pending` }]);
3113
+ const free = s.floors.reduce((n, f) => n + f.seats.dev.free + f.seats.qa.free, 0);
3114
+ cb.log([{ kind: "result", text: ` \u23BF ${s.floors.length} floors \xB7 ${people} people \xB7 ${free} free seats \xB7 ${issues} open issues \xB7 ${s.pendingProposals.length} proposals pending` }]);
2916
3115
  return s;
2917
3116
  };
2918
3117
  const use = async (name, args) => {
@@ -3103,8 +3302,8 @@ Acceptance criteria:
3103
3302
  }
3104
3303
 
3105
3304
  // server/swarm.ts
3106
- import fs10 from "node:fs/promises";
3107
- import path12 from "node:path";
3305
+ import fs11 from "node:fs/promises";
3306
+ import path13 from "node:path";
3108
3307
  import crypto6 from "node:crypto";
3109
3308
 
3110
3309
  // server/ceo.ts
@@ -3189,7 +3388,7 @@ function createOfficeTools(h) {
3189
3388
  const defs = [
3190
3389
  tool(
3191
3390
  "company_status",
3192
- "Everything about the company right now: settings, every floor (repo, clone path, brief, profile, QA brief), its team, backlog, pull requests and QA, pending proposals and recent decisions by the manager. Call this first.",
3391
+ "Everything about the company right now: settings, every floor (repo, clone path, brief, profile, QA brief), its free seats, team, backlog, pull requests and QA, pending proposals and recent decisions by the manager. Call this first.",
3193
3392
  {},
3194
3393
  () => run2(() => h.companyStatus())
3195
3394
  ),
@@ -3281,6 +3480,12 @@ function createOfficeTools(h) {
3281
3480
  }
3282
3481
  };
3283
3482
  }
3483
+ var FLOOR_DESKS = { dev: 12, qa: 3 };
3484
+ var MAX_PENDING_PROPOSALS = FLOOR_DESKS.dev + FLOOR_DESKS.qa;
3485
+ var seatCount = (total, taken, proposed) => ({ total, taken, proposed, free: Math.max(0, total - taken - proposed) });
3486
+ function checkPendingLimit(pending2, max = MAX_PENDING_PROPOSALS) {
3487
+ if (pending2 >= max) throw new Error(`${pending2} proposals are already waiting for the manager; propose the rest after they decide.`);
3488
+ }
3284
3489
  function ceoSystemPrompt(o) {
3285
3490
  const manager = o.manager ? `the manager, ${o.manager}` : "the human manager";
3286
3491
  return [
@@ -3289,7 +3494,7 @@ function ceoSystemPrompt(o) {
3289
3494
  "",
3290
3495
  "Your job is to run the company, not to write code:",
3291
3496
  "- Understand each project: what it is, its stack, how far along it is, and what kind of people it needs. Projects differ a lot. A static marketing site, a 3D browser game and a REST API need different specialists and different QA.",
3292
- `- Shape each floor's team. Propose specialists with a specific title and a job description written for this project. Keep teams lean: agents on the same coding agent share one subscription's usage limits${o.sessionLimit ? ` and at most ${o.sessionLimit} sessions run at once` : ""}, so a floor rarely needs more than ${o.teamCap} people. Propose letting people go when a floor is clearly overstaffed or a specialty is no longer needed.`,
3497
+ `- Shape each floor's team. Propose specialists with a specific title and a job description written for this project. Keep teams lean: agents on the same coding agent share one subscription's usage limits${o.sessionLimit ? ` and at most ${o.sessionLimit} sessions run at once` : ""}, so a floor rarely needs more than ${o.teamCap} people. When the manager asks for a bigger team, follow that, up to the floor's free seats (seats in company_status). Propose letting people go when a floor is clearly overstaffed or a specialty is no longer needed.`,
3293
3498
  "- Plan the work: turn a floor's brief into small, well-specified GitHub issues, one agent-session each, with acceptance criteria. Route each to a specialty. The office hands issues out itself: a free specialist gets first pick of their specialty, and otherwise any free developer takes the next issue that can start, so a specialty is a preference, not a lock.",
3294
3499
  "- Write each floor's QA brief: what QA testers must check for this kind of project (for a 3D game: the canvas renders, controls respond, frame rate is smooth; for a website: links, phone layout, accessibility; for an API: status codes, validation, error cases).",
3295
3500
  "",
@@ -3473,6 +3678,25 @@ var HttpError = class extends Error {
3473
3678
  status;
3474
3679
  };
3475
3680
 
3681
+ // server/issueClaims.ts
3682
+ var RECENT_MERGE_MS = 6 * 60 * 6e4;
3683
+ function branchIssue(headRefName) {
3684
+ const m = /^swarm\/issue-(\d+)-/.exec(headRefName);
3685
+ return m ? Number(m[1]) : null;
3686
+ }
3687
+ function issuesResolvedBy(pr) {
3688
+ const n = branchIssue(pr.headRefName);
3689
+ return [...new Set(n == null ? pr.closesIssues : [...pr.closesIssues, n])];
3690
+ }
3691
+ function issueTaken(n, agents, pulls, now2) {
3692
+ if (agents.some((a) => a.role === "dev" && a.task !== "qa" && a.issueNumber === n && a.status !== "idle" && a.status !== "error")) return true;
3693
+ return pulls.some((p) => {
3694
+ if (p.state === "CLOSED") return false;
3695
+ if (p.state === "MERGED" && (!p.mergedAt || now2 - Date.parse(p.mergedAt) > RECENT_MERGE_MS)) return false;
3696
+ return issuesResolvedBy(p).includes(n);
3697
+ });
3698
+ }
3699
+
3476
3700
  // server/mergeGate.ts
3477
3701
  var MAX_MERGE_FIXES = 3;
3478
3702
  var CHECKS_ALERT_MS = 30 * 6e4;
@@ -3680,6 +3904,7 @@ var Previews = class {
3680
3904
  }
3681
3905
  /** The floor is leaving the building: stop its preview and remove the preview worktree. */
3682
3906
  async remove(f) {
3907
+ const main = this.backend.mainDir(f.fullName);
3683
3908
  const r = this.runs.get(f.id);
3684
3909
  if (r) {
3685
3910
  r.gen++;
@@ -3688,7 +3913,7 @@ var Previews = class {
3688
3913
  this.runs.delete(f.id);
3689
3914
  this.hasDefault.delete(f.id);
3690
3915
  clearTimeout(this.emitTimers.get(f.id));
3691
- await this.backend.removeDesk(f.fullName, PREVIEW_SLUG).catch(() => void 0);
3916
+ await this.backend.removeDesk(f.fullName, PREVIEW_SLUG, main).catch(() => void 0);
3692
3917
  }
3693
3918
  /** Server shutdown: stop every running preview. */
3694
3919
  async stopAll(floors) {
@@ -3733,6 +3958,95 @@ var Previews = class {
3733
3958
  }
3734
3959
  };
3735
3960
 
3961
+ // server/prompts.ts
3962
+ var PLACEHOLDERS = {
3963
+ worktree: "<your worktree>",
3964
+ issue: "<issue>",
3965
+ pr: "<PR>",
3966
+ prTitle: "<PR title>",
3967
+ prUrl: "<PR URL>",
3968
+ branch: "<branch>"
3969
+ };
3970
+ var devBranch = (issue2, slug) => `swarm/issue-${issue2}-${slug}`;
3971
+ var qaBranch = (pr, slug) => `qa/pr-${pr}-${slug}`;
3972
+ function devSystemPrompt({ agent: a, repo, port, cwd, branch, linked, fixing }) {
3973
+ const push = fixing ? `git push origin HEAD:${fixing.headRef}` : `git push -u origin ${branch}`;
3974
+ const links = linked.map((r) => `- ${r.fullName}: read-only reference clone at ${r.dir}`);
3975
+ return [
3976
+ `You are ${a.name}, ${a.title ? `the team's ${a.title},` : "a software engineer"} on an autonomous agent team ("cubefarm"). Several teammates work in parallel on other issues of the same repository, each in their own git worktree. Nobody is watching live to answer questions, so make sensible decisions yourself and record assumptions in the PR description. The manager may occasionally send you messages; follow their instructions.`,
3977
+ `Every pull request is reviewed and tested by a QA teammate. ${repo.autoMerge ? "Once they sign off and GitHub's checks pass, the office merges it by itself" : "Once they sign off, the manager merges it"}. If they find problems, or checks fail, or it conflicts with the default branch, you will get the details; fix them on the same branch.`,
3978
+ a.brief ? `
3979
+ Your job description:
3980
+ ${a.brief}` : "",
3981
+ "",
3982
+ `Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
3983
+ repo.summary ? `Project: ${repo.summary}` : "",
3984
+ repo.mission ? `What the team is building (the manager's brief): ${repo.mission}` : "",
3985
+ `Your worktree: ${cwd}`,
3986
+ fixing ? `You are fixing pull request #${fixing.pr}. Its code is checked out on local branch ${branch}; push fixes with: ${push}. Do not open a new pull request.` : `Your branch: ${branch} (already checked out, created from origin/${repo.defaultBranch})`,
3987
+ links.length ? `Related repositories you may read for context (do not modify them):
3988
+ ${links.join("\n")}` : "",
3989
+ "",
3990
+ "Workflow:",
3991
+ "1. Read the issue and explore the relevant code before changing anything.",
3992
+ "2. Implement the change with focused commits and clear messages.",
3993
+ "3. Run the project's existing tests, linters and build (if any) and fix what you broke. Install dependencies first if needed.",
3994
+ repo.browserTesting ? `4. If the project has a web UI, start its dev server in the background on port ${port} (reserved for you, so you don't collide with teammates), then check your change with the Playwright browser tools (mcp__playwright__browser_navigate, browser_snapshot, browser_click, browser_take_screenshot). Stop the dev server when you're done.` : "4. Verify the behaviour you changed as directly as you can.",
3995
+ `5. Push: ${push}`,
3996
+ fixing ? "6. Reply with a short summary of what you fixed." : `6. Open a pull request with the GitHub CLI: gh pr create --base ${repo.defaultBranch} --head ${branch} --title "<concise title>" --body "<what changed, how you verified it, assumptions>". The body must contain "Closes #<issue number>".`,
3997
+ fixing ? "" : "7. End your final message with the pull request URL on its own line.",
3998
+ "",
3999
+ "Rules: never push to the default branch, never force-push, never merge pull requests yourself (the office merges them once QA and the checks pass), and never edit files outside your worktree. If you cannot finish, open a draft PR (gh pr create --draft) explaining what is left and why."
4000
+ ].filter((l) => l !== "").join("\n");
4001
+ }
4002
+ function qaSystemPrompt({ agent: a, repo, port, cwd, branch, pr }) {
4003
+ return [
4004
+ `You are ${a.name}, ${a.title ? `the team's ${a.title},` : "a QA engineer"} on an autonomous agent team ("cubefarm"). Developers open pull requests; you review and independently verify each one before it is merged. Your sign-off is the review: ${repo.autoMerge ? "on this floor a PR you pass merges by itself as soon as GitHub's checks are green, so nobody else reads the code after you. " : ""}Be thorough and skeptical, but fair: fail a PR only for real problems (broken behaviour, failing tests or build, the issue's requirements not met, obvious regressions), not for style preferences.`,
4005
+ ...a.brief ? ["", `Your job description:
4006
+ ${a.brief}`] : [],
4007
+ ...a.role === "dev" ? ["", "You're a developer covering for the QA lab while its testers are busy. You didn't write this pull request: test it as an independent QA engineer would."] : [],
4008
+ "",
4009
+ `Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
4010
+ ...repo.summary ? [`Project: ${repo.summary}`] : [],
4011
+ ...repo.mission ? [`What the team is building (the manager's brief): ${repo.mission}`] : [],
4012
+ ...repo.qaBrief ? [`What to check on this project (from the CEO):
4013
+ ${repo.qaBrief}`] : [],
4014
+ `Pull request #${pr.number} "${pr.title}" from branch ${pr.headRefName}: ${pr.url}`,
4015
+ `Your worktree: ${cwd}. It has the pull request's code checked out on local branch ${branch}.`,
4016
+ "",
4017
+ "How to test:",
4018
+ "1. Read the PR description and the linked issue, and work out the acceptance criteria.",
4019
+ `2. Review the code as a careful reviewer would: git diff origin/${repo.defaultBranch}...HEAD. Look for bugs, unhandled errors and edge cases, security problems, leftover debug code, and new logic without tests.`,
4020
+ "3. Install dependencies if needed, then run the project's test suite, linters, type checks and build (whichever exist).",
4021
+ repo.browserTesting ? `4. If the project has a UI, start it in the background on port ${port} (reserved for you) and exercise the change in a real browser with the Playwright tools: navigate, click, type, resize to a phone size, try edge cases, and check the console for errors. Take a screenshot with browser_take_screenshot (no filename) of every important state: the screenshots are attached to the PR as evidence. Stop the server afterwards.` : "4. Exercise the changed behaviour directly (run the program, call the API, write a quick script).",
4022
+ "5. You may write throwaway scripts to probe behaviour, but do not commit them.",
4023
+ "",
4024
+ "Rules: do not modify the code under test, do not commit, push, comment on, review or merge anything on GitHub. The office posts your report on the pull request. Finish with the structured QA report: verdict, summary, the checks you performed, the commands you ran and one caption per screenshot."
4025
+ ].join("\n");
4026
+ }
4027
+ var OFFICE = "Office instructions";
4028
+ var MARK = "\0job description\0";
4029
+ function promptParts(build, brief) {
4030
+ if (!brief) return [{ label: OFFICE, text: build(""), editable: false }];
4031
+ const [before, after] = build(MARK).split(MARK);
4032
+ return [
4033
+ { label: OFFICE, text: before, editable: false },
4034
+ { label: "Job description", text: brief, editable: true },
4035
+ { label: OFFICE, text: after, editable: false }
4036
+ ].filter((p) => p.text !== "");
4037
+ }
4038
+ var view = (kind, parts) => ({ kind, text: parts.map((p) => p.text).join(""), parts });
4039
+ function devPromptPreview({ slug, ...i }) {
4040
+ const branch = devBranch(PLACEHOLDERS.issue, slug);
4041
+ return view("dev", promptParts((brief) => devSystemPrompt({ ...i, agent: { ...i.agent, brief }, cwd: PLACEHOLDERS.worktree, branch }), i.agent.brief));
4042
+ }
4043
+ function qaPromptPreview({ slug, ...i }) {
4044
+ const pr = { number: PLACEHOLDERS.pr, title: PLACEHOLDERS.prTitle, headRefName: PLACEHOLDERS.branch, url: PLACEHOLDERS.prUrl };
4045
+ const branch = qaBranch(PLACEHOLDERS.pr, slug);
4046
+ return view("qa", promptParts((brief) => qaSystemPrompt({ ...i, agent: { ...i.agent, brief }, cwd: PLACEHOLDERS.worktree, branch, pr }), i.agent.brief));
4047
+ }
4048
+ var ceoPromptPreview = (text) => view("ceo", [{ label: OFFICE, text, editable: false }]);
4049
+
3736
4050
  // server/pacing.ts
3737
4051
  var PACING_MS = 60 * 6e4;
3738
4052
  var DEFAULT_PACING_SESSIONS = 3;
@@ -3771,8 +4085,8 @@ function pacingMessage(w, until, sessions, now2) {
3771
4085
  }
3772
4086
 
3773
4087
  // server/terminal.ts
3774
- import fs9 from "node:fs/promises";
3775
- import path11 from "node:path";
4088
+ import fs10 from "node:fs/promises";
4089
+ import path12 from "node:path";
3776
4090
  import xtermHeadless from "@xterm/headless";
3777
4091
  import xtermSerialize from "@xterm/addon-serialize";
3778
4092
  var { Terminal } = xtermHeadless;
@@ -3866,12 +4180,12 @@ var AgentTerminal = class {
3866
4180
  async save(file) {
3867
4181
  this.dirty = false;
3868
4182
  await this.flush();
3869
- await fs9.mkdir(path11.dirname(file), { recursive: true });
3870
- await fs9.writeFile(`${file}.tmp`, this.snapshot());
3871
- await fs9.rename(`${file}.tmp`, file);
4183
+ await fs10.mkdir(path12.dirname(file), { recursive: true });
4184
+ await fs10.writeFile(`${file}.tmp`, this.snapshot());
4185
+ await fs10.rename(`${file}.tmp`, file);
3872
4186
  }
3873
4187
  async load(file) {
3874
- const data = await fs9.readFile(file, "utf8").catch(() => "");
4188
+ const data = await fs10.readFile(file, "utf8").catch(() => "");
3875
4189
  if (!data) return;
3876
4190
  this.term.write(data);
3877
4191
  this.note("(the office restarted)");
@@ -4001,16 +4315,15 @@ var FEMININE_NAMES = new Set(
4001
4315
  );
4002
4316
  var lookFor = (name) => FEMININE_NAMES.has(name.trim().split(/\s+/)[0].toLowerCase()) ? "feminine" : "masculine";
4003
4317
  var LOOKS = ["feminine", "masculine"];
4004
- var MAX_DESKS = { dev: 12, qa: 3, ceo: 1 };
4318
+ var MAX_DESKS = { ...FLOOR_DESKS, ceo: 1 };
4005
4319
  var MAX_QA_ROUNDS = 3;
4006
4320
  var DEFAULT_MODEL = "claude-opus-5-5";
4007
4321
  var EFFORTS = ["low", "medium", "high", "xhigh", "max"];
4008
4322
  var CEO_MODEL = "claude-opus-5-5";
4009
4323
  var CEO_EFFORT = "xhigh";
4010
4324
  var CEO_NAME = "Morgan";
4011
- var CEO_DIR = path12.join(HOME_DIR, "ceo");
4012
- var DEFAULT_PROJECTS_DIR = defaultProjectsDir(path12.resolve(import.meta.dirname, ".."));
4013
- var MAX_PENDING_REQUESTS = 8;
4325
+ var CEO_DIR = path13.join(HOME_DIR, "ceo");
4326
+ var DEFAULT_PROJECTS_DIR = defaultProjectsDir(path13.resolve(import.meta.dirname, ".."));
4014
4327
  var MAX_ISSUES_PER_JOB = 12;
4015
4328
  var KEEP_MESSAGES = 200;
4016
4329
  var KEEP_DECIDED_REQUESTS = 40;
@@ -4022,14 +4335,14 @@ var ERROR_COOLDOWN_MS = 2 * 6e4;
4022
4335
  var MAX_ISSUE_FAILURES = 2;
4023
4336
  var LIMIT_PAUSE_MS = 15 * 6e4;
4024
4337
  var oneLine = (err) => (err instanceof Error ? err.message : String(err)).split(/\r?\n/)[0].slice(0, 200);
4025
- var SCREENS_DIR = path12.join(HOME_DIR, "screens");
4338
+ var SCREENS_DIR = path13.join(HOME_DIR, "screens");
4026
4339
  var MIME_EXT = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/svg+xml": "svg" };
4027
- var screenFile = (agentId, mime) => path12.join(SCREENS_DIR, `${agentId}.${MIME_EXT[mime] ?? "img"}`);
4340
+ var screenFile = (agentId, mime) => path13.join(SCREENS_DIR, `${agentId}.${MIME_EXT[mime] ?? "img"}`);
4028
4341
  async function loadScreen(agentId) {
4029
4342
  for (const [mime, ext] of Object.entries(MIME_EXT)) {
4030
- const file = path12.join(SCREENS_DIR, `${agentId}.${ext}`);
4343
+ const file = path13.join(SCREENS_DIR, `${agentId}.${ext}`);
4031
4344
  try {
4032
- const [data, stat] = await Promise.all([fs10.readFile(file), fs10.stat(file)]);
4345
+ const [data, stat] = await Promise.all([fs11.readFile(file), fs11.stat(file)]);
4033
4346
  return { data, mime, at: stat.mtimeMs };
4034
4347
  } catch {
4035
4348
  }
@@ -4037,10 +4350,10 @@ async function loadScreen(agentId) {
4037
4350
  return null;
4038
4351
  }
4039
4352
  async function removeScreens(agentId) {
4040
- await Promise.all(Object.values(MIME_EXT).map((ext) => fs10.rm(path12.join(SCREENS_DIR, `${agentId}.${ext}`), { force: true })));
4353
+ await Promise.all(Object.values(MIME_EXT).map((ext) => fs11.rm(path13.join(SCREENS_DIR, `${agentId}.${ext}`), { force: true })));
4041
4354
  }
4042
- var TERMINALS_DIR = path12.join(HOME_DIR, "terminals");
4043
- var terminalFile = (agentId) => path12.join(TERMINALS_DIR, `${agentId}.ansi`);
4355
+ var TERMINALS_DIR = path13.join(HOME_DIR, "terminals");
4356
+ var terminalFile = (agentId) => path13.join(TERMINALS_DIR, `${agentId}.ansi`);
4044
4357
  var TERMINAL_SAVE_MS = 2e4;
4045
4358
  var QA_SCHEMA = {
4046
4359
  type: "object",
@@ -4194,7 +4507,7 @@ var Swarm = class {
4194
4507
  // ---------- lifecycle ----------
4195
4508
  async init() {
4196
4509
  try {
4197
- const raw = await fs10.readFile(STATE_FILE, "utf8");
4510
+ const raw = await fs11.readFile(STATE_FILE, "utf8");
4198
4511
  const loaded = JSON.parse(raw);
4199
4512
  this.state = {
4200
4513
  settings: { ...this.state.settings, ...loaded.settings },
@@ -4320,6 +4633,7 @@ var Swarm = class {
4320
4633
  setInterval(() => this.state.repos.forEach((r, i) => setTimeout(() => void this.syncRepo(r.id), i * 1500)), SYNC_INTERVAL_MS);
4321
4634
  setInterval(() => this.schedule(), SCHEDULER_INTERVAL_MS);
4322
4635
  setInterval(() => void this.saveTerminals(), TERMINAL_SAVE_MS);
4636
+ setInterval(() => this.state.repos.forEach((r, i) => setTimeout(() => void this.sweepFloor(r.id), i * 1500)), DESK_SWEEP_INTERVAL_MS);
4323
4637
  void this.backend.detectClis().then((clis) => {
4324
4638
  this.clis = clis;
4325
4639
  this.broadcast({ type: "clis", clis });
@@ -4515,10 +4829,10 @@ var Swarm = class {
4515
4829
  if (this.saveTimer) clearTimeout(this.saveTimer);
4516
4830
  this.saveTimer = null;
4517
4831
  for (const a of this.state.agents) a.logTail = (this.agentRt.get(a.id)?.log ?? []).slice(-200);
4518
- await fs10.mkdir(path12.dirname(STATE_FILE), { recursive: true });
4832
+ await fs11.mkdir(path13.dirname(STATE_FILE), { recursive: true });
4519
4833
  const tmp = `${STATE_FILE}.tmp`;
4520
- await fs10.writeFile(tmp, JSON.stringify(this.state, null, 2));
4521
- await fs10.rename(tmp, STATE_FILE);
4834
+ await fs11.writeFile(tmp, JSON.stringify(this.state, null, 2));
4835
+ await fs11.rename(tmp, STATE_FILE);
4522
4836
  }
4523
4837
  // ---------- lookups ----------
4524
4838
  repo(id) {
@@ -4600,7 +4914,7 @@ var Swarm = class {
4600
4914
  const name = fullName.split("/")[1];
4601
4915
  const taken = folders.find((f) => f.name.toLowerCase() === name.toLowerCase());
4602
4916
  if (taken) throw new HttpError(409, `${taken.path} already exists and isn't a clone of ${fullName}. Connect that folder instead, or rename it first.`);
4603
- return path12.join(root, name);
4917
+ return path13.join(root, name);
4604
4918
  }
4605
4919
  /** A brand-new project: a folder in your projects folder, pushed to a new GitHub repo. */
4606
4920
  async createRepo(name, opts) {
@@ -4612,11 +4926,11 @@ var Swarm = class {
4612
4926
  }
4613
4927
  /** The folders in your projects folder (or another folder you point at), and which are floors already. */
4614
4928
  async listProjectFolders(dir) {
4615
- const root = dir?.trim() ? path12.resolve(dir.trim()) : this.state.settings.projectsDir;
4929
+ const root = dir?.trim() ? path13.resolve(dir.trim()) : this.state.settings.projectsDir;
4616
4930
  const folders = await this.backend.scanProjects(root).catch((err) => {
4617
4931
  throw new HttpError(400, err.message);
4618
4932
  });
4619
- const same = (a, b) => path12.resolve(a).toLowerCase() === path12.resolve(b).toLowerCase();
4933
+ const same = (a, b) => path13.resolve(a).toLowerCase() === path13.resolve(b).toLowerCase();
4620
4934
  const floorOf = (f) => this.state.repos.find((r) => r.localPath && same(r.localPath, f.path) || f.github && r.id.toLowerCase() === f.github.toLowerCase())?.floor ?? null;
4621
4935
  return { root, folders: folders.map((f) => ({ name: f.name, path: f.path, git: f.git, github: f.github, floor: floorOf(f) })) };
4622
4936
  }
@@ -4762,8 +5076,29 @@ var Swarm = class {
4762
5076
  return this.mergeNote(rec, `merge blocked: ${error}`);
4763
5077
  }
4764
5078
  this.toast("success", `\u{1F500} Merged PR #${pr.number} into ${repo.defaultBranch}: ${pr.title}`);
5079
+ await this.closeResolvedIssues(repo, pr.number, pr);
4765
5080
  return true;
4766
5081
  }
5082
+ /**
5083
+ * After the office merges a PR: close the issues it resolves now rather than waiting for GitHub's "Closes #N" (which
5084
+ * lags, and never comes when the link wasn't recognised), so the scheduler doesn't hand them out again. Never throws.
5085
+ */
5086
+ async closeResolvedIssues(repo, prNumber, pr) {
5087
+ const numbers = issuesResolvedBy(pr);
5088
+ const rt = this.repoRt.get(repo.id);
5089
+ if (rt && rt.issues.some((i) => numbers.includes(i.number))) {
5090
+ rt.issues = rt.issues.filter((i) => !numbers.includes(i.number));
5091
+ this.emitRepo(repo);
5092
+ }
5093
+ for (const n of numbers) {
5094
+ try {
5095
+ if (await this.backend.issueState(repo.fullName, n) !== "OPEN") continue;
5096
+ await this.backend.closeIssue(repo.fullName, n, `Shipped in #${prNumber}`);
5097
+ } catch (err) {
5098
+ console.warn(`could not close ${repo.fullName}#${n} after merging #${prNumber}`, err);
5099
+ }
5100
+ }
5101
+ }
4767
5102
  /** Show where auto-merge stands on a PR's card. Doesn't count as a change to the record. */
4768
5103
  mergeNote(rec, text) {
4769
5104
  if (rec.mergeNote !== text) {
@@ -4796,7 +5131,7 @@ var Swarm = class {
4796
5131
  rt.folderSync = status;
4797
5132
  this.emitRepo(repo);
4798
5133
  if (status && status !== before) {
4799
- const name = repo.localPath ? path12.basename(repo.localPath) : repo.fullName;
5134
+ const name = repo.localPath ? path13.basename(repo.localPath) : repo.fullName;
4800
5135
  if (status.startsWith("updated")) this.toast("success", `\u{1F4C1} ${name}: ${status}`);
4801
5136
  else if (/behind|diverged|failed/.test(status)) this.toast("info", `\u{1F4C1} ${name} wasn't updated: ${status}`);
4802
5137
  }
@@ -4806,6 +5141,31 @@ var Swarm = class {
4806
5141
  async syncFolderNow(repoId2) {
4807
5142
  return { folderSync: await this.syncFolder(this.repo(repoId2)) };
4808
5143
  }
5144
+ /** Remove the floor's desks, desk folders and swarm/qa branches nobody uses any more; say so when something went. */
5145
+ async sweepFloor(repoId2) {
5146
+ const repo = this.state.repos.find((r) => r.id === repoId2);
5147
+ const rt = this.repoRt.get(repoId2);
5148
+ if (!repo || !rt || rt.cloneStatus !== "ready") return;
5149
+ try {
5150
+ const pulls = rt.lastSync ? rt.pulls : await this.backend.listPulls(repo.fullName);
5151
+ const agents = this.state.agents.filter((a) => a.repoId === repo.id);
5152
+ const keep = {
5153
+ desks: [...agents.map((a) => this.agentSlug(a)), PREVIEW_SLUG],
5154
+ branches: [...agents.flatMap((a) => a.branch ? [a.branch] : []), ...pulls.filter((p) => p.state === "OPEN").map((p) => p.headRefName)]
5155
+ };
5156
+ const r = await this.backend.sweepDesks(repo.fullName, keep);
5157
+ const desks = r.desks + r.folders;
5158
+ const saved = r.patches.length ? ` (${r.patches.length} patch${r.patches.length === 1 ? "" : "es"} saved to ${path13.dirname(r.patches[0])})` : "";
5159
+ console.log(`desk sweep ${repo.fullName}: ${desks} desks, ${r.branches} branches removed${saved}${r.skipped.length ? `, ${r.skipped.length} still in use` : ""}`);
5160
+ if (!desks && !r.branches) return;
5161
+ const removed = [desks && `${desks} old desk${desks === 1 ? "" : "s"}`, r.branches && `${r.branches} finished branch${r.branches === 1 ? "" : "es"}`].filter(Boolean).join(" and ");
5162
+ const text = `\u{1F9F9} ${repo.fullName.split("/")[1]}: removed ${removed}${saved}`;
5163
+ this.toast("info", text);
5164
+ this.postMessage("office", text);
5165
+ } catch (err) {
5166
+ console.warn(`desk sweep ${repo.fullName} failed: ${oneLine(err)}`);
5167
+ }
5168
+ }
4809
5169
  // ---------- the floor's app (preview monitor) ----------
4810
5170
  /** Run the floor's app from its preview worktree: the default branch, or an open PR. Replaces what it is running now. */
4811
5171
  async startPreview(id, pr) {
@@ -4836,6 +5196,7 @@ var Swarm = class {
4836
5196
  await this.backend.ensureClone(repo.fullName);
4837
5197
  rt.cloneStatus = "ready";
4838
5198
  void this.previews.refreshDefault(repo);
5199
+ void this.sweepFloor(id);
4839
5200
  } catch (err) {
4840
5201
  rt.cloneStatus = "error";
4841
5202
  rt.cloneError = err.message;
@@ -4911,8 +5272,10 @@ var Swarm = class {
4911
5272
  }
4912
5273
  async mergePull(repoId2, number, method = "squash") {
4913
5274
  const repo = this.repo(repoId2);
5275
+ const pr = this.repoRt.get(repo.id)?.pulls.find((p) => p.number === number) ?? await this.backend.prDetails(repo.fullName, number);
4914
5276
  await this.backend.mergePull(repo.fullName, number, method);
4915
5277
  this.toast("success", `Merged PR #${number} into ${repo.defaultBranch}`);
5278
+ await this.closeResolvedIssues(repo, number, pr);
4916
5279
  await this.syncRepo(repo.id);
4917
5280
  setTimeout(() => this.schedule(), 200);
4918
5281
  }
@@ -5020,7 +5383,7 @@ var Swarm = class {
5020
5383
  this.agentRt.get(id)?.terminal?.releaseIdle?.();
5021
5384
  this.agentRt.get(id)?.terminal?.dispose();
5022
5385
  void removeScreens(id);
5023
- void fs10.rm(terminalFile(id), { force: true }).catch(() => void 0);
5386
+ void fs11.rm(terminalFile(id), { force: true }).catch(() => void 0);
5024
5387
  for (const q of this.state.qa) {
5025
5388
  if (q.qaAgentId === id && q.status === "testing") this.setQa(q, { status: "queued", qaAgentId: null });
5026
5389
  if (q.devAgentId === id) q.devAgentId = null;
@@ -5029,7 +5392,8 @@ var Swarm = class {
5029
5392
  const repo = this.state.repos.find((r) => r.id === a.repoId);
5030
5393
  if (repo) {
5031
5394
  const slug = this.agentSlug(a);
5032
- void this.backend.releaseDesk(repo.fullName, slug, this.port(a)).then(() => this.backend.removeDesk(repo.fullName, slug)).catch(() => void 0);
5395
+ const main = this.backend.mainDir(repo.fullName);
5396
+ void this.backend.releaseDesk(repo.fullName, slug, this.port(a)).then(() => this.backend.removeDesk(repo.fullName, slug, main)).catch(() => void 0).then(() => this.sweepFloor(repo.id));
5033
5397
  }
5034
5398
  this.state.agents = this.state.agents.filter((x) => x.id !== id);
5035
5399
  this.agentRt.delete(id);
@@ -5073,9 +5437,12 @@ var Swarm = class {
5073
5437
  this.save();
5074
5438
  }
5075
5439
  issueTaken(repo, n) {
5076
- if (this.state.agents.some((a) => a.repoId === repo.id && a.role === "dev" && a.task !== "qa" && a.issueNumber === n && a.status !== "idle" && a.status !== "error")) return true;
5077
- const pulls = this.repoRt.get(repo.id)?.pulls ?? [];
5078
- return pulls.some((p) => p.state === "OPEN" && (p.closesIssues.includes(n) || p.headRefName.startsWith(`swarm/issue-${n}-`)));
5440
+ return issueTaken(
5441
+ n,
5442
+ this.state.agents.filter((a) => a.repoId === repo.id),
5443
+ this.repoRt.get(repo.id)?.pulls ?? [],
5444
+ Date.now()
5445
+ );
5079
5446
  }
5080
5447
  ensureSlot() {
5081
5448
  if (this.slotsFull()) {
@@ -5123,7 +5490,7 @@ var Swarm = class {
5123
5490
  }
5124
5491
  // ---------- terminals ----------
5125
5492
  async loadTerminal(agentId) {
5126
- if (!await fs10.stat(terminalFile(agentId)).catch(() => null)) return null;
5493
+ if (!await fs11.stat(terminalFile(agentId)).catch(() => null)) return null;
5127
5494
  const t = this.newTerminal(agentId);
5128
5495
  await t.load(terminalFile(agentId));
5129
5496
  return t;
@@ -5154,34 +5521,18 @@ var Swarm = class {
5154
5521
  t.attach(ws);
5155
5522
  }
5156
5523
  buildSystemAppend(a, repo, cwd, branch, fixing) {
5157
- const linked = this.linkedRepos(repo).map((r) => `- ${r.fullName}: read-only reference clone at ${this.backend.mainDir(r.fullName)}`);
5158
- const push = fixing ? `git push origin HEAD:${fixing.headRef}` : `git push -u origin ${branch}`;
5159
- return [
5160
- `You are ${a.name}, ${a.title ? `the team's ${a.title},` : "a software engineer"} on an autonomous agent team ("cubefarm"). Several teammates work in parallel on other issues of the same repository, each in their own git worktree. Nobody is watching live to answer questions, so make sensible decisions yourself and record assumptions in the PR description. The manager may occasionally send you messages; follow their instructions.`,
5161
- `Every pull request is reviewed and tested by a QA teammate. ${repo.autoMerge ? "Once they sign off and GitHub's checks pass, the office merges it by itself" : "Once they sign off, the manager merges it"}. If they find problems, or checks fail, or it conflicts with the default branch, you will get the details; fix them on the same branch.`,
5162
- a.brief ? `
5163
- Your job description:
5164
- ${a.brief}` : "",
5165
- "",
5166
- `Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
5167
- repo.summary ? `Project: ${repo.summary}` : "",
5168
- repo.mission ? `What the team is building (the manager's brief): ${repo.mission}` : "",
5169
- `Your worktree: ${cwd}`,
5170
- fixing ? `You are fixing pull request #${fixing.pr}. Its code is checked out on local branch ${branch}; push fixes with: ${push}. Do not open a new pull request.` : `Your branch: ${branch} (already checked out, created from origin/${repo.defaultBranch})`,
5171
- linked.length ? `Related repositories you may read for context (do not modify them):
5172
- ${linked.join("\n")}` : "",
5173
- "",
5174
- "Workflow:",
5175
- "1. Read the issue and explore the relevant code before changing anything.",
5176
- "2. Implement the change with focused commits and clear messages.",
5177
- "3. Run the project's existing tests, linters and build (if any) and fix what you broke. Install dependencies first if needed.",
5178
- repo.browserTesting ? `4. If the project has a web UI, start its dev server in the background on port ${this.port(a)} (reserved for you, so you don't collide with teammates), then check your change with the Playwright browser tools (mcp__playwright__browser_navigate, browser_snapshot, browser_click, browser_take_screenshot). Stop the dev server when you're done.` : "4. Verify the behaviour you changed as directly as you can.",
5179
- `5. Push: ${push}`,
5180
- fixing ? "6. Reply with a short summary of what you fixed." : `6. Open a pull request with the GitHub CLI: gh pr create --base ${repo.defaultBranch} --head ${branch} --title "<concise title>" --body "<what changed, how you verified it, assumptions>". The body must contain "Closes #<issue number>".`,
5181
- fixing ? "" : "7. End your final message with the pull request URL on its own line.",
5182
- "",
5183
- "Rules: never push to the default branch, never force-push, never merge pull requests yourself (the office merges them once QA and the checks pass), and never edit files outside your worktree. If you cannot finish, open a draft PR (gh pr create --draft) explaining what is left and why."
5184
- ].filter((l) => l !== "").join("\n");
5524
+ return devSystemPrompt({ agent: a, repo, port: this.port(a), cwd, branch, linked: this.linkedDirs(repo), fixing });
5525
+ }
5526
+ linkedDirs(repo) {
5527
+ return this.linkedRepos(repo).map((r) => ({ fullName: r.fullName, dir: this.backend.mainDir(r.fullName) }));
5528
+ }
5529
+ /** What an agent is told on a task, previewed with placeholders for the task's details. */
5530
+ agentPrompt(id) {
5531
+ const a = this.agent(id);
5532
+ if (a.role === "ceo") return ceoPromptPreview(ceoSystemPrompt(this.ceoPromptInput(a)));
5533
+ const repo = this.repo(a.repoId);
5534
+ const base = { agent: a, repo, port: this.port(a), slug: slugify(a.name) };
5535
+ return a.role === "qa" ? qaPromptPreview(base) : devPromptPreview({ ...base, linked: this.linkedDirs(repo) });
5185
5536
  }
5186
5537
  beginTask(a, patch, banner, preparing) {
5187
5538
  const rt = this.agentRt.get(a.id);
@@ -5224,7 +5575,7 @@ ${linked.join("\n")}` : "",
5224
5575
  }
5225
5576
  }
5226
5577
  async runTask(a, repo, issue2, note) {
5227
- const branch = `swarm/issue-${issue2.number}-${slugify(a.name)}`;
5578
+ const branch = devBranch(issue2.number, slugify(a.name));
5228
5579
  this.beginTask(
5229
5580
  a,
5230
5581
  { task: "issue", issueNumber: issue2.number, issueTitle: issue2.title, branch, prNumber: null, prUrl: null, sessionId: null },
@@ -5289,7 +5640,7 @@ Note from the manager: ${note}` : ""
5289
5640
  rt.shots.push({ data, mime, url: rt.browserUrl, at });
5290
5641
  if (rt.shots.length > 12) rt.shots.shift();
5291
5642
  this.broadcast({ type: "screen", agentId: a.id, url: rt.browserUrl, at });
5292
- void removeScreens(a.id).then(() => fs10.mkdir(SCREENS_DIR, { recursive: true })).then(() => fs10.writeFile(screenFile(a.id, mime), data)).catch((err) => console.warn("could not save screenshot", err));
5643
+ void removeScreens(a.id).then(() => fs11.mkdir(SCREENS_DIR, { recursive: true })).then(() => fs11.writeFile(screenFile(a.id, mime), data)).catch((err) => console.warn("could not save screenshot", err));
5293
5644
  },
5294
5645
  limited: (at) => this.pauseForLimit(at),
5295
5646
  usageWarning: (info) => this.paceForWarning(info),
@@ -5426,32 +5777,10 @@ Note from the manager: ${note}` : ""
5426
5777
  this.toast("info", `PR #${prNumber} is queued for QA`);
5427
5778
  }
5428
5779
  buildQaSystemAppend(a, repo, cwd, branch, pr) {
5429
- return [
5430
- `You are ${a.name}, ${a.title ? `the team's ${a.title},` : "a QA engineer"} on an autonomous agent team ("cubefarm"). Developers open pull requests; you review and independently verify each one before it is merged. Your sign-off is the review: ${repo.autoMerge ? "on this floor a PR you pass merges by itself as soon as GitHub's checks are green, so nobody else reads the code after you. " : ""}Be thorough and skeptical, but fair: fail a PR only for real problems (broken behaviour, failing tests or build, the issue's requirements not met, obvious regressions), not for style preferences.`,
5431
- ...a.brief ? ["", `Your job description:
5432
- ${a.brief}`] : [],
5433
- ...a.role === "dev" ? ["", "You're a developer covering for the QA lab while its testers are busy. You didn't write this pull request: test it as an independent QA engineer would."] : [],
5434
- "",
5435
- `Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
5436
- ...repo.summary ? [`Project: ${repo.summary}`] : [],
5437
- ...repo.mission ? [`What the team is building (the manager's brief): ${repo.mission}`] : [],
5438
- ...repo.qaBrief ? [`What to check on this project (from the CEO):
5439
- ${repo.qaBrief}`] : [],
5440
- `Pull request #${pr.number} "${pr.title}" from branch ${pr.headRefName}: ${pr.url}`,
5441
- `Your worktree: ${cwd}. It has the pull request's code checked out on local branch ${branch}.`,
5442
- "",
5443
- "How to test:",
5444
- "1. Read the PR description and the linked issue, and work out the acceptance criteria.",
5445
- `2. Review the code as a careful reviewer would: git diff origin/${repo.defaultBranch}...HEAD. Look for bugs, unhandled errors and edge cases, security problems, leftover debug code, and new logic without tests.`,
5446
- "3. Install dependencies if needed, then run the project's test suite, linters, type checks and build (whichever exist).",
5447
- repo.browserTesting ? `4. If the project has a UI, start it in the background on port ${this.port(a)} (reserved for you) and exercise the change in a real browser with the Playwright tools: navigate, click, type, resize to a phone size, try edge cases, and check the console for errors. Take a screenshot with browser_take_screenshot (no filename) of every important state: the screenshots are attached to the PR as evidence. Stop the server afterwards.` : "4. Exercise the changed behaviour directly (run the program, call the API, write a quick script).",
5448
- "5. You may write throwaway scripts to probe behaviour, but do not commit them.",
5449
- "",
5450
- "Rules: do not modify the code under test, do not commit, push, comment on, review or merge anything on GitHub. The office posts your report on the pull request. Finish with the structured QA report: verdict, summary, the checks you performed, the commands you ran and one caption per screenshot."
5451
- ].join("\n");
5780
+ return qaSystemPrompt({ agent: a, repo, port: this.port(a), cwd, branch, pr });
5452
5781
  }
5453
5782
  async runQa(a, repo, rec) {
5454
- const branch = `qa/pr-${rec.prNumber}-${slugify(a.name)}`;
5783
+ const branch = qaBranch(rec.prNumber, slugify(a.name));
5455
5784
  this.setQa(rec, { status: "testing", qaAgentId: a.id });
5456
5785
  this.beginTask(
5457
5786
  a,
@@ -5745,7 +6074,7 @@ Full report with screenshots: ${rec.commentUrl}` : "",
5745
6074
  if (patch.ceoHeartbeatMin !== void 0) s.ceoHeartbeatMin = Math.max(0, Math.min(1440, Math.round(Number(patch.ceoHeartbeatMin)) || 0));
5746
6075
  if (typeof patch.managerName === "string") s.managerName = patch.managerName.trim().slice(0, 40);
5747
6076
  if (typeof patch.companyName === "string") s.companyName = patch.companyName.trim().slice(0, 60);
5748
- if (typeof patch.projectsDir === "string" && patch.projectsDir.trim()) s.projectsDir = path12.resolve(patch.projectsDir.trim());
6077
+ if (typeof patch.projectsDir === "string" && patch.projectsDir.trim()) s.projectsDir = path13.resolve(patch.projectsDir.trim());
5749
6078
  if (typeof patch.setupDone === "boolean") s.setupDone = patch.setupDone;
5750
6079
  if (patch.tutorialStep !== void 0) s.tutorialStep = Math.max(-1, Math.round(Number(patch.tutorialStep)) || 0);
5751
6080
  if (typeof patch.autoUpdate === "boolean") s.autoUpdate = patch.autoUpdate;
@@ -5967,11 +6296,11 @@ Full report with screenshots: ${rec.commentUrl}` : "",
5967
6296
  }
5968
6297
  emitOfficeUpdate() {
5969
6298
  if (!this.officeHead) return;
5970
- const view = this.officeUpdateView();
5971
- const key = JSON.stringify(view);
6299
+ const view2 = this.officeUpdateView();
6300
+ const key = JSON.stringify(view2);
5972
6301
  if (key === this.lastOfficeView) return;
5973
6302
  this.lastOfficeView = key;
5974
- this.broadcast({ type: "officeUpdate", officeUpdate: view });
6303
+ this.broadcast({ type: "officeUpdate", officeUpdate: view2 });
5975
6304
  }
5976
6305
  /** The own-folder check found the office this many commits behind GitHub. */
5977
6306
  setOfficeBehind(behind) {
@@ -6125,11 +6454,11 @@ Full report with screenshots: ${rec.commentUrl}` : "",
6125
6454
  }
6126
6455
  ceoInfo() {
6127
6456
  const c = this.state.ceo;
6128
- const view = (j) => ({ kind: j.kind, label: jobLabel(j, this.ceoFloor(j.repoId)) });
6457
+ const view2 = (j) => ({ kind: j.kind, label: jobLabel(j, this.ceoFloor(j.repoId)) });
6129
6458
  const min = this.state.settings.ceoHeartbeatMin;
6130
6459
  return {
6131
- queue: c.queue.map(view),
6132
- job: c.job ? view(c.job) : null,
6460
+ queue: c.queue.map(view2),
6461
+ job: c.job ? view2(c.job) : null,
6133
6462
  lastReviewAt: c.lastReviewAt,
6134
6463
  nextReviewAt: min > 0 && c.lastReviewAt ? c.lastReviewAt + min * 6e4 : null
6135
6464
  };
@@ -6173,6 +6502,10 @@ ${job.text}` } : job;
6173
6502
  }
6174
6503
  void this.runCeoJob(a, job);
6175
6504
  }
6505
+ ceoPromptInput(a) {
6506
+ const s = this.state.settings;
6507
+ return { name: a.name, company: s.companyName, manager: s.managerName, notesFile: path13.join(CEO_DIR, "NOTES.md"), sessionLimit: s.sessionLimit, teamCap: s.teamCap, hiring: s.hiring };
6508
+ }
6176
6509
  async runCeoJob(a, job) {
6177
6510
  const rt = this.agentRt.get(a.id);
6178
6511
  const floor = this.ceoFloor(job.repoId);
@@ -6192,27 +6525,18 @@ ${job.text}` } : job;
6192
6525
  this.emitAgent(a);
6193
6526
  this.emitCeo();
6194
6527
  this.save();
6195
- await fs10.mkdir(CEO_DIR, { recursive: true }).catch(() => void 0);
6528
+ await fs11.mkdir(CEO_DIR, { recursive: true }).catch(() => void 0);
6196
6529
  if (a.status !== "working") {
6197
6530
  this.state.ceo.job = null;
6198
6531
  this.emitCeo();
6199
6532
  return;
6200
6533
  }
6201
- const s = this.state.settings;
6202
6534
  const how = this.sessionRuntime(a, job.kind === "chat" ? a.sessionId ?? void 0 : void 0);
6203
6535
  rt.session = this.backend.startSession(
6204
6536
  {
6205
6537
  cwd: CEO_DIR,
6206
6538
  prompt: ceoJobPrompt(job, floor),
6207
- systemAppend: ceoSystemPrompt({
6208
- name: a.name,
6209
- company: s.companyName,
6210
- manager: s.managerName,
6211
- notesFile: path12.join(CEO_DIR, "NOTES.md"),
6212
- sessionLimit: s.sessionLimit,
6213
- teamCap: s.teamCap,
6214
- hiring: s.hiring
6215
- }),
6539
+ systemAppend: ceoSystemPrompt(this.ceoPromptInput(a)),
6216
6540
  model: a.model || CEO_MODEL,
6217
6541
  effort: a.effort || CEO_EFFORT,
6218
6542
  browserTesting: false,
@@ -6435,6 +6759,12 @@ ${t}`);
6435
6759
  const mark = `\u2026 (truncated, ${brief.length} chars; see agent_detail)`;
6436
6760
  return brief.slice(0, 400 - mark.length).trimEnd() + mark;
6437
6761
  };
6762
+ const pending2 = this.state.requests.filter((r) => r.status === "pending");
6763
+ const seats = (r, role) => seatCount(
6764
+ MAX_DESKS[role],
6765
+ this.state.agents.filter((a) => a.repoId === r.id && a.role === role).length,
6766
+ pending2.filter((p) => p.kind === "hire" && p.repoId === r.id && p.role === role).length
6767
+ );
6438
6768
  const floors = [...this.state.repos].sort((x, y) => x.floor - y.floor).map((r) => {
6439
6769
  const rt = this.repoRt.get(r.id);
6440
6770
  const open = new Set(rt.issues.map((i) => i.number));
@@ -6450,6 +6780,7 @@ ${t}`);
6450
6780
  autoAssign: r.autoAssign,
6451
6781
  autoMerge: r.autoMerge,
6452
6782
  folderSync: rt.folderSync,
6783
+ seats: { dev: seats(r, "dev"), qa: seats(r, "qa") },
6453
6784
  capacity: {
6454
6785
  developers: this.state.agents.filter((a) => a.repoId === r.id && a.role === "dev").length,
6455
6786
  developersFree: this.available(r, "dev").length,
@@ -6503,10 +6834,11 @@ ${t}`);
6503
6834
  sessionLimit: s.sessionLimit || "none",
6504
6835
  sessionsRunning: this.running(),
6505
6836
  usage: usageLabel(this.usageNow(), Date.now()),
6506
- deskLimits: { dev: MAX_DESKS.dev, qa: MAX_DESKS.qa }
6837
+ deskLimits: { dev: MAX_DESKS.dev, qa: MAX_DESKS.qa },
6838
+ proposalLimit: { pending: pending2.length, max: MAX_PENDING_PROPOSALS }
6507
6839
  },
6508
6840
  floors,
6509
- pendingProposals: this.state.requests.filter((r) => r.status === "pending").map(req),
6841
+ pendingProposals: pending2.map(req),
6510
6842
  recentDecisions: this.state.requests.filter((r) => r.status !== "pending").slice(-10).map(req)
6511
6843
  },
6512
6844
  null,
@@ -6565,7 +6897,7 @@ ${t}`);
6565
6897
  if (!title) throw new Error("A hire needs a job title.");
6566
6898
  const specialty = specialtySlug(x.specialty);
6567
6899
  const pending2 = this.state.requests.filter((r) => r.status === "pending");
6568
- if (pending2.length >= MAX_PENDING_REQUESTS) throw new Error(`${pending2.length} proposals are already waiting for the manager. Wait for their decisions first.`);
6900
+ checkPendingLimit(pending2.length);
6569
6901
  const dup = pending2.find((r) => r.kind === "hire" && r.repoId === repo.id && r.role === role && r.specialty === specialty);
6570
6902
  if (dup) throw new Error(`${dup.name} (${dup.title}) is already proposed for floor ${repo.floor} with that specialty.`);
6571
6903
  const seated = this.state.agents.filter((a) => a.repoId === repo.id && a.role === role).length;
@@ -6613,7 +6945,7 @@ ${t}`);
6613
6945
  }
6614
6946
  const pending2 = this.state.requests.filter((r) => r.status === "pending");
6615
6947
  if (pending2.some((r) => r.kind === "let-go" && r.agentId === a.id)) throw new Error(`Letting ${a.name} go is already proposed.`);
6616
- if (pending2.length >= MAX_PENDING_REQUESTS) throw new Error(`${pending2.length} proposals are already waiting for the manager. Wait for their decisions first.`);
6948
+ checkPendingLimit(pending2.length);
6617
6949
  const req = {
6618
6950
  id: crypto6.randomUUID(),
6619
6951
  kind: "let-go",
@@ -6797,6 +7129,7 @@ app.post("/api/agents/:id/assign", route((req) => swarm.assign(String(req.params
6797
7129
  app.post("/api/agents/:id/stop", route((req) => swarm.stopAgent(String(req.params.id))));
6798
7130
  app.post("/api/agents/:id/reset", route((req) => swarm.resetAgent(String(req.params.id))));
6799
7131
  app.post("/api/agents/:id/message", route((req) => swarm.message(String(req.params.id), str(req.body.text))));
7132
+ app.get("/api/agents/:id/prompt", route((req) => swarm.agentPrompt(String(req.params.id))));
6800
7133
  app.get("/api/agents/:id/screen", (req, res) => {
6801
7134
  const shot = swarm.screenshot(String(req.params.id));
6802
7135
  if (!shot) return void res.status(404).end();
@@ -6814,10 +7147,10 @@ app.post(
6814
7147
  route((req) => swarm.approveRequest(String(req.params.id), { name: str(req.body?.name) || void 0, model: typeof req.body?.model === "string" ? req.body.model : void 0, effort: typeof req.body?.effort === "string" ? req.body.effort : void 0 }))
6815
7148
  );
6816
7149
  app.post("/api/requests/:id/reject", route((req) => swarm.rejectRequest(String(req.params.id), str(req.body?.note))));
6817
- var dist = path13.resolve(import.meta.dirname, "../dist");
6818
- if (fs11.existsSync(dist)) {
7150
+ var dist = path14.resolve(import.meta.dirname, "../dist");
7151
+ if (fs12.existsSync(dist)) {
6819
7152
  app.use(express.static(dist));
6820
- app.get(/^(?!\/api).*/, (_req, res) => res.sendFile(path13.join(dist, "index.html")));
7153
+ app.get(/^(?!\/api).*/, (_req, res) => res.sendFile(path14.join(dist, "index.html")));
6821
7154
  }
6822
7155
  app.use((err, _req, res, _next) => {
6823
7156
  const parserStatus = err?.status;