cubefarm 0.1.0 → 0.2.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 path8 from "node:path";
4
+ import fs6 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) {
@@ -632,6 +654,8 @@ function describeOfficeTool(action, input) {
632
654
  return `\u{1F44B} propose_let_go ${String(input.agent_id ?? "")}`;
633
655
  case "file_issue":
634
656
  return `\u{1F4DD} file_issue "${clip(String(input.title ?? ""), 70)}"${floor}${input.specialty ? ` \xB7 ${input.specialty}` : ""}`;
657
+ case "route_issue":
658
+ 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
659
  }
636
660
  return `\u{1F3E2} ${action}(${clip(JSON.stringify(input), 120)})`;
637
661
  }
@@ -764,6 +788,9 @@ function startSession(opts, callbacks, defaultBranch) {
764
788
  cb.log([{ kind: "error", text: `\u26A0 Subscription usage ${info.status === "rejected" ? "limit reached" : "warning"} (${info.rateLimitType ?? "limit"}) \xB7 resets ${when}` }]);
765
789
  const overage = info.isUsingOverage || info.overageStatus === "allowed" || info.overageStatus === "allowed_warning";
766
790
  if (info.status === "rejected" && !overage) cb.limited?.(info.resetsAt ? info.resetsAt * 1e3 : null);
791
+ if (info.status === "allowed_warning" && !overage) {
792
+ cb.usageWarning?.({ resetsAt: info.resetsAt ? info.resetsAt * 1e3 : null, rateLimitType: info.rateLimitType ?? null, utilization: info.utilization ?? null });
793
+ }
767
794
  }
768
795
  break;
769
796
  }
@@ -1051,6 +1078,71 @@ var realPreviews = {
1051
1078
  hasDefault: async (fullName) => fs3.access(path4.join(mainDir(fullName), "package.json")).then(() => true).catch(() => false)
1052
1079
  };
1053
1080
 
1081
+ // server/officeUpdate.ts
1082
+ import fs4 from "node:fs/promises";
1083
+ import path5 from "node:path";
1084
+ var DRAIN_TIMEOUT_MS = 20 * 6e4;
1085
+ var POSTPONE_MS = 2 * 60 * 6e4;
1086
+ function underLauncher(proc = process) {
1087
+ return typeof proc.send === "function" && proc.env.SWARM_LAUNCHER === "1";
1088
+ }
1089
+ function drainDecision(s) {
1090
+ const idle = (state) => ({ state, drainingSince: null, send: false, timedOut: false });
1091
+ if (s.sent) return { state: "updating", drainingSince: s.drainingSince, send: false, timedOut: false };
1092
+ if (s.behind <= 0) return idle("none");
1093
+ if (!s.launcher) return idle(s.failed ? "failed" : "available");
1094
+ const postponed = s.postponedUntil !== null && s.now < s.postponedUntil && s.behind <= s.postponedBehind;
1095
+ const failedHold = s.failed !== null && s.behind <= (s.failedBehind ?? Infinity);
1096
+ const due = s.requested || s.autoUpdate && !postponed && !failedHold;
1097
+ if (!due) return idle(s.failed ? "failed" : s.autoUpdate && postponed ? "waiting" : "available");
1098
+ const since = s.drainingSince ?? s.now;
1099
+ const timedOut = s.running > 0 && s.now - since >= DRAIN_TIMEOUT_MS;
1100
+ return { state: "draining", drainingSince: since, send: s.running === 0 || timedOut, timedOut };
1101
+ }
1102
+ function parseLastUpdate(raw) {
1103
+ try {
1104
+ const u = JSON.parse(raw);
1105
+ if (!u || typeof u.from !== "string" || typeof u.ok !== "boolean") return null;
1106
+ return {
1107
+ from: u.from,
1108
+ to: typeof u.to === "string" ? u.to : "",
1109
+ ok: u.ok,
1110
+ error: typeof u.error === "string" ? u.error : void 0,
1111
+ installed: !!u.installed,
1112
+ built: !!u.built,
1113
+ at: Number(u.at) || Date.now()
1114
+ };
1115
+ } catch {
1116
+ return null;
1117
+ }
1118
+ }
1119
+ async function takeLastUpdate(home) {
1120
+ const file = path5.join(home, "last-update.json");
1121
+ const raw = await fs4.readFile(file, "utf8").catch(() => null);
1122
+ if (raw === null) return null;
1123
+ await fs4.rm(file, { force: true, maxRetries: 3 }).catch(() => void 0);
1124
+ return parseLastUpdate(raw);
1125
+ }
1126
+ function lastUpdateMessage(u, commits) {
1127
+ if (!u.ok) return `\u26A0\uFE0F Update failed and was rolled back: ${u.error || "unknown error"}`;
1128
+ const n = commits === null ? "" : ` (${commits} commit${commits === 1 ? "" : "s"})`;
1129
+ return `\u2B06\uFE0F Updated the office from ${u.from.slice(0, 7)} to ${u.to.slice(0, 7)}${n}`;
1130
+ }
1131
+ var OFFICE_DIR = path5.resolve(import.meta.dirname, "..");
1132
+ var isSamePath = (a, b) => process.platform === "win32" ? path5.resolve(a).toLowerCase() === path5.resolve(b).toLowerCase() : path5.resolve(a) === path5.resolve(b);
1133
+ var realOffice = {
1134
+ launcher: underLauncher(),
1135
+ head: () => git(["rev-parse", "HEAD"], { cwd: OFFICE_DIR }).catch(() => null),
1136
+ isOwnFolder: (dir) => isSamePath(dir, OFFICE_DIR),
1137
+ commitsBetween: (from, to) => git(["rev-list", "--count", `${from}..${to}`], { cwd: OFFICE_DIR }).then(Number).catch(() => null),
1138
+ takeLastUpdate: () => takeLastUpdate(HOME_DIR),
1139
+ async update(from) {
1140
+ if (!underLauncher()) throw new Error("The office was not started by the launcher");
1141
+ await new Promise((resolve, reject) => process.send({ type: "office:update", from }, void 0, {}, (err) => err ? reject(err) : resolve()));
1142
+ return null;
1143
+ }
1144
+ };
1145
+
1054
1146
  // server/backend.ts
1055
1147
  var realBackend = {
1056
1148
  demo: false,
@@ -1060,6 +1152,8 @@ var realBackend = {
1060
1152
  listIssues,
1061
1153
  listPulls,
1062
1154
  createIssue,
1155
+ issueState,
1156
+ editIssue,
1063
1157
  mergePull,
1064
1158
  updateBranch,
1065
1159
  closePull,
@@ -1081,13 +1175,14 @@ var realBackend = {
1081
1175
  removeDesk,
1082
1176
  releaseDesk,
1083
1177
  startSession,
1084
- previews: realPreviews
1178
+ previews: realPreviews,
1179
+ office: realOffice
1085
1180
  };
1086
1181
 
1087
1182
  // server/demo.ts
1088
1183
  import crypto2 from "node:crypto";
1089
1184
  import http from "node:http";
1090
- import path5 from "node:path";
1185
+ import path6 from "node:path";
1091
1186
  var now = () => (/* @__PURE__ */ new Date()).toISOString();
1092
1187
  function issue(n, title, body, fullName, labels = []) {
1093
1188
  return { number: n, title, body, url: `https://github.com/${fullName}/issues/${n}`, labels, createdAt: now() };
@@ -1095,6 +1190,7 @@ function issue(n, title, body, fullName, labels = []) {
1095
1190
  var repos = /* @__PURE__ */ new Map();
1096
1191
  var bareRepos = /* @__PURE__ */ new Set();
1097
1192
  var mergedSinceSync = /* @__PURE__ */ new Map();
1193
+ var closedIssues = /* @__PURE__ */ new Set();
1098
1194
  var fakeSha = () => crypto2.randomBytes(20).toString("hex");
1099
1195
  function runChecks(pr, fail = Math.random() < 0.2) {
1100
1196
  Object.assign(pr, { checks: "pending", pendingChecks: ["CI / build", "Vercel"], failedChecks: [] });
@@ -1139,7 +1235,7 @@ function screenshotSvg(title, url, hue) {
1139
1235
  </svg>`;
1140
1236
  }
1141
1237
  function devScript(opts, cb, issueNumber, issueTitle) {
1142
- const branch = path5.basename(opts.cwd);
1238
+ const branch = path6.basename(opts.cwd);
1143
1239
  const hue = issueNumber * 67 % 360;
1144
1240
  const file = ["src/App.tsx", "src/components/TodoList.tsx", "src/api/routes.ts", "src/styles.css"][issueNumber % 4];
1145
1241
  const port = 5200 + issueNumber % 50;
@@ -1277,7 +1373,7 @@ function fakeSession(opts, cb, fullName) {
1277
1373
  number: n,
1278
1374
  title,
1279
1375
  url,
1280
- headRefName: `swarm/issue-${number}-${path5.basename(opts.cwd).replace(/-[0-9a-f]{4}$/, "")}`,
1376
+ headRefName: `swarm/issue-${number}-${path6.basename(opts.cwd).replace(/-[0-9a-f]{4}$/, "")}`,
1281
1377
  state: "OPEN",
1282
1378
  isDraft: false,
1283
1379
  mergeable: "MERGEABLE",
@@ -1329,6 +1425,16 @@ function fakeSession(opts, cb, fullName) {
1329
1425
  }
1330
1426
  };
1331
1427
  }
1428
+ var USAGE_WARNING_AT = 4;
1429
+ var USAGE_WARNING_MS = 5 * 6e4;
1430
+ var sessionsStarted = 0;
1431
+ function fakeUsageWarning(cb) {
1432
+ setTimeout(() => {
1433
+ const resetsAt = Date.now() + USAGE_WARNING_MS;
1434
+ cb.log([{ kind: "error", text: `\u26A0 Subscription usage warning (five_hour) \xB7 resets ${new Date(resetsAt).toLocaleTimeString()} (demo)` }]);
1435
+ cb.usageWarning?.({ resetsAt, rateLimitType: "five_hour", utilization: 0.82 });
1436
+ }, 3e3);
1437
+ }
1332
1438
  function createDemoBackend() {
1333
1439
  const deskRepo = /* @__PURE__ */ new Map();
1334
1440
  const folders = /* @__PURE__ */ new Map();
@@ -1379,6 +1485,16 @@ function createDemoBackend() {
1379
1485
  r.issues.push(issue(n, title, body, fullName, labels));
1380
1486
  return n;
1381
1487
  },
1488
+ issueState: async (fullName, number) => {
1489
+ const r = repos.get(fullName);
1490
+ return r?.issues.some((i) => i.number === number) ? "OPEN" : closedIssues.has(`${fullName}#${number}`) ? "CLOSED" : null;
1491
+ },
1492
+ editIssue: async (fullName, number, edit) => {
1493
+ const i = repos.get(fullName)?.issues.find((x) => x.number === number);
1494
+ if (!i) throw new Error(`Unknown issue #${number}`);
1495
+ if (edit.body !== void 0) i.body = edit.body;
1496
+ i.labels = [...i.labels.filter((l) => !edit.removeLabels?.includes(l)), ...(edit.addLabels ?? []).filter((l) => !i.labels.includes(l))];
1497
+ },
1382
1498
  mergePull: async (fullName, number, _method, headSha) => {
1383
1499
  const r = repos.get(fullName);
1384
1500
  const pr = r?.pulls.find((p) => p.number === number);
@@ -1387,6 +1503,7 @@ function createDemoBackend() {
1387
1503
  mergedSinceSync.set(fullName, (mergedSinceSync.get(fullName) ?? 0) + 1);
1388
1504
  pr.state = "MERGED";
1389
1505
  pr.mergedAt = now();
1506
+ for (const n of pr.closesIssues) closedIssues.add(`${fullName}#${n}`);
1390
1507
  r.issues = r.issues.filter((i) => !pr.closesIssues.includes(i.number));
1391
1508
  },
1392
1509
  updateBranch: async (fullName, number) => {
@@ -1428,10 +1545,10 @@ Closes #${pr.closesIssues[0] ?? "?"}`,
1428
1545
  ensureClone: async () => new Promise((r) => setTimeout(r, 400)),
1429
1546
  syncMain: async (fullName, _branch, { touch }) => {
1430
1547
  const behind = mergedSinceSync.get(fullName) ?? 0;
1431
- if (behind === 0) return "in sync";
1432
- if (!touch) return `update ready (${behind} commit${behind === 1 ? "" : "s"})`;
1548
+ if (behind === 0) return { status: "in sync", behind: 0, updatable: false };
1549
+ if (!touch) return { status: `update ready (${behind} commit${behind === 1 ? "" : "s"})`, behind, updatable: true };
1433
1550
  mergedSinceSync.set(fullName, 0);
1434
- return `updated to ${fakeSha().slice(0, 7)}`;
1551
+ return { status: `updated to ${fakeSha().slice(0, 7)}`, behind: 0, updatable: false };
1435
1552
  },
1436
1553
  mainDir: (fullName) => `/demo/${fullName}/main`,
1437
1554
  deskDir: (fullName, slug) => `/demo/${fullName}/desks/${slug}`,
@@ -1443,10 +1560,32 @@ Closes #${pr.closesIssues[0] ?? "?"}`,
1443
1560
  },
1444
1561
  removeDesk: async () => void 0,
1445
1562
  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
1563
+ startSession: (opts, cb) => {
1564
+ if (++sessionsStarted === USAGE_WARNING_AT) fakeUsageWarning(cb);
1565
+ return opts.role === "ceo" ? ceoSession(opts, cb) : fakeSession(opts, cb, deskRepo.get(opts.cwd) ?? [...repos.keys()][0]);
1566
+ },
1567
+ previews: demoPreviews,
1568
+ office: demoOffice
1448
1569
  };
1449
1570
  }
1571
+ var OFFICE_REPO = "demo-co/pixel-todo";
1572
+ var DEMO_HEAD = `0ff1ce5${"0".repeat(33)}`;
1573
+ var lastFakeUpdate = { to: "", commits: 0 };
1574
+ var demoOffice = {
1575
+ launcher: underLauncher() || process.env.SWARM_DEMO_LAUNCHER === "1",
1576
+ head: async () => DEMO_HEAD,
1577
+ isOwnFolder: (dir) => dir === `/demo/${OFFICE_REPO}/main`,
1578
+ // Only its own fake updates have a count; one the real launcher did (u) gets no count rather than a wrong one.
1579
+ commitsBetween: async (_from, to) => to === lastFakeUpdate.to ? lastFakeUpdate.commits : null,
1580
+ takeLastUpdate: () => takeLastUpdate(HOME_DIR),
1581
+ async update(from) {
1582
+ await new Promise((r) => setTimeout(r, 4e3));
1583
+ lastFakeUpdate.commits = mergedSinceSync.get(OFFICE_REPO) ?? 0;
1584
+ mergedSinceSync.set(OFFICE_REPO, 0);
1585
+ lastFakeUpdate.to = fakeSha();
1586
+ return { from, to: lastFakeUpdate.to, ok: true, installed: true, built: true, at: Date.now() };
1587
+ }
1588
+ };
1450
1589
  function placeholderPage(title, hue) {
1451
1590
  const safe = title.replace(/[<>&"]/g, "");
1452
1591
  return `<!doctype html>
@@ -1716,6 +1855,13 @@ Acceptance criteria:
1716
1855
  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
1856
  }
1718
1857
  }
1858
+ for (const f of s.floors) {
1859
+ const specialist = f.team.find((a) => a.role === "dev" && a.specialty);
1860
+ const unrouted = f.backlog.find((i) => !i.specialty && !i.inProgress);
1861
+ if (!specialist || !unrouted) continue;
1862
+ const out = await use("route_issue", { floor: f.floor, number: unrouted.number, specialty: specialist.specialty });
1863
+ if (!out.startsWith("Refused")) return `Floor ${f.floor}: #${unrouted.number} had no specialty, so I routed it to ${specialist.specialty}, ${specialist.name}'s lane.`;
1864
+ }
1719
1865
  const issues = s.floors.reduce((n, f) => n + f.backlog.length, 0);
1720
1866
  const prs = s.floors.reduce((n, f) => n + f.pullRequests.length, 0);
1721
1867
  return `All ${s.floors.length} floors look healthy: ${issues} open issues and ${prs} pull requests in flight. No changes needed.`;
@@ -1794,13 +1940,80 @@ Acceptance criteria:
1794
1940
  }
1795
1941
 
1796
1942
  // server/swarm.ts
1797
- import fs4 from "node:fs/promises";
1798
- import path6 from "node:path";
1943
+ import fs5 from "node:fs/promises";
1944
+ import path7 from "node:path";
1799
1945
  import crypto3 from "node:crypto";
1800
1946
 
1801
1947
  // server/ceo.ts
1802
1948
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
1803
1949
  import { z } from "zod";
1950
+
1951
+ // shared/issues.ts
1952
+ function issueSpecialty(labels) {
1953
+ const l = labels.find((x) => /^swarm:/i.test(x) && !/^swarm:skip$/i.test(x));
1954
+ return l ? l.slice(6).toLowerCase() : "";
1955
+ }
1956
+ function blockers(body, open) {
1957
+ const out = /* @__PURE__ */ new Set();
1958
+ for (const m of (body ?? "").matchAll(/\b(?:depends\s+on|blocked\s+by)\s*:?\s*((?:#\d+(?:\s*(?:,|and|&)\s*)?)+)/gi)) {
1959
+ for (const n of m[1].matchAll(/#(\d+)/g)) if (open.has(Number(n[1]))) out.add(Number(n[1]));
1960
+ }
1961
+ return [...out];
1962
+ }
1963
+ var DEPENDENCY = /\b(?:depends\s+on|blocked\s+by)\s*:?\s*(?:#\d+(?:\s*(?:,|and|&)\s*)?)+/gi;
1964
+ var STATEMENT = new RegExp(`\\s*${DEPENDENCY.source}[.;,]?`, "gi");
1965
+ function setDependsOn(body, deps) {
1966
+ const lines = [];
1967
+ let dropped = false;
1968
+ for (const line of (body ?? "").split(/\r?\n/)) {
1969
+ const rest2 = line.match(DEPENDENCY) ? line.replace(STATEMENT, "").trimEnd() : line;
1970
+ if (rest2 !== line && !/[\p{L}\p{N}]/u.test(rest2)) {
1971
+ dropped = true;
1972
+ continue;
1973
+ }
1974
+ if (dropped && !rest2.trim() && !lines[lines.length - 1]?.trim()) continue;
1975
+ dropped = false;
1976
+ lines.push(rest2);
1977
+ }
1978
+ while (lines.length && !lines[0].trim()) lines.shift();
1979
+ const rest = lines.join("\n");
1980
+ if (deps.length === 0) return rest;
1981
+ return `Depends on ${deps.map((n) => `#${n}`).join(", ")}${rest ? `
1982
+
1983
+ ${rest}` : ""}`;
1984
+ }
1985
+ function holdUps(issues) {
1986
+ const open = new Set(issues.map((i) => i.number));
1987
+ const waiters = /* @__PURE__ */ new Map();
1988
+ for (const i of issues) for (const b of blockers(i.body, open)) waiters.set(b, [...waiters.get(b) ?? [], i.number]);
1989
+ const chains = /* @__PURE__ */ new Map();
1990
+ const visiting = /* @__PURE__ */ new Set();
1991
+ const chain = (n) => {
1992
+ const known = chains.get(n);
1993
+ if (known !== void 0) return known;
1994
+ if (visiting.has(n)) return 0;
1995
+ visiting.add(n);
1996
+ const longest = Math.max(0, ...(waiters.get(n) ?? []).map((w) => chain(w) + 1));
1997
+ visiting.delete(n);
1998
+ chains.set(n, longest);
1999
+ return longest;
2000
+ };
2001
+ const out = /* @__PURE__ */ new Map();
2002
+ for (const i of issues) {
2003
+ const seen = /* @__PURE__ */ new Set();
2004
+ const stack = [...waiters.get(i.number) ?? []];
2005
+ while (stack.length) {
2006
+ const n = stack.pop();
2007
+ if (n === i.number || seen.has(n)) continue;
2008
+ seen.add(n);
2009
+ stack.push(...waiters.get(n) ?? []);
2010
+ }
2011
+ out.set(i.number, { chain: chain(i.number), waiting: seen.size });
2012
+ }
2013
+ return out;
2014
+ }
2015
+
2016
+ // server/ceo.ts
1804
2017
  var EFFORT = z.enum(["low", "medium", "high", "xhigh", "max"]);
1805
2018
  function createOfficeTools(h) {
1806
2019
  const run2 = async (fn) => {
@@ -1880,6 +2093,17 @@ function createOfficeTools(h) {
1880
2093
  specialty: z.string().max(24).optional()
1881
2094
  },
1882
2095
  (a) => run2(() => h.fileIssue(a))
2096
+ ),
2097
+ tool(
2098
+ "route_issue",
2099
+ '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.',
2100
+ {
2101
+ floor: z.number().int(),
2102
+ number: z.number().int().positive().describe("The issue number"),
2103
+ 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.'),
2104
+ depends_on: z.array(z.number().int().positive()).max(10).optional().describe("Issues it waits for; [] for none. Not for an issue in progress.")
2105
+ },
2106
+ (a) => run2(() => h.routeIssue(a))
1883
2107
  )
1884
2108
  ];
1885
2109
  const server2 = createSdkMcpServer({ name: "office", version: "1.0.0", tools: defs });
@@ -1917,7 +2141,8 @@ function ceoSystemPrompt(o) {
1917
2141
  '- 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
2142
  "- 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
2143
  `- ${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.`,
2144
+ `- 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.`,
2145
+ "- 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
2146
  "- 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
2147
  ].join("\n");
1923
2148
  }
@@ -1951,7 +2176,7 @@ ${floor.backlog === 0 ? "The backlog is empty: plan the first milestone as issue
1951
2176
  return [
1952
2177
  "Periodic review of the company. For every floor, look at:",
1953
2178
  "- 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",
2179
+ "- 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
2180
  "- pull requests stuck in QA or marked as needing a human",
1956
2181
  "- floors with a brief and an empty backlog: plan the next milestone",
1957
2182
  "Propose hires or let-gos only when clearly justified. If nothing needs doing, reply with one short sentence saying so."
@@ -1974,6 +2199,81 @@ function jobLabel(job, floor) {
1974
2199
  return "Replying to you";
1975
2200
  }
1976
2201
  }
2202
+ var IssueCap = class {
2203
+ // floors that got issues, to refresh when the job ends
2204
+ constructor(max) {
2205
+ this.max = max;
2206
+ }
2207
+ max;
2208
+ filed = 0;
2209
+ // since the job started or the manager's last message
2210
+ total = 0;
2211
+ // in the whole job
2212
+ repos = /* @__PURE__ */ new Set();
2213
+ check() {
2214
+ 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.`);
2215
+ }
2216
+ record(repoId2) {
2217
+ this.filed++;
2218
+ this.total++;
2219
+ this.repos.add(repoId2);
2220
+ }
2221
+ managerMessage() {
2222
+ this.filed = 0;
2223
+ }
2224
+ };
2225
+ function waitsDepth(n, deps, seen = /* @__PURE__ */ new Set()) {
2226
+ if (seen.has(n)) return 0;
2227
+ seen.add(n);
2228
+ const depth = Math.max(0, ...(deps.get(n) ?? []).map((d) => waitsDepth(d, deps, seen) + 1));
2229
+ seen.delete(n);
2230
+ return depth;
2231
+ }
2232
+ function planRoute(r) {
2233
+ const issue2 = r.issues.find((i) => i.number === r.number);
2234
+ if (!issue2) throw new Error(r.closed(r.number) ? `#${r.number} is closed.` : `There is no open issue #${r.number} on floor ${r.floor}.`);
2235
+ if (r.specialty === void 0 && r.dependsOn === void 0) throw new Error("Nothing to change: pass specialty, depends_on or both.");
2236
+ const plan = { addLabels: [], removeLabels: [], body: null, summary: "" };
2237
+ const done = [];
2238
+ if (r.specialty !== void 0) {
2239
+ const slug = specialtySlug(r.specialty);
2240
+ if (r.specialty.trim() && !slug) throw new Error(`"${r.specialty}" is not a specialty. Use a short lowercase slug, or "" for none.`);
2241
+ if (slug && !r.specialties.includes(slug)) {
2242
+ const have = [...new Set(r.specialties)].join(", ") || "none";
2243
+ throw new Error(`Nobody on floor ${r.floor} has the specialty "${slug}", and no pending proposal does. Specialties there: ${have}.`);
2244
+ }
2245
+ const label = slug ? specialtyLabel(slug) : null;
2246
+ plan.removeLabels = issue2.labels.filter((l) => /^swarm:/i.test(l) && !/^swarm:skip$/i.test(l) && l !== label);
2247
+ if (label && !issue2.labels.includes(label)) plan.addLabels = [label];
2248
+ done.push(slug ? `routed to ${slug}` : "no specialty");
2249
+ }
2250
+ if (r.dependsOn !== void 0) {
2251
+ if (r.inProgress) throw new Error(`#${r.number} is already in progress, so its dependencies can't change. Changing its specialty is fine.`);
2252
+ const deps = [...new Set(r.dependsOn.map(Number))];
2253
+ const open = new Set(r.issues.map((i) => i.number));
2254
+ for (const d of deps) {
2255
+ if (d === r.number) throw new Error(`#${r.number} can't depend on itself.`);
2256
+ 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}.`);
2257
+ }
2258
+ const body = setDependsOn(issue2.body, deps);
2259
+ const after = r.issues.map((i) => i.number === r.number ? { ...i, body } : i);
2260
+ const waits = new Map(after.map((i) => [i.number, blockers(i.body, open)]));
2261
+ const loop = deps.find((d) => reaches(d, r.number, waits));
2262
+ if (loop !== void 0) throw new Error(`#${loop} already waits for #${r.number}, directly or through other issues, so that would be a cycle.`);
2263
+ const depth = waitsDepth(r.number, waits) + (holdUps(after).get(r.number)?.chain ?? 0);
2264
+ 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.`);
2265
+ if (body !== issue2.body) plan.body = body;
2266
+ done.push(deps.length ? `depends on ${deps.map((d) => `#${d}`).join(", ")}` : "no dependencies");
2267
+ }
2268
+ plan.summary = `#${r.number} on floor ${r.floor}: ${done.join(", ")}.`;
2269
+ return plan;
2270
+ }
2271
+ function reaches(from, to, waits, seen = /* @__PURE__ */ new Set()) {
2272
+ if (from === to) return true;
2273
+ if (seen.has(from)) return false;
2274
+ seen.add(from);
2275
+ return (waits.get(from) ?? []).some((n) => reaches(n, to, waits, seen));
2276
+ }
1977
2277
  function specialtySlug(s) {
1978
2278
  const slug = String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 24);
1979
2279
  return slug === "skip" ? "" : slug;
@@ -1989,6 +2289,38 @@ var HttpError = class extends Error {
1989
2289
  status;
1990
2290
  };
1991
2291
 
2292
+ // server/mergeGate.ts
2293
+ var MAX_MERGE_FIXES = 3;
2294
+ var CHECKS_ALERT_MS = 30 * 6e4;
2295
+ var MERGE_RETRY_MS = 10 * 6e4;
2296
+ function mergeStep(pr, rec, now2, opts) {
2297
+ if (pr.isDraft) return { do: "wait", note: "a draft: waiting", set: {} };
2298
+ if (!opts.detailed && (pr.mergeable === "UNKNOWN" || pr.mergeState === "UNKNOWN")) return { do: "details", set: {} };
2299
+ const set = {};
2300
+ if (rec.passedSha == null) set.passedSha = pr.headSha;
2301
+ if (pr.headSha !== (set.passedSha ?? rec.passedSha)) return { do: "requeue", set };
2302
+ const needsHuman = rec.mergeFixes >= MAX_MERGE_FIXES;
2303
+ if (pr.mergeable === "CONFLICTING" || pr.mergeState === "DIRTY") {
2304
+ return { do: "send-back", reason: "conflict", instructions: `It conflicts with ${opts.base}.`, needsHuman, set };
2305
+ }
2306
+ if (pr.checks === "failing") {
2307
+ const instructions = pr.failedChecks.map((c) => `- ${c.name}${c.url ? `: ${c.url}` : ""}`).join("\n");
2308
+ return { do: "send-back", reason: "checks", instructions, needsHuman, set };
2309
+ }
2310
+ if (pr.checks === "pending") {
2311
+ const since = rec.pendingSince ?? now2;
2312
+ if (rec.pendingSince == null) set.pendingSince = since;
2313
+ const alert = now2 - since > CHECKS_ALERT_MS && !rec.alerted;
2314
+ if (alert) set.alerted = true;
2315
+ return { do: "wait", note: `waiting for checks: ${pr.pendingChecks.join(", ") || "running"}`, alert, set };
2316
+ }
2317
+ set.pendingSince = null;
2318
+ if (pr.mergeable === "UNKNOWN") return { do: "wait", note: "GitHub is checking it can merge", set };
2319
+ if (pr.mergeState === "BEHIND") return { do: "update-branch", set };
2320
+ if (rec.mergeRetryAt && now2 < rec.mergeRetryAt) return { do: "wait", set };
2321
+ return { do: "merge", set };
2322
+ }
2323
+
1992
2324
  // server/previews.ts
1993
2325
  var PREVIEW_BASE_PORT = 6300;
1994
2326
  var LOG_KEEP = 200;
@@ -2189,47 +2521,41 @@ var Previews = class {
2189
2521
  }
2190
2522
  };
2191
2523
 
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() : "";
2524
+ // server/pacing.ts
2525
+ var PACING_MS = 60 * 6e4;
2526
+ var DEFAULT_PACING_SESSIONS = 3;
2527
+ function mayStart(kind, c) {
2528
+ if (c.now < c.pausedUntil) return false;
2529
+ if (kind === "issue" && c.now < c.pacingUntil) return c.running < c.pacingSessions;
2530
+ return true;
2196
2531
  }
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];
2532
+ function usageView(c) {
2533
+ if (c.now < c.pausedUntil) return { state: "paused", until: c.pausedUntil };
2534
+ if (c.now < c.pacingUntil) return { state: "pacing", until: c.pacingUntil };
2535
+ return { state: "normal", until: null };
2203
2536
  }
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) ?? []);
2229
- }
2230
- out.set(i.number, { chain: chain(i.number), waiting: seen.size });
2231
- }
2232
- return out;
2537
+ function clock(at, now2) {
2538
+ const d = new Date(at);
2539
+ const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
2540
+ return d.toDateString() === new Date(now2).toDateString() ? hm : `${d.toLocaleDateString("en-US", { weekday: "short" })} ${hm}`;
2541
+ }
2542
+ function usageLabel(u, now2) {
2543
+ return u.state === "normal" || u.until === null ? "normal" : `${u.state} until ${clock(u.until, now2)}`;
2544
+ }
2545
+ function clampPacingSessions(v) {
2546
+ const n = Math.round(Number(v));
2547
+ return Number.isFinite(n) && n > 0 ? Math.min(32, n) : DEFAULT_PACING_SESSIONS;
2548
+ }
2549
+ var LIMIT_NAMES = {
2550
+ five_hour: "5-hour limit",
2551
+ seven_day: "weekly limit",
2552
+ seven_day_opus: "weekly Opus limit",
2553
+ seven_day_sonnet: "weekly Sonnet limit"
2554
+ };
2555
+ function pacingMessage(w, until, sessions, now2) {
2556
+ const pct = w.utilization === null ? null : Math.round(w.utilization <= 1 ? w.utilization * 100 : w.utilization);
2557
+ const what = [w.rateLimitType ? LIMIT_NAMES[w.rateLimitType] ?? w.rateLimitType.replace(/_/g, " ") : null, pct === null ? null : `${pct}%`].filter(Boolean).join(", ");
2558
+ 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.`;
2233
2559
  }
2234
2560
 
2235
2561
  // shared/types.ts
@@ -2279,8 +2605,8 @@ var EFFORTS = ["low", "medium", "high", "xhigh", "max"];
2279
2605
  var CEO_MODEL = "claude-opus-5-5";
2280
2606
  var CEO_EFFORT = "xhigh";
2281
2607
  var CEO_NAME = "Morgan";
2282
- var CEO_DIR = path6.join(HOME_DIR, "ceo");
2283
- var DEFAULT_PROJECTS_DIR = defaultProjectsDir(path6.resolve(import.meta.dirname, ".."));
2608
+ var CEO_DIR = path7.join(HOME_DIR, "ceo");
2609
+ var DEFAULT_PROJECTS_DIR = defaultProjectsDir(path7.resolve(import.meta.dirname, ".."));
2284
2610
  var MAX_PENDING_REQUESTS = 8;
2285
2611
  var MAX_ISSUES_PER_JOB = 12;
2286
2612
  var KEEP_MESSAGES = 200;
@@ -2292,20 +2618,15 @@ var FREE = ["idle", "done"];
2292
2618
  var ERROR_COOLDOWN_MS = 2 * 6e4;
2293
2619
  var MAX_ISSUE_FAILURES = 2;
2294
2620
  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
2621
  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");
2622
+ var SCREENS_DIR = path7.join(HOME_DIR, "screens");
2302
2623
  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"}`);
2624
+ var screenFile = (agentId, mime) => path7.join(SCREENS_DIR, `${agentId}.${MIME_EXT[mime] ?? "img"}`);
2304
2625
  async function loadScreen(agentId) {
2305
2626
  for (const [mime, ext] of Object.entries(MIME_EXT)) {
2306
- const file = path6.join(SCREENS_DIR, `${agentId}.${ext}`);
2627
+ const file = path7.join(SCREENS_DIR, `${agentId}.${ext}`);
2307
2628
  try {
2308
- const [data, stat] = await Promise.all([fs4.readFile(file), fs4.stat(file)]);
2629
+ const [data, stat] = await Promise.all([fs5.readFile(file), fs5.stat(file)]);
2309
2630
  return { data, mime, at: stat.mtimeMs };
2310
2631
  } catch {
2311
2632
  }
@@ -2313,7 +2634,7 @@ async function loadScreen(agentId) {
2313
2634
  return null;
2314
2635
  }
2315
2636
  async function removeScreens(agentId) {
2316
- await Promise.all(Object.values(MIME_EXT).map((ext) => fs4.rm(path6.join(SCREENS_DIR, `${agentId}.${ext}`), { force: true })));
2637
+ await Promise.all(Object.values(MIME_EXT).map((ext) => fs5.rm(path7.join(SCREENS_DIR, `${agentId}.${ext}`), { force: true })));
2317
2638
  }
2318
2639
  var QA_SCHEMA = {
2319
2640
  type: "object",
@@ -2405,7 +2726,9 @@ var Swarm = class {
2405
2726
  companyName: "",
2406
2727
  projectsDir: DEFAULT_PROJECTS_DIR,
2407
2728
  setupDone: false,
2408
- tutorialStep: 0
2729
+ tutorialStep: 0,
2730
+ autoUpdate: true,
2731
+ pacingSessions: DEFAULT_PACING_SESSIONS
2409
2732
  },
2410
2733
  repos: [],
2411
2734
  agents: [],
@@ -2427,10 +2750,11 @@ var Swarm = class {
2427
2750
  updateJob: (a) => this.updateJob(a),
2428
2751
  proposeHire: (a) => this.proposeHire(a),
2429
2752
  proposeLetGo: (a) => this.proposeLetGo(a),
2430
- fileIssue: (a) => this.fileIssue(a)
2753
+ fileIssue: (a) => this.fileIssue(a),
2754
+ routeIssue: (a) => this.routeIssue(a)
2431
2755
  });
2432
2756
  }
2433
- ceoIssues = { filed: 0, repos: /* @__PURE__ */ new Set() };
2757
+ ceoIssues = new IssueCap(MAX_ISSUES_PER_JOB);
2434
2758
  // issues filed during the current CEO job
2435
2759
  messageSeq = 1;
2436
2760
  agentRt = /* @__PURE__ */ new Map();
@@ -2442,10 +2766,25 @@ var Swarm = class {
2442
2766
  flushTimer = null;
2443
2767
  logSeq = 1;
2444
2768
  previews;
2769
+ officeHead = null;
2770
+ // the commit the office runs (null: not a git checkout, so no self-update)
2771
+ officeUpdate = {
2772
+ behind: 0,
2773
+ requested: false,
2774
+ postponedUntil: null,
2775
+ postponedBehind: 0,
2776
+ failed: null,
2777
+ failedBehind: null,
2778
+ drainingSince: null,
2779
+ sent: false,
2780
+ handedOver: false
2781
+ // sessions cut off by the hand-over are the restarted office's to recover
2782
+ };
2783
+ lastOfficeView = "";
2445
2784
  // ---------- lifecycle ----------
2446
2785
  async init() {
2447
2786
  try {
2448
- const raw = await fs4.readFile(STATE_FILE, "utf8");
2787
+ const raw = await fs5.readFile(STATE_FILE, "utf8");
2449
2788
  const loaded = JSON.parse(raw);
2450
2789
  this.state = {
2451
2790
  settings: { ...this.state.settings, ...loaded.settings },
@@ -2540,6 +2879,9 @@ var Swarm = class {
2540
2879
  ]);
2541
2880
  for (const r of this.state.repos) void this.previews.refreshDefault(r);
2542
2881
  this.recover(interrupted);
2882
+ this.officeHead = await this.backend.office.head();
2883
+ const updated = await this.backend.office.takeLastUpdate().catch(() => null);
2884
+ if (updated) await this.reportUpdate(updated);
2543
2885
  setInterval(() => this.state.repos.forEach((r, i) => setTimeout(() => void this.syncRepo(r.id), i * 1500)), SYNC_INTERVAL_MS);
2544
2886
  setInterval(() => this.schedule(), SCHEDULER_INTERVAL_MS);
2545
2887
  this.save();
@@ -2660,7 +3002,10 @@ var Swarm = class {
2660
3002
  requests: this.state.requests,
2661
3003
  ceo: this.ceoInfo(),
2662
3004
  messages: this.state.messages.slice(-100),
2663
- phoneReadAt: this.state.phoneReadAt
3005
+ phoneReadAt: this.state.phoneReadAt,
3006
+ usage: this.usageNow(),
3007
+ officeCommit: this.officeHead?.slice(0, 7) ?? null,
3008
+ officeUpdate: this.officeHead ? this.officeUpdateView() : void 0
2664
3009
  };
2665
3010
  }
2666
3011
  screenshot(agentId) {
@@ -2719,14 +3064,17 @@ var Swarm = class {
2719
3064
  // ---------- persistence ----------
2720
3065
  save() {
2721
3066
  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);
3067
+ this.saveTimer = setTimeout(() => void this.writeState().catch((err) => console.warn("could not save the state", err)), 1500);
3068
+ }
3069
+ /** Write the state file now, e.g. before the office stops or hands itself to the launcher. */
3070
+ async writeState() {
3071
+ if (this.saveTimer) clearTimeout(this.saveTimer);
3072
+ this.saveTimer = null;
3073
+ for (const a of this.state.agents) a.logTail = (this.agentRt.get(a.id)?.log ?? []).slice(-200);
3074
+ await fs5.mkdir(path7.dirname(STATE_FILE), { recursive: true });
3075
+ const tmp = `${STATE_FILE}.tmp`;
3076
+ await fs5.writeFile(tmp, JSON.stringify(this.state, null, 2));
3077
+ await fs5.rename(tmp, STATE_FILE);
2730
3078
  }
2731
3079
  // ---------- lookups ----------
2732
3080
  repo(id) {
@@ -2808,7 +3156,7 @@ var Swarm = class {
2808
3156
  const name = fullName.split("/")[1];
2809
3157
  const taken = folders.find((f) => f.name.toLowerCase() === name.toLowerCase());
2810
3158
  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);
3159
+ return path7.join(root, name);
2812
3160
  }
2813
3161
  /** A brand-new project: a folder in your projects folder, pushed to a new GitHub repo. */
2814
3162
  async createRepo(name, opts) {
@@ -2820,11 +3168,11 @@ var Swarm = class {
2820
3168
  }
2821
3169
  /** The folders in your projects folder (or another folder you point at), and which are floors already. */
2822
3170
  async listProjectFolders(dir) {
2823
- const root = dir?.trim() ? path6.resolve(dir.trim()) : this.state.settings.projectsDir;
3171
+ const root = dir?.trim() ? path7.resolve(dir.trim()) : this.state.settings.projectsDir;
2824
3172
  const folders = await this.backend.scanProjects(root).catch((err) => {
2825
3173
  throw new HttpError(400, err.message);
2826
3174
  });
2827
- const same = (a, b) => path6.resolve(a).toLowerCase() === path6.resolve(b).toLowerCase();
3175
+ const same = (a, b) => path7.resolve(a).toLowerCase() === path7.resolve(b).toLowerCase();
2828
3176
  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
3177
  return { root, folders: folders.map((f) => ({ name: f.name, path: f.path, git: f.git, github: f.github, floor: floorOf(f) })) };
2830
3178
  }
@@ -2921,46 +3269,38 @@ var Swarm = class {
2921
3269
  }
2922
3270
  /** One step toward merging a QA-passed PR. Returns true when it merged. */
2923
3271
  async advanceMerge(repo, rec, pr) {
2924
- if (pr.isDraft) return this.mergeNote(rec, "a draft: waiting");
2925
- if (pr.mergeable === "UNKNOWN" || pr.mergeState === "UNKNOWN") {
3272
+ let step = mergeStep(pr, rec, Date.now(), { base: repo.defaultBranch });
3273
+ if (step.do === "details") {
2926
3274
  const d = await this.backend.prDetails(repo.fullName, pr.number);
2927
3275
  pr = { ...pr, headSha: d.headSha, mergeable: d.mergeable, mergeState: d.mergeState };
3276
+ step = mergeStep(pr, rec, Date.now(), { base: repo.defaultBranch, detailed: true });
2928
3277
  }
2929
- rec.passedSha ??= pr.headSha;
2930
- if (pr.headSha !== rec.passedSha) {
3278
+ Object.assign(rec, step.set);
3279
+ if (step.do === "requeue") {
2931
3280
  this.setQa(rec, { status: "queued", round: rec.round + 1, retests: rec.retests + 1, mergeNote: null, pendingSince: null });
2932
3281
  setTimeout(() => this.schedule(), 200);
2933
3282
  return false;
2934
3283
  }
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;
3284
+ if (step.do === "send-back") return this.sendBack(repo, rec, step.reason, step.instructions, step.needsHuman);
3285
+ if (step.do === "wait") {
3286
+ if (step.alert) {
2945
3287
  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
3288
  }
2947
- return this.mergeNote(rec, `waiting for checks: ${pr.pendingChecks.join(", ") || "running"}`);
3289
+ return step.note === void 0 ? false : this.mergeNote(rec, step.note);
2948
3290
  }
2949
- rec.pendingSince = null;
2950
- if (pr.mergeable === "UNKNOWN") return this.mergeNote(rec, "GitHub is checking it can merge");
2951
- if (pr.mergeState === "BEHIND") {
3291
+ if (step.do === "update-branch") {
2952
3292
  this.mergeNote(rec, `updating the branch with ${repo.defaultBranch}`);
2953
3293
  await this.backend.updateBranch(repo.fullName, pr.number);
2954
3294
  const details = await this.backend.prDetails(repo.fullName, pr.number);
2955
3295
  this.setQa(rec, { passedSha: details.headSha });
2956
3296
  return false;
2957
3297
  }
2958
- if (rec.mergeRetryAt && Date.now() < rec.mergeRetryAt) return false;
3298
+ if (step.do !== "merge") return false;
2959
3299
  this.mergeNote(rec, "merging\u2026");
2960
3300
  let error = "";
2961
3301
  for (const method of ["squash", "merge", "rebase"]) {
2962
3302
  try {
2963
- await this.backend.mergePull(repo.fullName, pr.number, method, rec.passedSha);
3303
+ await this.backend.mergePull(repo.fullName, pr.number, method, rec.passedSha ?? pr.headSha);
2964
3304
  error = "";
2965
3305
  break;
2966
3306
  } catch (err) {
@@ -2989,8 +3329,8 @@ var Swarm = class {
2989
3329
  return false;
2990
3330
  }
2991
3331
  /** 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) {
3332
+ sendBack(repo, rec, reason, fixInstructions, needsHuman) {
3333
+ if (needsHuman) {
2994
3334
  this.setQa(rec, { status: "needs-human", mergeNote: null });
2995
3335
  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
3336
  return false;
@@ -3003,14 +3343,16 @@ var Swarm = class {
3003
3343
  async syncFolder(repo) {
3004
3344
  const rt = this.repoRt.get(repo.id);
3005
3345
  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)}`);
3346
+ const own = this.backend.office.isOwnFolder(this.backend.mainDir(repo.fullName));
3347
+ const sync = await this.backend.syncMain(repo.fullName, repo.defaultBranch, { touch: !own }).catch((err) => ({ status: `sync failed: ${oneLine(err)}`, behind: 0, updatable: false }));
3348
+ const status = sync?.status ?? null;
3349
+ if (own && sync) this.setOfficeBehind(sync.updatable ? sync.behind : 0);
3008
3350
  if (!this.repoRt.has(repo.id)) return null;
3009
3351
  const before = rt.folderSync;
3010
3352
  rt.folderSync = status;
3011
3353
  this.emitRepo(repo);
3012
3354
  if (status && status !== before) {
3013
- const name = repo.localPath ? path6.basename(repo.localPath) : repo.fullName;
3355
+ const name = repo.localPath ? path7.basename(repo.localPath) : repo.fullName;
3014
3356
  if (status.startsWith("updated")) this.toast("success", `\u{1F4C1} ${name}: ${status}`);
3015
3357
  else if (/behind|diverged|failed/.test(status)) this.toast("info", `\u{1F4C1} ${name} wasn't updated: ${status}`);
3016
3358
  }
@@ -3030,8 +3372,9 @@ var Swarm = class {
3030
3372
  return this.previews.stop(this.repo(id));
3031
3373
  }
3032
3374
  /** 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);
3375
+ async shutdown() {
3376
+ await this.writeState().catch((err) => console.warn("could not save the state", err));
3377
+ await this.previews.stopAll(this.state.repos);
3035
3378
  }
3036
3379
  async cloneRepo(id) {
3037
3380
  const repo = this.state.repos.find((r) => r.id === id);
@@ -3419,9 +3762,10 @@ Note from the manager: ${note}` : ""
3419
3762
  rt.shots.push({ data, mime, url: rt.browserUrl, at });
3420
3763
  if (rt.shots.length > 12) rt.shots.shift();
3421
3764
  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));
3765
+ void removeScreens(a.id).then(() => fs5.mkdir(SCREENS_DIR, { recursive: true })).then(() => fs5.writeFile(screenFile(a.id, mime), data)).catch((err) => console.warn("could not save screenshot", err));
3423
3766
  },
3424
3767
  limited: (at) => this.pauseForLimit(at),
3768
+ usageWarning: (info) => this.paceForWarning(info),
3425
3769
  finished: (result) => void this.onFinished(a, repo, result)
3426
3770
  },
3427
3771
  repo.defaultBranch
@@ -3430,6 +3774,7 @@ Note from the manager: ${note}` : ""
3430
3774
  async onFinished(a, repo, result) {
3431
3775
  const rt = this.agentRt.get(a.id);
3432
3776
  if (!rt || !this.state.agents.includes(a)) return;
3777
+ if (this.officeUpdate.handedOver) return;
3433
3778
  rt.session = null;
3434
3779
  rt.currentTool = null;
3435
3780
  a.endedAt = Date.now();
@@ -3842,9 +4187,11 @@ Full report with screenshots: ${rec.commentUrl}` : "",
3842
4187
  if (patch.ceoHeartbeatMin !== void 0) s.ceoHeartbeatMin = Math.max(0, Math.min(1440, Math.round(Number(patch.ceoHeartbeatMin)) || 0));
3843
4188
  if (typeof patch.managerName === "string") s.managerName = patch.managerName.trim().slice(0, 40);
3844
4189
  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());
4190
+ if (typeof patch.projectsDir === "string" && patch.projectsDir.trim()) s.projectsDir = path7.resolve(patch.projectsDir.trim());
3846
4191
  if (typeof patch.setupDone === "boolean") s.setupDone = patch.setupDone;
3847
4192
  if (patch.tutorialStep !== void 0) s.tutorialStep = Math.max(-1, Math.round(Number(patch.tutorialStep)) || 0);
4193
+ if (typeof patch.autoUpdate === "boolean") s.autoUpdate = patch.autoUpdate;
4194
+ if (patch.pacingSessions !== void 0) s.pacingSessions = clampPacingSessions(patch.pacingSessions);
3848
4195
  this.save();
3849
4196
  this.broadcast({ type: "settings", settings: s });
3850
4197
  this.emitCeo();
@@ -3879,6 +4226,9 @@ Full report with screenshots: ${rec.commentUrl}` : "",
3879
4226
  // `${repoId}#${issue}`: its developer was asked once to finish the missing PR
3880
4227
  pausedUntil = 0;
3881
4228
  // Claude's usage limit was hit: nothing new starts before this
4229
+ pacingUntil = 0;
4230
+ // Claude warned about usage: new issues are paced until this
4231
+ lastUsage = "";
3882
4232
  /** Backlog issues that can start now, most urgent first: the ones holding up the longest chain of other issues, then the oldest. */
3883
4233
  readyIssues(repo) {
3884
4234
  const issues = this.repoRt.get(repo.id).issues;
@@ -3938,7 +4288,7 @@ Full report with screenshots: ${rec.commentUrl}` : "",
3938
4288
  * free developer is least needed elsewhere, so nobody sits idle while there is work that can start.
3939
4289
  */
3940
4290
  startIssueWork(repo) {
3941
- if (!repo.autoAssign) return false;
4291
+ if (!repo.autoAssign || !this.mayStart("issue")) return false;
3942
4292
  const free = this.available(repo, "dev");
3943
4293
  const ready = free.length ? this.readyIssues(repo) : [];
3944
4294
  if (ready.length === 0) return false;
@@ -3958,6 +4308,39 @@ Full report with screenshots: ${rec.commentUrl}` : "",
3958
4308
  const fresh = Date.now() >= this.pausedUntil;
3959
4309
  this.pausedUntil = until;
3960
4310
  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.`);
4311
+ this.emitUsage();
4312
+ }
4313
+ /** Claude warned that usage is getting high: pace new issues until the window resets, rather than run into the limit. */
4314
+ paceForWarning(info) {
4315
+ const now2 = Date.now();
4316
+ const until = info.resetsAt && info.resetsAt > now2 ? info.resetsAt : now2 + PACING_MS;
4317
+ if (until <= this.pacingUntil) return;
4318
+ const fresh = now2 >= this.pacingUntil;
4319
+ this.pacingUntil = until;
4320
+ if (fresh) this.postMessage("office", pacingMessage(info, until, this.state.settings.pacingSessions, now2));
4321
+ this.emitUsage();
4322
+ }
4323
+ /** May work of this kind start now, as far as Claude's usage goes? */
4324
+ mayStart(kind) {
4325
+ return mayStart(kind, { now: Date.now(), pausedUntil: this.pausedUntil, pacingUntil: this.pacingUntil, running: this.running(), pacingSessions: this.state.settings.pacingSessions });
4326
+ }
4327
+ usageNow() {
4328
+ return usageView({ now: Date.now(), pausedUntil: this.pausedUntil, pacingUntil: this.pacingUntil });
4329
+ }
4330
+ emitUsage() {
4331
+ const usage = this.usageNow();
4332
+ const key = JSON.stringify(usage);
4333
+ if (key === this.lastUsage) return;
4334
+ this.lastUsage = key;
4335
+ this.broadcast({ type: "usage", usage });
4336
+ }
4337
+ /** Pacing and pauses run out on their own: say so when pacing ends. */
4338
+ tickUsage() {
4339
+ if (this.pacingUntil && Date.now() >= this.pacingUntil) {
4340
+ this.pacingUntil = 0;
4341
+ this.postMessage("office", "\u2705 Claude's usage is back to normal. The office starts new work at full speed again.");
4342
+ }
4343
+ this.emitUsage();
3961
4344
  }
3962
4345
  /** A failed session releases its issue for someone else; an issue that keeps failing waits for the manager. */
3963
4346
  issueFailed(repo, n) {
@@ -3975,6 +4358,8 @@ Full report with screenshots: ${rec.commentUrl}` : "",
3975
4358
  * so no repo can hog the slots.
3976
4359
  */
3977
4360
  schedule() {
4361
+ this.tickUsage();
4362
+ if (this.officeUpdateTick()) return;
3978
4363
  if (this.limited()) return;
3979
4364
  this.maybeHeartbeat();
3980
4365
  this.startCeoWork();
@@ -3996,6 +4381,126 @@ Full report with screenshots: ${rec.commentUrl}` : "",
3996
4381
  }
3997
4382
  }
3998
4383
  }
4384
+ // ---------- the office's own update ----------
4385
+ drainInput() {
4386
+ const u = this.officeUpdate;
4387
+ return {
4388
+ now: Date.now(),
4389
+ behind: this.officeHead ? u.behind : 0,
4390
+ launcher: this.backend.office.launcher,
4391
+ autoUpdate: this.state.settings.autoUpdate !== false,
4392
+ requested: u.requested,
4393
+ postponedUntil: u.postponedUntil,
4394
+ postponedBehind: u.postponedBehind,
4395
+ failed: u.failed,
4396
+ failedBehind: u.failedBehind,
4397
+ drainingSince: u.drainingSince,
4398
+ sent: u.sent,
4399
+ running: this.running()
4400
+ };
4401
+ }
4402
+ officeUpdateView() {
4403
+ const u = this.officeUpdate;
4404
+ const input = this.drainInput();
4405
+ const d = drainDecision(input);
4406
+ const until = u.postponedUntil ? new Date(u.postponedUntil).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
4407
+ const detail = d.state === "failed" ? u.failed : d.state === "waiting" ? `Postponed until ${until}, or until a newer commit lands.` : null;
4408
+ return { state: d.state, behind: input.behind, launcher: input.launcher, drainingSince: u.drainingSince ?? d.drainingSince, running: input.running, detail };
4409
+ }
4410
+ emitOfficeUpdate() {
4411
+ if (!this.officeHead) return;
4412
+ const view = this.officeUpdateView();
4413
+ const key = JSON.stringify(view);
4414
+ if (key === this.lastOfficeView) return;
4415
+ this.lastOfficeView = key;
4416
+ this.broadcast({ type: "officeUpdate", officeUpdate: view });
4417
+ }
4418
+ /** The own-folder check found the office this many commits behind GitHub. */
4419
+ setOfficeBehind(behind) {
4420
+ const u = this.officeUpdate;
4421
+ if (u.failed && u.failedBehind === null) u.failedBehind = behind;
4422
+ u.behind = behind;
4423
+ if (behind === 0) Object.assign(u, { requested: false, failed: null, failedBehind: null });
4424
+ this.officeUpdateTick();
4425
+ }
4426
+ /**
4427
+ * Take the office's own update one step (see drainDecision): drain, and once nothing is running, or the drain timed
4428
+ * out, hand it to the launcher. True while nothing new may start.
4429
+ */
4430
+ officeUpdateTick() {
4431
+ if (!this.officeHead) return false;
4432
+ const u = this.officeUpdate;
4433
+ const d = drainDecision(this.drainInput());
4434
+ if (d.state === "draining" && u.drainingSince === null) {
4435
+ const running = this.running();
4436
+ this.postMessage(
4437
+ "office",
4438
+ `\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.`
4439
+ );
4440
+ }
4441
+ u.drainingSince = d.drainingSince;
4442
+ if (d.send) void this.handOver(d.timedOut);
4443
+ this.emitOfficeUpdate();
4444
+ return d.state === "draining" || d.state === "updating";
4445
+ }
4446
+ /** Drained: hand the update to the launcher. Sessions still running after the timeout stop the way a restart stops them. */
4447
+ async handOver(timedOut) {
4448
+ const u = this.officeUpdate;
4449
+ const from = this.officeHead;
4450
+ u.sent = true;
4451
+ const busy = this.state.agents.filter((a) => BUSY.includes(a.status));
4452
+ 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.`);
4453
+ this.emitOfficeUpdate();
4454
+ u.handedOver = true;
4455
+ await this.writeState().catch((err) => console.warn("could not save the state", err));
4456
+ for (const a of busy) this.agentRt.get(a.id)?.session?.stop();
4457
+ try {
4458
+ const result = await this.backend.office.update(from);
4459
+ if (result) await this.afterUpdate(result);
4460
+ } catch (err) {
4461
+ 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() });
4462
+ }
4463
+ }
4464
+ /** An update that didn't restart the office (the demo, or a failed hand-over): recover like a restart would, then report it. */
4465
+ async afterUpdate(result) {
4466
+ const u = this.officeUpdate;
4467
+ Object.assign(u, { sent: false, handedOver: false, requested: false, drainingSince: null });
4468
+ const interrupted = this.state.agents.filter((a) => BUSY.includes(a.status));
4469
+ for (const a of interrupted) {
4470
+ Object.assign(a, { status: "stopped", lastError: "The office updated itself while this agent was working." });
4471
+ const rt = this.agentRt.get(a.id);
4472
+ if (rt) Object.assign(rt, { session: null, currentTool: null });
4473
+ }
4474
+ this.ensureCeo(interrupted);
4475
+ this.recover(interrupted);
4476
+ interrupted.forEach((a) => this.emitAgent(a));
4477
+ await this.reportUpdate(result);
4478
+ if (!result.ok) u.failedBehind = u.behind;
4479
+ const own = this.state.repos.find((r) => this.backend.office.isOwnFolder(this.backend.mainDir(r.fullName)));
4480
+ if (own) await this.syncFolder(own);
4481
+ this.emitCeo();
4482
+ this.save();
4483
+ setTimeout(() => this.schedule(), 300);
4484
+ }
4485
+ /** One phone message about the last update (the launcher's last-update.json, or the demo's fake). */
4486
+ async reportUpdate(result) {
4487
+ const commits = result.ok && result.to ? await this.backend.office.commitsBetween(result.from, result.to) : null;
4488
+ this.postMessage("office", lastUpdateMessage(result, commits));
4489
+ Object.assign(this.officeUpdate, { failed: result.ok ? null : result.error || "unknown error", failedBehind: null });
4490
+ }
4491
+ /** The manager's Update now (drain now, even with automatic updates off) or Later (not for 2 hours, or until a newer commit). */
4492
+ updateOffice(action) {
4493
+ if (action !== "now" && action !== "later") throw new HttpError(400, 'action must be "now" or "later"');
4494
+ const u = this.officeUpdate;
4495
+ 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).");
4496
+ if (u.sent) throw new HttpError(409, "The update is already under way.");
4497
+ if (u.behind <= 0) throw new HttpError(409, "The office is up to date: there is nothing to update.");
4498
+ if (action === "now") Object.assign(u, { requested: true, postponedUntil: null });
4499
+ else Object.assign(u, { requested: false, postponedUntil: Date.now() + POSTPONE_MS, postponedBehind: u.behind });
4500
+ this.officeUpdateTick();
4501
+ setTimeout(() => this.schedule(), 200);
4502
+ return this.officeUpdateView();
4503
+ }
3999
4504
  // ---------- the CEO ----------
4000
4505
  ceo() {
4001
4506
  return this.state.agents.find((a) => a.id === CEO_ID);
@@ -4122,11 +4627,11 @@ ${job.text}` } : job;
4122
4627
  this.state.ceo.lastReviewAt = Date.now();
4123
4628
  this.state.ceo.lastFingerprint = this.fingerprint();
4124
4629
  }
4125
- this.ceoIssues = { filed: 0, repos: /* @__PURE__ */ new Set() };
4630
+ this.ceoIssues = new IssueCap(MAX_ISSUES_PER_JOB);
4126
4631
  this.emitAgent(a);
4127
4632
  this.emitCeo();
4128
4633
  this.save();
4129
- await fs4.mkdir(CEO_DIR, { recursive: true }).catch(() => void 0);
4634
+ await fs5.mkdir(CEO_DIR, { recursive: true }).catch(() => void 0);
4130
4635
  if (a.status !== "working") {
4131
4636
  this.state.ceo.job = null;
4132
4637
  this.emitCeo();
@@ -4141,7 +4646,7 @@ ${job.text}` } : job;
4141
4646
  name: a.name,
4142
4647
  company: s.companyName,
4143
4648
  manager: s.managerName,
4144
- notesFile: path6.join(CEO_DIR, "NOTES.md"),
4649
+ notesFile: path7.join(CEO_DIR, "NOTES.md"),
4145
4650
  sessionLimit: s.sessionLimit,
4146
4651
  teamCap: s.teamCap,
4147
4652
  hiring: s.hiring
@@ -4170,6 +4675,7 @@ ${job.text}` } : job;
4170
4675
  screenshot: () => void 0,
4171
4676
  turn: (text) => this.postMessage("ceo", text),
4172
4677
  limited: (at) => this.pauseForLimit(at),
4678
+ usageWarning: (info) => this.paceForWarning(info),
4173
4679
  finished: (result) => this.onCeoFinished(a, result)
4174
4680
  },
4175
4681
  ""
@@ -4177,7 +4683,7 @@ ${job.text}` } : job;
4177
4683
  }
4178
4684
  onCeoFinished(a, result) {
4179
4685
  const rt = this.agentRt.get(a.id);
4180
- if (!rt) return;
4686
+ if (!rt || this.officeUpdate.handedOver) return;
4181
4687
  rt.session = null;
4182
4688
  rt.currentTool = null;
4183
4689
  a.endedAt = Date.now();
@@ -4194,7 +4700,7 @@ ${job.text}` } : job;
4194
4700
  this.postMessage("office", `\u26A0\uFE0F ${a.name} hit a problem while ${what}: ${a.lastError.slice(0, 240)}`);
4195
4701
  } else {
4196
4702
  a.status = "done";
4197
- const filed = this.ceoIssues.filed ? ` \xB7 ${this.ceoIssues.filed} issue${this.ceoIssues.filed === 1 ? "" : "s"} filed` : "";
4703
+ const filed = this.ceoIssues.total ? ` \xB7 ${this.ceoIssues.total} issue${this.ceoIssues.total === 1 ? "" : "s"} filed` : "";
4198
4704
  this.appendLog(a, [{ kind: "done", text: `\u2714 Done in ${this.minutes(a)}m \xB7 ${a.turns} turns${filed}` }]);
4199
4705
  }
4200
4706
  for (const id of this.ceoIssues.repos) void this.syncRepo(id);
@@ -4212,6 +4718,7 @@ ${job.text}` } : job;
4212
4718
  const rt = this.agentRt.get(a.id);
4213
4719
  if (rt.session) {
4214
4720
  this.appendLog(a, [{ kind: "manager", text: `\u25B6 Manager: ${t}` }]);
4721
+ this.ceoIssues.managerMessage();
4215
4722
  rt.session.send(`Message from the manager (they read your reply on their phone, so keep it short):
4216
4723
  ${t}`);
4217
4724
  return;
@@ -4434,6 +4941,7 @@ ${t}`);
4434
4941
  teamCap: s.teamCap,
4435
4942
  sessionLimit: s.sessionLimit || "none",
4436
4943
  sessionsRunning: this.running(),
4944
+ usage: usageLabel(this.usageNow(), Date.now()),
4437
4945
  deskLimits: { dev: MAX_DESKS.dev, qa: MAX_DESKS.qa }
4438
4946
  },
4439
4947
  floors,
@@ -4577,7 +5085,7 @@ ${t}`);
4577
5085
  }
4578
5086
  async fileIssue(x) {
4579
5087
  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.`);
5088
+ this.ceoIssues.check();
4581
5089
  const title = String(x.title ?? "").trim().slice(0, 120);
4582
5090
  if (!title) throw new Error("An issue needs a title.");
4583
5091
  const slug = specialtySlug(x.specialty);
@@ -4586,10 +5094,35 @@ ${t}`);
4586
5094
  ---
4587
5095
  _Filed by ${this.ceo().name}, the cubefarm CEO._`;
4588
5096
  const n = await this.backend.createIssue(repo.fullName, title, body, slug ? [specialtyLabel(slug)] : []);
4589
- this.ceoIssues.filed++;
4590
- this.ceoIssues.repos.add(repo.id);
5097
+ this.ceoIssues.record(repo.id);
4591
5098
  return `Filed #${n} on floor ${repo.floor}: ${title}${slug ? ` (routed to ${slug})` : ""}.`;
4592
5099
  }
5100
+ /** Change an open issue's specialty and/or dependencies (see planRoute for what is refused). */
5101
+ async routeIssue(x) {
5102
+ const repo = this.floorRepo(x.floor);
5103
+ const issues = this.repoRt.get(repo.id)?.issues ?? [];
5104
+ const asked = [Number(x.number), ...(x.depends_on ?? []).map(Number)].filter((n) => !issues.some((i) => i.number === n));
5105
+ const states = new Map(await Promise.all([...new Set(asked)].map(async (n) => [n, await this.backend.issueState(repo.fullName, n).catch(() => null)])));
5106
+ const specialties = [
5107
+ ...this.state.agents.filter((a) => a.repoId === repo.id && a.specialty).map((a) => a.specialty.toLowerCase()),
5108
+ ...this.state.requests.filter((r) => r.status === "pending" && r.kind === "hire" && r.repoId === repo.id && r.specialty).map((r) => r.specialty)
5109
+ ];
5110
+ const plan = planRoute({
5111
+ floor: repo.floor,
5112
+ number: Number(x.number),
5113
+ specialty: x.specialty,
5114
+ dependsOn: x.depends_on,
5115
+ issues,
5116
+ closed: (n) => states.get(n) === "CLOSED",
5117
+ inProgress: this.issueTaken(repo, Number(x.number)),
5118
+ specialties
5119
+ });
5120
+ if (plan.body !== null || plan.addLabels.length || plan.removeLabels.length) {
5121
+ await this.backend.editIssue(repo.fullName, Number(x.number), { body: plan.body ?? void 0, addLabels: plan.addLabels, removeLabels: plan.removeLabels });
5122
+ await this.syncRepo(repo.id);
5123
+ }
5124
+ return plan.summary;
5125
+ }
4593
5126
  };
4594
5127
 
4595
5128
  // server/index.ts
@@ -4706,6 +5239,7 @@ app.get("/api/agents/:id/screen", (req, res) => {
4706
5239
  res.end(shot.data);
4707
5240
  });
4708
5241
  app.patch("/api/settings", route((req) => swarm.updateSettings(req.body ?? {})));
5242
+ app.post("/api/office/update", route((req) => swarm.updateOffice(req.body?.action)));
4709
5243
  app.post("/api/ceo/message", route((req) => swarm.messageCeo(str(req.body.text))));
4710
5244
  app.post("/api/ceo/review", route(() => swarm.requestReview()));
4711
5245
  app.post("/api/phone/read", route((req) => swarm.markPhoneRead(Number(req.body?.at) || Date.now())));
@@ -4714,10 +5248,10 @@ app.post(
4714
5248
  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
5249
  );
4716
5250
  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)) {
5251
+ var dist = path8.resolve(import.meta.dirname, "../dist");
5252
+ if (fs6.existsSync(dist)) {
4719
5253
  app.use(express.static(dist));
4720
- app.get(/^(?!\/api).*/, (_req, res) => res.sendFile(path7.join(dist, "index.html")));
5254
+ app.get(/^(?!\/api).*/, (_req, res) => res.sendFile(path8.join(dist, "index.html")));
4721
5255
  }
4722
5256
  app.use((err, _req, res, _next) => {
4723
5257
  const parserStatus = err?.status;
@@ -4750,4 +5284,9 @@ var shutdown = (signal) => {
4750
5284
  };
4751
5285
  process.once("SIGINT", () => shutdown("SIGINT"));
4752
5286
  process.once("SIGTERM", () => shutdown("SIGTERM"));
5287
+ if (underLauncher()) {
5288
+ process.on("message", (msg) => {
5289
+ if (msg?.type === "office:shutdown") shutdown("update");
5290
+ });
5291
+ }
4753
5292
  //# sourceMappingURL=index.js.map