cubefarm 0.1.0 → 0.3.0

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 path7 from "node:path";
4
- import fs5 from "node:fs";
3
+ import path13 from "node:path";
4
+ import fs11 from "node:fs";
5
5
  import express from "express";
6
6
  import { WebSocketServer } from "ws";
7
7
 
@@ -154,6 +154,25 @@ async function createIssue(fullName, title, body, labels = []) {
154
154
  if (!match) throw new Error(`Could not read the new issue number from gh output: ${out}`);
155
155
  return Number(match[1]);
156
156
  }
157
+ async function issueState(fullName, number) {
158
+ try {
159
+ const raw = await ghJson(["issue", "view", String(number), "-R", fullName, "--json", "state"]);
160
+ return raw.state === "OPEN" ? "OPEN" : "CLOSED";
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+ async function editIssue(fullName, number, edit) {
166
+ const args = ["issue", "edit", String(number), "-R", fullName];
167
+ if (edit.body !== void 0) args.push("--body-file", "-");
168
+ for (const l of edit.addLabels ?? []) {
169
+ await ensureLabel(fullName, l);
170
+ args.push("--add-label", l);
171
+ }
172
+ for (const l of edit.removeLabels ?? []) args.push("--remove-label", l);
173
+ if (args.length === 5) return;
174
+ await gh(args, edit.body !== void 0 ? { input: edit.body || " " } : void 0);
175
+ }
157
176
  async function mergePull(fullName, number, method, headSha) {
158
177
  const pr = await ghJson(["pr", "view", String(number), "-R", fullName, "--json", "headRefName,isCrossRepository"]);
159
178
  await gh(["pr", "merge", String(number), "-R", fullName, `--${method}`, ...headSha ? ["--match-head-commit", headSha] : []], { timeoutMs: 6e4 });
@@ -282,37 +301,40 @@ async function syncMain(fullName, defaultBranch, opts) {
282
301
  const result = await withRepoLock(fullName, async () => {
283
302
  if (!await exists(path2.join(dir, ".git"))) return null;
284
303
  const g = (args, timeoutMs) => git(args, { cwd: dir, timeoutMs });
304
+ let behind = 0;
285
305
  try {
286
306
  await g(["fetch", "origin", "--prune"], 18e4);
287
307
  const target = `origin/${defaultBranch}`;
288
- const behind = Number(await g(["rev-list", "--count", `HEAD..${target}`]));
308
+ behind = Number(await g(["rev-list", "--count", `HEAD..${target}`]));
289
309
  const commits = `${behind} commit${behind === 1 ? "" : "s"}`;
290
310
  const branch = await g(["symbolic-ref", "--short", "-q", "HEAD"]).catch(() => "");
291
- if (branch !== defaultBranch) return { status: `on ${branch ? `branch ${branch}` : "a detached HEAD"}${behind ? ` (${defaultBranch} is ${commits} ahead)` : ""}` };
292
- if (behind === 0) return { status: "in sync" };
293
- if (Number(await g(["rev-list", "--count", `${target}..HEAD`])) > 0) return { status: `diverged: local commits, and ${commits} to pull` };
294
- if (!opts.touch) return { status: `update ready (${commits})` };
295
- if (await g(["status", "--porcelain", "--untracked-files=no"])) return { status: `${behind} behind: local changes` };
311
+ const stays = (status) => ({ status, behind, updatable: false });
312
+ if (branch !== defaultBranch) return stays(`on ${branch ? `branch ${branch}` : "a detached HEAD"}${behind ? ` (${defaultBranch} is ${commits} ahead)` : ""}`);
313
+ if (behind === 0) return stays("in sync");
314
+ if (Number(await g(["rev-list", "--count", `${target}..HEAD`])) > 0) return stays(`diverged: local commits, and ${commits} to pull`);
315
+ 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 };
296
317
  const before = await g(["rev-parse", "HEAD"]);
297
318
  try {
298
319
  await g(["merge", "--ff-only", target], 12e4);
299
320
  } catch {
300
- return { status: `${behind} behind: local files are in the way` };
321
+ return { status: `${behind} behind: local files are in the way`, behind, updatable: true };
301
322
  }
302
323
  const changed = (await g(["diff", "--name-only", before, "HEAD"])).split(/\r?\n/);
303
324
  const install = changed.some((f) => f === "package.json" || f === "package-lock.json") && await exists(path2.join(dir, "package.json"));
304
- return { status: `updated to ${await g(["rev-parse", "--short", "HEAD"])}`, install };
325
+ return { status: `updated to ${await g(["rev-parse", "--short", "HEAD"])}`, behind: 0, updatable: false, install };
305
326
  } catch (err) {
306
- return { status: `sync failed: ${err.message.split(/\r?\n/)[0].slice(0, 160)}` };
327
+ return { status: `sync failed: ${err.message.split(/\r?\n/)[0].slice(0, 160)}`, behind, updatable: false };
307
328
  }
308
329
  });
309
- if (!result?.install) return result?.status ?? null;
330
+ if (!result?.install) return result;
331
+ const { install: _install, ...sync } = result;
310
332
  try {
311
333
  await npmInstall(dir);
312
- return `${result.status} \xB7 dependencies installed`;
334
+ return { ...sync, status: `${sync.status} \xB7 dependencies installed` };
313
335
  } catch (err) {
314
336
  console.warn(`npm install in ${dir} failed:`, err.message);
315
- return `${result.status} \xB7 npm install failed`;
337
+ return { ...sync, status: `${sync.status} \xB7 npm install failed` };
316
338
  }
317
339
  }
318
340
  function githubFromConfig(config) {
@@ -452,20 +474,38 @@ function removeDesk(fullName, agentSlug) {
452
474
  });
453
475
  }
454
476
  var LAUNCHERS = ["node.exe", "cmd.exe", "bash.exe", "sh.exe", "conhost.exe", "python.exe", "npm.exe", "npx.exe", "bun.exe", "deno.exe"];
455
- async function releaseDesk(fullName, agentSlug, port) {
477
+ var pathForms = (p) => [.../* @__PURE__ */ new Set([p, p.replaceAll("\\", "/"), p.replaceAll("\\", "\\\\")])];
478
+ function leftoversInDesk(listing, desk, keep) {
479
+ const out = [];
480
+ for (const line of listing.split("\n")) {
481
+ const m = line.trim().match(/^(\d+)\s+(.*)$/);
482
+ if (!m) continue;
483
+ const [pid, args] = [Number(m[1]), m[2]];
484
+ if (keep.pids.includes(pid) || keep.markers.some((mark) => pathForms(mark).some((f) => args.includes(f)))) continue;
485
+ if (pathForms(desk).some((f) => args.includes(f))) out.push(pid);
486
+ }
487
+ return out;
488
+ }
489
+ async function releaseDesk(fullName, agentSlug, port, keep = { pids: [], markers: [] }) {
456
490
  const desk = deskDir(fullName, agentSlug);
491
+ const spare = [process.pid, ...keep.pids];
457
492
  if (process.platform === "win32") {
458
- const variants = [desk, desk.replaceAll("\\", "/")].map((d) => `'${d.toLowerCase().replaceAll("'", "''")}'`).join(", ");
493
+ const psList = (paths) => paths.flatMap(pathForms).map((d) => `'${d.toLowerCase().replaceAll("'", "''")}'`).join(", ");
459
494
  const script = `
460
495
  $ErrorActionPreference = 'SilentlyContinue'
496
+ $spare = @(${spare.join(", ")})
461
497
  $seed = @()
462
498
  Get-NetTCPConnection -LocalPort ${port} -State Listen | ForEach-Object { $seed += [int]$_.OwningProcess }
463
- $desks = @(${variants})
499
+ $desks = @(${psList([desk])})
500
+ $marks = @(${psList(keep.markers)})
464
501
  $all = Get-CimInstance Win32_Process
465
502
  $byId = @{}; foreach ($p in $all) { $byId[[int]$p.ProcessId] = $p }
466
503
  foreach ($p in $all) {
467
504
  if (-not $p.CommandLine) { continue }
468
505
  $cmd = $p.CommandLine.ToLower()
506
+ $ours = $false
507
+ foreach ($m in $marks) { if ($cmd.Contains($m)) { $ours = $true } }
508
+ if ($ours) { continue }
469
509
  foreach ($d in $desks) { if ($cmd.Contains($d)) { $seed += [int]$p.ProcessId } }
470
510
  }
471
511
  $launchers = @(${LAUNCHERS.map((n) => `'${n}'`).join(", ")})
@@ -473,7 +513,7 @@ $kill = New-Object 'System.Collections.Generic.HashSet[int]'
473
513
  foreach ($id in $seed) {
474
514
  $cur = $byId[$id]
475
515
  $first = $true
476
- while ($cur -and [int]$cur.ProcessId -ne ${process.pid} -and ($first -or $launchers -contains $cur.Name.ToLower())) {
516
+ while ($cur -and -not ($spare -contains [int]$cur.ProcessId) -and ($first -or $launchers -contains $cur.Name.ToLower())) {
477
517
  [void]$kill.Add([int]$cur.ProcessId)
478
518
  $first = $false
479
519
  $cur = $byId[[int]$cur.ParentProcessId]
@@ -484,8 +524,11 @@ Write-Output $kill.Count`;
484
524
  await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], { timeoutMs: 3e4 }).catch(() => void 0);
485
525
  return;
486
526
  }
487
- const pids = await run("sh", ["-c", `lsof -ti tcp:${port} -sTCP:LISTEN 2>/dev/null; pgrep -f ${JSON.stringify(desk)} 2>/dev/null`]).catch(() => "");
488
- for (const pid of pids.split(/\s+/).map(Number).filter((p) => p && p !== process.pid)) {
527
+ const listening = await run("sh", ["-c", `lsof -ti tcp:${port} -sTCP:LISTEN 2>/dev/null || true`]).catch(() => "");
528
+ const listing = await run("ps", ["-A", "-ww", "-o", "pid=,args="]).catch(() => "");
529
+ const pids = /* @__PURE__ */ new Set([...listening.split(/\s+/).map(Number), ...leftoversInDesk(listing, desk, keep)]);
530
+ for (const pid of pids) {
531
+ if (!pid || spare.includes(pid)) continue;
489
532
  try {
490
533
  process.kill(pid, "SIGKILL");
491
534
  } catch {
@@ -494,6 +537,7 @@ Write-Output $kill.Count`;
494
537
  }
495
538
 
496
539
  // server/agentRunner.ts
540
+ import fs3 from "node:fs";
497
541
  import path3 from "node:path";
498
542
  import { query } from "@anthropic-ai/claude-agent-sdk";
499
543
  var InputQueue = class {
@@ -522,48 +566,37 @@ var InputQueue = class {
522
566
  };
523
567
  }
524
568
  };
525
- var WRITE_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
526
- var BLOCKED_COMMANDS = [
527
- { re: /\bgit\s+push\b[^\n]*(\s--force\b|\s-f\b|\s--force-with-lease\b|\s\+\S)/, why: "force-pushing is not allowed" },
528
- { re: /\bgh\s+pr\s+merge\b/, why: "the office merges pull requests once QA and the checks pass, not agents" },
529
- { re: /\bgh\s+(repo\s+(delete|edit|rename|archive)|secret|auth|release\s+delete|api\s+-X\s*DELETE)\b/, why: "repository administration is off-limits" },
530
- { re: /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+(\/|~|[A-Za-z]:[\\/]?)(\s|$)/, why: "deleting a filesystem root is not allowed" },
531
- { re: /\b(shutdown|format\s+[a-z]:|mkfs|Remove-Item\s+[^\n]*-Recurse[^\n]*[A-Za-z]:\\\s*$)/i, why: "destructive system command" },
532
- { re: /\bgit\s+worktree\b/, why: "worktrees are managed by the swarm" }
533
- ];
534
- function isInside(child, parent) {
535
- const rel2 = path3.relative(parent, child);
536
- return rel2 === "" || !rel2.startsWith("..") && !path3.isAbsolute(rel2);
569
+ var allowAll = async (_toolName, input) => ({ behavior: "allow", updatedInput: input });
570
+ function screenshotFile(cwd, text) {
571
+ const file = text.match(/\]\(([^)\s]+\.(png|jpe?g))\)/i);
572
+ if (!file) return null;
573
+ try {
574
+ return { data: fs3.readFileSync(path3.resolve(cwd, file[1])), mime: /png$/i.test(file[2]) ? "image/png" : "image/jpeg" };
575
+ } catch {
576
+ return null;
577
+ }
537
578
  }
538
- var QA_BLOCKED = [
539
- { re: /\bgit\s+push\b/, why: "QA testers do not push code" },
540
- { re: /\bgh\s+(pr|issue)\s+(merge|close|reopen|review|comment|edit|ready|create)\b/, why: "QA testers report back to the office instead of changing PRs or issues" }
541
- ];
542
- function guardedCanUseTool(cwd, defaultBranchPush, role) {
543
- return async (toolName, input, { blockedPath }) => {
544
- if (toolName.startsWith("mcp__") && !toolName.startsWith("mcp__playwright__") && !(role === "ceo" && toolName.startsWith("mcp__office__"))) {
545
- return { behavior: "deny", message: "Only the Playwright browser tools are available to swarm agents." };
546
- }
547
- if (WRITE_TOOLS.has(toolName)) {
548
- const target = String(input.file_path ?? input.notebook_path ?? "");
549
- if (target && !isInside(path3.resolve(cwd, target), cwd)) {
550
- return { behavior: "deny", message: `You may only edit files inside your worktree (${cwd}).` };
551
- }
552
- }
553
- if (toolName === "Bash" || toolName === "PowerShell") {
554
- const cmd = String(input.command ?? "");
555
- for (const rule of role === "qa" ? [...BLOCKED_COMMANDS, ...QA_BLOCKED] : BLOCKED_COMMANDS) {
556
- if (rule.re.test(cmd)) return { behavior: "deny", message: `Blocked by cubefarm: ${rule.why}.` };
557
- }
558
- if (defaultBranchPush.test(cmd)) {
559
- return { behavior: "deny", message: "Push your own branch and open a PR; pushing to the default branch is not allowed." };
560
- }
561
- if (blockedPath && /\b(rm|del|rmdir|mv|move|Remove-Item)\b/i.test(cmd) && !isInside(path3.resolve(blockedPath), cwd)) {
562
- return { behavior: "deny", message: `That command touches ${blockedPath}, which is outside your worktree.` };
563
- }
579
+ var IMAGE_MIME = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg" };
580
+ function newScreenshots(dir, seen, now2 = Date.now()) {
581
+ let names;
582
+ try {
583
+ names = fs3.readdirSync(dir);
584
+ } catch {
585
+ return [];
586
+ }
587
+ const found2 = [];
588
+ for (const name of names.sort()) {
589
+ const mime = IMAGE_MIME[path3.extname(name).toLowerCase()];
590
+ const file = path3.join(dir, name);
591
+ if (!mime || seen.has(name)) continue;
592
+ try {
593
+ if (now2 - fs3.statSync(file).mtimeMs < 1e3) continue;
594
+ } catch {
595
+ continue;
564
596
  }
565
- return { behavior: "allow", updatedInput: input };
566
- };
597
+ found2.push({ name, file, mime });
598
+ }
599
+ return found2;
567
600
  }
568
601
  var clip = (s, n) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
569
602
  function rel(cwd, p) {
@@ -606,6 +639,12 @@ function describeTool(cwd, name, input) {
606
639
  return `Subagent: ${input.description ?? ""}`;
607
640
  case "TodoWrite":
608
641
  return "Update todo list";
642
+ case "ToolSearch":
643
+ return `Load tools ${String(input.query ?? "").replace(/^select:/, "")}`;
644
+ case "apply_patch": {
645
+ const files = [...String(input.command ?? "").matchAll(/^\*\*\* (Add|Update|Delete) File: (.+)$/gm)].map((m) => `${m[1] === "Update" ? "" : `${m[1].toLowerCase()} `}${rel(cwd, m[2].trim())}`);
646
+ return `Edit ${clip(files.join(", ") || "files", 200)}`;
647
+ }
609
648
  }
610
649
  if (name.startsWith("mcp__playwright__")) {
611
650
  const action = name.replace("mcp__playwright__browser_", "").replace("mcp__playwright__", "");
@@ -632,6 +671,8 @@ function describeOfficeTool(action, input) {
632
671
  return `\u{1F44B} propose_let_go ${String(input.agent_id ?? "")}`;
633
672
  case "file_issue":
634
673
  return `\u{1F4DD} file_issue "${clip(String(input.title ?? ""), 70)}"${floor}${input.specialty ? ` \xB7 ${input.specialty}` : ""}`;
674
+ case "route_issue":
675
+ return `\u{1F500} route_issue #${String(input.number ?? "?")}${floor}${input.specialty !== void 0 ? ` \xB7 ${input.specialty || "no specialty"}` : ""}${Array.isArray(input.depends_on) ? ` \xB7 depends on ${input.depends_on.map((n) => `#${n}`).join(", ") || "nothing"}` : ""}`;
635
676
  }
636
677
  return `\u{1F3E2} ${action}(${clip(JSON.stringify(input), 120)})`;
637
678
  }
@@ -652,6 +693,7 @@ function resultText(content) {
652
693
  var lineCount = (text) => text.replace(/\r/g, "").split("\n").filter((l) => l.trim() !== "").length;
653
694
  function summariseResult(cwd, tool2, text) {
654
695
  if (tool2 === "Edit" || tool2 === "MultiEdit" || tool2 === "NotebookEdit") return [{ kind: "result", text: " \u23BF Updated" }];
696
+ if (tool2 === "apply_patch" && /Success\. Updated the following files/.test(text)) return [{ kind: "result", text: " \u23BF Updated" }];
655
697
  if (tool2 === "Write") return [{ kind: "result", text: " \u23BF Saved" }];
656
698
  if (tool2 === "Read") return [{ kind: "result", text: ` \u23BF Read ${lineCount(text)} lines` }];
657
699
  if (tool2 === "Glob") return [{ kind: "result", text: ` \u23BF Found ${lineCount(text)} files` }];
@@ -661,7 +703,7 @@ function summariseResult(cwd, tool2, text) {
661
703
  if (lines.length > 3) shown.push({ kind: "result", text: ` \u2026 +${lines.length - 3} lines` });
662
704
  return shown;
663
705
  }
664
- function startSession(opts, callbacks, defaultBranch) {
706
+ function startSession(opts, callbacks) {
665
707
  let done = false;
666
708
  const cb = {
667
709
  ...callbacks,
@@ -693,8 +735,6 @@ function startSession(opts, callbacks, defaultBranch) {
693
735
  if (opts.office) mcpServers.office = opts.office.server;
694
736
  const disallowedTools = ["AskUserQuestion", "EnterPlanMode", "ExitPlanMode"];
695
737
  if (opts.role === "ceo") disallowedTools.push("Bash", "PowerShell", "NotebookEdit");
696
- const escaped = defaultBranch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
697
- const defaultBranchPush = new RegExp(`\\bgit\\s+push\\b[^\\n]*\\s(HEAD:)?(refs/heads/)?${escaped}(\\s|$)`);
698
738
  const options = {
699
739
  cwd: opts.cwd,
700
740
  additionalDirectories: opts.additionalDirectories,
@@ -702,11 +742,8 @@ function startSession(opts, callbacks, defaultBranch) {
702
742
  env,
703
743
  model: opts.model || void 0,
704
744
  effort: opts.effort,
705
- settingSources: ["project", "local"],
706
- // Agents get exactly the MCP servers passed below: no claude.ai connectors (Gmail, Drive…),
707
- // no user-level servers or plugins from the host machine.
708
- strictMcpConfig: true,
709
- settings: { disableClaudeAiConnectors: true },
745
+ // The manager's own setup (settings, skills, plugins, MCP servers, connectors), plus the office's servers below.
746
+ settingSources: ["user", "project", "local"],
710
747
  systemPrompt: { type: "preset", preset: "claude_code", append: opts.systemAppend },
711
748
  mcpServers,
712
749
  disallowedTools,
@@ -715,7 +752,8 @@ function startSession(opts, callbacks, defaultBranch) {
715
752
  const line = data.trim();
716
753
  if (line && /error|fail/i.test(line)) cb.log([{ kind: "error", text: clip(line, 200) }]);
717
754
  },
718
- ...opts.permissionMode === "bypass" ? { permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true } : { permissionMode: "acceptEdits", canUseTool: guardedCanUseTool(opts.cwd, defaultBranchPush, opts.role) },
755
+ permissionMode: "acceptEdits",
756
+ canUseTool: allowAll,
719
757
  ...opts.outputSchema ? { outputFormat: { type: "json_schema", schema: opts.outputSchema } } : {}
720
758
  };
721
759
  let emptyTurn;
@@ -747,7 +785,7 @@ function startSession(opts, callbacks, defaultBranch) {
747
785
  cb.sessionId(msg.session_id);
748
786
  const mcp = msg.mcp_servers.map((s) => `${s.name}:${s.status}`).join(", ");
749
787
  cb.log([
750
- { kind: "system", text: `\u273B Claude Code ${msg.claude_code_version} \xB7 ${msg.model} \xB7 ${msg.effort ?? opts.effort} effort \xB7 ${opts.permissionMode}` },
788
+ { kind: "system", text: `\u273B Claude Code ${msg.claude_code_version} \xB7 ${msg.model} \xB7 ${msg.effort ?? opts.effort} effort` },
751
789
  { kind: "system", text: ` cwd ${msg.cwd}${mcp ? ` \xB7 mcp ${mcp}` : ""}` }
752
790
  ]);
753
791
  } else if (msg.subtype === "api_retry") {
@@ -764,6 +802,9 @@ function startSession(opts, callbacks, defaultBranch) {
764
802
  cb.log([{ kind: "error", text: `\u26A0 Subscription usage ${info.status === "rejected" ? "limit reached" : "warning"} (${info.rateLimitType ?? "limit"}) \xB7 resets ${when}` }]);
765
803
  const overage = info.isUsingOverage || info.overageStatus === "allowed" || info.overageStatus === "allowed_warning";
766
804
  if (info.status === "rejected" && !overage) cb.limited?.(info.resetsAt ? info.resetsAt * 1e3 : null);
805
+ if (info.status === "allowed_warning" && !overage) {
806
+ cb.usageWarning?.({ resetsAt: info.resetsAt ? info.resetsAt * 1e3 : null, rateLimitType: info.rateLimitType ?? null, utilization: info.utilization ?? null });
807
+ }
767
808
  }
768
809
  break;
769
810
  }
@@ -778,12 +819,12 @@ function startSession(opts, callbacks, defaultBranch) {
778
819
  } else if (block.type === "thinking") {
779
820
  out.push({ kind: "thinking", text: "\u273B Thinking\u2026" });
780
821
  } else if (block.type === "tool_use") {
781
- const toolInput = block.input ?? {};
822
+ const toolInput2 = block.input ?? {};
782
823
  toolNames.set(block.id, block.name);
783
- out.push({ kind: "tool", tool: block.name, text: `\u23FA ${describeTool(opts.cwd, block.name, toolInput)}` });
784
- if (block.name === "TodoWrite") out.push(...todoLines(toolInput));
824
+ out.push({ kind: "tool", tool: block.name, text: `\u23FA ${describeTool(opts.cwd, block.name, toolInput2)}` });
825
+ if (block.name === "TodoWrite") out.push(...todoLines(toolInput2));
785
826
  cb.tool(block.name);
786
- if (block.name === "mcp__playwright__browser_navigate" && typeof toolInput.url === "string") cb.browserUrl(toolInput.url);
827
+ if (block.name === "mcp__playwright__browser_navigate" && typeof toolInput2.url === "string") cb.browserUrl(toolInput2.url);
787
828
  }
788
829
  }
789
830
  if (out.length) cb.log(out);
@@ -798,13 +839,17 @@ function startSession(opts, callbacks, defaultBranch) {
798
839
  if (block.type !== "tool_result") continue;
799
840
  const name = toolNames.get(block.tool_use_id) ?? "";
800
841
  const text = resultText(block.content);
842
+ let images = 0;
801
843
  if (Array.isArray(block.content)) {
802
844
  for (const part of block.content) {
803
845
  if (part.type === "image" && part.source.type === "base64") {
846
+ images += 1;
804
847
  cb.screenshot(Buffer.from(part.source.data, "base64"), part.source.media_type);
805
848
  }
806
849
  }
807
850
  }
851
+ const saved = name === "mcp__playwright__browser_take_screenshot" && !images ? screenshotFile(opts.cwd, text) : null;
852
+ if (saved) cb.screenshot(saved.data, saved.mime);
808
853
  if (name.startsWith("mcp__playwright__")) {
809
854
  const url = text.match(/Page URL:\s*(\S+)/);
810
855
  if (url) cb.browserUrl(url[1]);
@@ -856,12 +901,1029 @@ function startSession(opts, callbacks, defaultBranch) {
856
901
  };
857
902
  }
858
903
 
859
- // server/previewRunner.ts
904
+ // server/cliRunner.ts
905
+ import crypto3 from "node:crypto";
906
+ import fs6 from "node:fs";
907
+ import path7 from "node:path";
908
+ import { pathToFileURL } from "node:url";
909
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
910
+
911
+ // server/clis.ts
912
+ import fs4 from "node:fs";
913
+ import { createRequire } from "node:module";
914
+ import path4 from "node:path";
915
+ var CLIS = [
916
+ { id: "claude", label: "Claude Code", command: "claude", integrated: true },
917
+ { id: "codex", label: "Codex", command: "codex", integrated: false },
918
+ { id: "opencode", label: "OpenCode", command: "opencode", integrated: false }
919
+ ];
920
+ var isCli = (v) => CLIS.some((c) => c.id === v);
921
+ var cliLabel = (id) => CLIS.find((c) => c.id === id)?.label ?? id;
922
+ var WIN = process.platform === "win32";
923
+ function resolveCommand(name, env = process.env) {
924
+ const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH";
925
+ const dirs = (env[key] ?? "").split(path4.delimiter).filter(Boolean);
926
+ const exts = WIN ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((e) => /^\.(exe|cmd|bat|com)$/i.test(e)) : [""];
927
+ for (const dir of dirs) {
928
+ for (const ext of exts) {
929
+ const file = path4.join(dir, name + ext.toLowerCase());
930
+ try {
931
+ const st = fs4.statSync(file);
932
+ if (st.isFile() && (WIN || (st.mode & 73) !== 0)) return file;
933
+ } catch {
934
+ }
935
+ }
936
+ }
937
+ return null;
938
+ }
939
+ function unwrapCmdShim(shimPath, text) {
940
+ const line = text.split(/\r?\n/).find((l) => /%\*\s*$/.test(l));
941
+ if (!line) return null;
942
+ const dir = path4.dirname(shimPath);
943
+ const targets = [...line.matchAll(/"%dp0%\\([^"]+)"/g)].map((m) => path4.join(dir, m[1]));
944
+ const target = targets[targets.length - 1];
945
+ if (!target) return null;
946
+ if (/\.exe$/i.test(target)) return { file: target, args: [] };
947
+ if (!/\.[cm]?js$/i.test(target)) return null;
948
+ const between = line.slice(line.indexOf('"%_prog%"') + '"%_prog%"'.length, line.lastIndexOf(`"%dp0%\\`));
949
+ 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] };
952
+ }
953
+ var bundled;
954
+ function bundledClaude() {
955
+ if (bundled !== void 0) return bundled;
956
+ bundled = null;
957
+ let sdk;
958
+ try {
959
+ sdk = createRequire(import.meta.url).resolve("@anthropic-ai/claude-agent-sdk");
960
+ } catch {
961
+ return null;
962
+ }
963
+ const { platform, arch } = process;
964
+ const report = platform === "linux" ? process.report?.getReport() : void 0;
965
+ const musl = platform === "linux" && !report?.header?.glibcVersionRuntime;
966
+ const targets = platform === "linux" ? musl ? [`linux-${arch}-musl`, `linux-${arch}`] : [`linux-${arch}`, `linux-${arch}-musl`] : [`${platform}-${arch}`];
967
+ for (const target of targets) {
968
+ try {
969
+ bundled = createRequire(sdk).resolve(`@anthropic-ai/claude-agent-sdk-${target}/claude${WIN ? ".exe" : ""}`);
970
+ return bundled;
971
+ } catch {
972
+ }
973
+ }
974
+ return null;
975
+ }
976
+ function commandFor(id) {
977
+ const bundled2 = id === "claude" ? bundledClaude() : null;
978
+ if (bundled2) return { file: bundled2, args: [] };
979
+ const def = CLIS.find((c) => c.id === id);
980
+ const found2 = def && resolveCommand(def.command);
981
+ if (!found2) return null;
982
+ if (WIN && /\.(cmd|bat)$/i.test(found2)) {
983
+ try {
984
+ return unwrapCmdShim(found2, fs4.readFileSync(found2, "utf8")) ?? { file: found2, args: [] };
985
+ } catch {
986
+ return { file: found2, args: [] };
987
+ }
988
+ }
989
+ return { file: found2, args: [] };
990
+ }
991
+ async function detectClis() {
992
+ return Promise.all(
993
+ CLIS.map(async (c) => {
994
+ const cmd = commandFor(c.id);
995
+ const version = cmd ? await run(cmd.file, [...cmd.args, "--version"], { timeoutMs: 2e4 }).catch(() => null) : null;
996
+ return { id: c.id, label: c.label, installed: !!cmd, version: version?.split(/\r?\n/)[0].trim().slice(0, 60) || null, integrated: c.integrated };
997
+ })
998
+ );
999
+ }
1000
+ var EFFORT_CODEX = { low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "xhigh" };
1001
+ var CODEX_HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Interrupt"];
1002
+ function codexHookCommand(node, script, win = WIN) {
1003
+ return win ? `node "${script}"` : [node, script].map((s) => `'${s.replaceAll("'", `'"'"'`)}'`).join(" ");
1004
+ }
1005
+ function launchArgs(id, ctx) {
1006
+ switch (id) {
1007
+ case "claude": {
1008
+ const disallowed = ["AskUserQuestion", "EnterPlanMode", "ExitPlanMode", ...ctx.role === "ceo" ? ["Bash", "PowerShell", "NotebookEdit"] : []];
1009
+ const args = [
1010
+ ...ctx.resumeId ? ["--resume", ctx.resumeId] : ["--session-id", ctx.sessionId],
1011
+ ...ctx.model ? ["--model", ctx.model] : [],
1012
+ ...ctx.effort ? ["--effort", ctx.effort] : [],
1013
+ // The office's PreToolUse hook answers every permission question (allow, or deny with an office rule).
1014
+ "--permission-mode",
1015
+ "acceptEdits",
1016
+ // On top of the manager's own setup (settings, skills, plugins, MCP servers): the office's hooks and servers.
1017
+ "--settings",
1018
+ ctx.files.settings,
1019
+ ...ctx.files.mcp ? ["--mcp-config", ctx.files.mcp] : [],
1020
+ "--append-system-prompt-file",
1021
+ ctx.files.system,
1022
+ "--name",
1023
+ ctx.name,
1024
+ ...ctx.additionalDirectories.flatMap((d) => ["--add-dir", d]),
1025
+ "--disallowedTools",
1026
+ ...disallowed,
1027
+ "--",
1028
+ ctx.prompt
1029
+ ];
1030
+ return { args, env: { DISABLE_AUTOUPDATER: "1" } };
1031
+ }
1032
+ case "codex": {
1033
+ const toml = (s) => JSON.stringify(s);
1034
+ const args = [
1035
+ "--no-alt-screen",
1036
+ "-c",
1037
+ `notify=[${[process.execPath, ctx.notify.script, ctx.notify.url].map(toml).join(",")}]`,
1038
+ "-c",
1039
+ `developer_instructions=${toml(ctx.systemAppend)}`,
1040
+ ...ctx.model ? ["-m", ctx.model] : [],
1041
+ ...ctx.effort ? ["-c", `model_reasoning_effort=${toml(EFFORT_CODEX[ctx.effort])}`] : [],
1042
+ ...ctx.browser ? ["-c", `mcp_servers.playwright={command=${toml(ctx.browser.command)},args=[${ctx.browser.args.map(toml).join(",")}]}`] : [],
1043
+ ...CODEX_HOOK_EVENTS.flatMap((e) => ["-c", `hooks.${e}=[{hooks=[{type="command",command=${toml(codexHookCommand(process.execPath, ctx.codexHook))},timeout=10}]}]`]),
1044
+ // Like the manager's own Codex, but it can't stop to ask: no approvals and no sandbox (its sandbox can't reach
1045
+ // the credential store, so git and gh fail in it).
1046
+ "--dangerously-bypass-approvals-and-sandbox"
1047
+ ];
1048
+ return { args: ctx.resumeId ? ["resume", ...args, ctx.resumeId, ctx.prompt] : [...args, "--", ctx.prompt], env: { CUBEFARM_HOOK_URL: ctx.notify.url } };
1049
+ }
1050
+ case "opencode": {
1051
+ const config = {
1052
+ plugin: [ctx.plugin],
1053
+ instructions: [ctx.files.system],
1054
+ autoupdate: false,
1055
+ // several agents starting at once must not each reinstall it
1056
+ permission: { edit: "allow", bash: "allow", webfetch: "allow" },
1057
+ // it can't stop to ask either
1058
+ ...ctx.browser ? { mcp: { playwright: { type: "local", command: [ctx.browser.command, ...ctx.browser.args], enabled: true } } } : {}
1059
+ };
1060
+ const args = [
1061
+ "--auto",
1062
+ ...ctx.model ? ["--model", ctx.model] : [],
1063
+ ...ctx.resumeId ? ["--session", ctx.resumeId] : [],
1064
+ "--prompt",
1065
+ ctx.prompt
1066
+ ];
1067
+ return { args, env: { OPENCODE_CONFIG_CONTENT: JSON.stringify(config), CUBEFARM_NOTIFY_URL: ctx.notify.url } };
1068
+ }
1069
+ }
1070
+ }
1071
+ var TRUST_PROMPT = /Quick safety check|Do you trust the (files|contents) (in|of) this|trust this folder|allow Codex to work in this folder/i;
1072
+ function trustKey(screen) {
1073
+ if (!TRUST_PROMPT.test(screen)) return null;
1074
+ const selected = screen.match(/^\s*[❯›]\s*(?:\d+\.\s*)?(.+)$/m)?.[1] ?? "";
1075
+ if (/^(yes|trust|continue|proceed)\b/i.test(selected.trim())) return "enter";
1076
+ if (/^(no|exit|quit|cancel)\b/i.test(selected.trim())) return "down";
1077
+ return null;
1078
+ }
1079
+ function hookReviewKey(screen) {
1080
+ if (!/Hooks need review/i.test(screen)) return null;
1081
+ const selected = screen.match(/^\s*[❯›]\s*(?:\d+\.\s*)?(.+)$/m)?.[1] ?? "";
1082
+ return /^continue without trusting/i.test(selected.trim()) ? "enter" : "down";
1083
+ }
1084
+ function interruptions(screen) {
1085
+ return screen.split("\n").filter((l) => /\binterrupted\b/i.test(l)).length;
1086
+ }
1087
+ var STATUSLINE_SOURCE = String.raw`// cubefarm: Claude Code's status line. Forwards the session's status to the office and prints the office's line.
1088
+ const url = process.argv[2];
1089
+ let body = '';
1090
+ process.stdin.on('data', (c) => (body += c));
1091
+ process.stdin.on('end', async () => {
1092
+ try {
1093
+ const status = JSON.parse(body);
1094
+ const res = await fetch(url, {
1095
+ method: 'POST',
1096
+ headers: { 'content-type': 'application/json' },
1097
+ body: JSON.stringify({ hook_event_name: 'StatusLine', session_id: status.session_id, cost: status.cost, rate_limits: status.rate_limits }),
1098
+ signal: AbortSignal.timeout(2000),
1099
+ });
1100
+ const out = await res.json();
1101
+ process.stdout.write(String(out.statusLine ?? ''));
1102
+ } catch {
1103
+ process.stdout.write('cubefarm');
1104
+ }
1105
+ });
1106
+ `;
1107
+ var NOTIFY_SOURCE = String.raw`// cubefarm: Codex's notify program. Tells the office a turn is complete.
1108
+ const [url, payload] = process.argv.slice(2);
1109
+ let event = {};
1110
+ try {
1111
+ event = JSON.parse(payload ?? '{}');
1112
+ } catch {}
1113
+ if (event.type === 'agent-turn-complete') {
1114
+ const input = String((event['input-messages'] ?? [])[0] ?? '').slice(0, 300);
1115
+ fetch(url, {
1116
+ method: 'POST',
1117
+ headers: { 'content-type': 'application/json' },
1118
+ body: JSON.stringify({ hook_event_name: 'TurnComplete', session_id: event['thread-id'] ?? null, last_assistant_message: event['last-assistant-message'] ?? '', input }),
1119
+ signal: AbortSignal.timeout(3000),
1120
+ }).catch(() => {});
1121
+ }
1122
+ `;
1123
+ var CODEX_HOOK_SOURCE = String.raw`// cubefarm: Codex's hooks. Tells the office what Codex is doing; never changes what it does.
1124
+ const url = process.env.CUBEFARM_HOOK_URL;
1125
+ const chunks = [];
1126
+ process.stdin.on('data', (c) => chunks.push(c));
1127
+ process.stdin.on('end', async () => {
1128
+ try {
1129
+ if (url) await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: Buffer.concat(chunks), signal: AbortSignal.timeout(5000) });
1130
+ } catch {}
1131
+ process.stdout.write('{}');
1132
+ });
1133
+ `;
1134
+ var OPENCODE_PLUGIN_SOURCE = String.raw`// cubefarm: tells the office when OpenCode's session goes idle.
1135
+ export default async function CubefarmPlugin({ client } = {}) {
1136
+ const url = process.env.CUBEFARM_NOTIFY_URL;
1137
+ if (!url) return {};
1138
+ let root;
1139
+ const post = (body) =>
1140
+ fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(3000) }).catch(() => {});
1141
+ const lastReply = async (id) => {
1142
+ try {
1143
+ const res = await client.session.messages({ path: { id } });
1144
+ const reply = (res?.data ?? []).filter((m) => m?.info?.role === 'assistant').pop();
1145
+ return (reply?.parts ?? []).filter((p) => p?.type === 'text').map((p) => p.text).join('\n').trim();
1146
+ } catch {
1147
+ return '';
1148
+ }
1149
+ };
1150
+ return {
1151
+ event: async ({ event }) => {
1152
+ const props = event?.properties ?? {};
1153
+ if (event?.type === 'session.created' && !props.info?.parentID && !root) root = props.info?.id;
1154
+ const sessionID = props.sessionID ?? props.info?.id;
1155
+ if (event?.type === 'session.idle' && (!root || sessionID === root)) {
1156
+ post({ hook_event_name: 'TurnComplete', session_id: sessionID ?? null, last_assistant_message: sessionID ? await lastReply(sessionID) : '' });
1157
+ }
1158
+ if (event?.type === 'session.error') post({ hook_event_name: 'TurnError', session_id: sessionID ?? null, error: String(props.error?.data?.message ?? props.error?.name ?? 'error') });
1159
+ },
1160
+ };
1161
+ }
1162
+ `;
1163
+
1164
+ // server/ptyClient.ts
1165
+ import crypto2 from "node:crypto";
1166
+ import fs5 from "node:fs";
1167
+ import net from "node:net";
1168
+ import path6 from "node:path";
860
1169
  import { spawn } from "node:child_process";
1170
+
1171
+ // server/ptyProtocol.ts
861
1172
  import crypto from "node:crypto";
862
- import fs3 from "node:fs/promises";
863
- import net from "node:net";
864
- import path4 from "node:path";
1173
+ import path5 from "node:path";
1174
+ var PTY_PROTOCOL = 1;
1175
+ function hostSocket(home, platform = process.platform) {
1176
+ if (platform !== "win32") return path5.join(home, "pty.sock");
1177
+ const tag = crypto.createHash("sha1").update(path5.resolve(home).toLowerCase()).digest("hex").slice(0, 12);
1178
+ return `\\\\.\\pipe\\cubefarm-pty-${tag}`;
1179
+ }
1180
+ var encode = (m) => `${JSON.stringify(m)}
1181
+ `;
1182
+ function readMessages(sock, onMessage2) {
1183
+ let buf = "";
1184
+ sock.setEncoding("utf8");
1185
+ sock.on("data", (chunk) => {
1186
+ buf += chunk;
1187
+ let nl;
1188
+ while ((nl = buf.indexOf("\n")) >= 0) {
1189
+ const line = buf.slice(0, nl);
1190
+ buf = buf.slice(nl + 1);
1191
+ if (!line) continue;
1192
+ let m;
1193
+ try {
1194
+ m = JSON.parse(line);
1195
+ } catch {
1196
+ continue;
1197
+ }
1198
+ onMessage2(m);
1199
+ }
1200
+ });
1201
+ }
1202
+
1203
+ // server/ptyClient.ts
1204
+ var nodePty = await import("@lydell/node-pty").catch((err) => {
1205
+ console.warn(` terminals unavailable: ${err.message}`);
1206
+ return null;
1207
+ });
1208
+ var terminalsAvailable = nodePty !== null;
1209
+ var conn = null;
1210
+ var leaving = false;
1211
+ var hookPort = 0;
1212
+ var hostPid = 0;
1213
+ var found = [];
1214
+ var remote = /* @__PURE__ */ new Map();
1215
+ var hookHandler = () => ({});
1216
+ var send = (m) => {
1217
+ if (conn && !conn.destroyed) conn.write(encode(m));
1218
+ };
1219
+ var RemotePty = class {
1220
+ constructor(id) {
1221
+ this.id = id;
1222
+ remote.set(id, this);
1223
+ }
1224
+ id;
1225
+ pid = 0;
1226
+ dataFns = [];
1227
+ exitFns = [];
1228
+ done = false;
1229
+ write(data) {
1230
+ send({ op: "write", id: this.id, data });
1231
+ }
1232
+ resize(cols, rows) {
1233
+ send({ op: "resize", id: this.id, cols, rows });
1234
+ }
1235
+ kill() {
1236
+ send({ op: "kill", id: this.id });
1237
+ }
1238
+ setMeta(meta) {
1239
+ send({ op: "meta", id: this.id, meta });
1240
+ }
1241
+ onData(cb) {
1242
+ this.dataFns.push(cb);
1243
+ }
1244
+ onExit(cb) {
1245
+ this.exitFns.push(cb);
1246
+ }
1247
+ data(data) {
1248
+ for (const fn of this.dataFns) fn(data);
1249
+ }
1250
+ exit(code) {
1251
+ if (this.done) return;
1252
+ this.done = true;
1253
+ remote.delete(this.id);
1254
+ for (const fn of this.exitFns) fn(code);
1255
+ }
1256
+ };
1257
+ function onMessage(m) {
1258
+ switch (m.op) {
1259
+ case "spawned": {
1260
+ const p = remote.get(m.id);
1261
+ if (p) p.pid = m.pid;
1262
+ return;
1263
+ }
1264
+ case "failed":
1265
+ console.warn(` could not start a terminal: ${m.error}`);
1266
+ return remote.get(m.id)?.exit(-1);
1267
+ case "data":
1268
+ return remote.get(m.id)?.data(m.data);
1269
+ case "exit":
1270
+ return remote.get(m.id)?.exit(m.code);
1271
+ case "hook":
1272
+ void Promise.resolve().then(() => hookHandler(m.token, m.body)).catch(() => ({})).then((answer) => send({ op: "hookReply", rid: m.rid, answer }));
1273
+ return;
1274
+ }
1275
+ }
1276
+ function connect(socket, secret) {
1277
+ return new Promise((resolve) => {
1278
+ const sock = net.connect(socket);
1279
+ let ready = false;
1280
+ const fail = () => {
1281
+ clearTimeout(timer);
1282
+ sock.destroy();
1283
+ resolve(null);
1284
+ };
1285
+ const timer = setTimeout(fail, 2e3);
1286
+ sock.once("error", fail);
1287
+ sock.once("connect", () => sock.write(encode({ op: "hello", secret, version: PTY_PROTOCOL })));
1288
+ readMessages(sock, (m) => {
1289
+ if (ready) return onMessage(m);
1290
+ if (m.op !== "ready") return;
1291
+ ready = true;
1292
+ clearTimeout(timer);
1293
+ sock.removeListener("error", fail);
1294
+ sock.on("error", () => void 0);
1295
+ resolve({ ready: m, sock });
1296
+ });
1297
+ sock.on("close", () => {
1298
+ if (conn !== sock) return;
1299
+ conn = null;
1300
+ if (leaving) return;
1301
+ console.warn(" the terminal keeper stopped; running agents lost their terminals");
1302
+ for (const p of [...remote.values()]) p.exit(-1);
1303
+ });
1304
+ });
1305
+ }
1306
+ var runtimeFlags = () => process.execArgv.filter((a, i, all) => !/^--(inspect|watch|debug)/.test(a) && !/^--(inspect|watch|debug)/.test(all[i - 1] ?? ""));
1307
+ function launch(socket, secretFile) {
1308
+ const entry = path6.join(import.meta.dirname, `ptyHost${path6.extname(import.meta.filename)}`);
1309
+ spawn(process.execPath, [...runtimeFlags(), entry, "launch", socket, secretFile], { detached: true, stdio: "ignore", windowsHide: true }).once("error", () => void 0).unref();
1310
+ }
1311
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1312
+ async function startKeeper(home, onHook) {
1313
+ hookHandler = onHook;
1314
+ if (!nodePty) return [];
1315
+ const socket = hostSocket(home);
1316
+ const secretFile = path6.join(home, "pty.secret");
1317
+ try {
1318
+ if (!fs5.existsSync(secretFile)) {
1319
+ fs5.mkdirSync(home, { recursive: true });
1320
+ fs5.writeFileSync(secretFile, crypto2.randomBytes(24).toString("hex"), { mode: 384 });
1321
+ }
1322
+ } catch (err) {
1323
+ console.warn(` terminal keeper unavailable: ${err.message}`);
1324
+ return [];
1325
+ }
1326
+ const secret = fs5.readFileSync(secretFile, "utf8").trim();
1327
+ let hello = await connect(socket, secret);
1328
+ if (hello && hello.ready.version !== PTY_PROTOCOL) {
1329
+ console.log(` replacing the terminal keeper (v${hello.ready.version}); the ${hello.ready.ptys.length} terminal(s) it held are stopped`);
1330
+ hello.sock.end(encode({ op: "shutdown" }));
1331
+ hello = null;
1332
+ await sleep(3500);
1333
+ }
1334
+ if (!hello) {
1335
+ launch(socket, secretFile);
1336
+ for (let i = 0; i < 30 && !hello; i++) {
1337
+ await sleep(200);
1338
+ hello = await connect(socket, secret);
1339
+ }
1340
+ }
1341
+ if (!hello) {
1342
+ console.warn(" terminal keeper unavailable: agents' terminals run in the office and stop when it restarts");
1343
+ return [];
1344
+ }
1345
+ conn = hello.sock;
1346
+ leaving = false;
1347
+ hookPort = hello.ready.hookPort;
1348
+ hostPid = hello.ready.pid ?? 0;
1349
+ found = hello.ready.ptys;
1350
+ return found;
1351
+ }
1352
+ var keeperPid = () => conn ? hostPid : 0;
1353
+ function keeperHookUrl() {
1354
+ return conn ? `http://127.0.0.1:${hookPort}` : null;
1355
+ }
1356
+ function spawnPty(file, args, o, meta) {
1357
+ if (conn) {
1358
+ const p2 = new RemotePty(crypto2.randomUUID());
1359
+ send({ op: "spawn", id: p2.id, file, args, cwd: o.cwd, env: o.env, cols: o.cols, rows: o.rows, meta });
1360
+ return p2;
1361
+ }
1362
+ if (!nodePty) throw new Error("the terminal module could not be loaded on this machine");
1363
+ const p = nodePty.spawn(file, args, { name: "xterm-256color", cols: o.cols, rows: o.rows, cwd: o.cwd, env: o.env });
1364
+ return {
1365
+ get pid() {
1366
+ return p.pid;
1367
+ },
1368
+ write: (data) => p.write(data),
1369
+ resize: (cols, rows) => p.resize(cols, rows),
1370
+ kill: () => killTree(p),
1371
+ setMeta: () => void 0,
1372
+ onData: (cb) => void p.onData(cb),
1373
+ onExit: (cb) => void p.onExit(({ exitCode }) => cb(exitCode))
1374
+ };
1375
+ }
1376
+ function adoptPty(held) {
1377
+ const p = new RemotePty(held.id);
1378
+ p.pid = held.pid;
1379
+ queueMicrotask(() => send({ op: "attach", id: held.id }));
1380
+ return p;
1381
+ }
1382
+ function discardPty(held) {
1383
+ send({ op: "kill", id: held.id });
1384
+ }
1385
+ function hooksReady() {
1386
+ send({ op: "hooksReady" });
1387
+ }
1388
+ function leaveKeeper() {
1389
+ leaving = true;
1390
+ conn?.end();
1391
+ }
1392
+ function killTree(proc) {
1393
+ if (process.platform === "win32") {
1394
+ const tk = spawn("taskkill", ["/PID", String(proc.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
1395
+ tk.once("error", () => void 0);
1396
+ return;
1397
+ }
1398
+ try {
1399
+ process.kill(-proc.pid, "SIGTERM");
1400
+ } catch {
1401
+ proc.kill();
1402
+ }
1403
+ }
1404
+
1405
+ // server/cliRunner.ts
1406
+ var SESSIONS_DIR = path7.join(HOME_DIR, "sessions");
1407
+ var BIN_DIR = path7.join(HOME_DIR, "bin");
1408
+ var FINISH_GRACE_MS = 3e3;
1409
+ var BOOT_MS = 6e4;
1410
+ var USAGE_WARN_PCT = 90;
1411
+ var INLINE_PROMPT_MAX = 8e3;
1412
+ var officeUrl = "";
1413
+ function setOfficeUrl(url) {
1414
+ officeUrl = url;
1415
+ }
1416
+ var routes = /* @__PURE__ */ new Map();
1417
+ function handleHook(token, body) {
1418
+ const route2 = routes.get(token);
1419
+ if (!route2 || !body || typeof body !== "object" || Array.isArray(body)) return {};
1420
+ try {
1421
+ return route2.hook(body);
1422
+ } catch (err) {
1423
+ console.warn("hook failed", err);
1424
+ return {};
1425
+ }
1426
+ }
1427
+ async function handleMcp(token, req, res) {
1428
+ const office = routes.get(token)?.office;
1429
+ if (!office) {
1430
+ res.status(404).json({ jsonrpc: "2.0", error: { code: -32001, message: "This session has ended." }, id: null });
1431
+ return;
1432
+ }
1433
+ const server2 = office.serve();
1434
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0, enableJsonResponse: true });
1435
+ res.on("close", () => {
1436
+ void transport.close();
1437
+ void server2.close();
1438
+ });
1439
+ await server2.connect(transport);
1440
+ await transport.handleRequest(req, res, req.body);
1441
+ }
1442
+ var helpersWritten = false;
1443
+ function writeHelpers() {
1444
+ if (helpersWritten) return;
1445
+ fs6.mkdirSync(BIN_DIR, { recursive: true });
1446
+ fs6.writeFileSync(path7.join(BIN_DIR, "statusline.cjs"), STATUSLINE_SOURCE);
1447
+ fs6.writeFileSync(path7.join(BIN_DIR, "notify.cjs"), NOTIFY_SOURCE);
1448
+ fs6.writeFileSync(path7.join(BIN_DIR, "codex-hook.cjs"), CODEX_HOOK_SOURCE);
1449
+ fs6.writeFileSync(path7.join(BIN_DIR, "opencode-plugin.mjs"), OPENCODE_PLUGIN_SOURCE);
1450
+ helpersWritten = true;
1451
+ }
1452
+ var shellArg = (p) => `"${p.replaceAll("\\", "/")}"`;
1453
+ var HOOK_EVENTS = ["UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "Stop", "StopFailure", "Notification"];
1454
+ function playwrightServer(outputDir) {
1455
+ const args = ["-y", "@playwright/mcp@latest", "--headless", "--isolated", "--output-dir", outputDir, "--init-script", path7.join(import.meta.dirname, "browser-init.js")];
1456
+ return process.platform === "win32" ? { command: "cmd", args: ["/c", "npx", ...args] } : { command: "npx", args };
1457
+ }
1458
+ function toolOutput(response) {
1459
+ const images = [];
1460
+ const blocks = Array.isArray(response) ? response : Array.isArray(response?.content) ? response.content : null;
1461
+ if (blocks) {
1462
+ for (const b of blocks) {
1463
+ const source = b?.source;
1464
+ if (b?.type === "image" && source?.type === "base64" && source.data) images.push({ data: source.data, mime: source.media_type ?? "image/png" });
1465
+ else if (b?.type === "image" && typeof b.data === "string") images.push({ data: b.data, mime: String(b.mimeType ?? "image/png") });
1466
+ }
1467
+ return { text: resultText(blocks), images };
1468
+ }
1469
+ if (typeof response === "string") return { text: response, images };
1470
+ const r = response;
1471
+ if (r && typeof r === "object") {
1472
+ if (typeof r.stdout === "string" || typeof r.stderr === "string") return { text: [r.stdout, r.stderr].filter((s) => typeof s === "string" && s).join("\n"), images };
1473
+ if (typeof r.file?.content === "string") return { text: r.file.content, images };
1474
+ if (Array.isArray(r.filenames)) return { text: r.filenames.join("\n"), images };
1475
+ }
1476
+ return { text: "", images };
1477
+ }
1478
+ function quietly(fn) {
1479
+ try {
1480
+ fn();
1481
+ } catch {
1482
+ }
1483
+ }
1484
+ var lives = /* @__PURE__ */ new Map();
1485
+ var hooksHinted = false;
1486
+ var IDLE_MS = 30 * 6e4;
1487
+ var ALLOW = { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", permissionDecisionReason: "Approved by cubefarm" } };
1488
+ var toolInput = (b) => b.tool_input && typeof b.tool_input === "object" ? b.tool_input : {};
1489
+ function endLive(live) {
1490
+ if (lives.get(live.term) !== live) return;
1491
+ lives.delete(live.term);
1492
+ clearTimeout(live.idleTimer);
1493
+ routes.delete(live.token);
1494
+ live.term.releaseIdle = null;
1495
+ live.term.bind(null);
1496
+ live.proc?.kill();
1497
+ live.proc = null;
1498
+ live.term.note(`\u2500\u2500 ${cliLabel(live.cli)} session ended \u2500\u2500`);
1499
+ fs6.rm(live.dir, { recursive: true, force: true, maxRetries: 5 }, () => void 0);
1500
+ }
1501
+ var metaOf = (l) => ({ agentId: l.agentId, cli: l.cli, token: l.token, dir: l.dir, resumeId: l.resumeId, busy: !!l.session });
1502
+ var remember = (l) => l.proc?.setMeta(metaOf(l));
1503
+ function wire(l, office) {
1504
+ const p = l.proc;
1505
+ lives.set(l.term, l);
1506
+ routes.set(l.token, { hook: (b) => l.session ? l.session.hook(b) : idleHook(l, b), office });
1507
+ l.term.bind({ write: (d) => quietly(() => p.write(d)), resize: (c, r) => quietly(() => p.resize(c, r)) });
1508
+ p.onData((data) => {
1509
+ if (lives.get(l.term) === l) l.term.write(data);
1510
+ });
1511
+ p.onExit((code) => {
1512
+ l.proc = null;
1513
+ if (lives.get(l.term) !== l) return;
1514
+ if (l.session) l.session.exited(code);
1515
+ else endLive(l);
1516
+ });
1517
+ }
1518
+ function waitAtPrompt(l) {
1519
+ clearTimeout(l.idleTimer);
1520
+ l.idleTimer = setTimeout(() => endLive(l), IDLE_MS);
1521
+ l.term.releaseIdle = () => endLive(l);
1522
+ remember(l);
1523
+ }
1524
+ async function reconnectClis(terminalFor) {
1525
+ const held = await startKeeper(HOME_DIR, handleHook);
1526
+ const back = [];
1527
+ for (const h of held) {
1528
+ const meta = h.meta ?? {};
1529
+ const term = meta.agentId ? terminalFor(meta.agentId) : null;
1530
+ if (h.exit !== null || !term || lives.has(term) || !isCli(meta.cli) || !meta.token || !meta.dir || !meta.agentId) {
1531
+ discardPty(h);
1532
+ continue;
1533
+ }
1534
+ const l = { agentId: meta.agentId, cli: meta.cli, term, proc: adoptPty(h), token: meta.token, dir: meta.dir, resumeId: meta.resumeId ?? null, statusLine: "\u{1F3E2} cubefarm", shots: /* @__PURE__ */ new Set(), session: null };
1535
+ wire(l);
1536
+ waitAtPrompt(l);
1537
+ setTimeout(() => quietly(() => (l.proc?.resize(term.cols - 1, term.rows), l.proc?.resize(term.cols, term.rows))), 500);
1538
+ back.push({ agentId: l.agentId, cli: l.cli, resumeId: l.resumeId, busy: !!meta.busy });
1539
+ }
1540
+ return back;
1541
+ }
1542
+ function officeProcesses() {
1543
+ const pids = [keeperPid(), ...[...lives.values()].map((l) => l.proc?.pid ?? 0)].filter((p) => p > 0);
1544
+ return { pids, markers: [SESSIONS_DIR, BIN_DIR] };
1545
+ }
1546
+ function releaseClis(restart) {
1547
+ if (!restart) for (const l of [...lives.values()]) endLive(l);
1548
+ leaveKeeper();
1549
+ }
1550
+ function idleHook(live, b) {
1551
+ switch (String(b.hook_event_name ?? "")) {
1552
+ case "PreToolUse":
1553
+ return ALLOW;
1554
+ case "UserPromptSubmit": {
1555
+ const text = String(b.prompt ?? "").trim();
1556
+ if (!text || live.term.onIdlePrompt?.(text)) return {};
1557
+ return { decision: "block", reason: "The office couldn't take that on as a follow-up right now (see the agent's panel). Nothing was sent." };
1558
+ }
1559
+ case "StatusLine":
1560
+ return { statusLine: live.statusLine };
1561
+ }
1562
+ return {};
1563
+ }
1564
+ function startCliSession(opts, callbacks) {
1565
+ const term = opts.terminal;
1566
+ const prev = lives.get(term);
1567
+ const waiting = !!prev?.proc && !prev.session;
1568
+ const adopt = waiting && (!!opts.reattach || prev.cli === (opts.cli ?? "claude") && !!opts.resumeSessionId && prev.resumeId === opts.resumeSessionId);
1569
+ if (prev && !adopt) endLive(prev);
1570
+ let live = adopt ? prev : null;
1571
+ const cli = live?.cli ?? opts.cli ?? "claude";
1572
+ const label = cliLabel(cli);
1573
+ const token = crypto3.randomUUID();
1574
+ const sessionId = opts.resumeSessionId ?? crypto3.randomUUID();
1575
+ const dir = path7.join(SESSIONS_DIR, token);
1576
+ const started = Date.now();
1577
+ const statusLine = `\u{1F3E2} cubefarm${opts.label ? ` \xB7 ${opts.label}` : ""}`;
1578
+ const toolNames = /* @__PURE__ */ new Map();
1579
+ const officePrompts = [];
1580
+ const launched = [];
1581
+ let mainThread = opts.resumeSessionId ?? null;
1582
+ const usageResets = /* @__PURE__ */ new Map();
1583
+ const warned = /* @__PURE__ */ new Set();
1584
+ let done = false;
1585
+ let costUsd = 0;
1586
+ let turns = 0;
1587
+ let lastText = "";
1588
+ let turnError = null;
1589
+ let begun = false;
1590
+ let finishTimer;
1591
+ let bootTimer;
1592
+ let screenTimer;
1593
+ let shotTimer;
1594
+ const nudged = /* @__PURE__ */ new Set();
1595
+ let trustKeys = 0;
1596
+ let trustedAt = 0;
1597
+ const cb = { ...callbacks };
1598
+ const log = (entries) => entries.length && cb.log(entries);
1599
+ const collectShots = (all = false) => {
1600
+ const l2 = live;
1601
+ if (!l2 || cli === "claude" || !opts.browserTesting) return;
1602
+ for (const shot of newScreenshots(path7.join(l2.dir, "browser"), l2.shots, all ? Infinity : Date.now())) {
1603
+ l2.shots.add(shot.name);
1604
+ try {
1605
+ cb.screenshot(fs6.readFileSync(shot.file), shot.mime);
1606
+ } catch {
1607
+ }
1608
+ }
1609
+ };
1610
+ const finish = (r, keep = false) => {
1611
+ if (done) return;
1612
+ done = true;
1613
+ for (const t of [finishTimer, bootTimer]) clearTimeout(t);
1614
+ clearInterval(screenTimer);
1615
+ clearInterval(shotTimer);
1616
+ collectShots(true);
1617
+ if (term.onInput === watchKeys) term.onInput = null;
1618
+ const l2 = live;
1619
+ if (l2 && lives.get(term) === l2) {
1620
+ l2.session = null;
1621
+ if (keep && opts.keepAlive && l2.proc) waitAtPrompt(l2);
1622
+ else endLive(l2);
1623
+ }
1624
+ cb.tool(null);
1625
+ cb.finished({ ...r, costUsd, turns });
1626
+ };
1627
+ const turnEnded = (text) => {
1628
+ lastText = text;
1629
+ cb.tool(null);
1630
+ clearTimeout(finishTimer);
1631
+ finishTimer = setTimeout(() => finish({ ok: !turnError, text: lastText, errors: turnError ? [turnError] : [] }, true), FINISH_GRACE_MS);
1632
+ };
1633
+ const busy = () => {
1634
+ begun = true;
1635
+ clearTimeout(finishTimer);
1636
+ };
1637
+ const interrupted = () => finish({ ok: false, text: lastText, errors: ["Interrupted in the terminal"], interrupted: true }, true);
1638
+ const watchKeys = (data) => {
1639
+ if (data !== "\x1B" || done || !begun) return;
1640
+ const before = interruptions(term.screen());
1641
+ let looks = 0;
1642
+ const look = () => {
1643
+ if (done) return;
1644
+ if (interruptions(term.screen()) > before) interrupted();
1645
+ else if (++looks < 4) setTimeout(look, 1e3);
1646
+ };
1647
+ setTimeout(look, 800);
1648
+ };
1649
+ term.onInput = watchKeys;
1650
+ const assistantLines = (text) => {
1651
+ const lines = text.split("\n");
1652
+ const out = lines.slice(0, 30).map((l2, i) => ({ kind: "text", text: `${i === 0 ? "\u25CF " : " "}${l2}` }));
1653
+ if (lines.length > 30) out.push({ kind: "text", text: ` \u2026 (${lines.length - 30} more lines)` });
1654
+ return out;
1655
+ };
1656
+ const onStatus = (b) => {
1657
+ const cost = Number(b.cost?.total_cost_usd);
1658
+ if (Number.isFinite(cost)) costUsd = cost;
1659
+ const limits = b.rate_limits ?? {};
1660
+ for (const [type, l2] of Object.entries(limits)) {
1661
+ const used = Number(l2?.used_percentage);
1662
+ if (!Number.isFinite(used)) continue;
1663
+ const resetsAt = Number(l2?.resets_at) > 0 ? Number(l2?.resets_at) * 1e3 : null;
1664
+ usageResets.set(type, used >= 100 ? resetsAt : null);
1665
+ if (used >= USAGE_WARN_PCT && used < 100 && !warned.has(type)) {
1666
+ warned.add(type);
1667
+ cb.usageWarning?.({ resetsAt, rateLimitType: type, utilization: used / 100 });
1668
+ }
1669
+ }
1670
+ return { statusLine };
1671
+ };
1672
+ const hook = (b) => {
1673
+ const event = String(b.hook_event_name ?? "");
1674
+ const sub = typeof b.agent_id === "string" && b.agent_id !== "";
1675
+ switch (event) {
1676
+ case "PreToolUse": {
1677
+ const name = String(b.tool_name ?? "");
1678
+ const input = toolInput(b);
1679
+ if (!sub) {
1680
+ busy();
1681
+ turns += 1;
1682
+ toolNames.set(String(b.tool_use_id ?? ""), name);
1683
+ log([{ kind: "tool", tool: name, text: `\u23FA ${describeTool(opts.cwd, name, input)}` }, ...name === "TodoWrite" ? todoLines(input) : []]);
1684
+ cb.tool(name);
1685
+ if (name === "mcp__playwright__browser_navigate" && typeof input.url === "string") cb.browserUrl(input.url);
1686
+ }
1687
+ return ALLOW;
1688
+ }
1689
+ case "PostToolUse":
1690
+ case "PostToolUseFailure": {
1691
+ if (sub) return {};
1692
+ const name = toolNames.get(String(b.tool_use_id ?? "")) ?? String(b.tool_name ?? "");
1693
+ const out = toolOutput(b.tool_response);
1694
+ if (cli === "claude") for (const img of out.images) cb.screenshot(Buffer.from(img.data, "base64"), img.mime);
1695
+ const saved = name === "mcp__playwright__browser_take_screenshot" && !out.images.length ? screenshotFile(opts.cwd, out.text) : null;
1696
+ if (saved) cb.screenshot(saved.data, saved.mime);
1697
+ if (name.startsWith("mcp__playwright__")) {
1698
+ const url = out.text.match(/Page URL:\s*(\S+)/);
1699
+ if (url) cb.browserUrl(url[1]);
1700
+ }
1701
+ if (event === "PostToolUseFailure") log([{ kind: "error", text: ` \u23BF ${clip(tidyPaths(opts.cwd, String(b.error ?? out.text ?? "Error")).split("\n")[0] || "Error", 200)}` }]);
1702
+ else if (name !== "TodoWrite") log(summariseResult(opts.cwd, name, out.text));
1703
+ cb.tool(null);
1704
+ return {};
1705
+ }
1706
+ case "UserPromptSubmit": {
1707
+ if (sub) return {};
1708
+ busy();
1709
+ const prompt2 = String(b.prompt ?? "").trim();
1710
+ const i = officePrompts.findIndex((p2) => p2.slice(0, 60) === prompt2.slice(0, 60));
1711
+ if (i >= 0) officePrompts.splice(i, 1);
1712
+ else if (prompt2) log([{ kind: "manager", text: `\u25B6 Typed in the terminal: ${clip(prompt2.split("\n")[0], 200)}` }]);
1713
+ return {};
1714
+ }
1715
+ case "Stop": {
1716
+ const text = String(b.last_assistant_message ?? "").trim();
1717
+ turns += 1;
1718
+ if (text) {
1719
+ log(assistantLines(text));
1720
+ cb.turn?.(text);
1721
+ }
1722
+ turnEnded(text);
1723
+ return {};
1724
+ }
1725
+ case "StopFailure": {
1726
+ const error = String(b.error ?? "unknown");
1727
+ const details = String(b.error_details ?? "").trim();
1728
+ if (error === "rate_limit") cb.limited?.([...usageResets.values()].find((t) => t !== null) ?? null);
1729
+ finish({ ok: false, text: lastText, errors: [`${label} stopped: ${error.replace(/_/g, " ")}${details ? ` (${clip(details, 160)})` : ""}`] }, true);
1730
+ return {};
1731
+ }
1732
+ case "Notification": {
1733
+ if (b.notification_type === "permission_prompt") log([{ kind: "error", text: `\u26A0 ${label} is waiting for a permission answer: open the terminal to answer it.` }]);
1734
+ return {};
1735
+ }
1736
+ case "StatusLine":
1737
+ return onStatus(b);
1738
+ case "Interrupt":
1739
+ if (!sub) interrupted();
1740
+ return {};
1741
+ // Codex's notify program and the OpenCode plugin
1742
+ case "TurnComplete": {
1743
+ const thread = typeof b.session_id === "string" ? b.session_id : "";
1744
+ if (cli === "codex" && thread !== mainThread) {
1745
+ const input = String(b.input ?? "").trim();
1746
+ if (mainThread || !launched.some((p2) => input.startsWith(p2.slice(0, 40)))) return {};
1747
+ mainThread = thread;
1748
+ }
1749
+ if (thread) {
1750
+ cb.sessionId(thread);
1751
+ if (live && live.resumeId !== thread) {
1752
+ live.resumeId = thread;
1753
+ remember(live);
1754
+ }
1755
+ }
1756
+ const text = String(b.last_assistant_message ?? "").trim();
1757
+ turns += 1;
1758
+ if (text) {
1759
+ log(assistantLines(text));
1760
+ cb.turn?.(text);
1761
+ }
1762
+ turnEnded(text);
1763
+ return {};
1764
+ }
1765
+ case "TurnError":
1766
+ turnError = `${label}: ${clip(String(b.error ?? "error"), 200)}`;
1767
+ log([{ kind: "error", text: `\u2717 ${turnError}` }]);
1768
+ return {};
1769
+ }
1770
+ return {};
1771
+ };
1772
+ const exited = (exitCode) => {
1773
+ const ok = exitCode === 0 && (lastText !== "" || cli !== "claude" && Date.now() - started > 2e4);
1774
+ finish({ ok, text: lastText, errors: ok ? [] : [`${label} exited${exitCode ? ` with code ${exitCode}` : ""} before finishing.`] });
1775
+ };
1776
+ const handle = {
1777
+ send(text) {
1778
+ const proc = live?.proc;
1779
+ if (done || !proc) return;
1780
+ busy();
1781
+ turnError = null;
1782
+ officePrompts.push(text.trim());
1783
+ const clean = text.replace(/\x1b\[20[01]~/g, "").replace(/\r\n?/g, "\n");
1784
+ launched.push(clean.trim());
1785
+ quietly(() => proc.write(`\x1B[200~${clean}\x1B[201~`));
1786
+ setTimeout(() => quietly(() => live?.proc?.write("\r")), 150);
1787
+ },
1788
+ stop() {
1789
+ finish({ ok: false, text: "", errors: ["Stopped by manager"] });
1790
+ }
1791
+ };
1792
+ if (adopt && live) {
1793
+ clearTimeout(live.idleTimer);
1794
+ term.releaseIdle = null;
1795
+ live.session = { hook, exited };
1796
+ Object.assign(live, { statusLine });
1797
+ remember(live);
1798
+ if (live.resumeId) cb.sessionId(live.resumeId);
1799
+ if (opts.typed || opts.reattach) busy();
1800
+ else handle.send(opts.prompt);
1801
+ shotTimer = setInterval(collectShots, 2e3);
1802
+ return handle;
1803
+ }
1804
+ if (opts.reattach) {
1805
+ setTimeout(() => finish({ ok: false, text: "", errors: [`${label} stopped while the office restarted.`] }), 0);
1806
+ return handle;
1807
+ }
1808
+ const cmd = commandFor(cli);
1809
+ if (!terminalsAvailable || !cmd) {
1810
+ const why = !terminalsAvailable ? "the terminal module could not be loaded on this machine" : `${label} isn't installed (no "${cli}" on PATH)`;
1811
+ setTimeout(() => finish({ ok: false, text: "", errors: [`Could not start ${label}: ${why}.`] }), 0);
1812
+ return handle;
1813
+ }
1814
+ const keeper = keeperHookUrl();
1815
+ const hookUrl = `${keeper ?? officeUrl}/api/hooks/${token}`;
1816
+ const browser = opts.browserTesting ? playwrightServer(path7.join(dir, "browser")) : null;
1817
+ let prompt = opts.prompt;
1818
+ if (opts.outputSchema) {
1819
+ prompt += `
1820
+
1821
+ When you are done, end your final message with your report as one JSON object in a \`\`\`json block, matching this JSON schema:
1822
+ ${JSON.stringify(opts.outputSchema)}`;
1823
+ }
1824
+ let files;
1825
+ try {
1826
+ writeHelpers();
1827
+ fs6.mkdirSync(dir, { recursive: true });
1828
+ const settings = {
1829
+ hooks: Object.fromEntries(HOOK_EVENTS.map((e) => [e, [{ hooks: [{ type: "http", url: hookUrl, timeout: keeper ? 120 : 30 }] }]])),
1830
+ statusLine: { type: "command", command: `${shellArg(process.execPath)} ${shellArg(path7.join(BIN_DIR, "statusline.cjs"))} ${shellArg(hookUrl)}`, padding: 0 }
1831
+ };
1832
+ const servers = {};
1833
+ if (browser) servers.playwright = browser;
1834
+ if (opts.office) servers.office = { type: "http", url: `${officeUrl}/api/mcp/${token}` };
1835
+ files = { settings: path7.join(dir, "settings.json"), mcp: Object.keys(servers).length ? path7.join(dir, "mcp.json") : null, system: path7.join(dir, "instructions.md") };
1836
+ fs6.writeFileSync(files.settings, JSON.stringify(settings, null, 2));
1837
+ if (files.mcp) fs6.writeFileSync(files.mcp, JSON.stringify({ mcpServers: servers }, null, 2));
1838
+ fs6.writeFileSync(files.system, opts.systemAppend);
1839
+ if (prompt.length > INLINE_PROMPT_MAX) {
1840
+ const file = path7.join(dir, "task.md");
1841
+ fs6.writeFileSync(file, prompt);
1842
+ prompt = `Your task is in ${file}. Read all of it first, then do what it says.`;
1843
+ }
1844
+ } catch (err) {
1845
+ setTimeout(() => finish({ ok: false, text: "", errors: [`Could not prepare ${label}: ${err.message}`] }), 0);
1846
+ return handle;
1847
+ }
1848
+ officePrompts.push(prompt.trim());
1849
+ launched.push(prompt.trim());
1850
+ const launch2 = launchArgs(cli, {
1851
+ prompt,
1852
+ systemAppend: opts.systemAppend,
1853
+ model: opts.model,
1854
+ effort: opts.effort,
1855
+ resumeId: opts.resumeSessionId,
1856
+ sessionId,
1857
+ name: opts.label ?? "cubefarm",
1858
+ role: opts.role,
1859
+ additionalDirectories: opts.additionalDirectories,
1860
+ files,
1861
+ notify: { script: path7.join(BIN_DIR, "notify.cjs"), url: hookUrl },
1862
+ codexHook: path7.join(BIN_DIR, "codex-hook.cjs"),
1863
+ plugin: pathToFileURL(path7.join(BIN_DIR, "opencode-plugin.mjs")).href,
1864
+ browser
1865
+ });
1866
+ const env = {};
1867
+ for (const [k, v] of Object.entries(process.env)) {
1868
+ if (v === void 0) continue;
1869
+ if (/^(ANTHROPIC_|CLAUDE)/i.test(k) && k !== "CLAUDE_CONFIG_DIR") continue;
1870
+ if (/^(NO_COLOR|FORCE_COLOR|TERM_PROGRAM|TERM_PROGRAM_VERSION|CODEX_THREAD_ID|OPENCODE_CONFIG_CONTENT)$/i.test(k)) continue;
1871
+ env[k] = v;
1872
+ }
1873
+ Object.assign(env, { TERM: "xterm-256color", COLORTERM: "truecolor" }, launch2.env);
1874
+ if (cli === "claude") cb.sessionId(sessionId);
1875
+ term.note(`\u2500\u2500 ${label}${opts.label ? ` \xB7 ${opts.label}` : ""} \u2500\u2500`);
1876
+ const resumeId = cli === "claude" ? sessionId : opts.resumeSessionId ?? null;
1877
+ const l = { agentId: opts.agentId ?? "", cli, term, proc: null, token, dir, resumeId, statusLine, shots: /* @__PURE__ */ new Set(), session: { hook, exited } };
1878
+ let p;
1879
+ try {
1880
+ p = spawnPty(cmd.file, [...cmd.args, ...launch2.args], { cols: term.cols, rows: term.rows, cwd: opts.cwd, env }, metaOf(l));
1881
+ } catch (err) {
1882
+ setTimeout(() => finish({ ok: false, text: "", errors: [`Could not start ${label}: ${err.message}`] }), 0);
1883
+ return handle;
1884
+ }
1885
+ l.proc = p;
1886
+ live = l;
1887
+ wire(l, opts.office);
1888
+ shotTimer = setInterval(collectShots, 2e3);
1889
+ screenTimer = setInterval(() => {
1890
+ if (done || Date.now() - started > BOOT_MS * 2) return clearInterval(screenTimer);
1891
+ const screen = term.screen();
1892
+ const review = hookReviewKey(screen);
1893
+ const key = trustKeys < 12 && Date.now() - trustedAt > 3e3 ? trustKey(screen) ?? review : null;
1894
+ if (key === "down") {
1895
+ trustKeys += 1;
1896
+ quietly(() => p.write(term.appCursor ? "\x1BOB" : "\x1B[B"));
1897
+ } else if (key === "enter") {
1898
+ trustKeys += 1;
1899
+ trustedAt = Date.now();
1900
+ if (!review) log([{ kind: "system", text: `\u2713 Trusted the worktree for ${label}.` }]);
1901
+ else if (!hooksHinted) {
1902
+ hooksHinted = true;
1903
+ log([{ kind: "system", text: `\u2139 To see ${label}'s steps here, trust the office's hooks once: in this terminal, type /hooks and press t. Until then the office shows its task.` }]);
1904
+ }
1905
+ quietly(() => p.write("\r"));
1906
+ }
1907
+ if (!nudged.has("login") && /Not logged in|run \/login|Select login method|Sign in with ChatGPT|Please login|Invalid API key/i.test(screen)) {
1908
+ nudged.add("login");
1909
+ log([{ kind: "error", text: `\u26A0 ${label} needs you to sign in: open the terminal and sign in there, or run "${cli}" in a terminal of your own.` }]);
1910
+ } else if (!nudged.has("setup") && !trustKey(screen) && Date.now() - trustedAt > 5e3 && /Choose the text style|Press Enter to continue|Bypass Permissions mode|Settings Error/i.test(screen)) {
1911
+ nudged.add("setup");
1912
+ log([{ kind: "error", text: `\u26A0 ${label} is waiting on a setup screen: open the terminal to answer it.` }]);
1913
+ }
1914
+ }, 1e3);
1915
+ bootTimer = setTimeout(() => {
1916
+ if (!done && !begun && cli === "claude") log([{ kind: "error", text: `\u26A0 ${label} hasn't started on the task yet. Open the terminal to see what it's waiting for.` }]);
1917
+ }, BOOT_MS);
1918
+ return handle;
1919
+ }
1920
+
1921
+ // server/previewRunner.ts
1922
+ import { spawn as spawn2 } from "node:child_process";
1923
+ import crypto4 from "node:crypto";
1924
+ import fs7 from "node:fs/promises";
1925
+ import net2 from "node:net";
1926
+ import path8 from "node:path";
865
1927
  var PREVIEW_SLUG = "preview";
866
1928
  var PREVIEW_BRANCH = "swarm-preview";
867
1929
  var PREVIEW_TMP = ".preview-tmp";
@@ -882,7 +1944,7 @@ function previewEnv(extra, port, tmp) {
882
1944
  }
883
1945
  async function portOpen(port) {
884
1946
  const probe = (host) => new Promise((resolve) => {
885
- const sock = net.connect({ port, host });
1947
+ const sock = net2.connect({ port, host });
886
1948
  const done = (ok) => {
887
1949
  sock.destroy();
888
1950
  resolve(ok);
@@ -894,10 +1956,10 @@ async function portOpen(port) {
894
1956
  const [v4, v6] = await Promise.all([probe("127.0.0.1"), probe("::1")]);
895
1957
  return v4 || v6;
896
1958
  }
897
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1959
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
898
1960
  async function readJson(file) {
899
1961
  try {
900
- return JSON.parse(await fs3.readFile(file, "utf8"));
1962
+ return JSON.parse(await fs7.readFile(file, "utf8"));
901
1963
  } catch {
902
1964
  return null;
903
1965
  }
@@ -910,11 +1972,11 @@ function defaultCommand(pkg) {
910
1972
  const vite = /^\s*vite(\s|$)/.test(script) && !/--port\b/.test(script);
911
1973
  return `npm run ${name}${vite ? " -- --port {port} --strictPort" : ""}`;
912
1974
  }
913
- function killTree(child) {
1975
+ function killTree2(child) {
914
1976
  if (child.pid === void 0 || child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
915
1977
  if (process.platform === "win32") {
916
1978
  return new Promise((resolve) => {
917
- const tk = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
1979
+ const tk = spawn2("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
918
1980
  tk.once("error", () => resolve());
919
1981
  tk.once("close", () => resolve());
920
1982
  });
@@ -935,7 +1997,7 @@ var RealPreview = class {
935
1997
  void this.run().catch((err) => {
936
1998
  if (this.stopped || err instanceof Stopped) return;
937
1999
  this.stopped = true;
938
- if (this.child) void killTree(this.child);
2000
+ if (this.child) void killTree2(this.child);
939
2001
  this.cb.failed(err.message);
940
2002
  });
941
2003
  }
@@ -946,7 +2008,7 @@ var RealPreview = class {
946
2008
  partial = "";
947
2009
  async stop() {
948
2010
  this.stopped = true;
949
- if (this.child) await killTree(this.child);
2011
+ if (this.child) await killTree2(this.child);
950
2012
  }
951
2013
  check() {
952
2014
  if (this.stopped) throw new Stopped();
@@ -958,9 +2020,9 @@ var RealPreview = class {
958
2020
  this.check();
959
2021
  await ensureLocalExcludes(job.fullName).catch(() => void 0);
960
2022
  cb.commit(await git(["rev-parse", "--short", "HEAD"], { cwd: wt }).catch(() => ""));
961
- const tmp = path4.join(wt, PREVIEW_TMP);
962
- await fs3.mkdir(tmp, { recursive: true });
963
- const pkg = await readJson(path4.join(wt, "package.json"));
2023
+ const tmp = path8.join(wt, PREVIEW_TMP);
2024
+ await fs7.mkdir(tmp, { recursive: true });
2025
+ const pkg = await readJson(path8.join(wt, "package.json"));
964
2026
  const command = job.command ?? defaultCommand(pkg);
965
2027
  if (!command) {
966
2028
  this.stopped = true;
@@ -987,42 +2049,42 @@ var RealPreview = class {
987
2049
  this.check();
988
2050
  if (exitCode !== void 0) throw new Error(`The app exited (code ${exitCode}) before it listened on port ${job.port}.`);
989
2051
  if (Date.now() > deadline) throw new Error(`The app did not listen on port ${job.port} within 3 minutes. Does the command use PORT or {port}?`);
990
- await sleep(700);
2052
+ await sleep2(700);
991
2053
  }
992
2054
  this.check();
993
2055
  cb.status("running");
994
2056
  const code = await exited;
995
- while (!this.stopped && await portOpen(job.port)) await sleep(3e3);
2057
+ while (!this.stopped && await portOpen(job.port)) await sleep2(3e3);
996
2058
  if (this.stopped) return;
997
2059
  this.stopped = true;
998
2060
  cb.failed(`The app stopped (exit code ${code}).`);
999
2061
  }
1000
2062
  /** npm ci (or npm install without a lockfile), skipped when package.json and the lockfile haven't changed. */
1001
2063
  async install(wt, env) {
1002
- const lockFile = path4.join(wt, "package-lock.json");
1003
- const lock = await fs3.readFile(lockFile, "utf8").catch(() => null);
1004
- const hash = crypto.createHash("sha256").update(await fs3.readFile(path4.join(wt, "package.json"), "utf8")).update("\0").update(lock ?? "").digest("hex");
1005
- const marker = path4.join(wt, "node_modules", INSTALL_MARKER);
1006
- if (await fs3.readFile(marker, "utf8").catch(() => "") === hash) {
2064
+ const lockFile = path8.join(wt, "package-lock.json");
2065
+ const lock = await fs7.readFile(lockFile, "utf8").catch(() => null);
2066
+ const hash = crypto4.createHash("sha256").update(await fs7.readFile(path8.join(wt, "package.json"), "utf8")).update("\0").update(lock ?? "").digest("hex");
2067
+ const marker = path8.join(wt, "node_modules", INSTALL_MARKER);
2068
+ if (await fs7.readFile(marker, "utf8").catch(() => "") === hash) {
1007
2069
  this.cb.log(["Dependencies unchanged since the last install; skipping it."]);
1008
2070
  return;
1009
2071
  }
1010
2072
  const cmd = lock !== null ? "npm ci" : "npm install";
1011
2073
  this.cb.log([`$ ${cmd}`]);
1012
- const code = await Promise.race([this.spawn(cmd, wt, env), sleep(INSTALL_TIMEOUT_MS).then(() => "timeout")]);
2074
+ const code = await Promise.race([this.spawn(cmd, wt, env), sleep2(INSTALL_TIMEOUT_MS).then(() => "timeout")]);
1013
2075
  this.check();
1014
2076
  if (code === "timeout") {
1015
- if (this.child) await killTree(this.child);
2077
+ if (this.child) await killTree2(this.child);
1016
2078
  throw new Error(`${cmd} took longer than ${INSTALL_TIMEOUT_MS / 6e4} minutes.`);
1017
2079
  }
1018
2080
  if (code !== 0) throw new Error(`${cmd} failed (code ${code}).`);
1019
2081
  this.child = null;
1020
- await fs3.writeFile(marker, hash).catch(() => void 0);
2082
+ await fs7.writeFile(marker, hash).catch(() => void 0);
1021
2083
  }
1022
2084
  /** Run a command line through the platform shell; resolves with its exit code. */
1023
2085
  spawn(cmd, cwd, env) {
1024
2086
  this.check();
1025
- const child = spawn(cmd, { cwd, env, shell: true, windowsHide: true, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
2087
+ const child = spawn2(cmd, { cwd, env, shell: true, windowsHide: true, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
1026
2088
  this.child = child;
1027
2089
  const onData = (buf) => {
1028
2090
  const text = this.partial + buf.toString().replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
@@ -1048,7 +2110,72 @@ var RealPreview = class {
1048
2110
  };
1049
2111
  var realPreviews = {
1050
2112
  start: (job, cb) => new RealPreview(job, cb),
1051
- hasDefault: async (fullName) => fs3.access(path4.join(mainDir(fullName), "package.json")).then(() => true).catch(() => false)
2113
+ hasDefault: async (fullName) => fs7.access(path8.join(mainDir(fullName), "package.json")).then(() => true).catch(() => false)
2114
+ };
2115
+
2116
+ // server/officeUpdate.ts
2117
+ import fs8 from "node:fs/promises";
2118
+ import path9 from "node:path";
2119
+ var DRAIN_TIMEOUT_MS = 20 * 6e4;
2120
+ var POSTPONE_MS = 2 * 60 * 6e4;
2121
+ function underLauncher(proc = process) {
2122
+ return typeof proc.send === "function" && proc.env.SWARM_LAUNCHER === "1";
2123
+ }
2124
+ function drainDecision(s) {
2125
+ const idle = (state) => ({ state, drainingSince: null, send: false, timedOut: false });
2126
+ if (s.sent) return { state: "updating", drainingSince: s.drainingSince, send: false, timedOut: false };
2127
+ if (s.behind <= 0) return idle("none");
2128
+ if (!s.launcher) return idle(s.failed ? "failed" : "available");
2129
+ const postponed = s.postponedUntil !== null && s.now < s.postponedUntil && s.behind <= s.postponedBehind;
2130
+ const failedHold = s.failed !== null && s.behind <= (s.failedBehind ?? Infinity);
2131
+ const due = s.requested || s.autoUpdate && !postponed && !failedHold;
2132
+ if (!due) return idle(s.failed ? "failed" : s.autoUpdate && postponed ? "waiting" : "available");
2133
+ const since = s.drainingSince ?? s.now;
2134
+ const timedOut = s.running > 0 && s.now - since >= DRAIN_TIMEOUT_MS;
2135
+ return { state: "draining", drainingSince: since, send: s.running === 0 || timedOut, timedOut };
2136
+ }
2137
+ function parseLastUpdate(raw) {
2138
+ try {
2139
+ const u = JSON.parse(raw);
2140
+ if (!u || typeof u.from !== "string" || typeof u.ok !== "boolean") return null;
2141
+ return {
2142
+ from: u.from,
2143
+ to: typeof u.to === "string" ? u.to : "",
2144
+ ok: u.ok,
2145
+ error: typeof u.error === "string" ? u.error : void 0,
2146
+ installed: !!u.installed,
2147
+ built: !!u.built,
2148
+ at: Number(u.at) || Date.now()
2149
+ };
2150
+ } catch {
2151
+ return null;
2152
+ }
2153
+ }
2154
+ async function takeLastUpdate(home) {
2155
+ const file = path9.join(home, "last-update.json");
2156
+ const raw = await fs8.readFile(file, "utf8").catch(() => null);
2157
+ if (raw === null) return null;
2158
+ await fs8.rm(file, { force: true, maxRetries: 3 }).catch(() => void 0);
2159
+ return parseLastUpdate(raw);
2160
+ }
2161
+ function lastUpdateMessage(u, commits) {
2162
+ if (!u.ok) return `\u26A0\uFE0F Update failed and was rolled back: ${u.error || "unknown error"}`;
2163
+ const n = commits === null ? "" : ` (${commits} commit${commits === 1 ? "" : "s"})`;
2164
+ return `\u2B06\uFE0F Updated the office from ${u.from.slice(0, 7)} to ${u.to.slice(0, 7)}${n}`;
2165
+ }
2166
+ var OFFICE_DIR = path9.resolve(import.meta.dirname, "..");
2167
+ var isSamePath = (a, b) => process.platform === "win32" ? path9.resolve(a).toLowerCase() === path9.resolve(b).toLowerCase() : path9.resolve(a) === path9.resolve(b);
2168
+ var realOffice = {
2169
+ launcher: underLauncher(),
2170
+ head: () => git(["rev-parse", "HEAD"], { cwd: OFFICE_DIR }).catch(() => null),
2171
+ isOwnFolder: (dir) => isSamePath(dir, OFFICE_DIR),
2172
+ commitsBetween: (from, to) => git(["rev-list", "--count", `${from}..${to}`], { cwd: OFFICE_DIR }).then(Number).catch(() => null),
2173
+ takeLastUpdate: () => takeLastUpdate(HOME_DIR),
2174
+ async update(from) {
2175
+ if (!underLauncher()) throw new Error("The office was not started by the launcher");
2176
+ await new Promise((resolve, reject) => process.send({ type: "office:update", from }, void 0, {}, (err) => err ? reject(err) : resolve()));
2177
+ return null;
2178
+ }
1052
2179
  };
1053
2180
 
1054
2181
  // server/backend.ts
@@ -1060,6 +2187,8 @@ var realBackend = {
1060
2187
  listIssues,
1061
2188
  listPulls,
1062
2189
  createIssue,
2190
+ issueState,
2191
+ editIssue,
1063
2192
  mergePull,
1064
2193
  updateBranch,
1065
2194
  closePull,
@@ -1079,15 +2208,21 @@ var realBackend = {
1079
2208
  deskDir,
1080
2209
  prepareDesk,
1081
2210
  removeDesk,
1082
- releaseDesk,
1083
- startSession,
1084
- previews: realPreviews
2211
+ releaseDesk: (fullName, agentSlug, port) => releaseDesk(fullName, agentSlug, port, officeProcesses()),
2212
+ startSession: (opts, cb) => opts.terminal ? startCliSession(opts, cb) : startSession(opts, cb),
2213
+ terminals: terminalsAvailable,
2214
+ reconnectClis: (terminalFor) => terminalsAvailable ? reconnectClis(terminalFor) : Promise.resolve([]),
2215
+ hooksReady,
2216
+ releaseClis,
2217
+ detectClis,
2218
+ previews: realPreviews,
2219
+ office: realOffice
1085
2220
  };
1086
2221
 
1087
2222
  // server/demo.ts
1088
- import crypto2 from "node:crypto";
2223
+ import crypto5 from "node:crypto";
1089
2224
  import http from "node:http";
1090
- import path5 from "node:path";
2225
+ import path10 from "node:path";
1091
2226
  var now = () => (/* @__PURE__ */ new Date()).toISOString();
1092
2227
  function issue(n, title, body, fullName, labels = []) {
1093
2228
  return { number: n, title, body, url: `https://github.com/${fullName}/issues/${n}`, labels, createdAt: now() };
@@ -1095,7 +2230,8 @@ function issue(n, title, body, fullName, labels = []) {
1095
2230
  var repos = /* @__PURE__ */ new Map();
1096
2231
  var bareRepos = /* @__PURE__ */ new Set();
1097
2232
  var mergedSinceSync = /* @__PURE__ */ new Map();
1098
- var fakeSha = () => crypto2.randomBytes(20).toString("hex");
2233
+ var closedIssues = /* @__PURE__ */ new Set();
2234
+ var fakeSha = () => crypto5.randomBytes(20).toString("hex");
1099
2235
  function runChecks(pr, fail = Math.random() < 0.2) {
1100
2236
  Object.assign(pr, { checks: "pending", pendingChecks: ["CI / build", "Vercel"], failedChecks: [] });
1101
2237
  setTimeout(() => {
@@ -1139,7 +2275,7 @@ function screenshotSvg(title, url, hue) {
1139
2275
  </svg>`;
1140
2276
  }
1141
2277
  function devScript(opts, cb, issueNumber, issueTitle) {
1142
- const branch = path5.basename(opts.cwd);
2278
+ const branch = path10.basename(opts.cwd);
1143
2279
  const hue = issueNumber * 67 % 360;
1144
2280
  const file = ["src/App.tsx", "src/components/TodoList.tsx", "src/api/routes.ts", "src/styles.css"][issueNumber % 4];
1145
2281
  const port = 5200 + issueNumber % 50;
@@ -1218,7 +2354,7 @@ function fakeSession(opts, cb, fullName) {
1218
2354
  const title = (kind === "issue" ? issueMatch?.[2] : prMatch?.[2])?.trim() ?? "follow-up";
1219
2355
  const round = Number(opts.prompt.match(/QA round (\d+)/)?.[1] ?? 1);
1220
2356
  const header = [
1221
- { kind: "system", text: `\u273B Claude Code (demo) \xB7 ${opts.model} \xB7 ${opts.effort} effort \xB7 ${opts.permissionMode}` },
2357
+ { kind: "system", text: `\u273B Claude Code (demo) \xB7 ${opts.model} \xB7 ${opts.effort} effort` },
1222
2358
  { kind: "system", text: ` cwd ${opts.cwd}` }
1223
2359
  ];
1224
2360
  const body = kind === "qa" ? qaScript(cb, number, title, round) : kind === "fix" ? fixScript(number) : devScript(opts, cb, number, title);
@@ -1277,7 +2413,7 @@ function fakeSession(opts, cb, fullName) {
1277
2413
  number: n,
1278
2414
  title,
1279
2415
  url,
1280
- headRefName: `swarm/issue-${number}-${path5.basename(opts.cwd).replace(/-[0-9a-f]{4}$/, "")}`,
2416
+ headRefName: `swarm/issue-${number}-${path10.basename(opts.cwd).replace(/-[0-9a-f]{4}$/, "")}`,
1281
2417
  state: "OPEN",
1282
2418
  isDraft: false,
1283
2419
  mergeable: "MERGEABLE",
@@ -1329,6 +2465,81 @@ function fakeSession(opts, cb, fullName) {
1329
2465
  }
1330
2466
  };
1331
2467
  }
2468
+ var ANSI = {
2469
+ text: (t) => t.replace(/^● /, "\x1B[97m\u25CF\x1B[0m "),
2470
+ tool: (t) => `\x1B[32m\u25CF\x1B[0m \x1B[1m${t.replace(/^⏺ /, "")}\x1B[0m`,
2471
+ result: (t) => `\x1B[2m${t}\x1B[0m`,
2472
+ thinking: (t) => `\x1B[35m${t}\x1B[0m`,
2473
+ error: (t) => `\x1B[31m${t}\x1B[0m`,
2474
+ system: (t) => `\x1B[2m${t}\x1B[0m`,
2475
+ manager: (t) => `\x1B[36m${t.replace(/^▶ /, "\u276F ")}\x1B[0m`,
2476
+ done: (t) => `\x1B[32m${t}\x1B[0m`
2477
+ };
2478
+ function inTerminal(opts, cb, start) {
2479
+ const term = opts.terminal;
2480
+ if (!term) return start(cb);
2481
+ const name = CLIS.find((c) => c.id === (opts.cli ?? "claude"))?.label ?? "Claude Code";
2482
+ term.note(`\u2500\u2500 ${name}${opts.label ? ` \xB7 ${opts.label}` : ""} \u2500\u2500`);
2483
+ term.write(
2484
+ [
2485
+ "",
2486
+ ` \x1B[38;5;209m\u2590\u259B\u2588\u2588\u2588\u259C\u258C\x1B[0m \x1B[1m${name}\x1B[0m (demo)`,
2487
+ `\x1B[38;5;209m\u259D\u259C\u2588\u2588\u2588\u2588\u2588\u259B\u2598\x1B[0m ${opts.model || "default model"} \xB7 ${opts.effort} effort`,
2488
+ `\x1B[38;5;209m \u2598\u2598 \u259D\u259D\x1B[0m \x1B[2m${opts.cwd}\x1B[0m`,
2489
+ "",
2490
+ `\x1B[36m\u276F\x1B[0m ${opts.prompt.split("\n")[0].slice(0, 200)}`,
2491
+ ""
2492
+ ].join("\r\n")
2493
+ );
2494
+ let handle = null;
2495
+ let line = "";
2496
+ term.bind({
2497
+ write: (data) => {
2498
+ for (const ch of data.replace(/\x1b\[[0-9;?]*[A-Za-z~]|\x1b./g, "")) {
2499
+ if (ch === "\r") {
2500
+ const text = line.trim();
2501
+ line = "";
2502
+ term.write("\r\n");
2503
+ if (text && handle) {
2504
+ cb.log([{ kind: "manager", text: `\u25B6 Typed in the terminal: ${text}` }]);
2505
+ handle.send(text);
2506
+ }
2507
+ } else if (ch === "\x7F" || ch === "\b") {
2508
+ if (line) term.write("\b \b");
2509
+ line = line.slice(0, -1);
2510
+ } else if (ch >= " ") {
2511
+ line += ch;
2512
+ term.write(ch);
2513
+ }
2514
+ }
2515
+ },
2516
+ resize: () => void 0
2517
+ });
2518
+ handle = start({
2519
+ ...cb,
2520
+ log: (entries) => {
2521
+ cb.log(entries);
2522
+ for (const e of entries) term.write(`${e.kind === "tool" || e.kind === "text" && e.text.startsWith("\u25CF") ? "\r\n" : ""}${ANSI[e.kind](e.text)}\r
2523
+ `);
2524
+ },
2525
+ finished: (r) => {
2526
+ term.bind(null);
2527
+ term.note(`\u2500\u2500 ${name} session ended \u2500\u2500`);
2528
+ cb.finished(r);
2529
+ }
2530
+ });
2531
+ return handle;
2532
+ }
2533
+ var USAGE_WARNING_AT = 4;
2534
+ var USAGE_WARNING_MS = 5 * 6e4;
2535
+ var sessionsStarted = 0;
2536
+ function fakeUsageWarning(cb) {
2537
+ setTimeout(() => {
2538
+ const resetsAt = Date.now() + USAGE_WARNING_MS;
2539
+ cb.log([{ kind: "error", text: `\u26A0 Subscription usage warning (five_hour) \xB7 resets ${new Date(resetsAt).toLocaleTimeString()} (demo)` }]);
2540
+ cb.usageWarning?.({ resetsAt, rateLimitType: "five_hour", utilization: 0.82 });
2541
+ }, 3e3);
2542
+ }
1332
2543
  function createDemoBackend() {
1333
2544
  const deskRepo = /* @__PURE__ */ new Map();
1334
2545
  const folders = /* @__PURE__ */ new Map();
@@ -1379,6 +2590,16 @@ function createDemoBackend() {
1379
2590
  r.issues.push(issue(n, title, body, fullName, labels));
1380
2591
  return n;
1381
2592
  },
2593
+ issueState: async (fullName, number) => {
2594
+ const r = repos.get(fullName);
2595
+ return r?.issues.some((i) => i.number === number) ? "OPEN" : closedIssues.has(`${fullName}#${number}`) ? "CLOSED" : null;
2596
+ },
2597
+ editIssue: async (fullName, number, edit) => {
2598
+ const i = repos.get(fullName)?.issues.find((x) => x.number === number);
2599
+ if (!i) throw new Error(`Unknown issue #${number}`);
2600
+ if (edit.body !== void 0) i.body = edit.body;
2601
+ i.labels = [...i.labels.filter((l) => !edit.removeLabels?.includes(l)), ...(edit.addLabels ?? []).filter((l) => !i.labels.includes(l))];
2602
+ },
1382
2603
  mergePull: async (fullName, number, _method, headSha) => {
1383
2604
  const r = repos.get(fullName);
1384
2605
  const pr = r?.pulls.find((p) => p.number === number);
@@ -1387,6 +2608,7 @@ function createDemoBackend() {
1387
2608
  mergedSinceSync.set(fullName, (mergedSinceSync.get(fullName) ?? 0) + 1);
1388
2609
  pr.state = "MERGED";
1389
2610
  pr.mergedAt = now();
2611
+ for (const n of pr.closesIssues) closedIssues.add(`${fullName}#${n}`);
1390
2612
  r.issues = r.issues.filter((i) => !pr.closesIssues.includes(i.number));
1391
2613
  },
1392
2614
  updateBranch: async (fullName, number) => {
@@ -1428,10 +2650,10 @@ Closes #${pr.closesIssues[0] ?? "?"}`,
1428
2650
  ensureClone: async () => new Promise((r) => setTimeout(r, 400)),
1429
2651
  syncMain: async (fullName, _branch, { touch }) => {
1430
2652
  const behind = mergedSinceSync.get(fullName) ?? 0;
1431
- if (behind === 0) return "in sync";
1432
- if (!touch) return `update ready (${behind} commit${behind === 1 ? "" : "s"})`;
2653
+ if (behind === 0) return { status: "in sync", behind: 0, updatable: false };
2654
+ if (!touch) return { status: `update ready (${behind} commit${behind === 1 ? "" : "s"})`, behind, updatable: true };
1433
2655
  mergedSinceSync.set(fullName, 0);
1434
- return `updated to ${fakeSha().slice(0, 7)}`;
2656
+ return { status: `updated to ${fakeSha().slice(0, 7)}`, behind: 0, updatable: false };
1435
2657
  },
1436
2658
  mainDir: (fullName) => `/demo/${fullName}/main`,
1437
2659
  deskDir: (fullName, slug) => `/demo/${fullName}/desks/${slug}`,
@@ -1443,10 +2665,38 @@ Closes #${pr.closesIssues[0] ?? "?"}`,
1443
2665
  },
1444
2666
  removeDesk: async () => void 0,
1445
2667
  releaseDesk: async () => void 0,
1446
- startSession: (opts, cb) => opts.role === "ceo" ? ceoSession(opts, cb) : fakeSession(opts, cb, deskRepo.get(opts.cwd) ?? [...repos.keys()][0]),
1447
- previews: demoPreviews
2668
+ startSession: (opts, cb) => {
2669
+ if (++sessionsStarted === USAGE_WARNING_AT) fakeUsageWarning(cb);
2670
+ return inTerminal(opts, cb, (c) => opts.role === "ceo" ? ceoSession(opts, c) : fakeSession(opts, c, deskRepo.get(opts.cwd) ?? [...repos.keys()][0]));
2671
+ },
2672
+ terminals: true,
2673
+ reconnectClis: async () => [],
2674
+ // fake sessions end with the office
2675
+ hooksReady: () => void 0,
2676
+ releaseClis: () => void 0,
2677
+ detectClis: async () => CLIS.map((c) => ({ id: c.id, label: c.label, installed: true, version: "demo", integrated: c.integrated })),
2678
+ previews: demoPreviews,
2679
+ office: demoOffice
1448
2680
  };
1449
2681
  }
2682
+ var OFFICE_REPO = "demo-co/pixel-todo";
2683
+ var DEMO_HEAD = `0ff1ce5${"0".repeat(33)}`;
2684
+ var lastFakeUpdate = { to: "", commits: 0 };
2685
+ var demoOffice = {
2686
+ launcher: underLauncher() || process.env.SWARM_DEMO_LAUNCHER === "1",
2687
+ head: async () => DEMO_HEAD,
2688
+ isOwnFolder: (dir) => dir === `/demo/${OFFICE_REPO}/main`,
2689
+ // Only its own fake updates have a count; one the real launcher did (u) gets no count rather than a wrong one.
2690
+ commitsBetween: async (_from, to) => to === lastFakeUpdate.to ? lastFakeUpdate.commits : null,
2691
+ takeLastUpdate: () => takeLastUpdate(HOME_DIR),
2692
+ async update(from) {
2693
+ await new Promise((r) => setTimeout(r, 4e3));
2694
+ lastFakeUpdate.commits = mergedSinceSync.get(OFFICE_REPO) ?? 0;
2695
+ mergedSinceSync.set(OFFICE_REPO, 0);
2696
+ lastFakeUpdate.to = fakeSha();
2697
+ return { from, to: lastFakeUpdate.to, ok: true, installed: true, built: true, at: Date.now() };
2698
+ }
2699
+ };
1450
2700
  function placeholderPage(title, hue) {
1451
2701
  const safe = title.replace(/[<>&"]/g, "");
1452
2702
  return `<!doctype html>
@@ -1716,6 +2966,13 @@ Acceptance criteria:
1716
2966
  return `Floor ${f.floor} is overstaffed: ${devs} developers for ${f.backlog.length} open issues. I suggest letting ${idle[idle.length - 1].name} go; it's on your phone.`;
1717
2967
  }
1718
2968
  }
2969
+ for (const f of s.floors) {
2970
+ const specialist = f.team.find((a) => a.role === "dev" && a.specialty);
2971
+ const unrouted = f.backlog.find((i) => !i.specialty && !i.inProgress);
2972
+ if (!specialist || !unrouted) continue;
2973
+ const out = await use("route_issue", { floor: f.floor, number: unrouted.number, specialty: specialist.specialty });
2974
+ if (!out.startsWith("Refused")) return `Floor ${f.floor}: #${unrouted.number} had no specialty, so I routed it to ${specialist.specialty}, ${specialist.name}'s lane.`;
2975
+ }
1719
2976
  const issues = s.floors.reduce((n, f) => n + f.backlog.length, 0);
1720
2977
  const prs = s.floors.reduce((n, f) => n + f.pullRequests.length, 0);
1721
2978
  return `All ${s.floors.length} floors look healthy: ${issues} open issues and ${prs} pull requests in flight. No changes needed.`;
@@ -1794,13 +3051,80 @@ Acceptance criteria:
1794
3051
  }
1795
3052
 
1796
3053
  // server/swarm.ts
1797
- import fs4 from "node:fs/promises";
1798
- import path6 from "node:path";
1799
- import crypto3 from "node:crypto";
3054
+ import fs10 from "node:fs/promises";
3055
+ import path12 from "node:path";
3056
+ import crypto6 from "node:crypto";
1800
3057
 
1801
3058
  // server/ceo.ts
1802
3059
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
1803
3060
  import { z } from "zod";
3061
+
3062
+ // shared/issues.ts
3063
+ function issueSpecialty(labels) {
3064
+ const l = labels.find((x) => /^swarm:/i.test(x) && !/^swarm:skip$/i.test(x));
3065
+ return l ? l.slice(6).toLowerCase() : "";
3066
+ }
3067
+ function blockers(body, open) {
3068
+ const out = /* @__PURE__ */ new Set();
3069
+ for (const m of (body ?? "").matchAll(/\b(?:depends\s+on|blocked\s+by)\s*:?\s*((?:#\d+(?:\s*(?:,|and|&)\s*)?)+)/gi)) {
3070
+ for (const n of m[1].matchAll(/#(\d+)/g)) if (open.has(Number(n[1]))) out.add(Number(n[1]));
3071
+ }
3072
+ return [...out];
3073
+ }
3074
+ var DEPENDENCY = /\b(?:depends\s+on|blocked\s+by)\s*:?\s*(?:#\d+(?:\s*(?:,|and|&)\s*)?)+/gi;
3075
+ var STATEMENT = new RegExp(`\\s*${DEPENDENCY.source}[.;,]?`, "gi");
3076
+ function setDependsOn(body, deps) {
3077
+ const lines = [];
3078
+ let dropped = false;
3079
+ for (const line of (body ?? "").split(/\r?\n/)) {
3080
+ const rest2 = line.match(DEPENDENCY) ? line.replace(STATEMENT, "").trimEnd() : line;
3081
+ if (rest2 !== line && !/[\p{L}\p{N}]/u.test(rest2)) {
3082
+ dropped = true;
3083
+ continue;
3084
+ }
3085
+ if (dropped && !rest2.trim() && !lines[lines.length - 1]?.trim()) continue;
3086
+ dropped = false;
3087
+ lines.push(rest2);
3088
+ }
3089
+ while (lines.length && !lines[0].trim()) lines.shift();
3090
+ const rest = lines.join("\n");
3091
+ if (deps.length === 0) return rest;
3092
+ return `Depends on ${deps.map((n) => `#${n}`).join(", ")}${rest ? `
3093
+
3094
+ ${rest}` : ""}`;
3095
+ }
3096
+ function holdUps(issues) {
3097
+ const open = new Set(issues.map((i) => i.number));
3098
+ const waiters = /* @__PURE__ */ new Map();
3099
+ for (const i of issues) for (const b of blockers(i.body, open)) waiters.set(b, [...waiters.get(b) ?? [], i.number]);
3100
+ const chains = /* @__PURE__ */ new Map();
3101
+ const visiting = /* @__PURE__ */ new Set();
3102
+ const chain = (n) => {
3103
+ const known = chains.get(n);
3104
+ if (known !== void 0) return known;
3105
+ if (visiting.has(n)) return 0;
3106
+ visiting.add(n);
3107
+ const longest = Math.max(0, ...(waiters.get(n) ?? []).map((w) => chain(w) + 1));
3108
+ visiting.delete(n);
3109
+ chains.set(n, longest);
3110
+ return longest;
3111
+ };
3112
+ const out = /* @__PURE__ */ new Map();
3113
+ for (const i of issues) {
3114
+ const seen = /* @__PURE__ */ new Set();
3115
+ const stack = [...waiters.get(i.number) ?? []];
3116
+ while (stack.length) {
3117
+ const n = stack.pop();
3118
+ if (n === i.number || seen.has(n)) continue;
3119
+ seen.add(n);
3120
+ stack.push(...waiters.get(n) ?? []);
3121
+ }
3122
+ out.set(i.number, { chain: chain(i.number), waiting: seen.size });
3123
+ }
3124
+ return out;
3125
+ }
3126
+
3127
+ // server/ceo.ts
1804
3128
  var EFFORT = z.enum(["low", "medium", "high", "xhigh", "max"]);
1805
3129
  function createOfficeTools(h) {
1806
3130
  const run2 = async (fn) => {
@@ -1880,11 +3204,23 @@ function createOfficeTools(h) {
1880
3204
  specialty: z.string().max(24).optional()
1881
3205
  },
1882
3206
  (a) => run2(() => h.fileIssue(a))
3207
+ ),
3208
+ tool(
3209
+ "route_issue",
3210
+ 'Fix the routing of an open issue instead of filing a duplicate: change its specialty, rewrite its "Depends on #N" line, or both. The rest of the body stays as it is.',
3211
+ {
3212
+ floor: z.number().int(),
3213
+ number: z.number().int().positive().describe("The issue number"),
3214
+ specialty: z.string().max(24).optional().describe('Sets swarm:<specialty> and removes any other; "" for none. Someone on the floor, or a pending proposal, must have it.'),
3215
+ depends_on: z.array(z.number().int().positive()).max(10).optional().describe("Issues it waits for; [] for none. Not for an issue in progress.")
3216
+ },
3217
+ (a) => run2(() => h.routeIssue(a))
1883
3218
  )
1884
3219
  ];
1885
3220
  const server2 = createSdkMcpServer({ name: "office", version: "1.0.0", tools: defs });
1886
3221
  return {
1887
3222
  server: server2,
3223
+ serve: () => createSdkMcpServer({ name: "office", version: "1.0.0", tools: defs }).instance,
1888
3224
  async call(name, args) {
1889
3225
  const def = defs.find((d) => d.name === name);
1890
3226
  if (!def) throw new Error(`No office tool ${name}`);
@@ -1897,11 +3233,11 @@ function ceoSystemPrompt(o) {
1897
3233
  const manager = o.manager ? `the manager, ${o.manager}` : "the human manager";
1898
3234
  return [
1899
3235
  `You are ${o.name}, the CEO of ${o.company || "an autonomous software company"}, run from an office building called cubefarm. You work from the corner office in the lobby.`,
1900
- `Every floor of the building is one GitHub repository with its own team of Claude Code agents. Developers pick up GitHub issues, each in their own git worktree, and open pull requests. QA testers review and verify every pull request (code review, tests, build, and a real browser via Playwright); when every tester is busy, a free developer who didn't write the PR covers QA. On floors with auto-merge on, the office merges a PR by itself once QA passes and GitHub's checks are green, and sends failing checks or merge conflicts back to a developer; on the others, ${manager} merges. The manager is your board: they approve hires and let-gos.`,
3236
+ `Every floor of the building is one GitHub repository with its own team of AI coding agents. Developers pick up GitHub issues, each in their own git worktree, and open pull requests. QA testers review and verify every pull request (code review, tests, build, and a real browser via Playwright); when every tester is busy, a free developer who didn't write the PR covers QA. On floors with auto-merge on, the office merges a PR by itself once QA passes and GitHub's checks are green, and sends failing checks or merge conflicts back to a developer; on the others, ${manager} merges. The manager is your board: they approve hires and let-gos.`,
1901
3237
  "",
1902
3238
  "Your job is to run the company, not to write code:",
1903
3239
  "- 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.",
1904
- `- Shape each floor's team. Propose specialists with a specific title and a job description written for this project. Keep teams lean: every agent shares one Claude 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.`,
3240
+ `- 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.`,
1905
3241
  "- 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.",
1906
3242
  "- 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).",
1907
3243
  "",
@@ -1917,7 +3253,8 @@ function ceoSystemPrompt(o) {
1917
3253
  '- Titles are specific ("Three.js graphics engineer", not "Developer"). A specialty is a short lowercase slug ("graphics", "gameplay", "frontend", "backend", "content", "a11y", "devops"). Only route an issue to a specialty that someone on the floor has, or that you are proposing to hire.',
1918
3254
  "- Before proposing a hire, check the floor and the pending proposals for someone who already covers it. If the manager declined a similar proposal (recentDecisions), do not propose it again unless something has changed, and say what.",
1919
3255
  `- ${o.hiring === "auto" ? "Hiring is on auto: proposals within the team cap are approved immediately, so be deliberate." : "The manager approves every hire, so explain each reason in a sentence or two they can decide on."}`,
1920
- `- Issues: plan for parallel work. What keeps a floor busy is the number of issues that can start right now (capacity.issuesReadyToStart in company_status); aim for at least one per developer. Write "Depends on #N" only when an issue truly cannot start until #N's code is merged, because it waits until #N is closed. Keep dependency chains to two steps at most, keep foundation issues small, and split big pieces into parts that can be built side by side. The office starts the issues that hold up others first. Do not duplicate open issues. File at most 12 issues per job.`,
3256
+ `- Issues: plan for parallel work. What keeps a floor busy is the number of issues that can start right now (capacity.issuesReadyToStart in company_status); aim for at least one per developer. Write "Depends on #N" only when an issue truly cannot start until #N's code is merged, because it waits until #N is closed. Keep dependency chains to two steps at most, keep foundation issues small, and split big pieces into parts that can be built side by side. The office starts the issues that hold up others first. Do not duplicate open issues: fix an existing issue's specialty or dependencies with route_issue. File at most 12 issues per job, or per message from the manager.`,
3257
+ "- When company.usage in company_status says pacing or paused, Claude's usage is running low and the office is finishing open work first: file only what is needed next, not a whole milestone.",
1921
3258
  "- Your final message goes straight to the manager's phone. Keep it short and plain: what you found, what you proposed, what you filed, and any question you need answered. No headings, no tables."
1922
3259
  ].join("\n");
1923
3260
  }
@@ -1951,7 +3288,7 @@ ${floor.backlog === 0 ? "The backlog is empty: plan the first milestone as issue
1951
3288
  return [
1952
3289
  "Periodic review of the company. For every floor, look at:",
1953
3290
  "- floors without a profile or QA brief: study them and write one",
1954
- "- backlog against the team (capacity): fewer issues ready to start than free developers, long dependency chains, a specialty with a long queue",
3291
+ "- backlog against the team (capacity): fewer issues ready to start than free developers, long dependency chains, a specialty with a long queue (fix those with route_issue)",
1955
3292
  "- pull requests stuck in QA or marked as needing a human",
1956
3293
  "- floors with a brief and an empty backlog: plan the next milestone",
1957
3294
  "Propose hires or let-gos only when clearly justified. If nothing needs doing, reply with one short sentence saying so."
@@ -1974,6 +3311,81 @@ function jobLabel(job, floor) {
1974
3311
  return "Replying to you";
1975
3312
  }
1976
3313
  }
3314
+ var IssueCap = class {
3315
+ // floors that got issues, to refresh when the job ends
3316
+ constructor(max) {
3317
+ this.max = max;
3318
+ }
3319
+ max;
3320
+ filed = 0;
3321
+ // since the job started or the manager's last message
3322
+ total = 0;
3323
+ // in the whole job
3324
+ repos = /* @__PURE__ */ new Set();
3325
+ check() {
3326
+ if (this.filed >= this.max) throw new Error(`You already filed ${this.max} issues in this job. That's plenty for one milestone. The manager's next message allows more.`);
3327
+ }
3328
+ record(repoId2) {
3329
+ this.filed++;
3330
+ this.total++;
3331
+ this.repos.add(repoId2);
3332
+ }
3333
+ managerMessage() {
3334
+ this.filed = 0;
3335
+ }
3336
+ };
3337
+ function waitsDepth(n, deps, seen = /* @__PURE__ */ new Set()) {
3338
+ if (seen.has(n)) return 0;
3339
+ seen.add(n);
3340
+ const depth = Math.max(0, ...(deps.get(n) ?? []).map((d) => waitsDepth(d, deps, seen) + 1));
3341
+ seen.delete(n);
3342
+ return depth;
3343
+ }
3344
+ function planRoute(r) {
3345
+ const issue2 = r.issues.find((i) => i.number === r.number);
3346
+ if (!issue2) throw new Error(r.closed(r.number) ? `#${r.number} is closed.` : `There is no open issue #${r.number} on floor ${r.floor}.`);
3347
+ if (r.specialty === void 0 && r.dependsOn === void 0) throw new Error("Nothing to change: pass specialty, depends_on or both.");
3348
+ const plan = { addLabels: [], removeLabels: [], body: null, summary: "" };
3349
+ const done = [];
3350
+ if (r.specialty !== void 0) {
3351
+ const slug = specialtySlug(r.specialty);
3352
+ if (r.specialty.trim() && !slug) throw new Error(`"${r.specialty}" is not a specialty. Use a short lowercase slug, or "" for none.`);
3353
+ if (slug && !r.specialties.includes(slug)) {
3354
+ const have = [...new Set(r.specialties)].join(", ") || "none";
3355
+ throw new Error(`Nobody on floor ${r.floor} has the specialty "${slug}", and no pending proposal does. Specialties there: ${have}.`);
3356
+ }
3357
+ const label = slug ? specialtyLabel(slug) : null;
3358
+ plan.removeLabels = issue2.labels.filter((l) => /^swarm:/i.test(l) && !/^swarm:skip$/i.test(l) && l !== label);
3359
+ if (label && !issue2.labels.includes(label)) plan.addLabels = [label];
3360
+ done.push(slug ? `routed to ${slug}` : "no specialty");
3361
+ }
3362
+ if (r.dependsOn !== void 0) {
3363
+ if (r.inProgress) throw new Error(`#${r.number} is already in progress, so its dependencies can't change. Changing its specialty is fine.`);
3364
+ const deps = [...new Set(r.dependsOn.map(Number))];
3365
+ const open = new Set(r.issues.map((i) => i.number));
3366
+ for (const d of deps) {
3367
+ if (d === r.number) throw new Error(`#${r.number} can't depend on itself.`);
3368
+ if (!open.has(d)) throw new Error(r.closed(d) ? `#${d} is closed, so there's nothing to wait for.` : `There is no open issue #${d} on floor ${r.floor}.`);
3369
+ }
3370
+ const body = setDependsOn(issue2.body, deps);
3371
+ const after = r.issues.map((i) => i.number === r.number ? { ...i, body } : i);
3372
+ const waits = new Map(after.map((i) => [i.number, blockers(i.body, open)]));
3373
+ const loop = deps.find((d) => reaches(d, r.number, waits));
3374
+ if (loop !== void 0) throw new Error(`#${loop} already waits for #${r.number}, directly or through other issues, so that would be a cycle.`);
3375
+ const depth = waitsDepth(r.number, waits) + (holdUps(after).get(r.number)?.chain ?? 0);
3376
+ if (depth > 2) throw new Error(`That makes a dependency chain ${depth} steps deep through #${r.number}. Keep chains to 2 steps at most: split the work so more of it can start side by side.`);
3377
+ if (body !== issue2.body) plan.body = body;
3378
+ done.push(deps.length ? `depends on ${deps.map((d) => `#${d}`).join(", ")}` : "no dependencies");
3379
+ }
3380
+ plan.summary = `#${r.number} on floor ${r.floor}: ${done.join(", ")}.`;
3381
+ return plan;
3382
+ }
3383
+ function reaches(from, to, waits, seen = /* @__PURE__ */ new Set()) {
3384
+ if (from === to) return true;
3385
+ if (seen.has(from)) return false;
3386
+ seen.add(from);
3387
+ return (waits.get(from) ?? []).some((n) => reaches(n, to, waits, seen));
3388
+ }
1977
3389
  function specialtySlug(s) {
1978
3390
  const slug = String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 24);
1979
3391
  return slug === "skip" ? "" : slug;
@@ -1989,6 +3401,38 @@ var HttpError = class extends Error {
1989
3401
  status;
1990
3402
  };
1991
3403
 
3404
+ // server/mergeGate.ts
3405
+ var MAX_MERGE_FIXES = 3;
3406
+ var CHECKS_ALERT_MS = 30 * 6e4;
3407
+ var MERGE_RETRY_MS = 10 * 6e4;
3408
+ function mergeStep(pr, rec, now2, opts) {
3409
+ if (pr.isDraft) return { do: "wait", note: "a draft: waiting", set: {} };
3410
+ if (!opts.detailed && (pr.mergeable === "UNKNOWN" || pr.mergeState === "UNKNOWN")) return { do: "details", set: {} };
3411
+ const set = {};
3412
+ if (rec.passedSha == null) set.passedSha = pr.headSha;
3413
+ if (pr.headSha !== (set.passedSha ?? rec.passedSha)) return { do: "requeue", set };
3414
+ const needsHuman = rec.mergeFixes >= MAX_MERGE_FIXES;
3415
+ if (pr.mergeable === "CONFLICTING" || pr.mergeState === "DIRTY") {
3416
+ return { do: "send-back", reason: "conflict", instructions: `It conflicts with ${opts.base}.`, needsHuman, set };
3417
+ }
3418
+ if (pr.checks === "failing") {
3419
+ const instructions = pr.failedChecks.map((c) => `- ${c.name}${c.url ? `: ${c.url}` : ""}`).join("\n");
3420
+ return { do: "send-back", reason: "checks", instructions, needsHuman, set };
3421
+ }
3422
+ if (pr.checks === "pending") {
3423
+ const since = rec.pendingSince ?? now2;
3424
+ if (rec.pendingSince == null) set.pendingSince = since;
3425
+ const alert = now2 - since > CHECKS_ALERT_MS && !rec.alerted;
3426
+ if (alert) set.alerted = true;
3427
+ return { do: "wait", note: `waiting for checks: ${pr.pendingChecks.join(", ") || "running"}`, alert, set };
3428
+ }
3429
+ set.pendingSince = null;
3430
+ if (pr.mergeable === "UNKNOWN") return { do: "wait", note: "GitHub is checking it can merge", set };
3431
+ if (pr.mergeState === "BEHIND") return { do: "update-branch", set };
3432
+ if (rec.mergeRetryAt && now2 < rec.mergeRetryAt) return { do: "wait", set };
3433
+ return { do: "merge", set };
3434
+ }
3435
+
1992
3436
  // server/previews.ts
1993
3437
  var PREVIEW_BASE_PORT = 6300;
1994
3438
  var LOG_KEEP = 200;
@@ -2189,47 +3633,232 @@ var Previews = class {
2189
3633
  }
2190
3634
  };
2191
3635
 
2192
- // shared/issues.ts
2193
- function issueSpecialty(labels) {
2194
- const l = labels.find((x) => /^swarm:/i.test(x) && !/^swarm:skip$/i.test(x));
2195
- return l ? l.slice(6).toLowerCase() : "";
3636
+ // server/pacing.ts
3637
+ var PACING_MS = 60 * 6e4;
3638
+ var DEFAULT_PACING_SESSIONS = 3;
3639
+ function mayStart(kind, c) {
3640
+ if (c.now < c.pausedUntil) return false;
3641
+ if (kind === "issue" && c.now < c.pacingUntil) return c.running < c.pacingSessions;
3642
+ return true;
2196
3643
  }
2197
- function blockers(body, open) {
2198
- const out = /* @__PURE__ */ new Set();
2199
- for (const m of (body ?? "").matchAll(/\b(?:depends\s+on|blocked\s+by)\s*:?\s*((?:#\d+(?:\s*(?:,|and|&)\s*)?)+)/gi)) {
2200
- for (const n of m[1].matchAll(/#(\d+)/g)) if (open.has(Number(n[1]))) out.add(Number(n[1]));
2201
- }
2202
- return [...out];
3644
+ function usageView(c) {
3645
+ if (c.now < c.pausedUntil) return { state: "paused", until: c.pausedUntil };
3646
+ if (c.now < c.pacingUntil) return { state: "pacing", until: c.pacingUntil };
3647
+ return { state: "normal", until: null };
2203
3648
  }
2204
- function holdUps(issues) {
2205
- const open = new Set(issues.map((i) => i.number));
2206
- const waiters = /* @__PURE__ */ new Map();
2207
- for (const i of issues) for (const b of blockers(i.body, open)) waiters.set(b, [...waiters.get(b) ?? [], i.number]);
2208
- const chains = /* @__PURE__ */ new Map();
2209
- const visiting = /* @__PURE__ */ new Set();
2210
- const chain = (n) => {
2211
- const known = chains.get(n);
2212
- if (known !== void 0) return known;
2213
- if (visiting.has(n)) return 0;
2214
- visiting.add(n);
2215
- const longest = Math.max(0, ...(waiters.get(n) ?? []).map((w) => chain(w) + 1));
2216
- visiting.delete(n);
2217
- chains.set(n, longest);
2218
- return longest;
2219
- };
2220
- const out = /* @__PURE__ */ new Map();
2221
- for (const i of issues) {
2222
- const seen = /* @__PURE__ */ new Set();
2223
- const stack = [...waiters.get(i.number) ?? []];
2224
- while (stack.length) {
2225
- const n = stack.pop();
2226
- if (n === i.number || seen.has(n)) continue;
2227
- seen.add(n);
2228
- stack.push(...waiters.get(n) ?? []);
3649
+ function clock(at, now2) {
3650
+ const d = new Date(at);
3651
+ const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
3652
+ return d.toDateString() === new Date(now2).toDateString() ? hm : `${d.toLocaleDateString("en-US", { weekday: "short" })} ${hm}`;
3653
+ }
3654
+ function usageLabel(u, now2) {
3655
+ return u.state === "normal" || u.until === null ? "normal" : `${u.state} until ${clock(u.until, now2)}`;
3656
+ }
3657
+ function clampPacingSessions(v) {
3658
+ const n = Math.round(Number(v));
3659
+ return Number.isFinite(n) && n > 0 ? Math.min(32, n) : DEFAULT_PACING_SESSIONS;
3660
+ }
3661
+ var LIMIT_NAMES = {
3662
+ five_hour: "5-hour limit",
3663
+ seven_day: "weekly limit",
3664
+ seven_day_opus: "weekly Opus limit",
3665
+ seven_day_sonnet: "weekly Sonnet limit"
3666
+ };
3667
+ function pacingMessage(w, until, sessions, now2) {
3668
+ const pct = w.utilization === null ? null : Math.round(w.utilization <= 1 ? w.utilization * 100 : w.utilization);
3669
+ const what = [w.rateLimitType ? LIMIT_NAMES[w.rateLimitType] ?? w.rateLimitType.replace(/_/g, " ") : null, pct === null ? null : `${pct}%`].filter(Boolean).join(", ");
3670
+ return `\u{1F422} Claude's usage is getting high${what ? ` (${what})` : ""}. Until ${clock(until, now2)} the office finishes open work first and starts at most ${sessions} session${sessions === 1 ? "" : "s"} at a time.`;
3671
+ }
3672
+
3673
+ // server/terminal.ts
3674
+ import fs9 from "node:fs/promises";
3675
+ import path11 from "node:path";
3676
+ import xtermHeadless from "@xterm/headless";
3677
+ import xtermSerialize from "@xterm/addon-serialize";
3678
+ var { Terminal } = xtermHeadless;
3679
+ var { SerializeAddon } = xtermSerialize;
3680
+ var TERM_COLS = 120;
3681
+ var TERM_ROWS = 32;
3682
+ var SCROLLBACK = 5e3;
3683
+ var STALE_BYTES = 4 * 1024 * 1024;
3684
+ var MOUSE_ENCODINGS = [1006, 1016];
3685
+ var clamp = (n, lo, hi, dflt) => {
3686
+ const v = Math.round(Number(n));
3687
+ return Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : dflt;
3688
+ };
3689
+ var AgentTerminal = class {
3690
+ cols = TERM_COLS;
3691
+ rows = TERM_ROWS;
3692
+ /** Output arrived since the last save. */
3693
+ dirty = false;
3694
+ /** Closes a CLI left waiting at its prompt after its task (set by the runtime that left it there). */
3695
+ releaseIdle = null;
3696
+ /** Something the manager typed at that waiting prompt: the office takes it on as a follow-up (true) or refuses. */
3697
+ onIdlePrompt = null;
3698
+ /** Keys a viewer typed, after they went to the CLI (the runtime watches for Esc interrupting a turn). */
3699
+ onInput = null;
3700
+ term = new Terminal({ cols: TERM_COLS, rows: TERM_ROWS, scrollback: SCROLLBACK, allowProposedApi: true, scrollOnEraseInDisplay: true });
3701
+ ser = new SerializeAddon();
3702
+ viewers = /* @__PURE__ */ new Map();
3703
+ sink = null;
3704
+ mouseEncoding = null;
3705
+ resync = null;
3706
+ constructor() {
3707
+ this.term.loadAddon(this.ser);
3708
+ const decset = (on) => (params) => {
3709
+ for (const p of params) if (typeof p === "number" && MOUSE_ENCODINGS.includes(p)) this.mouseEncoding = on ? p : null;
3710
+ return false;
3711
+ };
3712
+ this.term.parser.registerCsiHandler({ prefix: "?", final: "h" }, decset(true));
3713
+ this.term.parser.registerCsiHandler({ prefix: "?", final: "l" }, decset(false));
3714
+ }
3715
+ /** Output from the CLI (or a note from the office): into the mirror and out to everyone watching. */
3716
+ write(data) {
3717
+ if (!data) return;
3718
+ this.term.write(data);
3719
+ this.dirty = true;
3720
+ this.send({ t: "data", data });
3721
+ }
3722
+ /** A dim line from the office itself, e.g. between sessions. */
3723
+ note(text) {
3724
+ this.write(`\r
3725
+ \x1B[2m${text}\x1B[0m\r
3726
+ `);
3727
+ }
3728
+ get live() {
3729
+ return this.sink !== null;
3730
+ }
3731
+ /** The CLI asked for application cursor keys: arrows are sent as ESC O A… rather than ESC [ A… */
3732
+ get appCursor() {
3733
+ return this.term.modes.applicationCursorKeysMode;
3734
+ }
3735
+ /** The running CLI, or null when it exits. */
3736
+ bind(sink) {
3737
+ this.sink = sink;
3738
+ sink?.resize(this.cols, this.rows);
3739
+ this.send({ t: "live", live: sink !== null });
3740
+ }
3741
+ resize(cols, rows) {
3742
+ const c = clamp(cols, 20, 400, this.cols);
3743
+ const r = clamp(rows, 5, 200, this.rows);
3744
+ if (c === this.cols && r === this.rows) return;
3745
+ this.cols = c;
3746
+ this.rows = r;
3747
+ this.term.resize(c, r);
3748
+ this.sink?.resize(c, r);
3749
+ }
3750
+ /** Everything a fresh terminal needs to show the same screen: scrollback, colours, cursor and modes. */
3751
+ snapshot() {
3752
+ return this.ser.serialize({ scrollback: SCROLLBACK }) + (this.mouseEncoding ? `\x1B[?${this.mouseEncoding}h` : "");
3753
+ }
3754
+ /** The visible rows as plain text, for spotting prompts the CLI is waiting on. */
3755
+ screen() {
3756
+ const b = this.term.buffer.active;
3757
+ const rows = [];
3758
+ for (let i = b.viewportY; i < b.viewportY + this.rows; i++) rows.push(b.getLine(i)?.translateToString(true) ?? "");
3759
+ return rows.join("\n");
3760
+ }
3761
+ /** Written to disk so a restarted office still shows what each agent did. */
3762
+ /** Resolves once everything written so far is parsed (xterm parses asynchronously). */
3763
+ flush() {
3764
+ return new Promise((resolve) => this.term.write("", resolve));
3765
+ }
3766
+ async save(file) {
3767
+ this.dirty = false;
3768
+ await this.flush();
3769
+ await fs9.mkdir(path11.dirname(file), { recursive: true });
3770
+ await fs9.writeFile(`${file}.tmp`, this.snapshot());
3771
+ await fs9.rename(`${file}.tmp`, file);
3772
+ }
3773
+ async load(file) {
3774
+ const data = await fs9.readFile(file, "utf8").catch(() => "");
3775
+ if (!data) return;
3776
+ this.term.write(data);
3777
+ this.note("(the office restarted)");
3778
+ this.dirty = false;
3779
+ }
3780
+ // ---------- viewers ----------
3781
+ attach(ws) {
3782
+ const viewer = { stale: false, queued: [] };
3783
+ this.viewers.set(ws, viewer);
3784
+ this.term.write("", () => {
3785
+ const queued = viewer.queued ?? [];
3786
+ viewer.queued = null;
3787
+ this.sendTo(ws, { t: "snapshot", data: this.snapshot(), cols: this.cols, rows: this.rows, live: this.live });
3788
+ for (const json of queued) if (ws.readyState === ws.OPEN) ws.send(json);
3789
+ });
3790
+ ws.on("message", (raw) => {
3791
+ let msg;
3792
+ try {
3793
+ msg = JSON.parse(String(raw));
3794
+ } catch {
3795
+ return;
3796
+ }
3797
+ if (msg.t === "input" && typeof msg.data === "string") {
3798
+ const data = msg.data.slice(0, 64 * 1024);
3799
+ this.sink?.write(data);
3800
+ this.onInput?.(data);
3801
+ } else if (msg.t === "resize") {
3802
+ this.resize(msg.cols, msg.rows);
3803
+ for (const other of this.viewers.keys()) if (other !== ws) this.sendTo(other, { t: "size", cols: this.cols, rows: this.rows });
3804
+ }
3805
+ });
3806
+ ws.on("close", () => this.viewers.delete(ws));
3807
+ }
3808
+ dispose() {
3809
+ for (const ws of this.viewers.keys()) ws.close();
3810
+ this.viewers.clear();
3811
+ if (this.resync) clearTimeout(this.resync);
3812
+ this.sink = null;
3813
+ this.term.dispose();
3814
+ }
3815
+ send(msg) {
3816
+ if (this.viewers.size === 0) return;
3817
+ const json = JSON.stringify(msg);
3818
+ for (const [ws, v] of this.viewers) {
3819
+ if (ws.readyState !== ws.OPEN || v.stale) continue;
3820
+ if (v.queued) {
3821
+ v.queued.push(json);
3822
+ continue;
3823
+ }
3824
+ if (ws.bufferedAmount > STALE_BYTES) {
3825
+ v.stale = true;
3826
+ this.scheduleResync();
3827
+ continue;
3828
+ }
3829
+ ws.send(json);
2229
3830
  }
2230
- out.set(i.number, { chain: chain(i.number), waiting: seen.size });
2231
3831
  }
2232
- return out;
3832
+ sendTo(ws, msg) {
3833
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
3834
+ }
3835
+ /** Viewers that fell behind get a fresh snapshot once their socket has drained, instead of an ever-growing backlog. */
3836
+ scheduleResync() {
3837
+ if (this.resync) return;
3838
+ this.resync = setTimeout(() => {
3839
+ this.resync = null;
3840
+ let waiting = false;
3841
+ for (const [ws, v] of this.viewers) {
3842
+ if (!v.stale) continue;
3843
+ if (ws.bufferedAmount > STALE_BYTES / 4) {
3844
+ waiting = true;
3845
+ continue;
3846
+ }
3847
+ v.stale = false;
3848
+ this.sendTo(ws, { t: "snapshot", data: this.snapshot(), cols: this.cols, rows: this.rows, live: this.live });
3849
+ }
3850
+ if (waiting) this.scheduleResync();
3851
+ }, 1e3);
3852
+ }
3853
+ };
3854
+
3855
+ // shared/models.ts
3856
+ var isClaudeModel = (model) => /^(claude|opus|sonnet|haiku|fable|default|best|opusplan)\b/i.test(model.trim());
3857
+ function effectiveModel(own, cli, office, claudeDefault) {
3858
+ const suits = (m) => m.trim() !== "" && cli === "claude" === isClaudeModel(m);
3859
+ if (suits(own)) return own.trim();
3860
+ if (cli === office.defaultCli && suits(office.defaultModel)) return office.defaultModel.trim();
3861
+ return cli === "claude" ? claudeDefault : "";
2233
3862
  }
2234
3863
 
2235
3864
  // shared/types.ts
@@ -2279,8 +3908,8 @@ var EFFORTS = ["low", "medium", "high", "xhigh", "max"];
2279
3908
  var CEO_MODEL = "claude-opus-5-5";
2280
3909
  var CEO_EFFORT = "xhigh";
2281
3910
  var CEO_NAME = "Morgan";
2282
- var CEO_DIR = path6.join(HOME_DIR, "ceo");
2283
- var DEFAULT_PROJECTS_DIR = defaultProjectsDir(path6.resolve(import.meta.dirname, ".."));
3911
+ var CEO_DIR = path12.join(HOME_DIR, "ceo");
3912
+ var DEFAULT_PROJECTS_DIR = defaultProjectsDir(path12.resolve(import.meta.dirname, ".."));
2284
3913
  var MAX_PENDING_REQUESTS = 8;
2285
3914
  var MAX_ISSUES_PER_JOB = 12;
2286
3915
  var KEEP_MESSAGES = 200;
@@ -2292,20 +3921,15 @@ var FREE = ["idle", "done"];
2292
3921
  var ERROR_COOLDOWN_MS = 2 * 6e4;
2293
3922
  var MAX_ISSUE_FAILURES = 2;
2294
3923
  var LIMIT_PAUSE_MS = 15 * 6e4;
2295
- var MAX_MERGE_FIXES = 3;
2296
- var CHECKS_ALERT_MS = 30 * 6e4;
2297
- var MERGE_RETRY_MS = 10 * 6e4;
2298
- var OFFICE_DIR = path6.resolve(import.meta.dirname, "..");
2299
- var isSamePath = (a, b) => process.platform === "win32" ? path6.resolve(a).toLowerCase() === path6.resolve(b).toLowerCase() : path6.resolve(a) === path6.resolve(b);
2300
3924
  var oneLine = (err) => (err instanceof Error ? err.message : String(err)).split(/\r?\n/)[0].slice(0, 200);
2301
- var SCREENS_DIR = path6.join(HOME_DIR, "screens");
3925
+ var SCREENS_DIR = path12.join(HOME_DIR, "screens");
2302
3926
  var MIME_EXT = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/svg+xml": "svg" };
2303
- var screenFile = (agentId, mime) => path6.join(SCREENS_DIR, `${agentId}.${MIME_EXT[mime] ?? "img"}`);
3927
+ var screenFile = (agentId, mime) => path12.join(SCREENS_DIR, `${agentId}.${MIME_EXT[mime] ?? "img"}`);
2304
3928
  async function loadScreen(agentId) {
2305
3929
  for (const [mime, ext] of Object.entries(MIME_EXT)) {
2306
- const file = path6.join(SCREENS_DIR, `${agentId}.${ext}`);
3930
+ const file = path12.join(SCREENS_DIR, `${agentId}.${ext}`);
2307
3931
  try {
2308
- const [data, stat] = await Promise.all([fs4.readFile(file), fs4.stat(file)]);
3932
+ const [data, stat] = await Promise.all([fs10.readFile(file), fs10.stat(file)]);
2309
3933
  return { data, mime, at: stat.mtimeMs };
2310
3934
  } catch {
2311
3935
  }
@@ -2313,8 +3937,11 @@ async function loadScreen(agentId) {
2313
3937
  return null;
2314
3938
  }
2315
3939
  async function removeScreens(agentId) {
2316
- await Promise.all(Object.values(MIME_EXT).map((ext) => fs4.rm(path6.join(SCREENS_DIR, `${agentId}.${ext}`), { force: true })));
3940
+ await Promise.all(Object.values(MIME_EXT).map((ext) => fs10.rm(path12.join(SCREENS_DIR, `${agentId}.${ext}`), { force: true })));
2317
3941
  }
3942
+ var TERMINALS_DIR = path12.join(HOME_DIR, "terminals");
3943
+ var terminalFile = (agentId) => path12.join(TERMINALS_DIR, `${agentId}.ansi`);
3944
+ var TERMINAL_SAVE_MS = 2e4;
2318
3945
  var QA_SCHEMA = {
2319
3946
  type: "object",
2320
3947
  additionalProperties: false,
@@ -2357,10 +3984,11 @@ var QA_SCHEMA = {
2357
3984
  function parseReport(result) {
2358
3985
  let raw = result.structured;
2359
3986
  if (!raw && result.text) {
2360
- const json = result.text.match(/\{[\s\S]*\}/);
2361
- if (json) {
3987
+ const fenced = [...result.text.matchAll(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/g)].map((m) => m[1]).reverse();
3988
+ for (const json of [...fenced, result.text.match(/\{[\s\S]*\}/)?.[0]].filter((j) => !!j)) {
2362
3989
  try {
2363
- raw = JSON.parse(json[0]);
3990
+ raw = JSON.parse(json);
3991
+ break;
2364
3992
  } catch {
2365
3993
  raw = null;
2366
3994
  }
@@ -2381,6 +4009,7 @@ function parseReport(result) {
2381
4009
  var cell = (s) => s.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
2382
4010
  var ICON = { pass: "\u2705", fail: "\u274C", skip: "\u23ED\uFE0F" };
2383
4011
  var Swarm = class {
4012
+ // coding-agent CLIs found on this machine (detected at startup)
2384
4013
  constructor(backend) {
2385
4014
  this.backend = backend;
2386
4015
  this.previews = new Previews(backend, {
@@ -2397,7 +4026,8 @@ var Swarm = class {
2397
4026
  sessionLimit: 0,
2398
4027
  defaultModel: DEFAULT_MODEL,
2399
4028
  defaultEffort: "medium",
2400
- permissionMode: "guarded",
4029
+ runtime: "terminal",
4030
+ defaultCli: "claude",
2401
4031
  hiring: "approve",
2402
4032
  teamCap: 6,
2403
4033
  ceoHeartbeatMin: 60,
@@ -2405,7 +4035,9 @@ var Swarm = class {
2405
4035
  companyName: "",
2406
4036
  projectsDir: DEFAULT_PROJECTS_DIR,
2407
4037
  setupDone: false,
2408
- tutorialStep: 0
4038
+ tutorialStep: 0,
4039
+ autoUpdate: true,
4040
+ pacingSessions: DEFAULT_PACING_SESSIONS
2409
4041
  },
2410
4042
  repos: [],
2411
4043
  agents: [],
@@ -2427,10 +4059,11 @@ var Swarm = class {
2427
4059
  updateJob: (a) => this.updateJob(a),
2428
4060
  proposeHire: (a) => this.proposeHire(a),
2429
4061
  proposeLetGo: (a) => this.proposeLetGo(a),
2430
- fileIssue: (a) => this.fileIssue(a)
4062
+ fileIssue: (a) => this.fileIssue(a),
4063
+ routeIssue: (a) => this.routeIssue(a)
2431
4064
  });
2432
4065
  }
2433
- ceoIssues = { filed: 0, repos: /* @__PURE__ */ new Set() };
4066
+ ceoIssues = new IssueCap(MAX_ISSUES_PER_JOB);
2434
4067
  // issues filed during the current CEO job
2435
4068
  messageSeq = 1;
2436
4069
  agentRt = /* @__PURE__ */ new Map();
@@ -2442,10 +4075,26 @@ var Swarm = class {
2442
4075
  flushTimer = null;
2443
4076
  logSeq = 1;
2444
4077
  previews;
4078
+ officeHead = null;
4079
+ // the commit the office runs (null: not a git checkout, so no self-update)
4080
+ officeUpdate = {
4081
+ behind: 0,
4082
+ requested: false,
4083
+ postponedUntil: null,
4084
+ postponedBehind: 0,
4085
+ failed: null,
4086
+ failedBehind: null,
4087
+ drainingSince: null,
4088
+ sent: false,
4089
+ handedOver: false
4090
+ // sessions cut off by the hand-over are the restarted office's to recover
4091
+ };
4092
+ lastOfficeView = "";
4093
+ clis = [];
2445
4094
  // ---------- lifecycle ----------
2446
4095
  async init() {
2447
4096
  try {
2448
- const raw = await fs4.readFile(STATE_FILE, "utf8");
4097
+ const raw = await fs10.readFile(STATE_FILE, "utf8");
2449
4098
  const loaded = JSON.parse(raw);
2450
4099
  this.state = {
2451
4100
  settings: { ...this.state.settings, ...loaded.settings },
@@ -2468,7 +4117,9 @@ var Swarm = class {
2468
4117
  brief: a.brief ?? "",
2469
4118
  hiredBy: a.hiredBy ?? "manager",
2470
4119
  look: a.look ?? lookFor(a.name),
2471
- task: a.task ?? (a.issueNumber ? "issue" : null)
4120
+ task: a.task ?? (a.issueNumber ? "issue" : null),
4121
+ cli: isCli(a.cli) ? a.cli : "",
4122
+ sessionCli: a.sessionCli ?? (a.sessionId ? "claude" : null)
2472
4123
  })),
2473
4124
  qa: (loaded.qa ?? []).map((q) => ({
2474
4125
  ...q,
@@ -2488,13 +4139,16 @@ var Swarm = class {
2488
4139
  phoneReadAt: loaded.phoneReadAt ?? 0
2489
4140
  };
2490
4141
  for (const m of this.state.messages) this.messageSeq = Math.max(this.messageSeq, m.id + 1);
2491
- if (!this.state.settings.defaultModel) this.state.settings.defaultModel = DEFAULT_MODEL;
2492
4142
  if (!EFFORTS.includes(this.state.settings.defaultEffort)) this.state.settings.defaultEffort = "medium";
4143
+ if (this.state.settings.runtime !== "sdk") this.state.settings.runtime = "terminal";
4144
+ if (!isCli(this.state.settings.defaultCli)) this.state.settings.defaultCli = "claude";
4145
+ if (!this.state.settings.defaultModel && this.state.settings.defaultCli === "claude") this.state.settings.defaultModel = DEFAULT_MODEL;
2493
4146
  const old = this.state.settings;
2494
4147
  if (old.maxConcurrent !== void 0) {
2495
4148
  if (loaded.settings?.sessionLimit === void 0) old.sessionLimit = old.maxConcurrent === 4 ? 0 : old.maxConcurrent;
2496
4149
  delete old.maxConcurrent;
2497
4150
  }
4151
+ delete old.permissionMode;
2498
4152
  if (loaded.settings && loaded.settings.setupDone === void 0 && this.state.repos.length > 0) {
2499
4153
  Object.assign(this.state.settings, { setupDone: true, tutorialStep: -1 });
2500
4154
  }
@@ -2505,14 +4159,30 @@ var Swarm = class {
2505
4159
  for (const a of this.state.agents) {
2506
4160
  const tail = a.logTail ?? [];
2507
4161
  for (const l of tail) this.logSeq = Math.max(this.logSeq, l.id + 1);
2508
- this.agentRt.set(a.id, { log: tail, pending: [], session: null, currentTool: null, browserUrl: null, screenshot: await loadScreen(a.id), shots: [] });
2509
- if (BUSY.includes(a.status)) {
2510
- a.status = "stopped";
2511
- a.lastError = "The swarm server restarted while this agent was working.";
2512
- interrupted.push(a);
4162
+ this.agentRt.set(a.id, { log: tail, pending: [], session: null, currentTool: null, browserUrl: null, screenshot: await loadScreen(a.id), shots: [], terminal: await this.loadTerminal(a.id) });
4163
+ }
4164
+ const back = await this.backend.reconnectClis((id) => {
4165
+ const a = this.state.agents.find((x) => x.id === id);
4166
+ return a && a.role !== "ceo" ? this.terminalFor(a) : null;
4167
+ }).catch((err) => {
4168
+ console.warn("could not reconnect to the terminal keeper", err);
4169
+ return [];
4170
+ });
4171
+ const carryOn = [];
4172
+ for (const a of this.state.agents) {
4173
+ if (!BUSY.includes(a.status)) continue;
4174
+ if (back.some((c) => c.agentId === a.id && c.busy)) {
4175
+ carryOn.push(a);
4176
+ continue;
2513
4177
  }
4178
+ a.status = "stopped";
4179
+ a.lastError = "The swarm server restarted while this agent was working.";
4180
+ interrupted.push(a);
2514
4181
  }
4182
+ for (const c of back) if (c.busy && !carryOn.some((a) => a.id === c.agentId)) this.agentRt.get(c.agentId)?.terminal?.releaseIdle?.();
2515
4183
  for (const r of this.state.repos) this.repoRt.set(r.id, { issues: [], pulls: [], lastSync: null, syncing: false, cloneStatus: "pending", lastMergedAt: null, folderSync: null, merging: false });
4184
+ for (const a of carryOn) this.reattachSession(a);
4185
+ this.backend.hooksReady();
2516
4186
  try {
2517
4187
  this.user = await this.backend.user();
2518
4188
  } catch (err) {
@@ -2534,14 +4204,23 @@ var Swarm = class {
2534
4204
  await Promise.all([
2535
4205
  ...this.state.agents.map((a) => {
2536
4206
  const repo = this.state.repos.find((r) => r.id === a.repoId);
2537
- return repo ? this.backend.releaseDesk(repo.fullName, this.agentSlug(a), this.port(a)).catch(() => void 0) : void 0;
4207
+ if (!repo || back.some((c) => c.agentId === a.id)) return void 0;
4208
+ return this.backend.releaseDesk(repo.fullName, this.agentSlug(a), this.port(a)).catch(() => void 0);
2538
4209
  }),
2539
4210
  this.previews.clearOrphans(this.state.repos)
2540
4211
  ]);
2541
4212
  for (const r of this.state.repos) void this.previews.refreshDefault(r);
2542
4213
  this.recover(interrupted);
4214
+ this.officeHead = await this.backend.office.head();
4215
+ const updated = await this.backend.office.takeLastUpdate().catch(() => null);
4216
+ if (updated) await this.reportUpdate(updated);
2543
4217
  setInterval(() => this.state.repos.forEach((r, i) => setTimeout(() => void this.syncRepo(r.id), i * 1500)), SYNC_INTERVAL_MS);
2544
4218
  setInterval(() => this.schedule(), SCHEDULER_INTERVAL_MS);
4219
+ setInterval(() => void this.saveTerminals(), TERMINAL_SAVE_MS);
4220
+ void this.backend.detectClis().then((clis) => {
4221
+ this.clis = clis;
4222
+ this.broadcast({ type: "clis", clis });
4223
+ }).catch((err) => console.warn("could not look for agent CLIs", err));
2545
4224
  this.save();
2546
4225
  setTimeout(() => this.schedule(), 1e3);
2547
4226
  }
@@ -2613,6 +4292,8 @@ var Swarm = class {
2613
4292
  skin: a.skin,
2614
4293
  model: a.model,
2615
4294
  effort: a.effort,
4295
+ cli: a.cli,
4296
+ terminal: !!rt.terminal,
2616
4297
  status: a.status,
2617
4298
  issueNumber: a.issueNumber,
2618
4299
  issueTitle: a.issueTitle,
@@ -2660,7 +4341,11 @@ var Swarm = class {
2660
4341
  requests: this.state.requests,
2661
4342
  ceo: this.ceoInfo(),
2662
4343
  messages: this.state.messages.slice(-100),
2663
- phoneReadAt: this.state.phoneReadAt
4344
+ phoneReadAt: this.state.phoneReadAt,
4345
+ usage: this.usageNow(),
4346
+ clis: this.clis,
4347
+ officeCommit: this.officeHead?.slice(0, 7) ?? null,
4348
+ officeUpdate: this.officeHead ? this.officeUpdateView() : void 0
2664
4349
  };
2665
4350
  }
2666
4351
  screenshot(agentId) {
@@ -2719,14 +4404,17 @@ var Swarm = class {
2719
4404
  // ---------- persistence ----------
2720
4405
  save() {
2721
4406
  if (this.saveTimer) return;
2722
- this.saveTimer = setTimeout(async () => {
2723
- this.saveTimer = null;
2724
- for (const a of this.state.agents) a.logTail = (this.agentRt.get(a.id)?.log ?? []).slice(-200);
2725
- await fs4.mkdir(path6.dirname(STATE_FILE), { recursive: true });
2726
- const tmp = `${STATE_FILE}.tmp`;
2727
- await fs4.writeFile(tmp, JSON.stringify(this.state, null, 2));
2728
- await fs4.rename(tmp, STATE_FILE);
2729
- }, 1500);
4407
+ this.saveTimer = setTimeout(() => void this.writeState().catch((err) => console.warn("could not save the state", err)), 1500);
4408
+ }
4409
+ /** Write the state file now, e.g. before the office stops or hands itself to the launcher. */
4410
+ async writeState() {
4411
+ if (this.saveTimer) clearTimeout(this.saveTimer);
4412
+ this.saveTimer = null;
4413
+ for (const a of this.state.agents) a.logTail = (this.agentRt.get(a.id)?.log ?? []).slice(-200);
4414
+ await fs10.mkdir(path12.dirname(STATE_FILE), { recursive: true });
4415
+ const tmp = `${STATE_FILE}.tmp`;
4416
+ await fs10.writeFile(tmp, JSON.stringify(this.state, null, 2));
4417
+ await fs10.rename(tmp, STATE_FILE);
2730
4418
  }
2731
4419
  // ---------- lookups ----------
2732
4420
  repo(id) {
@@ -2808,7 +4496,7 @@ var Swarm = class {
2808
4496
  const name = fullName.split("/")[1];
2809
4497
  const taken = folders.find((f) => f.name.toLowerCase() === name.toLowerCase());
2810
4498
  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.`);
2811
- return path6.join(root, name);
4499
+ return path12.join(root, name);
2812
4500
  }
2813
4501
  /** A brand-new project: a folder in your projects folder, pushed to a new GitHub repo. */
2814
4502
  async createRepo(name, opts) {
@@ -2820,11 +4508,11 @@ var Swarm = class {
2820
4508
  }
2821
4509
  /** The folders in your projects folder (or another folder you point at), and which are floors already. */
2822
4510
  async listProjectFolders(dir) {
2823
- const root = dir?.trim() ? path6.resolve(dir.trim()) : this.state.settings.projectsDir;
4511
+ const root = dir?.trim() ? path12.resolve(dir.trim()) : this.state.settings.projectsDir;
2824
4512
  const folders = await this.backend.scanProjects(root).catch((err) => {
2825
4513
  throw new HttpError(400, err.message);
2826
4514
  });
2827
- const same = (a, b) => path6.resolve(a).toLowerCase() === path6.resolve(b).toLowerCase();
4515
+ const same = (a, b) => path12.resolve(a).toLowerCase() === path12.resolve(b).toLowerCase();
2828
4516
  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;
2829
4517
  return { root, folders: folders.map((f) => ({ name: f.name, path: f.path, git: f.git, github: f.github, floor: floorOf(f) })) };
2830
4518
  }
@@ -2921,46 +4609,38 @@ var Swarm = class {
2921
4609
  }
2922
4610
  /** One step toward merging a QA-passed PR. Returns true when it merged. */
2923
4611
  async advanceMerge(repo, rec, pr) {
2924
- if (pr.isDraft) return this.mergeNote(rec, "a draft: waiting");
2925
- if (pr.mergeable === "UNKNOWN" || pr.mergeState === "UNKNOWN") {
4612
+ let step = mergeStep(pr, rec, Date.now(), { base: repo.defaultBranch });
4613
+ if (step.do === "details") {
2926
4614
  const d = await this.backend.prDetails(repo.fullName, pr.number);
2927
4615
  pr = { ...pr, headSha: d.headSha, mergeable: d.mergeable, mergeState: d.mergeState };
4616
+ step = mergeStep(pr, rec, Date.now(), { base: repo.defaultBranch, detailed: true });
2928
4617
  }
2929
- rec.passedSha ??= pr.headSha;
2930
- if (pr.headSha !== rec.passedSha) {
4618
+ Object.assign(rec, step.set);
4619
+ if (step.do === "requeue") {
2931
4620
  this.setQa(rec, { status: "queued", round: rec.round + 1, retests: rec.retests + 1, mergeNote: null, pendingSince: null });
2932
4621
  setTimeout(() => this.schedule(), 200);
2933
4622
  return false;
2934
4623
  }
2935
- if (pr.mergeable === "CONFLICTING" || pr.mergeState === "DIRTY") {
2936
- return this.sendBack(repo, rec, "conflict", `It conflicts with ${repo.defaultBranch}.`);
2937
- }
2938
- if (pr.checks === "failing") {
2939
- return this.sendBack(repo, rec, "checks", pr.failedChecks.map((c) => `- ${c.name}${c.url ? `: ${c.url}` : ""}`).join("\n"));
2940
- }
2941
- if (pr.checks === "pending") {
2942
- rec.pendingSince ??= Date.now();
2943
- if (Date.now() - rec.pendingSince > CHECKS_ALERT_MS && !rec.alerted) {
2944
- rec.alerted = true;
4624
+ if (step.do === "send-back") return this.sendBack(repo, rec, step.reason, step.instructions, step.needsHuman);
4625
+ if (step.do === "wait") {
4626
+ if (step.alert) {
2945
4627
  this.postMessage("office", `\u23F3 PR #${pr.number} on ${repo.fullName} passed QA, but its checks (${pr.pendingChecks.join(", ")}) have been running for over ${CHECKS_ALERT_MS / 6e4} minutes. It merges as soon as they finish.`);
2946
4628
  }
2947
- return this.mergeNote(rec, `waiting for checks: ${pr.pendingChecks.join(", ") || "running"}`);
4629
+ return step.note === void 0 ? false : this.mergeNote(rec, step.note);
2948
4630
  }
2949
- rec.pendingSince = null;
2950
- if (pr.mergeable === "UNKNOWN") return this.mergeNote(rec, "GitHub is checking it can merge");
2951
- if (pr.mergeState === "BEHIND") {
4631
+ if (step.do === "update-branch") {
2952
4632
  this.mergeNote(rec, `updating the branch with ${repo.defaultBranch}`);
2953
4633
  await this.backend.updateBranch(repo.fullName, pr.number);
2954
4634
  const details = await this.backend.prDetails(repo.fullName, pr.number);
2955
4635
  this.setQa(rec, { passedSha: details.headSha });
2956
4636
  return false;
2957
4637
  }
2958
- if (rec.mergeRetryAt && Date.now() < rec.mergeRetryAt) return false;
4638
+ if (step.do !== "merge") return false;
2959
4639
  this.mergeNote(rec, "merging\u2026");
2960
4640
  let error = "";
2961
4641
  for (const method of ["squash", "merge", "rebase"]) {
2962
4642
  try {
2963
- await this.backend.mergePull(repo.fullName, pr.number, method, rec.passedSha);
4643
+ await this.backend.mergePull(repo.fullName, pr.number, method, rec.passedSha ?? pr.headSha);
2964
4644
  error = "";
2965
4645
  break;
2966
4646
  } catch (err) {
@@ -2989,8 +4669,8 @@ var Swarm = class {
2989
4669
  return false;
2990
4670
  }
2991
4671
  /** QA passed, but the PR can't merge as it is: a developer fixes it, and QA re-tests if the code changed. */
2992
- sendBack(repo, rec, reason, fixInstructions) {
2993
- if (rec.mergeFixes >= MAX_MERGE_FIXES) {
4672
+ sendBack(repo, rec, reason, fixInstructions, needsHuman) {
4673
+ if (needsHuman) {
2994
4674
  this.setQa(rec, { status: "needs-human", mergeNote: null });
2995
4675
  this.postMessage("office", `\u26A0\uFE0F PR #${rec.prNumber} on ${repo.fullName} still ${reason === "conflict" ? `conflicts with ${repo.defaultBranch}` : "fails its checks"} after ${MAX_MERGE_FIXES} fixes, so it needs you.`);
2996
4676
  return false;
@@ -3003,14 +4683,16 @@ var Swarm = class {
3003
4683
  async syncFolder(repo) {
3004
4684
  const rt = this.repoRt.get(repo.id);
3005
4685
  if (!rt || rt.cloneStatus !== "ready") return rt?.folderSync ?? null;
3006
- const own = isSamePath(this.backend.mainDir(repo.fullName), OFFICE_DIR);
3007
- const status = await this.backend.syncMain(repo.fullName, repo.defaultBranch, { touch: !own }).catch((err) => `sync failed: ${oneLine(err)}`);
4686
+ const own = this.backend.office.isOwnFolder(this.backend.mainDir(repo.fullName));
4687
+ const sync = await this.backend.syncMain(repo.fullName, repo.defaultBranch, { touch: !own }).catch((err) => ({ status: `sync failed: ${oneLine(err)}`, behind: 0, updatable: false }));
4688
+ const status = sync?.status ?? null;
4689
+ if (own && sync) this.setOfficeBehind(sync.updatable ? sync.behind : 0);
3008
4690
  if (!this.repoRt.has(repo.id)) return null;
3009
4691
  const before = rt.folderSync;
3010
4692
  rt.folderSync = status;
3011
4693
  this.emitRepo(repo);
3012
4694
  if (status && status !== before) {
3013
- const name = repo.localPath ? path6.basename(repo.localPath) : repo.fullName;
4695
+ const name = repo.localPath ? path12.basename(repo.localPath) : repo.fullName;
3014
4696
  if (status.startsWith("updated")) this.toast("success", `\u{1F4C1} ${name}: ${status}`);
3015
4697
  else if (/behind|diverged|failed/.test(status)) this.toast("info", `\u{1F4C1} ${name} wasn't updated: ${status}`);
3016
4698
  }
@@ -3029,9 +4711,15 @@ var Swarm = class {
3029
4711
  stopPreview(id) {
3030
4712
  return this.previews.stop(this.repo(id));
3031
4713
  }
3032
- /** Server shutdown: stop every floor's app so nothing is left holding a preview port. */
3033
- shutdown() {
3034
- return this.previews.stopAll(this.state.repos);
4714
+ /**
4715
+ * Server shutdown: stop every floor's app so nothing is left holding a preview port. Agents' CLIs keep working
4716
+ * through a restart (the terminal keeper holds them for the next start) and stop when the office quits.
4717
+ */
4718
+ async shutdown(restart = false) {
4719
+ await this.writeState().catch((err) => console.warn("could not save the state", err));
4720
+ this.backend.releaseClis(restart);
4721
+ await this.saveTerminals(true);
4722
+ await this.previews.stopAll(this.state.repos);
3035
4723
  }
3036
4724
  async cloneRepo(id) {
3037
4725
  const repo = this.state.repos.find((r) => r.id === id);
@@ -3141,7 +4829,7 @@ var Swarm = class {
3141
4829
  }
3142
4830
  const name = opts.name?.trim() || this.freeName(role);
3143
4831
  const agent = {
3144
- id: crypto3.randomUUID(),
4832
+ id: crypto6.randomUUID(),
3145
4833
  name,
3146
4834
  repoId: repo.id,
3147
4835
  role,
@@ -3157,6 +4845,7 @@ var Swarm = class {
3157
4845
  skin: opts.appearance?.skin ?? pick(SKIN),
3158
4846
  model: opts.model ?? "",
3159
4847
  effort: EFFORTS.includes(opts.effort) ? opts.effort : "",
4848
+ cli: isCli(opts.cli) ? opts.cli : "",
3160
4849
  status: "idle",
3161
4850
  issueNumber: null,
3162
4851
  issueTitle: null,
@@ -3168,11 +4857,12 @@ var Swarm = class {
3168
4857
  costUsd: 0,
3169
4858
  turns: 0,
3170
4859
  sessionId: null,
4860
+ sessionCli: null,
3171
4861
  lastError: null,
3172
4862
  logTail: []
3173
4863
  };
3174
4864
  this.state.agents.push(agent);
3175
- this.agentRt.set(agent.id, { log: [], pending: [], session: null, currentTool: null, browserUrl: null, screenshot: null, shots: [] });
4865
+ this.agentRt.set(agent.id, { log: [], pending: [], session: null, currentTool: null, browserUrl: null, screenshot: null, shots: [], terminal: null });
3176
4866
  this.appendLog(agent, [
3177
4867
  { kind: "system", text: role === "qa" ? `\u{1F50D} ${name} joined the QA lab on floor ${repo.floor} (${repo.fullName}).` : `\u{1F44B} ${name} joined floor ${repo.floor} (${repo.fullName}).` },
3178
4868
  ...agent.title ? [{ kind: "system", text: `\u{1FAAA} ${agent.title}${agent.specialty ? ` \xB7 takes swarm:${agent.specialty} issues first` : ""}` }] : []
@@ -3195,6 +4885,7 @@ var Swarm = class {
3195
4885
  }
3196
4886
  updateAgent(id, patch) {
3197
4887
  const a = this.agent(id);
4888
+ if (patch.cli !== void 0 && a.role !== "ceo") a.cli = isCli(patch.cli) ? patch.cli : "";
3198
4889
  if (patch.name?.trim() && patch.name.trim() !== a.name) {
3199
4890
  a.name = patch.name.trim().slice(0, 24);
3200
4891
  a.look = lookFor(a.name);
@@ -3222,7 +4913,10 @@ var Swarm = class {
3222
4913
  this.decide(r, { status: "approved", note: "They were let go directly.", decidedBy: "manager" });
3223
4914
  }
3224
4915
  this.agentRt.get(id)?.session?.stop();
4916
+ this.agentRt.get(id)?.terminal?.releaseIdle?.();
4917
+ this.agentRt.get(id)?.terminal?.dispose();
3225
4918
  void removeScreens(id);
4919
+ void fs10.rm(terminalFile(id), { force: true }).catch(() => void 0);
3226
4920
  for (const q of this.state.qa) {
3227
4921
  if (q.qaAgentId === id && q.status === "testing") this.setQa(q, { status: "queued", qaAgentId: null });
3228
4922
  if (q.devAgentId === id) q.devAgentId = null;
@@ -3248,6 +4942,20 @@ var Swarm = class {
3248
4942
  this.emitAgent(a);
3249
4943
  this.save();
3250
4944
  }
4945
+ /** An agent whose CLI kept working through the office's restart: the office follows its session again. */
4946
+ reattachSession(a) {
4947
+ const repo = this.state.repos.find((r) => r.id === a.repoId);
4948
+ if (!repo) return;
4949
+ this.appendLog(a, [{ kind: "system", text: "\u21BB The office restarted; their CLI kept working and the office is following it again." }]);
4950
+ this.startAgentSession(a, repo, this.backend.deskDir(repo.fullName, this.agentSlug(a)), "", "", a.sessionId ?? void 0, void 0, "reattach");
4951
+ }
4952
+ /** The manager pressed Esc in the agent's terminal and the CLI stopped its turn: a Stop that leaves the CLI to them. */
4953
+ interrupted(a) {
4954
+ if (!BUSY.includes(a.status)) return;
4955
+ a.status = "stopped";
4956
+ a.lastError = "Interrupted in the terminal";
4957
+ this.appendLog(a, [{ kind: "manager", text: "\u25A0 Interrupted in the terminal. Type there to carry on." }]);
4958
+ }
3251
4959
  resetAgent(id) {
3252
4960
  const a = this.agent(id);
3253
4961
  if (BUSY.includes(a.status)) throw new HttpError(409, `${a.name} is busy; stop them first`);
@@ -3255,6 +4963,7 @@ var Swarm = class {
3255
4963
  if (q.qaAgentId === id && q.status === "testing") this.setQa(q, { status: "queued", qaAgentId: null });
3256
4964
  if (q.devAgentId === id && q.status === "fixing") this.setQa(q, { status: "failed" });
3257
4965
  }
4966
+ this.agentRt.get(id)?.terminal?.releaseIdle?.();
3258
4967
  this.clearTask(a);
3259
4968
  this.appendLog(a, [{ kind: "system", text: "\u21BA Cleared desk. Ready for new work." }]);
3260
4969
  this.save();
@@ -3289,6 +4998,57 @@ var Swarm = class {
3289
4998
  linkedRepos(repo) {
3290
4999
  return repo.links.map((id) => this.state.repos.find((r) => r.id === id)).filter((r) => !!r);
3291
5000
  }
5001
+ /**
5002
+ * How an agent's next session runs: the CLI in their terminal (the terminal runtime), or Claude Code through the
5003
+ * SDK. A session can only be resumed by the CLI that made it, so a follow-up stays with that CLI.
5004
+ */
5005
+ sessionRuntime(a, resume) {
5006
+ const inTerminal2 = this.state.settings.runtime === "terminal" && this.backend.terminals;
5007
+ let cli = a.role === "ceo" ? "claude" : a.cli || this.state.settings.defaultCli;
5008
+ if (resume && a.sessionCli && a.sessionCli !== cli) {
5009
+ if (inTerminal2) cli = a.sessionCli;
5010
+ else if (a.sessionCli !== "claude") resume = void 0;
5011
+ }
5012
+ if (!inTerminal2) return { resumeSessionId: resume };
5013
+ const what = a.role === "ceo" ? a.issueTitle : a.task === "qa" ? `QA \xB7 PR #${a.prNumber}` : a.task === "fix" ? `fixing PR #${a.prNumber}` : a.issueNumber ? `#${a.issueNumber} ${a.issueTitle ?? ""}` : null;
5014
+ return { terminal: this.terminalFor(a), cli, label: `${a.name}${what ? ` \xB7 ${what}` : ""}`.slice(0, 80).trim(), resumeSessionId: resume };
5015
+ }
5016
+ /** The model to ask for (the Agent SDK runs Claude Code). '' lets another coding agent use its own default. */
5017
+ modelFor(a, cli) {
5018
+ return effectiveModel(a.model, cli ?? "claude", this.state.settings, DEFAULT_MODEL);
5019
+ }
5020
+ // ---------- terminals ----------
5021
+ async loadTerminal(agentId) {
5022
+ if (!await fs10.stat(terminalFile(agentId)).catch(() => null)) return null;
5023
+ const t = this.newTerminal(agentId);
5024
+ await t.load(terminalFile(agentId));
5025
+ return t;
5026
+ }
5027
+ /** The agent's terminal, made the first time they run in the terminal runtime. */
5028
+ terminalFor(a) {
5029
+ const rt = this.agentRt.get(a.id);
5030
+ rt.terminal ??= this.newTerminal(a.id);
5031
+ return rt.terminal;
5032
+ }
5033
+ newTerminal(agentId) {
5034
+ const t = new AgentTerminal();
5035
+ t.onIdlePrompt = (text) => {
5036
+ void this.message(agentId, text, true).catch(() => void 0);
5037
+ return !!this.agentRt.get(agentId)?.session;
5038
+ };
5039
+ return t;
5040
+ }
5041
+ async saveTerminals(all = false) {
5042
+ for (const [id, rt] of this.agentRt) {
5043
+ if (rt.terminal && (all || rt.terminal.dirty)) await rt.terminal.save(terminalFile(id)).catch((err) => console.warn("could not save a terminal", err));
5044
+ }
5045
+ }
5046
+ /** A browser opened an agent's terminal (/ws/term?agent=<id>). */
5047
+ attachTerminal(agentId, ws) {
5048
+ const t = this.agentRt.get(agentId)?.terminal;
5049
+ if (!t) return ws.close(4404, "That agent has no terminal");
5050
+ t.attach(ws);
5051
+ }
3292
5052
  buildSystemAppend(a, repo, cwd, branch, fixing) {
3293
5053
  const linked = this.linkedRepos(repo).map((r) => `- ${r.fullName}: read-only reference clone at ${this.backend.mainDir(r.fullName)}`);
3294
5054
  const push = fixing ? `git push origin HEAD:${fixing.headRef}` : `git push -u origin ${branch}`;
@@ -3380,24 +5140,29 @@ Note from the manager: ${note}` : ""
3380
5140
  ].filter(Boolean).join("\n");
3381
5141
  this.startAgentSession(a, repo, cwd, prompt, this.buildSystemAppend(a, repo, cwd, branch));
3382
5142
  }
3383
- startAgentSession(a, repo, cwd, prompt, systemAppend, resumeSessionId, outputSchema) {
5143
+ startAgentSession(a, repo, cwd, prompt, systemAppend, resumeSessionId, outputSchema, mode = null) {
3384
5144
  const rt = this.agentRt.get(a.id);
3385
5145
  a.status = "working";
5146
+ const how = this.sessionRuntime(a, resumeSessionId);
3386
5147
  this.emitAgent(a);
3387
5148
  rt.session = this.backend.startSession(
3388
5149
  {
3389
5150
  cwd,
3390
5151
  prompt,
3391
5152
  systemAppend,
3392
- model: a.model || this.state.settings.defaultModel,
5153
+ model: this.modelFor(a, how.cli),
3393
5154
  effort: a.effort || this.state.settings.defaultEffort,
3394
5155
  browserTesting: repo.browserTesting,
3395
- permissionMode: this.state.settings.permissionMode,
3396
5156
  additionalDirectories: this.linkedRepos(repo).map((r) => this.backend.mainDir(r.fullName)),
3397
5157
  role: a.task === "qa" ? "qa" : a.role,
3398
5158
  // a developer covering QA works under QA's rules
3399
5159
  outputSchema,
3400
- resumeSessionId
5160
+ // A developer's CLI stays at its prompt afterwards, for the manager and for follow-ups; QA's closes.
5161
+ keepAlive: a.task !== "qa",
5162
+ typed: mode === "typed",
5163
+ reattach: mode === "reattach",
5164
+ agentId: a.id,
5165
+ ...how
3401
5166
  },
3402
5167
  {
3403
5168
  log: (entries) => this.appendLog(a, entries),
@@ -3408,6 +5173,7 @@ Note from the manager: ${note}` : ""
3408
5173
  },
3409
5174
  sessionId: (id) => {
3410
5175
  a.sessionId = id;
5176
+ a.sessionCli = how.cli ?? "claude";
3411
5177
  },
3412
5178
  browserUrl: (url) => {
3413
5179
  rt.browserUrl = url;
@@ -3419,23 +5185,25 @@ Note from the manager: ${note}` : ""
3419
5185
  rt.shots.push({ data, mime, url: rt.browserUrl, at });
3420
5186
  if (rt.shots.length > 12) rt.shots.shift();
3421
5187
  this.broadcast({ type: "screen", agentId: a.id, url: rt.browserUrl, at });
3422
- void removeScreens(a.id).then(() => fs4.mkdir(SCREENS_DIR, { recursive: true })).then(() => fs4.writeFile(screenFile(a.id, mime), data)).catch((err) => console.warn("could not save screenshot", err));
5188
+ 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));
3423
5189
  },
3424
5190
  limited: (at) => this.pauseForLimit(at),
5191
+ usageWarning: (info) => this.paceForWarning(info),
3425
5192
  finished: (result) => void this.onFinished(a, repo, result)
3426
- },
3427
- repo.defaultBranch
5193
+ }
3428
5194
  );
3429
5195
  }
3430
5196
  async onFinished(a, repo, result) {
3431
5197
  const rt = this.agentRt.get(a.id);
3432
5198
  if (!rt || !this.state.agents.includes(a)) return;
5199
+ if (this.officeUpdate.handedOver) return;
3433
5200
  rt.session = null;
3434
5201
  rt.currentTool = null;
3435
5202
  a.endedAt = Date.now();
3436
5203
  a.costUsd += result.costUsd;
3437
5204
  a.turns += result.turns;
3438
5205
  void this.backend.releaseDesk(repo.fullName, this.agentSlug(a), this.port(a)).catch(() => void 0);
5206
+ if (result.interrupted) this.interrupted(a);
3439
5207
  if (a.task === "qa") await this.onQaFinished(a, repo, result);
3440
5208
  else if (a.task === "fix") this.onFixFinished(a, repo, result);
3441
5209
  else await this.onIssueFinished(a, repo, result);
@@ -3571,7 +5339,7 @@ ${repo.qaBrief}`] : [],
3571
5339
  "1. Read the PR description and the linked issue, and work out the acceptance criteria.",
3572
5340
  `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.`,
3573
5341
  "3. Install dependencies if needed, then run the project's test suite, linters, type checks and build (whichever exist).",
3574
- 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 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).",
5342
+ 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).",
3575
5343
  "5. You may write throwaway scripts to probe behaviour, but do not commit them.",
3576
5344
  "",
3577
5345
  "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."
@@ -3808,7 +5576,8 @@ Full report with screenshots: ${rec.commentUrl}` : "",
3808
5576
  this.toast("info", `${a.name} pushed fixes for PR #${rec.prNumber}; QA round ${rec.round} is queued`);
3809
5577
  }
3810
5578
  }
3811
- async message(id, text) {
5579
+ /** A message for an agent: sent into their running session, or a follow-up that resumes it. typed: the manager typed it at their CLI's prompt, where it's already running. */
5580
+ async message(id, text, typed = false) {
3812
5581
  const a = this.agent(id);
3813
5582
  if (a.role === "ceo") return this.messageCeo(text);
3814
5583
  const repo = this.repo(a.repoId);
@@ -3816,7 +5585,7 @@ Full report with screenshots: ${rec.commentUrl}` : "",
3816
5585
  const rt = this.agentRt.get(id);
3817
5586
  if (rt.session) {
3818
5587
  this.appendLog(a, [{ kind: "manager", text: `\u25B6 Manager: ${text}` }]);
3819
- rt.session.send(text);
5588
+ if (!typed) rt.session.send(text);
3820
5589
  return;
3821
5590
  }
3822
5591
  if (a.role === "qa") throw new HttpError(409, `${a.name} isn't testing anything right now. Send a PR to QA from the Kanban board.`);
@@ -3829,22 +5598,28 @@ Full report with screenshots: ${rec.commentUrl}` : "",
3829
5598
  a.startedAt = Date.now();
3830
5599
  if (a.task === null) a.task = "issue";
3831
5600
  const fixing = a.task === "fix" && a.prNumber ? { pr: a.prNumber, headRef: a.branch } : void 0;
3832
- this.startAgentSession(a, repo, cwd, text, this.buildSystemAppend(a, repo, cwd, a.branch, fixing), a.sessionId);
5601
+ this.startAgentSession(a, repo, cwd, text, this.buildSystemAppend(a, repo, cwd, a.branch, fixing), a.sessionId, void 0, typed ? "typed" : null);
3833
5602
  }
3834
5603
  updateSettings(patch) {
3835
5604
  const s = this.state.settings;
3836
5605
  if (patch.sessionLimit !== void 0) s.sessionLimit = Math.max(0, Math.round(Number(patch.sessionLimit)) || 0);
3837
- if (patch.defaultModel !== void 0) s.defaultModel = String(patch.defaultModel).trim() || DEFAULT_MODEL;
5606
+ if (isCli(patch.defaultCli) && patch.defaultCli !== s.defaultCli) {
5607
+ s.defaultCli = patch.defaultCli;
5608
+ if (patch.defaultModel === void 0) s.defaultModel = s.defaultCli === "claude" ? DEFAULT_MODEL : "";
5609
+ }
5610
+ if (patch.defaultModel !== void 0) s.defaultModel = String(patch.defaultModel).trim() || (s.defaultCli === "claude" ? DEFAULT_MODEL : "");
3838
5611
  if (patch.defaultEffort !== void 0 && EFFORTS.includes(patch.defaultEffort)) s.defaultEffort = patch.defaultEffort;
3839
- if (patch.permissionMode === "guarded" || patch.permissionMode === "bypass") s.permissionMode = patch.permissionMode;
5612
+ if (patch.runtime === "terminal" || patch.runtime === "sdk") s.runtime = patch.runtime;
3840
5613
  if (patch.hiring === "approve" || patch.hiring === "auto") s.hiring = patch.hiring;
3841
5614
  if (patch.teamCap !== void 0) s.teamCap = Math.max(1, Math.min(15, Math.round(Number(patch.teamCap)) || 1));
3842
5615
  if (patch.ceoHeartbeatMin !== void 0) s.ceoHeartbeatMin = Math.max(0, Math.min(1440, Math.round(Number(patch.ceoHeartbeatMin)) || 0));
3843
5616
  if (typeof patch.managerName === "string") s.managerName = patch.managerName.trim().slice(0, 40);
3844
5617
  if (typeof patch.companyName === "string") s.companyName = patch.companyName.trim().slice(0, 60);
3845
- if (typeof patch.projectsDir === "string" && patch.projectsDir.trim()) s.projectsDir = path6.resolve(patch.projectsDir.trim());
5618
+ if (typeof patch.projectsDir === "string" && patch.projectsDir.trim()) s.projectsDir = path12.resolve(patch.projectsDir.trim());
3846
5619
  if (typeof patch.setupDone === "boolean") s.setupDone = patch.setupDone;
3847
5620
  if (patch.tutorialStep !== void 0) s.tutorialStep = Math.max(-1, Math.round(Number(patch.tutorialStep)) || 0);
5621
+ if (typeof patch.autoUpdate === "boolean") s.autoUpdate = patch.autoUpdate;
5622
+ if (patch.pacingSessions !== void 0) s.pacingSessions = clampPacingSessions(patch.pacingSessions);
3848
5623
  this.save();
3849
5624
  this.broadcast({ type: "settings", settings: s });
3850
5625
  this.emitCeo();
@@ -3879,6 +5654,9 @@ Full report with screenshots: ${rec.commentUrl}` : "",
3879
5654
  // `${repoId}#${issue}`: its developer was asked once to finish the missing PR
3880
5655
  pausedUntil = 0;
3881
5656
  // Claude's usage limit was hit: nothing new starts before this
5657
+ pacingUntil = 0;
5658
+ // Claude warned about usage: new issues are paced until this
5659
+ lastUsage = "";
3882
5660
  /** Backlog issues that can start now, most urgent first: the ones holding up the longest chain of other issues, then the oldest. */
3883
5661
  readyIssues(repo) {
3884
5662
  const issues = this.repoRt.get(repo.id).issues;
@@ -3938,7 +5716,7 @@ Full report with screenshots: ${rec.commentUrl}` : "",
3938
5716
  * free developer is least needed elsewhere, so nobody sits idle while there is work that can start.
3939
5717
  */
3940
5718
  startIssueWork(repo) {
3941
- if (!repo.autoAssign) return false;
5719
+ if (!repo.autoAssign || !this.mayStart("issue")) return false;
3942
5720
  const free = this.available(repo, "dev");
3943
5721
  const ready = free.length ? this.readyIssues(repo) : [];
3944
5722
  if (ready.length === 0) return false;
@@ -3958,6 +5736,39 @@ Full report with screenshots: ${rec.commentUrl}` : "",
3958
5736
  const fresh = Date.now() >= this.pausedUntil;
3959
5737
  this.pausedUntil = until;
3960
5738
  if (fresh) this.postMessage("office", `\u23F8 Claude's usage limit was reached. The office starts no new work until ${new Date(until).toLocaleTimeString()}; sessions already running carry on.`);
5739
+ this.emitUsage();
5740
+ }
5741
+ /** Claude warned that usage is getting high: pace new issues until the window resets, rather than run into the limit. */
5742
+ paceForWarning(info) {
5743
+ const now2 = Date.now();
5744
+ const until = info.resetsAt && info.resetsAt > now2 ? info.resetsAt : now2 + PACING_MS;
5745
+ if (until <= this.pacingUntil) return;
5746
+ const fresh = now2 >= this.pacingUntil;
5747
+ this.pacingUntil = until;
5748
+ if (fresh) this.postMessage("office", pacingMessage(info, until, this.state.settings.pacingSessions, now2));
5749
+ this.emitUsage();
5750
+ }
5751
+ /** May work of this kind start now, as far as Claude's usage goes? */
5752
+ mayStart(kind) {
5753
+ return mayStart(kind, { now: Date.now(), pausedUntil: this.pausedUntil, pacingUntil: this.pacingUntil, running: this.running(), pacingSessions: this.state.settings.pacingSessions });
5754
+ }
5755
+ usageNow() {
5756
+ return usageView({ now: Date.now(), pausedUntil: this.pausedUntil, pacingUntil: this.pacingUntil });
5757
+ }
5758
+ emitUsage() {
5759
+ const usage = this.usageNow();
5760
+ const key = JSON.stringify(usage);
5761
+ if (key === this.lastUsage) return;
5762
+ this.lastUsage = key;
5763
+ this.broadcast({ type: "usage", usage });
5764
+ }
5765
+ /** Pacing and pauses run out on their own: say so when pacing ends. */
5766
+ tickUsage() {
5767
+ if (this.pacingUntil && Date.now() >= this.pacingUntil) {
5768
+ this.pacingUntil = 0;
5769
+ this.postMessage("office", "\u2705 Claude's usage is back to normal. The office starts new work at full speed again.");
5770
+ }
5771
+ this.emitUsage();
3961
5772
  }
3962
5773
  /** A failed session releases its issue for someone else; an issue that keeps failing waits for the manager. */
3963
5774
  issueFailed(repo, n) {
@@ -3975,6 +5786,8 @@ Full report with screenshots: ${rec.commentUrl}` : "",
3975
5786
  * so no repo can hog the slots.
3976
5787
  */
3977
5788
  schedule() {
5789
+ this.tickUsage();
5790
+ if (this.officeUpdateTick()) return;
3978
5791
  if (this.limited()) return;
3979
5792
  this.maybeHeartbeat();
3980
5793
  this.startCeoWork();
@@ -3996,6 +5809,126 @@ Full report with screenshots: ${rec.commentUrl}` : "",
3996
5809
  }
3997
5810
  }
3998
5811
  }
5812
+ // ---------- the office's own update ----------
5813
+ drainInput() {
5814
+ const u = this.officeUpdate;
5815
+ return {
5816
+ now: Date.now(),
5817
+ behind: this.officeHead ? u.behind : 0,
5818
+ launcher: this.backend.office.launcher,
5819
+ autoUpdate: this.state.settings.autoUpdate !== false,
5820
+ requested: u.requested,
5821
+ postponedUntil: u.postponedUntil,
5822
+ postponedBehind: u.postponedBehind,
5823
+ failed: u.failed,
5824
+ failedBehind: u.failedBehind,
5825
+ drainingSince: u.drainingSince,
5826
+ sent: u.sent,
5827
+ running: this.running()
5828
+ };
5829
+ }
5830
+ officeUpdateView() {
5831
+ const u = this.officeUpdate;
5832
+ const input = this.drainInput();
5833
+ const d = drainDecision(input);
5834
+ const until = u.postponedUntil ? new Date(u.postponedUntil).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
5835
+ const detail = d.state === "failed" ? u.failed : d.state === "waiting" ? `Postponed until ${until}, or until a newer commit lands.` : null;
5836
+ return { state: d.state, behind: input.behind, launcher: input.launcher, drainingSince: u.drainingSince ?? d.drainingSince, running: input.running, detail };
5837
+ }
5838
+ emitOfficeUpdate() {
5839
+ if (!this.officeHead) return;
5840
+ const view = this.officeUpdateView();
5841
+ const key = JSON.stringify(view);
5842
+ if (key === this.lastOfficeView) return;
5843
+ this.lastOfficeView = key;
5844
+ this.broadcast({ type: "officeUpdate", officeUpdate: view });
5845
+ }
5846
+ /** The own-folder check found the office this many commits behind GitHub. */
5847
+ setOfficeBehind(behind) {
5848
+ const u = this.officeUpdate;
5849
+ if (u.failed && u.failedBehind === null) u.failedBehind = behind;
5850
+ u.behind = behind;
5851
+ if (behind === 0) Object.assign(u, { requested: false, failed: null, failedBehind: null });
5852
+ this.officeUpdateTick();
5853
+ }
5854
+ /**
5855
+ * Take the office's own update one step (see drainDecision): drain, and once nothing is running, or the drain timed
5856
+ * out, hand it to the launcher. True while nothing new may start.
5857
+ */
5858
+ officeUpdateTick() {
5859
+ if (!this.officeHead) return false;
5860
+ const u = this.officeUpdate;
5861
+ const d = drainDecision(this.drainInput());
5862
+ if (d.state === "draining" && u.drainingSince === null) {
5863
+ const running = this.running();
5864
+ this.postMessage(
5865
+ "office",
5866
+ `\u2B06\uFE0F Updating the office (${u.behind} new commit${u.behind === 1 ? "" : "s"}). Nothing new starts${running ? ` while ${running} running session${running === 1 ? "" : "s"} finish` : ""}; then it installs and restarts.`
5867
+ );
5868
+ }
5869
+ u.drainingSince = d.drainingSince;
5870
+ if (d.send) void this.handOver(d.timedOut);
5871
+ this.emitOfficeUpdate();
5872
+ return d.state === "draining" || d.state === "updating";
5873
+ }
5874
+ /** Drained: hand the update to the launcher. Sessions still running after the timeout stop the way a restart stops them. */
5875
+ async handOver(timedOut) {
5876
+ const u = this.officeUpdate;
5877
+ const from = this.officeHead;
5878
+ u.sent = true;
5879
+ const busy = this.state.agents.filter((a) => BUSY.includes(a.status));
5880
+ if (timedOut) this.postMessage("office", `\u23F1 ${busy.length} session${busy.length === 1 ? " was" : "s were"} still running after 20 minutes. Stopped; the work goes back to the queue after the update.`);
5881
+ this.emitOfficeUpdate();
5882
+ u.handedOver = true;
5883
+ await this.writeState().catch((err) => console.warn("could not save the state", err));
5884
+ for (const a of busy) this.agentRt.get(a.id)?.session?.stop();
5885
+ try {
5886
+ const result = await this.backend.office.update(from);
5887
+ if (result) await this.afterUpdate(result);
5888
+ } catch (err) {
5889
+ await this.afterUpdate({ from, to: from, ok: false, error: `the launcher didn't take the update: ${oneLine(err)}`, installed: false, built: false, at: Date.now() });
5890
+ }
5891
+ }
5892
+ /** An update that didn't restart the office (the demo, or a failed hand-over): recover like a restart would, then report it. */
5893
+ async afterUpdate(result) {
5894
+ const u = this.officeUpdate;
5895
+ Object.assign(u, { sent: false, handedOver: false, requested: false, drainingSince: null });
5896
+ const interrupted = this.state.agents.filter((a) => BUSY.includes(a.status));
5897
+ for (const a of interrupted) {
5898
+ Object.assign(a, { status: "stopped", lastError: "The office updated itself while this agent was working." });
5899
+ const rt = this.agentRt.get(a.id);
5900
+ if (rt) Object.assign(rt, { session: null, currentTool: null });
5901
+ }
5902
+ this.ensureCeo(interrupted);
5903
+ this.recover(interrupted);
5904
+ interrupted.forEach((a) => this.emitAgent(a));
5905
+ await this.reportUpdate(result);
5906
+ if (!result.ok) u.failedBehind = u.behind;
5907
+ const own = this.state.repos.find((r) => this.backend.office.isOwnFolder(this.backend.mainDir(r.fullName)));
5908
+ if (own) await this.syncFolder(own);
5909
+ this.emitCeo();
5910
+ this.save();
5911
+ setTimeout(() => this.schedule(), 300);
5912
+ }
5913
+ /** One phone message about the last update (the launcher's last-update.json, or the demo's fake). */
5914
+ async reportUpdate(result) {
5915
+ const commits = result.ok && result.to ? await this.backend.office.commitsBetween(result.from, result.to) : null;
5916
+ this.postMessage("office", lastUpdateMessage(result, commits));
5917
+ Object.assign(this.officeUpdate, { failed: result.ok ? null : result.error || "unknown error", failedBehind: null });
5918
+ }
5919
+ /** The manager's Update now (drain now, even with automatic updates off) or Later (not for 2 hours, or until a newer commit). */
5920
+ updateOffice(action) {
5921
+ if (action !== "now" && action !== "later") throw new HttpError(400, 'action must be "now" or "later"');
5922
+ const u = this.officeUpdate;
5923
+ if (!this.officeHead || !this.backend.office.launcher) throw new HttpError(409, "The office can only update itself when its launcher started it (npm run dev or npm start).");
5924
+ if (u.sent) throw new HttpError(409, "The update is already under way.");
5925
+ if (u.behind <= 0) throw new HttpError(409, "The office is up to date: there is nothing to update.");
5926
+ if (action === "now") Object.assign(u, { requested: true, postponedUntil: null });
5927
+ else Object.assign(u, { requested: false, postponedUntil: Date.now() + POSTPONE_MS, postponedBehind: u.behind });
5928
+ this.officeUpdateTick();
5929
+ setTimeout(() => this.schedule(), 200);
5930
+ return this.officeUpdateView();
5931
+ }
3999
5932
  // ---------- the CEO ----------
4000
5933
  ceo() {
4001
5934
  return this.state.agents.find((a) => a.id === CEO_ID);
@@ -4021,6 +5954,7 @@ Full report with screenshots: ${rec.commentUrl}` : "",
4021
5954
  skin: pick(SKIN),
4022
5955
  model: CEO_MODEL,
4023
5956
  effort: CEO_EFFORT,
5957
+ cli: "claude",
4024
5958
  status: "idle",
4025
5959
  issueNumber: null,
4026
5960
  issueTitle: null,
@@ -4032,11 +5966,12 @@ Full report with screenshots: ${rec.commentUrl}` : "",
4032
5966
  costUsd: 0,
4033
5967
  turns: 0,
4034
5968
  sessionId: null,
5969
+ sessionCli: null,
4035
5970
  lastError: null,
4036
5971
  logTail: []
4037
5972
  };
4038
5973
  this.state.agents.push(a);
4039
- this.agentRt.set(a.id, { log: [], pending: [], session: null, currentTool: null, browserUrl: null, screenshot: null, shots: [] });
5974
+ this.agentRt.set(a.id, { log: [], pending: [], session: null, currentTool: null, browserUrl: null, screenshot: null, shots: [], terminal: null });
4040
5975
  this.appendLog(a, [{ kind: "system", text: `\u{1F3DB}\uFE0F ${a.name} moved into the corner office. The CEO studies every floor, shapes its team and plans its work.` }]);
4041
5976
  }
4042
5977
  const i = interrupted.indexOf(a);
@@ -4122,17 +6057,18 @@ ${job.text}` } : job;
4122
6057
  this.state.ceo.lastReviewAt = Date.now();
4123
6058
  this.state.ceo.lastFingerprint = this.fingerprint();
4124
6059
  }
4125
- this.ceoIssues = { filed: 0, repos: /* @__PURE__ */ new Set() };
6060
+ this.ceoIssues = new IssueCap(MAX_ISSUES_PER_JOB);
4126
6061
  this.emitAgent(a);
4127
6062
  this.emitCeo();
4128
6063
  this.save();
4129
- await fs4.mkdir(CEO_DIR, { recursive: true }).catch(() => void 0);
6064
+ await fs10.mkdir(CEO_DIR, { recursive: true }).catch(() => void 0);
4130
6065
  if (a.status !== "working") {
4131
6066
  this.state.ceo.job = null;
4132
6067
  this.emitCeo();
4133
6068
  return;
4134
6069
  }
4135
6070
  const s = this.state.settings;
6071
+ const how = this.sessionRuntime(a, job.kind === "chat" ? a.sessionId ?? void 0 : void 0);
4136
6072
  rt.session = this.backend.startSession(
4137
6073
  {
4138
6074
  cwd: CEO_DIR,
@@ -4141,7 +6077,7 @@ ${job.text}` } : job;
4141
6077
  name: a.name,
4142
6078
  company: s.companyName,
4143
6079
  manager: s.managerName,
4144
- notesFile: path6.join(CEO_DIR, "NOTES.md"),
6080
+ notesFile: path12.join(CEO_DIR, "NOTES.md"),
4145
6081
  sessionLimit: s.sessionLimit,
4146
6082
  teamCap: s.teamCap,
4147
6083
  hiring: s.hiring
@@ -4149,12 +6085,10 @@ ${job.text}` } : job;
4149
6085
  model: a.model || CEO_MODEL,
4150
6086
  effort: a.effort || CEO_EFFORT,
4151
6087
  browserTesting: false,
4152
- permissionMode: s.permissionMode,
4153
6088
  additionalDirectories: this.state.repos.filter((r) => this.repoRt.get(r.id)?.cloneStatus === "ready").map((r) => this.backend.mainDir(r.fullName)),
4154
6089
  role: "ceo",
4155
6090
  office: this.officeTools(),
4156
- // A chat carries on from the CEO's last session, so "why did you propose that?" has an answer.
4157
- resumeSessionId: job.kind === "chat" ? a.sessionId ?? void 0 : void 0
6091
+ ...how
4158
6092
  },
4159
6093
  {
4160
6094
  log: (entries) => this.appendLog(a, entries),
@@ -4165,19 +6099,20 @@ ${job.text}` } : job;
4165
6099
  },
4166
6100
  sessionId: (id) => {
4167
6101
  a.sessionId = id;
6102
+ a.sessionCli = "claude";
4168
6103
  },
4169
6104
  browserUrl: () => void 0,
4170
6105
  screenshot: () => void 0,
4171
6106
  turn: (text) => this.postMessage("ceo", text),
4172
6107
  limited: (at) => this.pauseForLimit(at),
6108
+ usageWarning: (info) => this.paceForWarning(info),
4173
6109
  finished: (result) => this.onCeoFinished(a, result)
4174
- },
4175
- ""
6110
+ }
4176
6111
  );
4177
6112
  }
4178
6113
  onCeoFinished(a, result) {
4179
6114
  const rt = this.agentRt.get(a.id);
4180
- if (!rt) return;
6115
+ if (!rt || this.officeUpdate.handedOver) return;
4181
6116
  rt.session = null;
4182
6117
  rt.currentTool = null;
4183
6118
  a.endedAt = Date.now();
@@ -4185,6 +6120,7 @@ ${job.text}` } : job;
4185
6120
  a.turns += result.turns;
4186
6121
  const job = this.state.ceo.job;
4187
6122
  this.state.ceo.job = null;
6123
+ if (result.interrupted) this.interrupted(a);
4188
6124
  if (a.status === "stopped") {
4189
6125
  } else if (!result.ok) {
4190
6126
  a.status = "error";
@@ -4194,7 +6130,7 @@ ${job.text}` } : job;
4194
6130
  this.postMessage("office", `\u26A0\uFE0F ${a.name} hit a problem while ${what}: ${a.lastError.slice(0, 240)}`);
4195
6131
  } else {
4196
6132
  a.status = "done";
4197
- const filed = this.ceoIssues.filed ? ` \xB7 ${this.ceoIssues.filed} issue${this.ceoIssues.filed === 1 ? "" : "s"} filed` : "";
6133
+ const filed = this.ceoIssues.total ? ` \xB7 ${this.ceoIssues.total} issue${this.ceoIssues.total === 1 ? "" : "s"} filed` : "";
4198
6134
  this.appendLog(a, [{ kind: "done", text: `\u2714 Done in ${this.minutes(a)}m \xB7 ${a.turns} turns${filed}` }]);
4199
6135
  }
4200
6136
  for (const id of this.ceoIssues.repos) void this.syncRepo(id);
@@ -4212,6 +6148,7 @@ ${job.text}` } : job;
4212
6148
  const rt = this.agentRt.get(a.id);
4213
6149
  if (rt.session) {
4214
6150
  this.appendLog(a, [{ kind: "manager", text: `\u25B6 Manager: ${t}` }]);
6151
+ this.ceoIssues.managerMessage();
4215
6152
  rt.session.send(`Message from the manager (they read your reply on their phone, so keep it short):
4216
6153
  ${t}`);
4217
6154
  return;
@@ -4247,7 +6184,7 @@ ${t}`);
4247
6184
  qa: this.state.qa.map((q) => [q.repoId, q.prNumber, q.status]),
4248
6185
  requests: this.state.requests.filter((r) => r.status === "pending").map((r) => r.id)
4249
6186
  };
4250
- return crypto3.createHash("sha1").update(JSON.stringify(data)).digest("hex");
6187
+ return crypto6.createHash("sha1").update(JSON.stringify(data)).digest("hex");
4251
6188
  }
4252
6189
  maybeHeartbeat() {
4253
6190
  const min = this.state.settings.ceoHeartbeatMin;
@@ -4434,6 +6371,7 @@ ${t}`);
4434
6371
  teamCap: s.teamCap,
4435
6372
  sessionLimit: s.sessionLimit || "none",
4436
6373
  sessionsRunning: this.running(),
6374
+ usage: usageLabel(this.usageNow(), Date.now()),
4437
6375
  deskLimits: { dev: MAX_DESKS.dev, qa: MAX_DESKS.qa }
4438
6376
  },
4439
6377
  floors,
@@ -4460,7 +6398,8 @@ ${t}`);
4460
6398
  doing: this.agentDoing(a),
4461
6399
  issue: a.issueNumber ? { number: a.issueNumber, title: a.issueTitle } : null,
4462
6400
  pullRequest: a.prNumber ? { number: a.prNumber, url: a.prUrl } : null,
4463
- model: a.model || (ceo ? CEO_MODEL : this.state.settings.defaultModel),
6401
+ codingAgent: ceo ? "claude" : a.cli || this.state.settings.defaultCli,
6402
+ model: ceo ? a.model || CEO_MODEL : this.modelFor(a, a.cli || this.state.settings.defaultCli) || "the coding agent default",
4464
6403
  effort: a.effort || (ceo ? CEO_EFFORT : this.state.settings.defaultEffort),
4465
6404
  hiredBy: a.hiredBy,
4466
6405
  jobDescription: a.brief || null
@@ -4503,7 +6442,7 @@ ${t}`);
4503
6442
  if (seated + waiting >= MAX_DESKS[role]) throw new Error(role === "qa" ? `The QA lab on floor ${repo.floor} is full.` : `Floor ${repo.floor} has no free desks.`);
4504
6443
  const name = this.freeName(role);
4505
6444
  const req = {
4506
- id: crypto3.randomUUID(),
6445
+ id: crypto6.randomUUID(),
4507
6446
  kind: "hire",
4508
6447
  repoId: repo.id,
4509
6448
  role,
@@ -4545,7 +6484,7 @@ ${t}`);
4545
6484
  if (pending2.some((r) => r.kind === "let-go" && r.agentId === a.id)) throw new Error(`Letting ${a.name} go is already proposed.`);
4546
6485
  if (pending2.length >= MAX_PENDING_REQUESTS) throw new Error(`${pending2.length} proposals are already waiting for the manager. Wait for their decisions first.`);
4547
6486
  const req = {
4548
- id: crypto3.randomUUID(),
6487
+ id: crypto6.randomUUID(),
4549
6488
  kind: "let-go",
4550
6489
  repoId: repo.id,
4551
6490
  role: a.role,
@@ -4577,7 +6516,7 @@ ${t}`);
4577
6516
  }
4578
6517
  async fileIssue(x) {
4579
6518
  const repo = this.floorRepo(x.floor);
4580
- if (this.ceoIssues.filed >= MAX_ISSUES_PER_JOB) throw new Error(`You already filed ${MAX_ISSUES_PER_JOB} issues in this job. That's plenty for one milestone.`);
6519
+ this.ceoIssues.check();
4581
6520
  const title = String(x.title ?? "").trim().slice(0, 120);
4582
6521
  if (!title) throw new Error("An issue needs a title.");
4583
6522
  const slug = specialtySlug(x.specialty);
@@ -4586,16 +6525,45 @@ ${t}`);
4586
6525
  ---
4587
6526
  _Filed by ${this.ceo().name}, the cubefarm CEO._`;
4588
6527
  const n = await this.backend.createIssue(repo.fullName, title, body, slug ? [specialtyLabel(slug)] : []);
4589
- this.ceoIssues.filed++;
4590
- this.ceoIssues.repos.add(repo.id);
6528
+ this.ceoIssues.record(repo.id);
4591
6529
  return `Filed #${n} on floor ${repo.floor}: ${title}${slug ? ` (routed to ${slug})` : ""}.`;
4592
6530
  }
6531
+ /** Change an open issue's specialty and/or dependencies (see planRoute for what is refused). */
6532
+ async routeIssue(x) {
6533
+ const repo = this.floorRepo(x.floor);
6534
+ const issues = this.repoRt.get(repo.id)?.issues ?? [];
6535
+ const asked = [Number(x.number), ...(x.depends_on ?? []).map(Number)].filter((n) => !issues.some((i) => i.number === n));
6536
+ const states = new Map(await Promise.all([...new Set(asked)].map(async (n) => [n, await this.backend.issueState(repo.fullName, n).catch(() => null)])));
6537
+ const specialties = [
6538
+ ...this.state.agents.filter((a) => a.repoId === repo.id && a.specialty).map((a) => a.specialty.toLowerCase()),
6539
+ ...this.state.requests.filter((r) => r.status === "pending" && r.kind === "hire" && r.repoId === repo.id && r.specialty).map((r) => r.specialty)
6540
+ ];
6541
+ const plan = planRoute({
6542
+ floor: repo.floor,
6543
+ number: Number(x.number),
6544
+ specialty: x.specialty,
6545
+ dependsOn: x.depends_on,
6546
+ issues,
6547
+ closed: (n) => states.get(n) === "CLOSED",
6548
+ inProgress: this.issueTaken(repo, Number(x.number)),
6549
+ specialties
6550
+ });
6551
+ if (plan.body !== null || plan.addLabels.length || plan.removeLabels.length) {
6552
+ await this.backend.editIssue(repo.fullName, Number(x.number), { body: plan.body ?? void 0, addLabels: plan.addLabels, removeLabels: plan.removeLabels });
6553
+ await this.syncRepo(repo.id);
6554
+ }
6555
+ return plan.summary;
6556
+ }
4593
6557
  };
4594
6558
 
4595
6559
  // server/index.ts
4596
6560
  var swarm = new Swarm(DEMO ? createDemoBackend() : realBackend);
6561
+ if (PORT) setOfficeUrl(`http://127.0.0.1:${PORT}`);
4597
6562
  await swarm.init();
4598
6563
  var app = express();
6564
+ app.post("/api/hooks/:token", express.json({ limit: "64mb" }), (req, res) => void res.json(handleHook(String(req.params.token), req.body)));
6565
+ app.post("/api/mcp/:token", express.json({ limit: "4mb" }), (req, res, next) => void handleMcp(String(req.params.token), req, res).catch(next));
6566
+ app.all("/api/mcp/:token", (_req, res) => void res.status(405).set("Allow", "POST").end());
4599
6567
  app.use(express.json({ limit: "1mb" }));
4600
6568
  var route = (fn) => async (req, res, next) => {
4601
6569
  try {
@@ -4706,6 +6674,7 @@ app.get("/api/agents/:id/screen", (req, res) => {
4706
6674
  res.end(shot.data);
4707
6675
  });
4708
6676
  app.patch("/api/settings", route((req) => swarm.updateSettings(req.body ?? {})));
6677
+ app.post("/api/office/update", route((req) => swarm.updateOffice(req.body?.action)));
4709
6678
  app.post("/api/ceo/message", route((req) => swarm.messageCeo(str(req.body.text))));
4710
6679
  app.post("/api/ceo/review", route(() => swarm.requestReview()));
4711
6680
  app.post("/api/phone/read", route((req) => swarm.markPhoneRead(Number(req.body?.at) || Date.now())));
@@ -4714,10 +6683,10 @@ app.post(
4714
6683
  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 }))
4715
6684
  );
4716
6685
  app.post("/api/requests/:id/reject", route((req) => swarm.rejectRequest(String(req.params.id), str(req.body?.note))));
4717
- var dist = path7.resolve(import.meta.dirname, "../dist");
4718
- if (fs5.existsSync(dist)) {
6686
+ var dist = path13.resolve(import.meta.dirname, "../dist");
6687
+ if (fs11.existsSync(dist)) {
4719
6688
  app.use(express.static(dist));
4720
- app.get(/^(?!\/api).*/, (_req, res) => res.sendFile(path7.join(dist, "index.html")));
6689
+ app.get(/^(?!\/api).*/, (_req, res) => res.sendFile(path13.join(dist, "index.html")));
4721
6690
  }
4722
6691
  app.use((err, _req, res, _next) => {
4723
6692
  const parserStatus = err?.status;
@@ -4727,9 +6696,18 @@ app.use((err, _req, res, _next) => {
4727
6696
  res.status(status).json({ error: message });
4728
6697
  });
4729
6698
  var server = http2.createServer(app);
4730
- var wss = new WebSocketServer({ server, path: "/ws" });
6699
+ var wss = new WebSocketServer({ noServer: true });
6700
+ var terms = new WebSocketServer({ noServer: true });
4731
6701
  wss.on("connection", (ws) => swarm.addClient(ws));
6702
+ terms.on("connection", (ws, req) => swarm.attachTerminal(new URL(req.url ?? "", "http://localhost").searchParams.get("agent") ?? "", ws));
6703
+ server.on("upgrade", (req, socket, head) => {
6704
+ const { pathname } = new URL(req.url ?? "", "http://localhost");
6705
+ const target = pathname === "/ws" ? wss : pathname === "/ws/term" ? terms : null;
6706
+ if (!target) return void socket.destroy();
6707
+ target.handleUpgrade(req, socket, head, (ws) => target.emit("connection", ws, req));
6708
+ });
4732
6709
  server.listen(PORT, "127.0.0.1", () => {
6710
+ setOfficeUrl(`http://127.0.0.1:${server.address().port}`);
4733
6711
  console.log(`
4734
6712
  \u{1F3E2} cubefarm on http://localhost:${PORT}${DEMO ? " (DEMO MODE: fake GitHub + fake agents)" : ""}`);
4735
6713
  console.log(` state: ${STATE_FILE}`);
@@ -4743,11 +6721,18 @@ var shutdown = (signal) => {
4743
6721
  console.log(`
4744
6722
  ${signal}: stopping floor previews\u2026`);
4745
6723
  const force = setTimeout(() => process.exit(0), 15e3);
4746
- void swarm.shutdown().catch((err) => console.error(err)).finally(() => {
6724
+ void swarm.shutdown(signal === "restart").catch((err) => console.error(err)).finally(() => {
4747
6725
  clearTimeout(force);
4748
6726
  process.exit(0);
4749
6727
  });
4750
6728
  };
4751
6729
  process.once("SIGINT", () => shutdown("SIGINT"));
4752
6730
  process.once("SIGTERM", () => shutdown("SIGTERM"));
6731
+ process.once("SIGHUP", () => shutdown("SIGHUP"));
6732
+ if (underLauncher()) {
6733
+ process.on("message", (msg) => {
6734
+ const m = msg;
6735
+ if (m?.type === "office:shutdown") shutdown(m.restart === false ? "stop" : "restart");
6736
+ });
6737
+ }
4753
6738
  //# sourceMappingURL=index.js.map