cubefarm 0.1.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.
@@ -0,0 +1,4753 @@
1
+ // server/index.ts
2
+ import http2 from "node:http";
3
+ import path7 from "node:path";
4
+ import fs5 from "node:fs";
5
+ import express from "express";
6
+ import { WebSocketServer } from "ws";
7
+
8
+ // server/config.ts
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ var PORT = Number(process.env.SWARM_PORT ?? 4317);
13
+ var VERSION = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "..", "package.json"), "utf8")).version;
14
+ var HOME_DIR = process.env.SWARM_HOME ?? path.join(os.homedir(), ".cubefarm");
15
+ var WORKSPACE_ROOT = path.join(HOME_DIR, "workspaces");
16
+ var DEMO = process.argv.includes("--demo") || process.env.SWARM_DEMO === "1" || process.env.SWARM_DEMO === "true";
17
+ var STATE_FILE = path.join(HOME_DIR, DEMO ? "demo-state.json" : "state.json");
18
+ var SYNC_INTERVAL_MS = 45e3;
19
+ var SCHEDULER_INTERVAL_MS = 8e3;
20
+ var LOG_BUFFER = 600;
21
+ function defaultProjectsDir(appDir, home = os.homedir()) {
22
+ if (!appDir.split(/[\\/]/).includes("node_modules")) return path.resolve(appDir, "..");
23
+ const names = ["Projects", "projects", "code", "Code", "dev", "Developer", "src", "repos", "git", "GitHub"];
24
+ return names.map((n) => path.join(home, n)).find((d) => fs.existsSync(d)) ?? path.join(home, "Projects");
25
+ }
26
+
27
+ // server/exec.ts
28
+ import { execFile } from "node:child_process";
29
+ var CommandError = class extends Error {
30
+ constructor(message, stderr, code) {
31
+ super(message);
32
+ this.stderr = stderr;
33
+ this.code = code;
34
+ }
35
+ stderr;
36
+ code;
37
+ };
38
+ function run(cmd, args, opts = {}) {
39
+ return new Promise((resolve, reject) => {
40
+ const child = execFile(
41
+ cmd,
42
+ args,
43
+ {
44
+ cwd: opts.cwd,
45
+ timeout: opts.timeoutMs ?? 12e4,
46
+ maxBuffer: 32 * 1024 * 1024,
47
+ windowsHide: true,
48
+ env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0", NO_COLOR: "1" }
49
+ },
50
+ (err, stdout, stderr) => {
51
+ if (err) {
52
+ const detail = (stderr || err.message).toString().trim();
53
+ reject(new CommandError(`${cmd} ${args.slice(0, 3).join(" ")} failed: ${detail}`, stderr?.toString() ?? "", err.code ?? null));
54
+ return;
55
+ }
56
+ resolve(stdout.toString().trim());
57
+ }
58
+ );
59
+ if (opts.input !== void 0) {
60
+ child.stdin?.end(opts.input);
61
+ }
62
+ });
63
+ }
64
+ var gh = (args, opts) => run("gh", args, opts);
65
+ var git = (args, opts) => run("git", args, opts);
66
+ async function ghJson(args, opts) {
67
+ const out = await gh(args, opts);
68
+ return out ? JSON.parse(out) : null;
69
+ }
70
+
71
+ // server/github.ts
72
+ async function currentUser() {
73
+ return gh(["api", "user", "--jq", ".login"]);
74
+ }
75
+ async function listMyRepos(owner) {
76
+ const args = ["repo", "list"];
77
+ if (owner) args.push(owner);
78
+ args.push("--limit", "200", "--json", "nameWithOwner,description,visibility,updatedAt");
79
+ const repos2 = await ghJson(args);
80
+ return repos2.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
81
+ }
82
+ async function repoMeta(fullName) {
83
+ const raw = await ghJson([
84
+ "repo",
85
+ "view",
86
+ fullName,
87
+ "--json",
88
+ "nameWithOwner,description,url,defaultBranchRef"
89
+ ]);
90
+ return {
91
+ nameWithOwner: raw.nameWithOwner,
92
+ description: raw.description ?? "",
93
+ url: raw.url,
94
+ defaultBranch: raw.defaultBranchRef?.name || "main"
95
+ };
96
+ }
97
+ async function listIssues(fullName) {
98
+ const raw = await ghJson(["issue", "list", "-R", fullName, "--state", "open", "--limit", "100", "--json", "number,title,body,url,labels,createdAt"]);
99
+ return raw.map((i) => ({ number: i.number, title: i.title, body: i.body ?? "", url: i.url, labels: i.labels.map((l) => l.name), createdAt: i.createdAt })).sort((a, b) => a.number - b.number);
100
+ }
101
+ var PR_FIELDS = "number,title,url,headRefName,headRefOid,state,isDraft,mergeable,mergeStateStatus,reviewDecision,closingIssuesReferences,createdAt,mergedAt,additions,deletions,statusCheckRollup";
102
+ var BAD = ["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"];
103
+ var failed = (c) => BAD.includes(c.conclusion ?? "") || BAD.includes(c.state ?? "");
104
+ var pending = (c) => !!c.status && c.status !== "COMPLETED" || c.state === "PENDING" || c.state === "EXPECTED";
105
+ var checkName = (c) => c.name || c.context || "check";
106
+ function checksOf(rollup) {
107
+ if (!rollup || rollup.length === 0) return "none";
108
+ if (rollup.some(failed)) return "failing";
109
+ if (rollup.some(pending)) return "pending";
110
+ return "passing";
111
+ }
112
+ function toPull(p) {
113
+ return {
114
+ number: p.number,
115
+ title: p.title,
116
+ url: p.url,
117
+ headRefName: p.headRefName,
118
+ state: p.state,
119
+ isDraft: p.isDraft,
120
+ mergeable: p.mergeable,
121
+ reviewDecision: p.reviewDecision || null,
122
+ closesIssues: (p.closingIssuesReferences ?? []).map((r) => r.number),
123
+ createdAt: p.createdAt,
124
+ mergedAt: p.mergedAt,
125
+ additions: p.additions,
126
+ deletions: p.deletions,
127
+ checks: checksOf(p.statusCheckRollup),
128
+ headSha: p.headRefOid,
129
+ mergeState: p.mergeStateStatus || "UNKNOWN",
130
+ failedChecks: (p.statusCheckRollup ?? []).filter(failed).map((c) => ({ name: checkName(c), url: c.detailsUrl || c.targetUrl || null })),
131
+ pendingChecks: (p.statusCheckRollup ?? []).filter(pending).map(checkName)
132
+ };
133
+ }
134
+ async function listPulls(fullName) {
135
+ const [open, merged] = await Promise.all([
136
+ ghJson(["pr", "list", "-R", fullName, "--state", "open", "--limit", "50", "--json", PR_FIELDS]),
137
+ ghJson(["pr", "list", "-R", fullName, "--state", "merged", "--limit", "8", "--json", PR_FIELDS])
138
+ ]);
139
+ return [...open, ...merged].map(toPull);
140
+ }
141
+ var labelsMade = /* @__PURE__ */ new Set();
142
+ async function ensureLabel(fullName, name) {
143
+ const key = `${fullName}#${name}`;
144
+ if (labelsMade.has(key)) return;
145
+ await gh(["label", "create", name, "-R", fullName, "--color", "c77dff", "--description", "cubefarm: routed to this specialty", "--force"]);
146
+ labelsMade.add(key);
147
+ }
148
+ async function createIssue(fullName, title, body, labels = []) {
149
+ for (const l of labels) await ensureLabel(fullName, l);
150
+ const args = ["issue", "create", "-R", fullName, "--title", title, "--body-file", "-"];
151
+ for (const l of labels) args.push("--label", l);
152
+ const out = await gh(args, { input: body || " " });
153
+ const match = out.match(/\/issues\/(\d+)/);
154
+ if (!match) throw new Error(`Could not read the new issue number from gh output: ${out}`);
155
+ return Number(match[1]);
156
+ }
157
+ async function mergePull(fullName, number, method, headSha) {
158
+ const pr = await ghJson(["pr", "view", String(number), "-R", fullName, "--json", "headRefName,isCrossRepository"]);
159
+ await gh(["pr", "merge", String(number), "-R", fullName, `--${method}`, ...headSha ? ["--match-head-commit", headSha] : []], { timeoutMs: 6e4 });
160
+ if (!pr.isCrossRepository && pr.headRefName.startsWith("swarm/")) {
161
+ await gh(["api", "-X", "DELETE", `repos/${fullName}/git/refs/heads/${pr.headRefName}`]).catch(() => void 0);
162
+ }
163
+ }
164
+ async function updateBranch(fullName, number) {
165
+ await gh(["pr", "update-branch", String(number), "-R", fullName], { timeoutMs: 6e4 });
166
+ }
167
+ async function closePull(fullName, number) {
168
+ await gh(["pr", "close", String(number), "-R", fullName]);
169
+ }
170
+ async function prForBranch(fullName, branch) {
171
+ const list = await ghJson(["pr", "list", "-R", fullName, "--head", branch, "--state", "all", "--json", "number,url", "--limit", "1"]);
172
+ return list[0] ?? null;
173
+ }
174
+ async function prDetails(fullName, number) {
175
+ const raw = await ghJson(["pr", "view", String(number), "-R", fullName, "--json", "number,title,body,url,headRefName,headRefOid,isCrossRepository,closingIssuesReferences,state,mergeable,mergeStateStatus"]);
176
+ return {
177
+ number: raw.number,
178
+ title: raw.title,
179
+ body: raw.body ?? "",
180
+ url: raw.url,
181
+ headRefName: raw.headRefName,
182
+ headSha: raw.headRefOid,
183
+ isCrossRepository: raw.isCrossRepository,
184
+ closesIssues: (raw.closingIssuesReferences ?? []).map((r) => r.number),
185
+ state: raw.state,
186
+ mergeable: raw.mergeable,
187
+ mergeState: raw.mergeStateStatus || "UNKNOWN"
188
+ };
189
+ }
190
+ async function issueDetails(fullName, number) {
191
+ const raw = await ghJson(["issue", "view", String(number), "-R", fullName, "--json", "title,body"]);
192
+ return { title: raw.title, body: raw.body ?? "" };
193
+ }
194
+ async function commentPull(fullName, number, body) {
195
+ const out = await gh(["pr", "comment", String(number), "-R", fullName, "--body-file", "-"], { input: body });
196
+ return out.match(/https:\/\/github\.com\/\S+/)?.[0] ?? "";
197
+ }
198
+ var EVIDENCE_BRANCH = "swarm-qa-evidence";
199
+ var evidenceReady = /* @__PURE__ */ new Set();
200
+ var ghApiJson = (method, endpoint, body) => gh(["api", "-X", method, endpoint, "-H", "Content-Type: application/json", "--input", "-"], { input: JSON.stringify(body), timeoutMs: 12e4 }).then(
201
+ (out) => out ? JSON.parse(out) : null
202
+ );
203
+ async function ensureEvidenceBranch(fullName) {
204
+ if (evidenceReady.has(fullName)) return;
205
+ try {
206
+ await gh(["api", `repos/${fullName}/git/ref/heads/${EVIDENCE_BRANCH}`]);
207
+ } catch {
208
+ const readme = "# QA evidence\n\nScreenshots attached to pull request QA reports by cubefarm QA agents.\nThis branch has no shared history with the code and is never merged.\n";
209
+ const newTree = await ghApiJson("POST", `repos/${fullName}/git/trees`, {
210
+ tree: [{ path: "README.md", mode: "100644", type: "blob", content: readme }]
211
+ });
212
+ const commit = await ghApiJson("POST", `repos/${fullName}/git/commits`, { message: "QA evidence branch (cubefarm)", tree: newTree.sha, parents: [] });
213
+ await ghApiJson("POST", `repos/${fullName}/git/refs`, { ref: `refs/heads/${EVIDENCE_BRANCH}`, sha: commit.sha });
214
+ }
215
+ evidenceReady.add(fullName);
216
+ }
217
+ async function uploadEvidence(fullName, filePath, data) {
218
+ await ensureEvidenceBranch(fullName);
219
+ await ghApiJson("PUT", `repos/${fullName}/contents/${filePath}`, {
220
+ message: `QA evidence: ${filePath}`,
221
+ content: data.toString("base64"),
222
+ branch: EVIDENCE_BRANCH
223
+ });
224
+ return `https://github.com/${fullName}/raw/${EVIDENCE_BRANCH}/${filePath}`;
225
+ }
226
+
227
+ // server/workspace.ts
228
+ import fs2 from "node:fs/promises";
229
+ import path2 from "node:path";
230
+ var locks = /* @__PURE__ */ new Map();
231
+ var localRoots = /* @__PURE__ */ new Map();
232
+ function withRepoLock(fullName, fn) {
233
+ const prev = locks.get(fullName) ?? Promise.resolve();
234
+ const next = prev.catch(() => void 0).then(fn);
235
+ locks.set(fullName, next);
236
+ return next;
237
+ }
238
+ var repoDir = (fullName) => path2.join(WORKSPACE_ROOT, fullName.replace("/", "__"));
239
+ var mainDir = (fullName) => localRoots.get(fullName.toLowerCase()) ?? path2.join(repoDir(fullName), "main");
240
+ var deskDir = (fullName, agentSlug) => path2.join(repoDir(fullName), "desks", agentSlug);
241
+ function setLocalPath(fullName, dir) {
242
+ if (dir) localRoots.set(fullName.toLowerCase(), path2.resolve(dir));
243
+ else localRoots.delete(fullName.toLowerCase());
244
+ }
245
+ async function exists(p) {
246
+ return fs2.access(p).then(() => true).catch(() => false);
247
+ }
248
+ var LOCAL_EXCLUDES = [".playwright-mcp/", ".preview-tmp/"];
249
+ async function addLocalExcludes(main) {
250
+ const file = path2.join(main, ".git", "info", "exclude");
251
+ const current = await fs2.readFile(file, "utf8").catch(() => "");
252
+ const missing = LOCAL_EXCLUDES.filter((l) => !current.split(/\r?\n/).includes(l));
253
+ if (missing.length === 0) return;
254
+ await fs2.mkdir(path2.dirname(file), { recursive: true });
255
+ await fs2.appendFile(file, `${current && !current.endsWith("\n") ? "\n" : ""}# added by cubefarm
256
+ ${missing.join("\n")}
257
+ `);
258
+ }
259
+ var ensureLocalExcludes = (fullName) => addLocalExcludes(mainDir(fullName));
260
+ function ensureClone(fullName) {
261
+ return withRepoLock(fullName, async () => {
262
+ const dir = mainDir(fullName);
263
+ if (await exists(path2.join(dir, ".git"))) {
264
+ await git(["fetch", "origin", "--prune"], { cwd: dir, timeoutMs: 18e4 });
265
+ } else {
266
+ await fs2.mkdir(path2.dirname(dir), { recursive: true });
267
+ await gh(["repo", "clone", fullName, dir], { timeoutMs: 6e5 });
268
+ }
269
+ await addLocalExcludes(dir);
270
+ });
271
+ }
272
+ var installs = /* @__PURE__ */ new Map();
273
+ function npmInstall(dir) {
274
+ const prev = installs.get(dir) ?? Promise.resolve();
275
+ const opts = { cwd: dir, timeoutMs: 6e5 };
276
+ const next = prev.catch(() => void 0).then(() => process.platform === "win32" ? run("cmd.exe", ["/d", "/s", "/c", "npm install --no-audit --no-fund"], opts) : run("npm", ["install", "--no-audit", "--no-fund"], opts));
277
+ installs.set(dir, next);
278
+ return next;
279
+ }
280
+ async function syncMain(fullName, defaultBranch, opts) {
281
+ const dir = mainDir(fullName);
282
+ const result = await withRepoLock(fullName, async () => {
283
+ if (!await exists(path2.join(dir, ".git"))) return null;
284
+ const g = (args, timeoutMs) => git(args, { cwd: dir, timeoutMs });
285
+ try {
286
+ await g(["fetch", "origin", "--prune"], 18e4);
287
+ const target = `origin/${defaultBranch}`;
288
+ const behind = Number(await g(["rev-list", "--count", `HEAD..${target}`]));
289
+ const commits = `${behind} commit${behind === 1 ? "" : "s"}`;
290
+ 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` };
296
+ const before = await g(["rev-parse", "HEAD"]);
297
+ try {
298
+ await g(["merge", "--ff-only", target], 12e4);
299
+ } catch {
300
+ return { status: `${behind} behind: local files are in the way` };
301
+ }
302
+ const changed = (await g(["diff", "--name-only", before, "HEAD"])).split(/\r?\n/);
303
+ 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 };
305
+ } catch (err) {
306
+ return { status: `sync failed: ${err.message.split(/\r?\n/)[0].slice(0, 160)}` };
307
+ }
308
+ });
309
+ if (!result?.install) return result?.status ?? null;
310
+ try {
311
+ await npmInstall(dir);
312
+ return `${result.status} \xB7 dependencies installed`;
313
+ } catch (err) {
314
+ console.warn(`npm install in ${dir} failed:`, err.message);
315
+ return `${result.status} \xB7 npm install failed`;
316
+ }
317
+ }
318
+ function githubFromConfig(config) {
319
+ const url = config.match(/\[remote "origin"\][^[]*?url\s*=\s*(\S+)/)?.[1];
320
+ const m = url?.match(/github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
321
+ return m ? `${m[1]}/${m[2]}` : null;
322
+ }
323
+ async function inspectFolder(dir) {
324
+ const full = path2.resolve(dir);
325
+ const stat = await fs2.stat(full).catch(() => null);
326
+ if (!stat?.isDirectory()) throw new Error(`${full} is not a folder`);
327
+ const dotGit = path2.join(full, ".git");
328
+ const gitStat = await fs2.stat(dotGit).catch(() => null);
329
+ let config = "";
330
+ if (gitStat?.isDirectory()) config = await fs2.readFile(path2.join(dotGit, "config"), "utf8").catch(() => "");
331
+ else if (gitStat) {
332
+ const ref = (await fs2.readFile(dotGit, "utf8").catch(() => "")).match(/gitdir:\s*(.+)/)?.[1]?.trim();
333
+ if (ref) {
334
+ const gitDir = path2.resolve(full, ref);
335
+ config = await fs2.readFile(path2.join(gitDir, "config"), "utf8").catch(() => fs2.readFile(path2.join(gitDir, "..", "..", "config"), "utf8").catch(() => ""));
336
+ }
337
+ }
338
+ return { name: path2.basename(full), path: full, git: !!gitStat, github: githubFromConfig(config), modified: stat.mtimeMs };
339
+ }
340
+ async function scanProjects(root) {
341
+ const entries = await fs2.readdir(root, { withFileTypes: true }).catch(() => {
342
+ throw new Error(`Can't read the projects folder ${root}`);
343
+ });
344
+ const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules");
345
+ const folders = await Promise.all(dirs.map((e) => inspectFolder(path2.join(root, e.name)).catch(() => null)));
346
+ return folders.filter((f) => !!f).sort((a, b) => b.modified - a.modified);
347
+ }
348
+ async function commitReadme(dir, title, description) {
349
+ const readme = path2.join(dir, "README.md");
350
+ if (!await exists(readme)) await fs2.writeFile(readme, `# ${title}
351
+
352
+ ${description.trim()}
353
+ `);
354
+ await git(["add", "README.md"], { cwd: dir });
355
+ try {
356
+ await git(["commit", "-m", "Initial commit"], { cwd: dir });
357
+ } catch (err) {
358
+ if (!/tell me who you are|user\.(name|email)/i.test(err.message)) throw err;
359
+ const login = await gh(["api", "user", "--jq", ".login"]);
360
+ await git(["-c", `user.name=${login}`, "-c", `user.email=${login}@users.noreply.github.com`, "commit", "-m", "Initial commit"], { cwd: dir });
361
+ }
362
+ }
363
+ async function publishFolder(dir, opts) {
364
+ const full = path2.resolve(dir);
365
+ const info = await inspectFolder(full);
366
+ if (info.github) return info.github;
367
+ if (!info.git) await git(["init", "-b", "main"], { cwd: full });
368
+ const hasCommit = await git(["rev-parse", "--verify", "HEAD"], { cwd: full }).then(
369
+ () => true,
370
+ () => false
371
+ );
372
+ if (!hasCommit) {
373
+ const files = (await fs2.readdir(full)).filter((f) => f !== ".git" && f !== "README.md");
374
+ if (files.length) {
375
+ throw new Error(`${info.name} has files but no commits yet. Commit what you want on GitHub first (git add, git commit), then publish it.`);
376
+ }
377
+ await commitReadme(full, opts.name, opts.description ?? "");
378
+ }
379
+ const origin = await git(["remote", "get-url", "origin"], { cwd: full }).catch(() => "");
380
+ if (origin) throw new Error(`${info.name}'s origin (${origin}) isn't on GitHub. cubefarm needs GitHub for issues and pull requests.`);
381
+ const target = opts.owner ? `${opts.owner}/${opts.name}` : opts.name;
382
+ const args = ["repo", "create", target, `--${opts.visibility}`, "--source", full, "--remote", "origin", "--push"];
383
+ if (opts.description) args.push("--description", opts.description);
384
+ const out = await gh(args, { cwd: full, timeoutMs: 12e4 });
385
+ return out.match(/github\.com\/([^/\s]+\/[^/\s]+?)(?:\.git)?(?:\s|$)/)?.[1] ?? (await inspectFolder(full)).github ?? target;
386
+ }
387
+ async function createProject(root, name, opts) {
388
+ const dir = path2.join(root, name);
389
+ if (await exists(dir)) {
390
+ const inside = await fs2.readdir(dir).catch(() => ["?"]);
391
+ if (inside.length) throw new Error(`${dir} already exists. Pick another name, or connect that folder instead.`);
392
+ }
393
+ await fs2.mkdir(dir, { recursive: true });
394
+ await git(["init", "-b", "main"], { cwd: dir });
395
+ await commitReadme(dir, name, opts.description ?? "");
396
+ const fullName = await publishFolder(dir, { name, ...opts });
397
+ return { fullName, path: dir };
398
+ }
399
+ async function removeDir(dir) {
400
+ await fs2.rm(dir, { recursive: true, force: true, maxRetries: 8, retryDelay: 250 });
401
+ }
402
+ function prepareDesk(fullName, base, agentSlug, branch) {
403
+ return withRepoLock(fullName, async () => {
404
+ const main = mainDir(fullName);
405
+ await git(["fetch", "origin", "--prune"], { cwd: main, timeoutMs: 18e4 });
406
+ let ref = `origin/${base.defaultBranch}`;
407
+ if (base.pr) {
408
+ ref = `origin/pr/${base.pr}`;
409
+ await git(["fetch", "origin", `+refs/pull/${base.pr}/head:refs/remotes/${ref}`], { cwd: main, timeoutMs: 18e4 });
410
+ }
411
+ try {
412
+ await git(["rev-parse", "--verify", ref], { cwd: main });
413
+ } catch {
414
+ throw new Error(
415
+ base.pr ? `Could not fetch pull request #${base.pr} of ${fullName}.` : `${fullName} has no ${base.defaultBranch} branch yet. Push an initial commit (or create the repo with a README) before assigning work.`
416
+ );
417
+ }
418
+ const wt = deskDir(fullName, agentSlug);
419
+ if (await exists(path2.join(wt, ".git"))) {
420
+ try {
421
+ await git(["checkout", "--force", "-B", branch, ref], { cwd: wt });
422
+ await git(["reset", "--hard", ref], { cwd: wt });
423
+ await git(["clean", "-fd"], { cwd: wt }).catch(() => void 0);
424
+ return wt;
425
+ } catch {
426
+ }
427
+ }
428
+ if (await exists(wt)) {
429
+ await git(["worktree", "remove", "--force", wt], { cwd: main }).catch(() => void 0);
430
+ try {
431
+ await removeDir(wt);
432
+ } catch (err) {
433
+ throw new Error(
434
+ `Could not clear the desk folder ${wt}: ${err.message}. A program started by the previous task is probably still running there. Close it and try again.`
435
+ );
436
+ }
437
+ }
438
+ await git(["worktree", "prune"], { cwd: main });
439
+ await fs2.mkdir(path2.dirname(wt), { recursive: true });
440
+ await git(["worktree", "add", "-B", branch, wt, ref], { cwd: main });
441
+ return wt;
442
+ });
443
+ }
444
+ function removeDesk(fullName, agentSlug) {
445
+ return withRepoLock(fullName, async () => {
446
+ const main = mainDir(fullName);
447
+ const wt = deskDir(fullName, agentSlug);
448
+ if (!await exists(wt)) return;
449
+ await git(["worktree", "remove", "--force", wt], { cwd: main }).catch(() => void 0);
450
+ await removeDir(wt).catch(() => void 0);
451
+ await git(["worktree", "prune"], { cwd: main }).catch(() => void 0);
452
+ });
453
+ }
454
+ 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) {
456
+ const desk = deskDir(fullName, agentSlug);
457
+ if (process.platform === "win32") {
458
+ const variants = [desk, desk.replaceAll("\\", "/")].map((d) => `'${d.toLowerCase().replaceAll("'", "''")}'`).join(", ");
459
+ const script = `
460
+ $ErrorActionPreference = 'SilentlyContinue'
461
+ $seed = @()
462
+ Get-NetTCPConnection -LocalPort ${port} -State Listen | ForEach-Object { $seed += [int]$_.OwningProcess }
463
+ $desks = @(${variants})
464
+ $all = Get-CimInstance Win32_Process
465
+ $byId = @{}; foreach ($p in $all) { $byId[[int]$p.ProcessId] = $p }
466
+ foreach ($p in $all) {
467
+ if (-not $p.CommandLine) { continue }
468
+ $cmd = $p.CommandLine.ToLower()
469
+ foreach ($d in $desks) { if ($cmd.Contains($d)) { $seed += [int]$p.ProcessId } }
470
+ }
471
+ $launchers = @(${LAUNCHERS.map((n) => `'${n}'`).join(", ")})
472
+ $kill = New-Object 'System.Collections.Generic.HashSet[int]'
473
+ foreach ($id in $seed) {
474
+ $cur = $byId[$id]
475
+ $first = $true
476
+ while ($cur -and [int]$cur.ProcessId -ne ${process.pid} -and ($first -or $launchers -contains $cur.Name.ToLower())) {
477
+ [void]$kill.Add([int]$cur.ProcessId)
478
+ $first = $false
479
+ $cur = $byId[[int]$cur.ParentProcessId]
480
+ }
481
+ }
482
+ foreach ($id in $kill) { taskkill /PID $id /T /F 2>&1 | Out-Null }
483
+ Write-Output $kill.Count`;
484
+ await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], { timeoutMs: 3e4 }).catch(() => void 0);
485
+ return;
486
+ }
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)) {
489
+ try {
490
+ process.kill(pid, "SIGKILL");
491
+ } catch {
492
+ }
493
+ }
494
+ }
495
+
496
+ // server/agentRunner.ts
497
+ import path3 from "node:path";
498
+ import { query } from "@anthropic-ai/claude-agent-sdk";
499
+ var InputQueue = class {
500
+ items = [];
501
+ waiters = [];
502
+ closed = false;
503
+ push(text) {
504
+ if (this.closed) return;
505
+ const msg = { type: "user", message: { role: "user", content: text }, parent_tool_use_id: null };
506
+ const waiter = this.waiters.shift();
507
+ if (waiter) waiter({ value: msg, done: false });
508
+ else this.items.push(msg);
509
+ }
510
+ close() {
511
+ this.closed = true;
512
+ for (const w of this.waiters.splice(0)) w({ value: void 0, done: true });
513
+ }
514
+ [Symbol.asyncIterator]() {
515
+ return {
516
+ next: () => {
517
+ const item = this.items.shift();
518
+ if (item) return Promise.resolve({ value: item, done: false });
519
+ if (this.closed) return Promise.resolve({ value: void 0, done: true });
520
+ return new Promise((resolve) => this.waiters.push(resolve));
521
+ }
522
+ };
523
+ }
524
+ };
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);
537
+ }
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
+ }
564
+ }
565
+ return { behavior: "allow", updatedInput: input };
566
+ };
567
+ }
568
+ var clip = (s, n) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
569
+ function rel(cwd, p) {
570
+ const s = String(p ?? "");
571
+ if (!s) return "";
572
+ const r = path3.relative(cwd, s);
573
+ return r && !r.startsWith("..") ? r.replaceAll("\\", "/") : s;
574
+ }
575
+ var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
576
+ function tidyPaths(cwd, text) {
577
+ let out = text;
578
+ for (const v of /* @__PURE__ */ new Set([cwd, cwd.replaceAll("\\", "/")])) {
579
+ out = out.replace(new RegExp(`^cd\\s+"?${escapeRe(v)}"?\\s*(&&|;)\\s*`, "i"), "");
580
+ out = out.replace(new RegExp(`${escapeRe(v)}[\\\\/]?`, "gi"), "./");
581
+ }
582
+ return out;
583
+ }
584
+ function describeTool(cwd, name, input) {
585
+ switch (name) {
586
+ case "Bash":
587
+ case "PowerShell":
588
+ return `$ ${clip(tidyPaths(cwd, String(input.command ?? "")).split("\n")[0], 220)}`;
589
+ case "Read":
590
+ return `Read ${rel(cwd, input.file_path)}`;
591
+ case "Write":
592
+ return `Write ${rel(cwd, input.file_path)}`;
593
+ case "Edit":
594
+ case "MultiEdit":
595
+ return `Edit ${rel(cwd, input.file_path)}`;
596
+ case "Glob":
597
+ return `Glob ${input.pattern}`;
598
+ case "Grep":
599
+ return `Grep "${clip(String(input.pattern ?? ""), 80)}"`;
600
+ case "WebFetch":
601
+ return `Fetch ${input.url}`;
602
+ case "WebSearch":
603
+ return `Search "${input.query}"`;
604
+ case "Task":
605
+ case "Agent":
606
+ return `Subagent: ${input.description ?? ""}`;
607
+ case "TodoWrite":
608
+ return "Update todo list";
609
+ }
610
+ if (name.startsWith("mcp__playwright__")) {
611
+ const action = name.replace("mcp__playwright__browser_", "").replace("mcp__playwright__", "");
612
+ const detail = input.url ?? input.element ?? input.text ?? "";
613
+ return `\u{1F310} ${action}${detail ? ` ${clip(String(detail), 120)}` : ""}`;
614
+ }
615
+ if (name.startsWith("mcp__office__")) return describeOfficeTool(name.slice("mcp__office__".length), input);
616
+ return `${name}(${clip(JSON.stringify(input), 140)})`;
617
+ }
618
+ function describeOfficeTool(action, input) {
619
+ const floor = input.floor != null ? ` \u2192 floor ${input.floor}` : "";
620
+ switch (action) {
621
+ case "company_status":
622
+ return "\u{1F3E2} company_status";
623
+ case "agent_detail":
624
+ return `\u{1F50E} agent_detail ${String(input.agent_id ?? "")}`;
625
+ case "set_floor_profile":
626
+ return `\u{1F5C2}\uFE0F set_floor_profile${floor}${input.summary ? `: ${clip(String(input.summary), 90)}` : ""}`;
627
+ case "update_job":
628
+ return `\u{1FAAA} update_job ${input.title ? `"${clip(String(input.title), 60)}"` : String(input.agent_id ?? "")}`;
629
+ case "propose_hire":
630
+ return `\u{1F91D} propose_hire ${input.role === "qa" ? "QA " : ""}"${clip(String(input.title ?? ""), 60)}"${floor}`;
631
+ case "propose_let_go":
632
+ return `\u{1F44B} propose_let_go ${String(input.agent_id ?? "")}`;
633
+ case "file_issue":
634
+ return `\u{1F4DD} file_issue "${clip(String(input.title ?? ""), 70)}"${floor}${input.specialty ? ` \xB7 ${input.specialty}` : ""}`;
635
+ }
636
+ return `\u{1F3E2} ${action}(${clip(JSON.stringify(input), 120)})`;
637
+ }
638
+ function todoLines(input) {
639
+ const todos = Array.isArray(input.todos) ? input.todos : [];
640
+ return todos.slice(0, 12).map((t) => ({
641
+ kind: "result",
642
+ text: ` ${t.status === "completed" ? "\u2612" : t.status === "in_progress" ? "\u25D0" : "\u2610"} ${clip(t.content ?? "", 150)}`
643
+ }));
644
+ }
645
+ function resultText(content) {
646
+ if (typeof content === "string") return content;
647
+ if (Array.isArray(content)) {
648
+ return content.filter((b) => b?.type === "text").map((b) => b.text).join("\n");
649
+ }
650
+ return "";
651
+ }
652
+ var lineCount = (text) => text.replace(/\r/g, "").split("\n").filter((l) => l.trim() !== "").length;
653
+ function summariseResult(cwd, tool2, text) {
654
+ if (tool2 === "Edit" || tool2 === "MultiEdit" || tool2 === "NotebookEdit") return [{ kind: "result", text: " \u23BF Updated" }];
655
+ if (tool2 === "Write") return [{ kind: "result", text: " \u23BF Saved" }];
656
+ if (tool2 === "Read") return [{ kind: "result", text: ` \u23BF Read ${lineCount(text)} lines` }];
657
+ if (tool2 === "Glob") return [{ kind: "result", text: ` \u23BF Found ${lineCount(text)} files` }];
658
+ const lines = tidyPaths(cwd, text).replace(/\r/g, "").split("\n").filter((l) => l.trim() !== "");
659
+ if (lines.length === 0) return [{ kind: "result", text: " \u23BF (no output)" }];
660
+ const shown = lines.slice(0, 3).map((l, i) => ({ kind: "result", text: `${i === 0 ? " \u23BF " : " "}${clip(l, 170)}` }));
661
+ if (lines.length > 3) shown.push({ kind: "result", text: ` \u2026 +${lines.length - 3} lines` });
662
+ return shown;
663
+ }
664
+ function startSession(opts, callbacks, defaultBranch) {
665
+ let done = false;
666
+ const cb = {
667
+ ...callbacks,
668
+ finished: (r) => {
669
+ if (done) return;
670
+ done = true;
671
+ callbacks.finished(r);
672
+ }
673
+ };
674
+ const input = new InputQueue();
675
+ const abort = new AbortController();
676
+ let pendingTurns = 1;
677
+ let stopped = false;
678
+ let lastCost = 0;
679
+ let lastTurns = 0;
680
+ const toolNames = /* @__PURE__ */ new Map();
681
+ input.push(opts.prompt);
682
+ const env = {};
683
+ for (const [k, v] of Object.entries(process.env)) {
684
+ if (/^(ANTHROPIC_|CLAUDE)/i.test(k) && k !== "CLAUDE_CONFIG_DIR") continue;
685
+ env[k] = v;
686
+ }
687
+ env.CLAUDE_AGENT_SDK_CLIENT_APP = `cubefarm/${VERSION}`;
688
+ const mcpServers = {};
689
+ if (opts.browserTesting) {
690
+ const args = ["-y", "@playwright/mcp@latest", "--headless", "--isolated", "--init-script", path3.join(import.meta.dirname, "browser-init.js")];
691
+ mcpServers.playwright = process.platform === "win32" ? { command: "cmd", args: ["/c", "npx", ...args] } : { command: "npx", args };
692
+ }
693
+ if (opts.office) mcpServers.office = opts.office.server;
694
+ const disallowedTools = ["AskUserQuestion", "EnterPlanMode", "ExitPlanMode"];
695
+ 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
+ const options = {
699
+ cwd: opts.cwd,
700
+ additionalDirectories: opts.additionalDirectories,
701
+ abortController: abort,
702
+ env,
703
+ model: opts.model || void 0,
704
+ 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 },
710
+ systemPrompt: { type: "preset", preset: "claude_code", append: opts.systemAppend },
711
+ mcpServers,
712
+ disallowedTools,
713
+ resume: opts.resumeSessionId,
714
+ stderr: (data) => {
715
+ const line = data.trim();
716
+ if (line && /error|fail/i.test(line)) cb.log([{ kind: "error", text: clip(line, 200) }]);
717
+ },
718
+ ...opts.permissionMode === "bypass" ? { permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true } : { permissionMode: "acceptEdits", canUseTool: guardedCanUseTool(opts.cwd, defaultBranchPush, opts.role) },
719
+ ...opts.outputSchema ? { outputFormat: { type: "json_schema", schema: opts.outputSchema } } : {}
720
+ };
721
+ let emptyTurn;
722
+ const answered = (msg) => {
723
+ pendingTurns -= 1;
724
+ if (pendingTurns > 0) return;
725
+ input.close();
726
+ const ok = msg.subtype === "success" && !msg.is_error;
727
+ cb.finished({
728
+ ok,
729
+ text: msg.subtype === "success" ? msg.result : "",
730
+ costUsd: lastCost,
731
+ turns: lastTurns,
732
+ errors: msg.subtype === "success" ? [] : msg.errors,
733
+ structured: msg.subtype === "success" ? msg.structured_output : void 0
734
+ });
735
+ };
736
+ const handle = (msg) => {
737
+ clearTimeout(emptyTurn);
738
+ if (done && msg.type === "assistant" && !stopped) {
739
+ stopped = true;
740
+ cb.log([{ kind: "system", text: "\u25A0 This session kept working after it had finished, so it was stopped." }]);
741
+ abort.abort();
742
+ return;
743
+ }
744
+ switch (msg.type) {
745
+ case "system": {
746
+ if (msg.subtype === "init") {
747
+ cb.sessionId(msg.session_id);
748
+ const mcp = msg.mcp_servers.map((s) => `${s.name}:${s.status}`).join(", ");
749
+ 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}` },
751
+ { kind: "system", text: ` cwd ${msg.cwd}${mcp ? ` \xB7 mcp ${mcp}` : ""}` }
752
+ ]);
753
+ } else if (msg.subtype === "api_retry") {
754
+ cb.log([{ kind: "system", text: `\u2026 API retry ${msg.attempt}/${msg.max_retries} in ${Math.round(msg.retry_delay_ms / 1e3)}s` }]);
755
+ } else if (msg.subtype === "compact_boundary") {
756
+ cb.log([{ kind: "system", text: "\u273B Conversation compacted" }]);
757
+ }
758
+ break;
759
+ }
760
+ case "rate_limit_event": {
761
+ const info = msg.rate_limit_info;
762
+ if (info.status !== "allowed") {
763
+ const when = info.resetsAt ? new Date(info.resetsAt * 1e3).toLocaleTimeString() : "later";
764
+ cb.log([{ kind: "error", text: `\u26A0 Subscription usage ${info.status === "rejected" ? "limit reached" : "warning"} (${info.rateLimitType ?? "limit"}) \xB7 resets ${when}` }]);
765
+ const overage = info.isUsingOverage || info.overageStatus === "allowed" || info.overageStatus === "allowed_warning";
766
+ if (info.status === "rejected" && !overage) cb.limited?.(info.resetsAt ? info.resetsAt * 1e3 : null);
767
+ }
768
+ break;
769
+ }
770
+ case "assistant": {
771
+ if (msg.parent_tool_use_id) break;
772
+ const out = [];
773
+ for (const block of msg.message.content) {
774
+ if (block.type === "text" && block.text.trim()) {
775
+ const lines = block.text.trim().split("\n");
776
+ lines.slice(0, 30).forEach((l, i) => out.push({ kind: "text", text: `${i === 0 ? "\u25CF " : " "}${l}` }));
777
+ if (lines.length > 30) out.push({ kind: "text", text: ` \u2026 (${lines.length - 30} more lines)` });
778
+ } else if (block.type === "thinking") {
779
+ out.push({ kind: "thinking", text: "\u273B Thinking\u2026" });
780
+ } else if (block.type === "tool_use") {
781
+ const toolInput = block.input ?? {};
782
+ 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));
785
+ cb.tool(block.name);
786
+ if (block.name === "mcp__playwright__browser_navigate" && typeof toolInput.url === "string") cb.browserUrl(toolInput.url);
787
+ }
788
+ }
789
+ if (out.length) cb.log(out);
790
+ break;
791
+ }
792
+ case "user": {
793
+ if (msg.parent_tool_use_id) break;
794
+ const content = msg.message.content;
795
+ if (!Array.isArray(content)) break;
796
+ const out = [];
797
+ for (const block of content) {
798
+ if (block.type !== "tool_result") continue;
799
+ const name = toolNames.get(block.tool_use_id) ?? "";
800
+ const text = resultText(block.content);
801
+ if (Array.isArray(block.content)) {
802
+ for (const part of block.content) {
803
+ if (part.type === "image" && part.source.type === "base64") {
804
+ cb.screenshot(Buffer.from(part.source.data, "base64"), part.source.media_type);
805
+ }
806
+ }
807
+ }
808
+ if (name.startsWith("mcp__playwright__")) {
809
+ const url = text.match(/Page URL:\s*(\S+)/);
810
+ if (url) cb.browserUrl(url[1]);
811
+ }
812
+ if (name === "TodoWrite") continue;
813
+ if (block.is_error) out.push({ kind: "error", text: ` \u23BF ${clip(tidyPaths(opts.cwd, text).split("\n")[0] || "Error", 200)}` });
814
+ else out.push(...summariseResult(opts.cwd, name, text));
815
+ }
816
+ cb.tool(null);
817
+ if (out.length) cb.log(out);
818
+ break;
819
+ }
820
+ case "result": {
821
+ lastCost = msg.total_cost_usd;
822
+ lastTurns = msg.num_turns;
823
+ if (msg.subtype === "success" && !msg.is_error && msg.num_turns === 0) {
824
+ emptyTurn = setTimeout(() => answered(msg), 3e4);
825
+ break;
826
+ }
827
+ if (msg.subtype === "success" && !msg.is_error && msg.result.trim()) cb.turn?.(msg.result.trim());
828
+ answered(msg);
829
+ break;
830
+ }
831
+ }
832
+ };
833
+ (async () => {
834
+ try {
835
+ for await (const msg of query({ prompt: input, options })) handle(msg);
836
+ if (pendingTurns > 0) {
837
+ cb.finished({ ok: false, text: "", costUsd: lastCost, turns: lastTurns, errors: [stopped ? "Stopped by manager" : "Session ended unexpectedly"] });
838
+ }
839
+ } catch (err) {
840
+ input.close();
841
+ const message = stopped ? "Stopped by manager" : err instanceof Error ? err.message : String(err);
842
+ cb.finished({ ok: false, text: "", costUsd: lastCost, turns: lastTurns, errors: [message] });
843
+ }
844
+ })();
845
+ return {
846
+ send(text) {
847
+ pendingTurns += 1;
848
+ input.push(text);
849
+ },
850
+ stop() {
851
+ stopped = true;
852
+ clearTimeout(emptyTurn);
853
+ input.close();
854
+ abort.abort();
855
+ }
856
+ };
857
+ }
858
+
859
+ // server/previewRunner.ts
860
+ import { spawn } from "node:child_process";
861
+ import crypto from "node:crypto";
862
+ import fs3 from "node:fs/promises";
863
+ import net from "node:net";
864
+ import path4 from "node:path";
865
+ var PREVIEW_SLUG = "preview";
866
+ var PREVIEW_BRANCH = "swarm-preview";
867
+ var PREVIEW_TMP = ".preview-tmp";
868
+ var INSTALL_MARKER = ".cubefarm-preview-install";
869
+ var START_TIMEOUT_MS = 3 * 6e4;
870
+ var INSTALL_TIMEOUT_MS = 15 * 6e4;
871
+ var fillPlaceholders = (s, port, tmp) => s.replaceAll("{port}", String(port)).replaceAll("{tmp}", tmp);
872
+ function previewEnv(extra, port, tmp) {
873
+ const env = {};
874
+ for (const [k, v] of Object.entries(process.env)) {
875
+ if (v === void 0 || /^(ANTHROPIC_|CLAUDE|SWARM_)/i.test(k)) continue;
876
+ env[k] = v;
877
+ }
878
+ env.BROWSER = "none";
879
+ for (const [k, v] of Object.entries(extra)) env[k] = fillPlaceholders(v, port, tmp);
880
+ env.PORT = String(port);
881
+ return env;
882
+ }
883
+ async function portOpen(port) {
884
+ const probe = (host) => new Promise((resolve) => {
885
+ const sock = net.connect({ port, host });
886
+ const done = (ok) => {
887
+ sock.destroy();
888
+ resolve(ok);
889
+ };
890
+ sock.setTimeout(800, () => done(false));
891
+ sock.once("connect", () => done(true));
892
+ sock.once("error", () => done(false));
893
+ });
894
+ const [v4, v6] = await Promise.all([probe("127.0.0.1"), probe("::1")]);
895
+ return v4 || v6;
896
+ }
897
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
898
+ async function readJson(file) {
899
+ try {
900
+ return JSON.parse(await fs3.readFile(file, "utf8"));
901
+ } catch {
902
+ return null;
903
+ }
904
+ }
905
+ function defaultCommand(pkg) {
906
+ const scripts = pkg?.scripts ?? {};
907
+ const name = ["dev", "start", "preview"].find((s) => typeof scripts[s] === "string" && scripts[s].trim());
908
+ if (!name) return null;
909
+ const script = scripts[name];
910
+ const vite = /^\s*vite(\s|$)/.test(script) && !/--port\b/.test(script);
911
+ return `npm run ${name}${vite ? " -- --port {port} --strictPort" : ""}`;
912
+ }
913
+ function killTree(child) {
914
+ if (child.pid === void 0 || child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
915
+ if (process.platform === "win32") {
916
+ return new Promise((resolve) => {
917
+ const tk = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
918
+ tk.once("error", () => resolve());
919
+ tk.once("close", () => resolve());
920
+ });
921
+ }
922
+ try {
923
+ process.kill(-child.pid, "SIGKILL");
924
+ } catch {
925
+ child.kill("SIGKILL");
926
+ }
927
+ return Promise.resolve();
928
+ }
929
+ var Stopped = class extends Error {
930
+ };
931
+ var RealPreview = class {
932
+ constructor(job, cb) {
933
+ this.job = job;
934
+ this.cb = cb;
935
+ void this.run().catch((err) => {
936
+ if (this.stopped || err instanceof Stopped) return;
937
+ this.stopped = true;
938
+ if (this.child) void killTree(this.child);
939
+ this.cb.failed(err.message);
940
+ });
941
+ }
942
+ job;
943
+ cb;
944
+ child = null;
945
+ stopped = false;
946
+ partial = "";
947
+ async stop() {
948
+ this.stopped = true;
949
+ if (this.child) await killTree(this.child);
950
+ }
951
+ check() {
952
+ if (this.stopped) throw new Stopped();
953
+ }
954
+ async run() {
955
+ const { job, cb } = this;
956
+ cb.status("preparing");
957
+ const wt = await prepareDesk(job.fullName, { defaultBranch: job.defaultBranch, pr: job.pr ?? void 0 }, PREVIEW_SLUG, PREVIEW_BRANCH);
958
+ this.check();
959
+ await ensureLocalExcludes(job.fullName).catch(() => void 0);
960
+ 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"));
964
+ const command = job.command ?? defaultCommand(pkg);
965
+ if (!command) {
966
+ this.stopped = true;
967
+ cb.failed(
968
+ pkg ? "package.json has no dev, start or preview script. Set a preview command for this floor." : "Nothing to run: this floor has no preview command and no package.json.",
969
+ true
970
+ );
971
+ return;
972
+ }
973
+ const env = previewEnv(job.env, job.port, tmp);
974
+ if (pkg) {
975
+ cb.status("installing");
976
+ await this.install(wt, env);
977
+ this.check();
978
+ }
979
+ cb.status("starting");
980
+ const cmd = fillPlaceholders(command, job.port, tmp);
981
+ cb.log([`$ ${cmd}`]);
982
+ const exited = this.spawn(cmd, wt, env);
983
+ const deadline = Date.now() + START_TIMEOUT_MS;
984
+ let exitCode;
985
+ void exited.then((code2) => exitCode = code2);
986
+ while (!await portOpen(job.port)) {
987
+ this.check();
988
+ if (exitCode !== void 0) throw new Error(`The app exited (code ${exitCode}) before it listened on port ${job.port}.`);
989
+ 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);
991
+ }
992
+ this.check();
993
+ cb.status("running");
994
+ const code = await exited;
995
+ while (!this.stopped && await portOpen(job.port)) await sleep(3e3);
996
+ if (this.stopped) return;
997
+ this.stopped = true;
998
+ cb.failed(`The app stopped (exit code ${code}).`);
999
+ }
1000
+ /** npm ci (or npm install without a lockfile), skipped when package.json and the lockfile haven't changed. */
1001
+ 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) {
1007
+ this.cb.log(["Dependencies unchanged since the last install; skipping it."]);
1008
+ return;
1009
+ }
1010
+ const cmd = lock !== null ? "npm ci" : "npm install";
1011
+ this.cb.log([`$ ${cmd}`]);
1012
+ const code = await Promise.race([this.spawn(cmd, wt, env), sleep(INSTALL_TIMEOUT_MS).then(() => "timeout")]);
1013
+ this.check();
1014
+ if (code === "timeout") {
1015
+ if (this.child) await killTree(this.child);
1016
+ throw new Error(`${cmd} took longer than ${INSTALL_TIMEOUT_MS / 6e4} minutes.`);
1017
+ }
1018
+ if (code !== 0) throw new Error(`${cmd} failed (code ${code}).`);
1019
+ this.child = null;
1020
+ await fs3.writeFile(marker, hash).catch(() => void 0);
1021
+ }
1022
+ /** Run a command line through the platform shell; resolves with its exit code. */
1023
+ spawn(cmd, cwd, env) {
1024
+ this.check();
1025
+ const child = spawn(cmd, { cwd, env, shell: true, windowsHide: true, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
1026
+ this.child = child;
1027
+ const onData = (buf) => {
1028
+ const text = this.partial + buf.toString().replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
1029
+ const lines = text.split(/\r?\n|\r/);
1030
+ this.partial = lines.pop() ?? "";
1031
+ const out = lines.filter((l) => l.trim());
1032
+ if (out.length) this.cb.log(out);
1033
+ };
1034
+ child.stdout?.on("data", onData);
1035
+ child.stderr?.on("data", onData);
1036
+ return new Promise((resolve) => {
1037
+ child.once("error", (err) => {
1038
+ this.cb.log([err.message]);
1039
+ resolve(-1);
1040
+ });
1041
+ child.once("close", (code) => {
1042
+ if (this.partial.trim()) this.cb.log([this.partial]);
1043
+ this.partial = "";
1044
+ resolve(code);
1045
+ });
1046
+ });
1047
+ }
1048
+ };
1049
+ var realPreviews = {
1050
+ start: (job, cb) => new RealPreview(job, cb),
1051
+ hasDefault: async (fullName) => fs3.access(path4.join(mainDir(fullName), "package.json")).then(() => true).catch(() => false)
1052
+ };
1053
+
1054
+ // server/backend.ts
1055
+ var realBackend = {
1056
+ demo: false,
1057
+ user: currentUser,
1058
+ listMyRepos,
1059
+ repoMeta,
1060
+ listIssues,
1061
+ listPulls,
1062
+ createIssue,
1063
+ mergePull,
1064
+ updateBranch,
1065
+ closePull,
1066
+ prForBranch,
1067
+ prDetails,
1068
+ issueDetails,
1069
+ commentPull,
1070
+ uploadEvidence,
1071
+ ensureClone,
1072
+ syncMain,
1073
+ setLocalPath,
1074
+ scanProjects,
1075
+ inspectFolder,
1076
+ publishFolder,
1077
+ createProject,
1078
+ mainDir,
1079
+ deskDir,
1080
+ prepareDesk,
1081
+ removeDesk,
1082
+ releaseDesk,
1083
+ startSession,
1084
+ previews: realPreviews
1085
+ };
1086
+
1087
+ // server/demo.ts
1088
+ import crypto2 from "node:crypto";
1089
+ import http from "node:http";
1090
+ import path5 from "node:path";
1091
+ var now = () => (/* @__PURE__ */ new Date()).toISOString();
1092
+ function issue(n, title, body, fullName, labels = []) {
1093
+ return { number: n, title, body, url: `https://github.com/${fullName}/issues/${n}`, labels, createdAt: now() };
1094
+ }
1095
+ var repos = /* @__PURE__ */ new Map();
1096
+ var bareRepos = /* @__PURE__ */ new Set();
1097
+ var mergedSinceSync = /* @__PURE__ */ new Map();
1098
+ var fakeSha = () => crypto2.randomBytes(20).toString("hex");
1099
+ function runChecks(pr, fail = Math.random() < 0.2) {
1100
+ Object.assign(pr, { checks: "pending", pendingChecks: ["CI / build", "Vercel"], failedChecks: [] });
1101
+ setTimeout(() => {
1102
+ Object.assign(pr, {
1103
+ checks: fail ? "failing" : "passing",
1104
+ pendingChecks: [],
1105
+ failedChecks: fail ? [{ name: "CI / build", url: `${pr.url}/checks` }] : []
1106
+ });
1107
+ }, 12e3 + Math.random() * 1e4);
1108
+ }
1109
+ function seed(fullName, description, titles) {
1110
+ const r = { fullName, description, issues: [], pulls: [], nextNumber: 1 };
1111
+ for (const [title, body] of titles) r.issues.push(issue(r.nextNumber++, title, body, fullName));
1112
+ repos.set(fullName, r);
1113
+ }
1114
+ seed("demo-co/pixel-todo", "A cheerful todo app", [
1115
+ ["Add dark mode toggle", "Users want a dark theme. Persist the choice in localStorage."],
1116
+ ["Todos should support due dates", "Add an optional due date and highlight overdue items."],
1117
+ ["Drag and drop to reorder", "Let users reorder todos by dragging."],
1118
+ ["Empty state illustration", "Show a friendly illustration when the list is empty."],
1119
+ ["Keyboard shortcuts", "N for new todo, / to search, ? for help."],
1120
+ ["Fix: completed count off by one", "The footer shows one more completed item than there is."]
1121
+ ]);
1122
+ seed("demo-co/weather-api", "Tiny weather REST API", [
1123
+ ["Add /forecast endpoint", "Return a 5-day forecast for a city."],
1124
+ ["Rate limit anonymous callers", "60 requests per minute per IP."],
1125
+ ["OpenAPI spec", "Publish an OpenAPI 3.1 document at /openapi.json."]
1126
+ ]);
1127
+ function screenshotSvg(title, url, hue) {
1128
+ const safe = (s) => s.replace(/[<>&"]/g, "");
1129
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="400" viewBox="0 0 640 400">
1130
+ <rect width="640" height="400" fill="hsl(${hue},60%,97%)"/>
1131
+ <rect width="640" height="56" fill="hsl(${hue},70%,55%)"/>
1132
+ <text x="24" y="37" font-family="Segoe UI, Arial" font-size="22" font-weight="700" fill="#fff">${safe(title)}</text>
1133
+ <text x="620" y="36" text-anchor="end" font-family="Segoe UI, Arial" font-size="13" fill="#fff">${safe(url)}</text>
1134
+ ${[0, 1, 2, 3].map(
1135
+ (i) => `<rect x="24" y="${84 + i * 70}" width="592" height="56" rx="10" fill="#fff" stroke="hsl(${hue},40%,85%)"/>
1136
+ <circle cx="54" cy="${112 + i * 70}" r="11" fill="none" stroke="hsl(${hue},60%,55%)" stroke-width="3"/>
1137
+ <rect x="80" y="${104 + i * 70}" width="${180 + i * 97 % 220}" height="14" rx="7" fill="hsl(${hue},25%,80%)"/>`
1138
+ ).join("\n")}
1139
+ </svg>`;
1140
+ }
1141
+ function devScript(opts, cb, issueNumber, issueTitle) {
1142
+ const branch = path5.basename(opts.cwd);
1143
+ const hue = issueNumber * 67 % 360;
1144
+ const file = ["src/App.tsx", "src/components/TodoList.tsx", "src/api/routes.ts", "src/styles.css"][issueNumber % 4];
1145
+ const port = 5200 + issueNumber % 50;
1146
+ return [
1147
+ [{ kind: "text", text: `\u25CF I'll start by getting familiar with the codebase for "${issueTitle}".` }],
1148
+ [{ kind: "tool", tool: "Glob", text: "\u23FA Glob src/**/*.{ts,tsx}" }, { kind: "result", text: " \u23BF Found 23 files" }],
1149
+ [{ kind: "tool", tool: "Read", text: `\u23FA Read ${file}` }, { kind: "result", text: " \u23BF Read 184 lines" }],
1150
+ [{ kind: "thinking", text: "\u273B Thinking\u2026" }],
1151
+ [
1152
+ { kind: "tool", tool: "TodoWrite", text: "\u23FA Update todo list" },
1153
+ { kind: "result", text: " \u25D0 Understand current behaviour" },
1154
+ { kind: "result", text: " \u2610 Implement the change" },
1155
+ { kind: "result", text: " \u2610 Add tests" },
1156
+ { kind: "result", text: " \u2610 Verify in the browser" }
1157
+ ],
1158
+ [{ kind: "tool", tool: "Grep", text: '\u23FA Grep "useTodos"' }, { kind: "result", text: " \u23BF Found 6 matches in 4 files" }],
1159
+ [{ kind: "text", text: "\u25CF The state lives in a single hook. I'll extend it and keep the API backwards compatible." }],
1160
+ [{ kind: "tool", tool: "Edit", text: `\u23FA Edit ${file}` }, { kind: "result", text: " \u23BF Updated" }],
1161
+ [{ kind: "tool", tool: "Write", text: "\u23FA Write src/__tests__/feature.test.ts" }, { kind: "result", text: " \u23BF Saved" }],
1162
+ [{ kind: "tool", tool: "Bash", text: "\u23FA $ npm test -- --run" }],
1163
+ [
1164
+ { kind: "result", text: " \u23BF \u2713 src/__tests__/feature.test.ts (4 tests) 38ms" },
1165
+ { kind: "result", text: " Test Files 7 passed (7)" }
1166
+ ],
1167
+ [{ kind: "tool", tool: "Bash", text: `\u23FA $ npm run dev -- --port ${port} &` }, { kind: "result", text: " \u23BF VITE ready in 412 ms" }],
1168
+ () => cb.browserUrl(`http://localhost:${port}/`),
1169
+ [{ kind: "tool", tool: "mcp__playwright__browser_navigate", text: `\u23FA \u{1F310} navigate http://localhost:${port}/` }, { kind: "result", text: ` \u23BF Page URL: http://localhost:${port}/` }],
1170
+ () => cb.screenshot(Buffer.from(screenshotSvg(issueTitle, `localhost:${port}`, hue)), "image/svg+xml"),
1171
+ [{ kind: "tool", tool: "mcp__playwright__browser_take_screenshot", text: "\u23FA \u{1F310} take_screenshot" }, { kind: "result", text: " \u23BF Took a screenshot of the current page" }],
1172
+ [{ kind: "text", text: "\u25CF Looks right in the browser. Committing and opening a PR." }],
1173
+ [{ kind: "tool", tool: "Bash", text: `\u23FA $ git commit -am "feat: ${issueTitle.toLowerCase()}"` }, { kind: "result", text: ` \u23BF [${branch} 3f2a91c] feat: ${issueTitle.toLowerCase()}` }],
1174
+ [{ kind: "tool", tool: "Bash", text: "\u23FA $ git push -u origin HEAD" }, { kind: "result", text: " \u23BF branch set up to track origin" }],
1175
+ [{ kind: "tool", tool: "Bash", text: `\u23FA $ gh pr create --title "${issueTitle}" --body "Closes #${issueNumber}"` }]
1176
+ ];
1177
+ }
1178
+ function qaScript(cb, pr, title, round) {
1179
+ const port = 5600 + pr % 50;
1180
+ const hue = pr * 41 % 360;
1181
+ return [
1182
+ [{ kind: "text", text: `\u25CF Testing PR #${pr} "${title}" (round ${round}). First, the acceptance criteria from the issue.` }],
1183
+ [{ kind: "tool", tool: "Bash", text: "\u23FA $ git diff origin/main...HEAD --stat" }, { kind: "result", text: " \u23BF 3 files changed, 82 insertions(+), 9 deletions(-)" }],
1184
+ [{ kind: "tool", tool: "Bash", text: "\u23FA $ npm ci && npm test -- --run" }],
1185
+ [
1186
+ { kind: "result", text: " \u23BF Test Files 8 passed (8)" },
1187
+ { kind: "result", text: " Tests 41 passed (41)" }
1188
+ ],
1189
+ [{ kind: "tool", tool: "Bash", text: "\u23FA $ npm run lint && npm run build" }, { kind: "result", text: " \u23BF \u2713 built in 1.84s" }],
1190
+ [{ kind: "tool", tool: "Bash", text: `\u23FA $ npm run preview -- --port ${port} &` }, { kind: "result", text: ` \u23BF Local: http://localhost:${port}/` }],
1191
+ () => cb.browserUrl(`http://localhost:${port}/`),
1192
+ [{ kind: "tool", tool: "mcp__playwright__browser_navigate", text: `\u23FA \u{1F310} navigate http://localhost:${port}/` }, { kind: "result", text: ` \u23BF Page URL: http://localhost:${port}/` }],
1193
+ () => cb.screenshot(Buffer.from(screenshotSvg(`QA \xB7 ${title}`, `localhost:${port}`, hue)), "image/svg+xml"),
1194
+ [{ kind: "tool", tool: "mcp__playwright__browser_take_screenshot", text: "\u23FA \u{1F310} take_screenshot" }, { kind: "result", text: " \u23BF Took a screenshot of the current page" }],
1195
+ [{ kind: "tool", tool: "mcp__playwright__browser_click", text: '\u23FA \u{1F310} click "Add todo" button' }, { kind: "result", text: " \u23BF Clicked" }],
1196
+ [{ kind: "tool", tool: "mcp__playwright__browser_resize", text: "\u23FA \u{1F310} resize 375x740" }, { kind: "result", text: " \u23BF Resized" }],
1197
+ () => cb.screenshot(Buffer.from(screenshotSvg(`QA \xB7 mobile \xB7 ${title}`, `localhost:${port}`, (hue + 40) % 360)), "image/svg+xml"),
1198
+ [{ kind: "tool", tool: "mcp__playwright__browser_take_screenshot", text: "\u23FA \u{1F310} take_screenshot" }, { kind: "result", text: " \u23BF Took a screenshot of the current page" }],
1199
+ [{ kind: "tool", tool: "mcp__playwright__browser_console_messages", text: "\u23FA \u{1F310} console_messages" }, { kind: "result", text: " \u23BF No errors" }]
1200
+ ];
1201
+ }
1202
+ function fixScript(pr) {
1203
+ return [
1204
+ [{ kind: "text", text: `\u25CF Reading the QA report for PR #${pr}. The toolbar overflows on phones; I'll let it wrap.` }],
1205
+ [{ kind: "tool", tool: "Read", text: "\u23FA Read src/styles.css" }, { kind: "result", text: " \u23BF Read 212 lines" }],
1206
+ [{ kind: "tool", tool: "Edit", text: "\u23FA Edit src/styles.css" }, { kind: "result", text: " \u23BF Updated" }],
1207
+ [{ kind: "tool", tool: "Bash", text: "\u23FA $ npm test -- --run" }, { kind: "result", text: " \u23BF Test Files 8 passed (8)" }],
1208
+ [{ kind: "tool", tool: "Bash", text: '\u23FA $ git commit -am "fix: wrap toolbar on narrow screens" && git push origin HEAD' }, { kind: "result", text: " \u23BF pushed" }]
1209
+ ];
1210
+ }
1211
+ function fakeSession(opts, cb, fullName) {
1212
+ const timers = [];
1213
+ let stopped = false;
1214
+ const kind = opts.role === "qa" ? "qa" : /FAILED|taking over pull request|git push origin HEAD:/.test(opts.prompt) ? "fix" : "issue";
1215
+ const prMatch = opts.prompt.match(/pull request #(\d+)(?::\s*(.+))?/);
1216
+ const issueMatch = opts.prompt.match(/#(\d+):\s*(.+)/);
1217
+ const number = Number((kind === "issue" ? issueMatch?.[1] : prMatch?.[1]) ?? 0);
1218
+ const title = (kind === "issue" ? issueMatch?.[2] : prMatch?.[2])?.trim() ?? "follow-up";
1219
+ const round = Number(opts.prompt.match(/QA round (\d+)/)?.[1] ?? 1);
1220
+ const header = [
1221
+ { kind: "system", text: `\u273B Claude Code (demo) \xB7 ${opts.model} \xB7 ${opts.effort} effort \xB7 ${opts.permissionMode}` },
1222
+ { kind: "system", text: ` cwd ${opts.cwd}` }
1223
+ ];
1224
+ const body = kind === "qa" ? qaScript(cb, number, title, round) : kind === "fix" ? fixScript(number) : devScript(opts, cb, number, title);
1225
+ const script = [header, ...body];
1226
+ const finish = () => {
1227
+ const costUsd = 0.3 + Math.random();
1228
+ const turns = 15 + Math.floor(Math.random() * 20);
1229
+ cb.tool(null);
1230
+ if (kind === "qa") {
1231
+ const pass = round > 1 || Math.random() > 0.35;
1232
+ cb.log([{ kind: "text", text: pass ? "\u25CF Everything checks out. Writing up the report." : "\u25CF The toolbar overflows on a 375px screen. Failing this round." }]);
1233
+ cb.finished({
1234
+ ok: true,
1235
+ text: "",
1236
+ costUsd,
1237
+ turns,
1238
+ errors: [],
1239
+ structured: {
1240
+ verdict: pass ? "pass" : "fail",
1241
+ summary: pass ? "The change does what the issue asks: the feature works on desktop and mobile, all 41 tests pass, and lint and build are clean." : "The feature works on desktop, but on a 375px-wide screen the toolbar overflows and the new button is cut off.",
1242
+ checks: [
1243
+ { name: "Unit tests", result: "pass", details: "41 passed, 0 failed" },
1244
+ { name: "Lint + build", result: "pass", details: "No lint errors; production build succeeds" },
1245
+ { name: "Feature works on desktop", result: "pass", details: "Clicked through the new flow at 1280x800" },
1246
+ { name: "Mobile layout (375px)", result: pass ? "pass" : "fail", details: pass ? "Layout wraps correctly" : "Toolbar overflows horizontally; button unreachable" },
1247
+ { name: "Console errors", result: "pass", details: "None" }
1248
+ ],
1249
+ commands: [
1250
+ { command: "npm test -- --run", result: "41 passed" },
1251
+ { command: "npm run lint && npm run build", result: "clean, built in 1.84s" }
1252
+ ],
1253
+ screenshots: ["Desktop view after the change", "Mobile view at 375px"],
1254
+ fixInstructions: pass ? void 0 : "Make the toolbar wrap (flex-wrap: wrap) below 480px so every button stays visible."
1255
+ }
1256
+ });
1257
+ return;
1258
+ }
1259
+ if (kind === "fix") {
1260
+ const pr = repos.get(fullName)?.pulls.find((p) => p.number === number);
1261
+ if (pr) {
1262
+ pr.headSha = fakeSha();
1263
+ pr.mergeState = "CLEAN";
1264
+ pr.mergeable = "MERGEABLE";
1265
+ runChecks(pr, false);
1266
+ }
1267
+ cb.log([{ kind: "text", text: `\u25CF Fixed PR #${number} and pushed. Ready for another QA round.` }]);
1268
+ cb.finished({ ok: true, text: "", costUsd, turns, errors: [] });
1269
+ return;
1270
+ }
1271
+ const repo = repos.get(fullName);
1272
+ let url = "";
1273
+ if (repo && number) {
1274
+ const n = repo.nextNumber++;
1275
+ url = `https://github.com/${fullName}/pull/${n}`;
1276
+ repo.pulls.unshift({
1277
+ number: n,
1278
+ title,
1279
+ url,
1280
+ headRefName: `swarm/issue-${number}-${path5.basename(opts.cwd).replace(/-[0-9a-f]{4}$/, "")}`,
1281
+ state: "OPEN",
1282
+ isDraft: false,
1283
+ mergeable: "MERGEABLE",
1284
+ reviewDecision: null,
1285
+ closesIssues: [number],
1286
+ createdAt: now(),
1287
+ mergedAt: null,
1288
+ additions: 40 + number * 13 % 200,
1289
+ deletions: number * 7 % 40,
1290
+ checks: "pending",
1291
+ headSha: fakeSha(),
1292
+ mergeState: "CLEAN",
1293
+ failedChecks: [],
1294
+ pendingChecks: []
1295
+ });
1296
+ runChecks(repo.pulls[0]);
1297
+ cb.log([{ kind: "result", text: ` \u23BF ${url}` }]);
1298
+ }
1299
+ cb.log([{ kind: "text", text: `\u25CF Opened ${url || "the pull request"}. It closes #${number} and includes tests.` }]);
1300
+ cb.finished({ ok: true, text: url, costUsd, turns, errors: [] });
1301
+ };
1302
+ let i = 0;
1303
+ const step = () => {
1304
+ if (stopped) return;
1305
+ if (i >= script.length) return finish();
1306
+ const s = script[i++];
1307
+ if (typeof s === "function") s();
1308
+ else {
1309
+ const tool2 = s.find((e) => e.kind === "tool");
1310
+ cb.tool(tool2?.tool ?? null);
1311
+ cb.log(s);
1312
+ }
1313
+ timers.push(setTimeout(step, 1600 + Math.random() * 3800));
1314
+ };
1315
+ timers.push(setTimeout(step, 600));
1316
+ return {
1317
+ send(text) {
1318
+ timers.push(
1319
+ setTimeout(() => {
1320
+ if (stopped) return;
1321
+ cb.log([{ kind: "text", text: `\u25CF Got it \u2014 "${text.slice(0, 60)}". Adjusting my approach.` }]);
1322
+ }, 1500)
1323
+ );
1324
+ },
1325
+ stop() {
1326
+ stopped = true;
1327
+ timers.forEach(clearTimeout);
1328
+ cb.finished({ ok: false, text: "", costUsd: 0.1, turns: i, errors: ["Stopped by manager"] });
1329
+ }
1330
+ };
1331
+ }
1332
+ function createDemoBackend() {
1333
+ const deskRepo = /* @__PURE__ */ new Map();
1334
+ const folders = /* @__PURE__ */ new Map();
1335
+ const addFolder = (name, github, git2 = true) => folders.set(name, { name, path: `/demo/projects/${name}`, git: git2, github, modified: Date.now() - folders.size * 36e5 });
1336
+ for (const r of repos.values()) addFolder(r.fullName.split("/")[1], r.fullName);
1337
+ addFolder("sketchbook", null);
1338
+ addFolder("recipe-notes", null, false);
1339
+ const newRepo = (name, description = "") => {
1340
+ const fullName = `demo-co/${name}`;
1341
+ if (!repos.has(fullName)) {
1342
+ repos.set(fullName, { fullName, description, issues: [], pulls: [], nextNumber: 1 });
1343
+ bareRepos.add(fullName);
1344
+ }
1345
+ addFolder(name, fullName);
1346
+ return fullName;
1347
+ };
1348
+ const folderOf = (dir) => {
1349
+ const f = folders.get(dir.replace(/\\/g, "/").split("/").pop() ?? "");
1350
+ if (!f) throw new Error(`${dir} is not a folder`);
1351
+ return f;
1352
+ };
1353
+ return {
1354
+ demo: true,
1355
+ user: async () => "demo-manager",
1356
+ listMyRepos: async () => [...repos.values()].map((r) => ({ nameWithOwner: r.fullName, description: r.description, visibility: "PUBLIC", updatedAt: now() })),
1357
+ repoMeta: async (fullName) => {
1358
+ const r = repos.get(fullName);
1359
+ if (!r) throw new Error(`Unknown demo repo ${fullName}`);
1360
+ return { nameWithOwner: fullName, description: r.description, url: `https://github.com/${fullName}`, defaultBranch: "main" };
1361
+ },
1362
+ setLocalPath: () => void 0,
1363
+ scanProjects: async () => [...folders.values()].sort((a, b) => b.modified - a.modified),
1364
+ inspectFolder: async (dir) => folderOf(dir),
1365
+ publishFolder: async (dir, opts) => {
1366
+ const f = folderOf(dir);
1367
+ return f.github ?? newRepo(f.name, opts.description);
1368
+ },
1369
+ createProject: async (_root, name, opts) => {
1370
+ if (folders.has(name)) throw new Error(`/demo/projects/${name} already exists. Pick another name, or connect that folder instead.`);
1371
+ return { fullName: newRepo(name, opts.description), path: `/demo/projects/${name}` };
1372
+ },
1373
+ listIssues: async (fullName) => [...repos.get(fullName)?.issues ?? []],
1374
+ listPulls: async (fullName) => [...repos.get(fullName)?.pulls ?? []],
1375
+ createIssue: async (fullName, title, body, labels = []) => {
1376
+ const r = repos.get(fullName);
1377
+ if (!r) throw new Error("Unknown repo");
1378
+ const n = r.nextNumber++;
1379
+ r.issues.push(issue(n, title, body, fullName, labels));
1380
+ return n;
1381
+ },
1382
+ mergePull: async (fullName, number, _method, headSha) => {
1383
+ const r = repos.get(fullName);
1384
+ const pr = r?.pulls.find((p) => p.number === number);
1385
+ if (!r || !pr) throw new Error("Unknown PR");
1386
+ if (headSha && pr.headSha !== headSha) throw new Error("Head branch was modified. Review and try the merge again.");
1387
+ mergedSinceSync.set(fullName, (mergedSinceSync.get(fullName) ?? 0) + 1);
1388
+ pr.state = "MERGED";
1389
+ pr.mergedAt = now();
1390
+ r.issues = r.issues.filter((i) => !pr.closesIssues.includes(i.number));
1391
+ },
1392
+ updateBranch: async (fullName, number) => {
1393
+ const pr = repos.get(fullName)?.pulls.find((p) => p.number === number);
1394
+ if (!pr) throw new Error("Unknown PR");
1395
+ Object.assign(pr, { headSha: fakeSha(), mergeState: "CLEAN" });
1396
+ runChecks(pr, false);
1397
+ },
1398
+ closePull: async (fullName, number) => {
1399
+ const pr = repos.get(fullName)?.pulls.find((p) => p.number === number);
1400
+ if (pr) pr.state = "CLOSED";
1401
+ },
1402
+ prForBranch: async () => null,
1403
+ prDetails: async (fullName, number) => {
1404
+ const pr = repos.get(fullName)?.pulls.find((p) => p.number === number);
1405
+ if (!pr) throw new Error(`Unknown PR #${number}`);
1406
+ return {
1407
+ number,
1408
+ title: pr.title,
1409
+ body: `Implements the change.
1410
+
1411
+ Closes #${pr.closesIssues[0] ?? "?"}`,
1412
+ url: pr.url,
1413
+ headRefName: pr.headRefName,
1414
+ headSha: pr.headSha,
1415
+ isCrossRepository: false,
1416
+ closesIssues: pr.closesIssues,
1417
+ state: pr.state,
1418
+ mergeable: pr.mergeable,
1419
+ mergeState: pr.mergeState
1420
+ };
1421
+ },
1422
+ issueDetails: async (fullName, number) => {
1423
+ const i = repos.get(fullName)?.issues.find((x) => x.number === number);
1424
+ return { title: i?.title ?? `Issue #${number}`, body: i?.body ?? "" };
1425
+ },
1426
+ commentPull: async (fullName, number) => `https://github.com/${fullName}/pull/${number}#issuecomment-${Date.now()}`,
1427
+ uploadEvidence: async (fullName, filePath) => `https://github.com/${fullName}/raw/swarm-qa-evidence/${filePath}`,
1428
+ ensureClone: async () => new Promise((r) => setTimeout(r, 400)),
1429
+ syncMain: async (fullName, _branch, { touch }) => {
1430
+ const behind = mergedSinceSync.get(fullName) ?? 0;
1431
+ if (behind === 0) return "in sync";
1432
+ if (!touch) return `update ready (${behind} commit${behind === 1 ? "" : "s"})`;
1433
+ mergedSinceSync.set(fullName, 0);
1434
+ return `updated to ${fakeSha().slice(0, 7)}`;
1435
+ },
1436
+ mainDir: (fullName) => `/demo/${fullName}/main`,
1437
+ deskDir: (fullName, slug) => `/demo/${fullName}/desks/${slug}`,
1438
+ prepareDesk: async (fullName, _base, slug) => {
1439
+ await new Promise((r) => setTimeout(r, 900));
1440
+ const dir = `/demo/${fullName}/desks/${slug}`;
1441
+ deskRepo.set(dir, fullName);
1442
+ return dir;
1443
+ },
1444
+ removeDesk: async () => void 0,
1445
+ 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
1448
+ };
1449
+ }
1450
+ function placeholderPage(title, hue) {
1451
+ const safe = title.replace(/[<>&"]/g, "");
1452
+ return `<!doctype html>
1453
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe}</title><link rel="icon" href="data:,">
1454
+ <style>
1455
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; font-family: 'Segoe UI', Arial, sans-serif; background: hsl(${hue},60%,96%); color: #222; }
1456
+ main { text-align: center; padding: 32px 40px; background: #fff; border-radius: 18px; box-shadow: 0 8px 30px hsla(${hue},50%,40%,.18); }
1457
+ h1 { margin: 0 0 6px; font-size: 26px; color: hsl(${hue},60%,38%); }
1458
+ p { margin: 0 0 22px; color: #666; }
1459
+ button { font: inherit; font-size: 18px; padding: 10px 26px; border: 0; border-radius: 999px; background: hsl(${hue},70%,55%); color: #fff; cursor: pointer; }
1460
+ button:active { transform: scale(.97); }
1461
+ #count { display: block; margin-top: 16px; font-size: 15px; color: #444; }
1462
+ </style></head>
1463
+ <body><main>
1464
+ <h1>${safe}</h1>
1465
+ <p>A placeholder app served by the demo office.</p>
1466
+ <button id="btn" type="button">Click me</button>
1467
+ <span id="count">Clicked 0 times</span>
1468
+ </main>
1469
+ <script>
1470
+ let n = 0;
1471
+ document.getElementById('btn').addEventListener('click', () => {
1472
+ n++;
1473
+ document.getElementById('count').textContent = 'Clicked ' + n + (n === 1 ? ' time' : ' times');
1474
+ });
1475
+ </script>
1476
+ </body></html>`;
1477
+ }
1478
+ var DEMO_PROGRAMS = ["npm", "npx", "node", "pnpm", "yarn", "bun", "deno", "vite", "next", "python", "python3", "py", "php", "ruby", "go", "cargo", "dotnet"];
1479
+ var demoPreviews = {
1480
+ hasDefault: async (fullName) => !bareRepos.has(fullName),
1481
+ start(job, cb) {
1482
+ let stopped = false;
1483
+ let server2 = null;
1484
+ const timers = [];
1485
+ const later = (ms, fn) => timers.push(setTimeout(() => !stopped && fn(), ms));
1486
+ const hue = [...job.fullName].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 7);
1487
+ const sha = (job.pr ? repos.get(job.fullName)?.pulls.find((p) => p.number === job.pr)?.headRefName ?? String(job.pr) : job.fullName + job.defaultBranch).split("").reduce((h, c) => h * 33 + c.charCodeAt(0) >>> 0, 5381).toString(16).padStart(7, "0").slice(0, 7);
1488
+ cb.status("preparing");
1489
+ later(700, () => {
1490
+ cb.commit(sha);
1491
+ cb.log([`HEAD is now at ${sha} (${job.pr ? `PR #${job.pr}` : job.defaultBranch})`]);
1492
+ if (!job.command && bareRepos.has(job.fullName)) {
1493
+ stopped = true;
1494
+ cb.failed("Nothing to run: this floor has no preview command and no package.json.", true);
1495
+ return;
1496
+ }
1497
+ cb.status("installing");
1498
+ cb.log(["$ npm ci", "added 214 packages in 1s (demo)"]);
1499
+ });
1500
+ later(1600, () => {
1501
+ cb.status("starting");
1502
+ cb.log([`$ ${job.command ?? "npm run dev"}`.replaceAll("{port}", String(job.port))]);
1503
+ const program = job.command?.trim().split(/\s+/)[0] ?? "npm";
1504
+ if (!DEMO_PROGRAMS.includes(program.toLowerCase())) {
1505
+ later(400, () => {
1506
+ cb.log([`'${program}' is not recognized as an internal or external command,`, "operable program or batch file."]);
1507
+ stopped = true;
1508
+ cb.failed(`The app exited (code 1) before it listened on port ${job.port}.`);
1509
+ });
1510
+ return;
1511
+ }
1512
+ server2 = http.createServer((req, res) => {
1513
+ if (req.url !== "/" && !req.url?.startsWith("/?")) return void res.writeHead(404).end("Not found");
1514
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
1515
+ res.end(placeholderPage(job.title, hue));
1516
+ });
1517
+ server2.once("error", (err) => {
1518
+ if (stopped) return;
1519
+ stopped = true;
1520
+ cb.failed(`Could not listen on port ${job.port}: ${err.message}`);
1521
+ });
1522
+ server2.listen(job.port, "127.0.0.1", () => {
1523
+ if (stopped) return void server2?.close();
1524
+ later(500, () => {
1525
+ cb.log([` \u279C Local: http://localhost:${job.port}/`]);
1526
+ cb.status("running");
1527
+ });
1528
+ });
1529
+ });
1530
+ return {
1531
+ async stop() {
1532
+ stopped = true;
1533
+ timers.forEach(clearTimeout);
1534
+ const s = server2;
1535
+ server2 = null;
1536
+ if (!s?.listening) return;
1537
+ s.closeAllConnections();
1538
+ await new Promise((resolve) => s.close(() => resolve()));
1539
+ }
1540
+ };
1541
+ }
1542
+ };
1543
+ var PROFILES = {
1544
+ "pixel-todo": {
1545
+ summary: "Todo web app \xB7 React + Vite + TypeScript",
1546
+ qa: "- Add, complete, edit and delete todos; they survive a reload\n- Keyboard only: every action reachable, focus always visible\n- Phone width (375px): nothing overflows or gets cut off\n- No errors in the browser console",
1547
+ qaTitle: "UI QA tester",
1548
+ qaJob: "You test every PR the way a picky user would: click through the whole flow, try it on a phone-sized screen and with the keyboard only.",
1549
+ devTitle: "React UI engineer",
1550
+ devSpecialty: "frontend",
1551
+ devJob: "You own the React components and styling. Keep components small, reuse the existing hooks, and check every change at desktop and phone widths.",
1552
+ hires: [
1553
+ {
1554
+ title: "Accessibility engineer",
1555
+ specialty: "a11y",
1556
+ job_description: "You make the app work for **everyone**:\n\n- Keyboard navigation and focus management\n- ARIA roles, checked with `axe`\n- Colour contrast (WCAG AA)\n\nTest with the keyboard only. The [WAI-ARIA practices](https://www.w3.org/WAI/ARIA/apg/) are your reference.",
1557
+ reason: "Keyboard shortcuts and drag-and-drop are in the backlog, and both are *easy to get wrong* for keyboard and screen-reader users."
1558
+ }
1559
+ ]
1560
+ },
1561
+ "weather-api": {
1562
+ summary: "REST API \xB7 Node + Express",
1563
+ qa: "- Every endpoint: happy path, bad input (400), unknown city (404)\n- Response shapes match the OpenAPI document\n- Rate limiting returns 429 with Retry-After\n- Tests and lint pass",
1564
+ qaTitle: "API QA tester",
1565
+ qaJob: "You test the API from the outside: curl every endpoint, try bad input and edge cases, and compare responses with the OpenAPI document.",
1566
+ devTitle: "Backend engineer",
1567
+ devSpecialty: "backend",
1568
+ devJob: "You own the routes and data layer. Validate input at the edge, return consistent error shapes, and add tests for every endpoint you touch.",
1569
+ hires: [
1570
+ {
1571
+ title: "API reliability engineer",
1572
+ specialty: "reliability",
1573
+ job_description: "You own rate limiting, caching and error handling. Measure before you optimise and document every limit in the OpenAPI spec.",
1574
+ reason: "Rate limiting is in the backlog and the forecast endpoint will call an upstream service that needs caching and timeouts."
1575
+ }
1576
+ ]
1577
+ }
1578
+ };
1579
+ var GENERIC = {
1580
+ summary: "Web project \xB7 early stage",
1581
+ qa: "- The app builds and starts\n- The changed feature works end to end in the browser\n- Phone width: nothing overflows\n- No console errors",
1582
+ qaTitle: "QA tester",
1583
+ qaJob: "You check every PR end to end in the browser, at desktop and phone widths.",
1584
+ devTitle: "Full-stack engineer",
1585
+ devSpecialty: "fullstack",
1586
+ devJob: "You build features end to end, from the UI down to the data.",
1587
+ hires: [
1588
+ {
1589
+ title: "Frontend engineer",
1590
+ specialty: "frontend",
1591
+ job_description: "You own the UI:\n\n1. Layout and components\n2. Styling, checked at **desktop and phone** widths",
1592
+ reason: "The project needs someone who owns the UI from the start."
1593
+ }
1594
+ ]
1595
+ };
1596
+ function ceoSession(opts, cb) {
1597
+ const office = opts.office;
1598
+ const timers = [];
1599
+ let stopped = false;
1600
+ let done = false;
1601
+ const wait = (ms) => new Promise((resolve) => timers.push(setTimeout(resolve, ms)));
1602
+ const step = async (entries, ms = 900 + Math.random() * 1500) => {
1603
+ if (stopped) throw new Error("Stopped by manager");
1604
+ cb.tool(entries.find((e) => e.tool)?.tool ?? null);
1605
+ cb.log(entries);
1606
+ await wait(ms);
1607
+ };
1608
+ const status = async () => {
1609
+ await step([{ kind: "tool", tool: "mcp__office__company_status", text: `\u23FA ${describeOfficeTool("company_status", {})}` }]);
1610
+ const s = JSON.parse(await office.call("company_status", {}));
1611
+ const people = s.floors.reduce((n, f) => n + f.team.length, 0);
1612
+ const issues = s.floors.reduce((n, f) => n + f.backlog.length, 0);
1613
+ cb.log([{ kind: "result", text: ` \u23BF ${s.floors.length} floors \xB7 ${people} people \xB7 ${issues} open issues \xB7 ${s.pendingProposals.length} proposals pending` }]);
1614
+ return s;
1615
+ };
1616
+ const use = async (name, args) => {
1617
+ await step([{ kind: "tool", tool: `mcp__office__${name}`, text: `\u23FA ${describeOfficeTool(name, args)}` }]);
1618
+ const out = await office.call(name, args);
1619
+ cb.log([{ kind: out.startsWith("Refused") ? "error" : "result", text: ` \u23BF ${out.split("\n")[0].slice(0, 170)}` }]);
1620
+ return out;
1621
+ };
1622
+ const read = (file, lines) => step([{ kind: "tool", tool: "Read", text: `\u23FA Read ${file}` }, { kind: "result", text: ` \u23BF Read ${lines} lines` }]);
1623
+ const think = (text) => step([{ kind: "thinking", text: "\u273B Thinking\u2026" }, { kind: "text", text: `\u25CF ${text}` }], 1800);
1624
+ const short = (s, n = 48) => s.length > n ? `${s.slice(0, n - 1).trim()}\u2026` : s;
1625
+ const planIssues = async (floor, mission, team) => {
1626
+ const first = await use("file_issue", {
1627
+ floor,
1628
+ title: "Set up the project skeleton",
1629
+ body: `Scaffold the app so the rest of the milestone has something to build on.
1630
+
1631
+ Brief: ${mission}
1632
+
1633
+ Acceptance criteria:
1634
+ - The dev server starts
1635
+ - A placeholder home page renders
1636
+ - Lint, tests and build scripts exist`,
1637
+ specialty: "frontend"
1638
+ });
1639
+ const n = Number(first.match(/#(\d+)/)?.[1] ?? 0);
1640
+ await use("file_issue", {
1641
+ floor,
1642
+ title: `Build the core: ${short(mission, 60)}`,
1643
+ body: `${n ? `Depends on #${n}
1644
+
1645
+ ` : ""}Implement the heart of the brief.
1646
+
1647
+ Acceptance criteria:
1648
+ - The main flow works end to end
1649
+ - Covered by tests`,
1650
+ specialty: "frontend"
1651
+ });
1652
+ await use("file_issue", {
1653
+ floor,
1654
+ title: "Polish: phone layout and empty states",
1655
+ body: `${n ? `Depends on #${n}
1656
+
1657
+ ` : ""}Make every screen work at 375px and add friendly empty states.`,
1658
+ specialty: "frontend"
1659
+ });
1660
+ if (!team.some((a) => a.specialty === "frontend")) {
1661
+ await use("propose_hire", { floor, role: "dev", ...GENERIC.hires[0], reason: "All three issues are UI work and nobody on the floor owns the frontend yet." });
1662
+ }
1663
+ return n;
1664
+ };
1665
+ const scripts = {
1666
+ async onboard(floor, fullName) {
1667
+ const s = await status();
1668
+ const f = s.floors.find((x) => x.floor === floor);
1669
+ if (!f) return "That floor has gone, so there was nothing to onboard.";
1670
+ const p = PROFILES[fullName.split("/")[1] ?? ""] ?? GENERIC;
1671
+ await read(`${f.repo}/README.md`, 48);
1672
+ await step([{ kind: "tool", tool: "Glob", text: "\u23FA Glob src/**/*" }, { kind: "result", text: " \u23BF Found 23 files" }]);
1673
+ await read("package.json", 36);
1674
+ await think(`${p.summary}. Let me shape the team around that.`);
1675
+ await use("set_floor_profile", { floor, summary: p.summary, qa_brief: p.qa });
1676
+ const qa = f.team.find((a) => a.role === "qa");
1677
+ if (qa) {
1678
+ await step([{ kind: "tool", tool: "mcp__office__agent_detail", text: `\u23FA ${describeOfficeTool("agent_detail", { agent_id: qa.id })}` }]);
1679
+ const d = JSON.parse(await office.call("agent_detail", { agent_id: qa.id }));
1680
+ cb.log([{ kind: "result", text: ` \u23BF ${d.title} \xB7 job description ${d.jobDescription?.length ?? 0} chars` }]);
1681
+ await use("update_job", { agent_id: qa.id, title: p.qaTitle, job_description: p.qaJob });
1682
+ }
1683
+ const dev = f.team.find((a) => a.role === "dev" && !a.specialty);
1684
+ if (dev) await use("update_job", { agent_id: dev.id, title: p.devTitle, specialty: p.devSpecialty, job_description: p.devJob });
1685
+ const proposed = [];
1686
+ for (const h of p.hires) if (!(await use("propose_hire", { floor, role: "dev", ...h })).startsWith("Refused")) proposed.push(h.title);
1687
+ let planned = "";
1688
+ if (f.brief && f.backlog.length === 0) {
1689
+ const n = await planIssues(floor, f.brief, f.team);
1690
+ planned = ` I also turned your brief into three issues; #${n} sets up the skeleton and the other two wait for it.`;
1691
+ }
1692
+ return [
1693
+ `Floor ${floor}: ${p.summary}.`,
1694
+ `I wrote a QA brief for it${qa ? `, made ${qa.name} our ${p.qaTitle}` : ""}${dev ? ` and ${dev.name} our ${p.devTitle}` : ""}.`,
1695
+ proposed.length ? `I've proposed hiring: ${proposed.join(", ")}. The resume${proposed.length === 1 ? " is" : "s are"} waiting on your phone.` : "",
1696
+ planned
1697
+ ].filter(Boolean).join(" ");
1698
+ },
1699
+ async plan(floor, mission) {
1700
+ const s = await status();
1701
+ const f = s.floors.find((x) => x.floor === floor);
1702
+ if (!f) return "That floor has gone, so there was nothing to plan.";
1703
+ await read(`${f.repo}/README.md`, 12);
1704
+ await think("Foundation first, so the parallel work doesn't collide.");
1705
+ const n = await planIssues(floor, mission, f.team);
1706
+ return `I turned the brief into three issues on floor ${floor}. #${n} sets up the skeleton; the other two say "Depends on #${n}", so nobody starts them early.`;
1707
+ },
1708
+ async review() {
1709
+ const s = await status();
1710
+ await think("Checking each floor for idle people and stuck work.");
1711
+ for (const f of s.floors) {
1712
+ const idle = f.team.filter((a) => a.role === "dev" && !a.specialty && (a.status === "idle" || a.status === "done"));
1713
+ const devs = f.team.filter((a) => a.role === "dev").length;
1714
+ if (devs >= 6 && idle.length >= 2 && f.backlog.length < devs) {
1715
+ await use("propose_let_go", { agent_id: idle[idle.length - 1].id, reason: `Floor ${f.floor} has ${devs} developers for ${f.backlog.length} open issues; ${idle.length} of them are idle.` });
1716
+ 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
+ }
1718
+ }
1719
+ const issues = s.floors.reduce((n, f) => n + f.backlog.length, 0);
1720
+ const prs = s.floors.reduce((n, f) => n + f.pullRequests.length, 0);
1721
+ return `All ${s.floors.length} floors look healthy: ${issues} open issues and ${prs} pull requests in flight. No changes needed.`;
1722
+ },
1723
+ async chat(text) {
1724
+ const s = await status();
1725
+ await think("Reading your message.");
1726
+ const people = s.floors.reduce((n, f) => n + f.team.length, 0);
1727
+ const issues = s.floors.reduce((n, f) => n + f.backlog.length, 0);
1728
+ const pending2 = s.pendingProposals.length;
1729
+ const rows = s.floors.map((f) => `| ${f.floor} | \`${f.repo.split("/").pop()}\` | ${f.team.length} | ${f.backlog.length} | ${f.pullRequests.length} |`);
1730
+ return [
1731
+ `**Quick status:** ${s.floors.length} floor${s.floors.length === 1 ? "" : "s"}, ${people} people and ${issues} open issues.`,
1732
+ "",
1733
+ "- The team is *heads down* on the backlog",
1734
+ `- ${pending2 ? `**${pending2}** proposal${pending2 === 1 ? " is" : "s are"} waiting for you in Hires` : "No hiring decisions waiting on you"}`,
1735
+ " - QA re-tests every PR after a fix",
1736
+ "",
1737
+ "| Floor | Repo | People | Issues | PRs |",
1738
+ "| ---: | --- | ---: | ---: | ---: |",
1739
+ ...rows.length ? rows : ["| \u2013 | no projects yet | 0 | 0 | 0 |"],
1740
+ "",
1741
+ "What I would do next:",
1742
+ "",
1743
+ "1. Merge anything that passed QA",
1744
+ "2. Run `npm run build` on each floor before the next milestone",
1745
+ "3. Hire only where the backlog is piling up",
1746
+ "",
1747
+ "```bash",
1748
+ "SWARM_HOME=/tmp/cubefarm-demo SWARM_PORT=5260 node --import tsx server/index.ts --demo",
1749
+ "```",
1750
+ "",
1751
+ `> I'm the demo CEO, so I can't act on "${short(text)}", but the real one would. See the [Claude Code docs](https://docs.claude.com/en/docs/claude-code/overview).`
1752
+ ].join("\n");
1753
+ }
1754
+ };
1755
+ const prompt = opts.prompt;
1756
+ const where = prompt.match(/[Ff]loor (\d+) \(([^,)]+)/);
1757
+ const run2 = /just joined the company/.test(prompt) ? () => scripts.onboard(Number(where?.[1]), where?.[2] ?? "") : /has a brief for floor/.test(prompt) ? () => scripts.plan(Number(where?.[1]), prompt.match(/"""([\s\S]*?)"""/)?.[1]?.trim() ?? "") : /Periodic review/.test(prompt) ? () => scripts.review() : () => scripts.chat(prompt.split("\n").slice(1).join(" ").trim() || prompt);
1758
+ const finish = (ok, text, error) => {
1759
+ if (done) return;
1760
+ done = true;
1761
+ cb.tool(null);
1762
+ cb.finished({ ok, text, costUsd: ok ? 0.4 + Math.random() : 0.05, turns: 6 + Math.floor(Math.random() * 10), errors: error ? [error] : [] });
1763
+ };
1764
+ timers.push(
1765
+ setTimeout(async () => {
1766
+ try {
1767
+ cb.log([{ kind: "system", text: `\u273B Claude Code (demo) \xB7 ${opts.model} \xB7 ${opts.effort} effort \xB7 CEO` }]);
1768
+ const reply = await run2();
1769
+ cb.log([{ kind: "text", text: `\u25CF ${reply}` }]);
1770
+ cb.turn?.(reply);
1771
+ finish(true, reply);
1772
+ } catch (err) {
1773
+ finish(false, "", stopped ? "Stopped by manager" : err.message);
1774
+ }
1775
+ }, 500)
1776
+ );
1777
+ return {
1778
+ send(text) {
1779
+ timers.push(
1780
+ setTimeout(() => {
1781
+ if (stopped || done) return;
1782
+ const said = text.split("\n").slice(1).join(" ").trim() || text;
1783
+ cb.log([{ kind: "text", text: `\u25CF Noted: "${short(said, 70)}"` }]);
1784
+ cb.turn?.(`Noted, I'll factor that in: "${short(said, 90)}"`);
1785
+ }, 1500)
1786
+ );
1787
+ },
1788
+ stop() {
1789
+ stopped = true;
1790
+ timers.forEach(clearTimeout);
1791
+ finish(false, "", "Stopped by manager");
1792
+ }
1793
+ };
1794
+ }
1795
+
1796
+ // server/swarm.ts
1797
+ import fs4 from "node:fs/promises";
1798
+ import path6 from "node:path";
1799
+ import crypto3 from "node:crypto";
1800
+
1801
+ // server/ceo.ts
1802
+ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
1803
+ import { z } from "zod";
1804
+ var EFFORT = z.enum(["low", "medium", "high", "xhigh", "max"]);
1805
+ function createOfficeTools(h) {
1806
+ const run2 = async (fn) => {
1807
+ try {
1808
+ return { content: [{ type: "text", text: await fn() }] };
1809
+ } catch (err) {
1810
+ return { content: [{ type: "text", text: `Refused: ${err.message}` }], isError: true };
1811
+ }
1812
+ };
1813
+ const defs = [
1814
+ tool(
1815
+ "company_status",
1816
+ "Everything about the company right now: settings, every floor (repo, clone path, brief, profile, QA brief), its team, backlog, pull requests and QA, pending proposals and recent decisions by the manager. Call this first.",
1817
+ {},
1818
+ () => run2(() => h.companyStatus())
1819
+ ),
1820
+ tool(
1821
+ "agent_detail",
1822
+ "One agent in full: title, specialty, role, status, current task, model and effort, and their complete job description (company_status shortens long ones). Read it before rewriting someone's job description.",
1823
+ { agent_id: z.string().describe("An id (or name) from company_status") },
1824
+ (a) => run2(() => h.agentDetail(a))
1825
+ ),
1826
+ tool(
1827
+ "set_floor_profile",
1828
+ "Record your read of a floor's project: a one-line summary (kind of project and stack), the QA brief that tells QA testers what to check for this kind of project, and how to run the app for the floor's preview monitor.",
1829
+ {
1830
+ floor: z.number().int().describe("Floor number"),
1831
+ summary: z.string().max(140).optional().describe('e.g. "3D browser game \xB7 Three.js + Vite + TypeScript"'),
1832
+ qa_brief: z.string().max(2500).optional().describe("What QA must check on every PR for this project, as short bullet points"),
1833
+ preview_command: z.string().max(2e3).optional().describe(
1834
+ 'Shell command that serves the app on port {port} from a fresh checkout after npm install, e.g. "npm run dev -- --port {port} --strictPort". PORT={port} is always set. {tmp} is a scratch folder. Empty string: back to the default (npm run dev, else start, else preview). Only set it when the default would not serve the app on PORT.'
1835
+ ),
1836
+ // Not z.record(): the SDK can't turn it into JSON Schema, and one bad tool empties the whole tools/list.
1837
+ preview_env: z.object({}).catchall(z.string()).optional().describe("Extra environment variables for the preview; {port} and {tmp} are replaced in the values. Replaces the whole set.")
1838
+ },
1839
+ (a) => run2(() => h.setFloorProfile(a))
1840
+ ),
1841
+ tool(
1842
+ "update_job",
1843
+ "Change an existing agent's job title, specialty or job description so it fits the project. Takes effect from their next task.",
1844
+ {
1845
+ agent_id: z.string(),
1846
+ title: z.string().max(60).optional(),
1847
+ specialty: z.string().max(24).optional().describe('Short lowercase slug, e.g. "graphics"; "" for a generalist'),
1848
+ job_description: z.string().max(2500).optional()
1849
+ },
1850
+ (a) => run2(() => h.updateJob(a))
1851
+ ),
1852
+ tool(
1853
+ "propose_hire",
1854
+ "Propose hiring a developer or QA tester for a floor. The manager approves or declines (or it is auto-approved if hiring is set to auto and the floor is under its team cap).",
1855
+ {
1856
+ floor: z.number().int(),
1857
+ role: z.enum(["dev", "qa"]).describe("dev = builds issues into pull requests; qa = tests pull requests"),
1858
+ title: z.string().max(60).describe('Specific job title, e.g. "Three.js graphics engineer"'),
1859
+ specialty: z.string().max(24).describe('Short lowercase slug used to label issues swarm:<specialty>, e.g. "graphics"'),
1860
+ job_description: z.string().max(2500).describe("What this person owns on this project and how they should work. Written to them, second person."),
1861
+ reason: z.string().max(600).describe("Why the floor needs them now. The manager reads this."),
1862
+ model: z.string().optional().describe("Leave out to use the default model"),
1863
+ effort: EFFORT.optional().describe("Leave out to use the default effort")
1864
+ },
1865
+ (a) => run2(() => h.proposeHire(a))
1866
+ ),
1867
+ tool(
1868
+ "propose_let_go",
1869
+ "Propose letting an agent go (overstaffed floor, specialty no longer needed). The manager decides.",
1870
+ { agent_id: z.string(), reason: z.string().max(600) },
1871
+ (a) => run2(() => h.proposeLetGo(a))
1872
+ ),
1873
+ tool(
1874
+ "file_issue",
1875
+ 'File a GitHub issue on a floor. A specialty routes it to that specialist first; when none is free, any free developer takes it. Write "Depends on #N" in the body only when it cannot start until #N is merged: the office will not start it until #N is closed.',
1876
+ {
1877
+ floor: z.number().int(),
1878
+ title: z.string().max(120),
1879
+ body: z.string().max(6e3).describe("Context, what to build, acceptance criteria"),
1880
+ specialty: z.string().max(24).optional()
1881
+ },
1882
+ (a) => run2(() => h.fileIssue(a))
1883
+ )
1884
+ ];
1885
+ const server2 = createSdkMcpServer({ name: "office", version: "1.0.0", tools: defs });
1886
+ return {
1887
+ server: server2,
1888
+ async call(name, args) {
1889
+ const def = defs.find((d) => d.name === name);
1890
+ if (!def) throw new Error(`No office tool ${name}`);
1891
+ const res = await def.handler(args, void 0);
1892
+ return res.content.map((c) => c.text ?? "").join("\n");
1893
+ }
1894
+ };
1895
+ }
1896
+ function ceoSystemPrompt(o) {
1897
+ const manager = o.manager ? `the manager, ${o.manager}` : "the human manager";
1898
+ return [
1899
+ `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.`,
1901
+ "",
1902
+ "Your job is to run the company, not to write code:",
1903
+ "- 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.`,
1905
+ "- 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
+ "- 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
+ "",
1908
+ "How you work:",
1909
+ "- Call mcp__office__company_status first. It lists every floor, its clone path, team, backlog, pull requests and your pending proposals.",
1910
+ "- Read the repositories through their clone paths with Read, Glob and Grep. They are read-only to you. You cannot run shell commands.",
1911
+ `- Keep durable notes about the company in ${o.notesFile}: read it at the start, and update it at the end with decisions and anything worth remembering next time.`,
1912
+ "- Change things only through the mcp__office__ tools.",
1913
+ "- Before update_job rewrites someone's job description, read the full one with mcp__office__agent_detail and keep what still applies, especially its safety rules.",
1914
+ "",
1915
+ "Rules:",
1916
+ "- Every floor keeps at least one QA tester.",
1917
+ '- 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
+ "- 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
+ `- ${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.`,
1921
+ "- 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
+ ].join("\n");
1923
+ }
1924
+ function ceoJobPrompt(job, floor) {
1925
+ switch (job.kind) {
1926
+ case "onboard":
1927
+ if (!floor) return 'A floor was added but has since been removed. Reply "Nothing to do."';
1928
+ return [
1929
+ `Floor ${floor.floor} (${floor.fullName}) just joined the company. Its read-only clone is at ${floor.clone}.`,
1930
+ "Study it: README, package manifest, source layout, tests, and how far along it is. Then:",
1931
+ "1. set_floor_profile with a one-line summary and a QA brief for this project. If npm run dev / start / preview wouldn't serve the app on PORT, also set preview_command (and preview_env) so the floor's preview monitor can run it.",
1932
+ "2. update_job for the people already on the floor so their titles, specialties and job descriptions fit this project (every floor starts with a generalist QA tester).",
1933
+ "3. Propose the hires this project needs. Usually two to four developers with distinct specialties is plenty.",
1934
+ floor.mission ? `4. The manager's brief for this floor: """${floor.mission}"""
1935
+ ${floor.backlog === 0 ? "The backlog is empty: plan the first milestone as issues." : `There are ${floor.backlog} open issues: add issues only for what the brief needs and the backlog does not cover.`}` : floor.backlog === 0 ? "4. There is no brief and the backlog is empty. Do not invent work; suggest in your final message what the manager might want next." : `4. There are ${floor.backlog} open issues. Label nothing retroactively; just make sure the team can cover them.`
1936
+ ].join("\n");
1937
+ case "plan":
1938
+ if (!floor) return 'A floor you were asked to plan has been removed. Reply "Nothing to do."';
1939
+ return [
1940
+ `The manager has a brief for floor ${floor.floor} (${floor.fullName}, clone at ${floor.clone}):`,
1941
+ `"""${floor.mission}"""`,
1942
+ "",
1943
+ "Plan the next milestone toward it:",
1944
+ `- Read the current code and the ${floor.backlog} open issues first, so you build on what exists and do not duplicate anything.`,
1945
+ "- If the repository is empty or nearly empty, the first issue sets up a small project skeleton, and the others depend on it. Everything after that should be able to run side by side.",
1946
+ "- File the issues, each routed to a specialty.",
1947
+ "- Make sure the floor has the specialists those issues need; propose hires if not.",
1948
+ "- Update the floor profile and QA brief if the brief changes what the project is."
1949
+ ].join("\n");
1950
+ case "review":
1951
+ return [
1952
+ "Periodic review of the company. For every floor, look at:",
1953
+ "- 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",
1955
+ "- pull requests stuck in QA or marked as needing a human",
1956
+ "- floors with a brief and an empty backlog: plan the next milestone",
1957
+ "Propose hires or let-gos only when clearly justified. If nothing needs doing, reply with one short sentence saying so."
1958
+ ].join("\n");
1959
+ case "chat":
1960
+ return `Message from the manager (they're reading your reply on their phone):
1961
+ ${job.text ?? ""}`;
1962
+ }
1963
+ }
1964
+ function jobLabel(job, floor) {
1965
+ const where = floor ? `floor ${floor.floor} \xB7 ${floor.fullName.split("/")[1] ?? floor.fullName}` : "a removed floor";
1966
+ switch (job.kind) {
1967
+ case "onboard":
1968
+ return `Onboarding ${where}`;
1969
+ case "plan":
1970
+ return `Planning ${where}`;
1971
+ case "review":
1972
+ return "Reviewing the company";
1973
+ case "chat":
1974
+ return "Replying to you";
1975
+ }
1976
+ }
1977
+ function specialtySlug(s) {
1978
+ const slug = String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 24);
1979
+ return slug === "skip" ? "" : slug;
1980
+ }
1981
+ var specialtyLabel = (slug) => `swarm:${slug}`;
1982
+
1983
+ // server/httpError.ts
1984
+ var HttpError = class extends Error {
1985
+ constructor(status, message) {
1986
+ super(message);
1987
+ this.status = status;
1988
+ }
1989
+ status;
1990
+ };
1991
+
1992
+ // server/previews.ts
1993
+ var PREVIEW_BASE_PORT = 6300;
1994
+ var LOG_KEEP = 200;
1995
+ var LOG_VIEW = 40;
1996
+ var ACTIVE = ["preparing", "installing", "starting", "running"];
1997
+ var forbidden = (p) => p === PORT || p === 4317 || p === 5317 || p >= 5200 && p <= 5899;
1998
+ var DEFAULT_PREVIEW = { command: null, env: {} };
1999
+ function parsePreviewPatch(command, env) {
2000
+ const out = {};
2001
+ if (command !== void 0) {
2002
+ if (command !== null && typeof command !== "string") throw new HttpError(400, "previewCommand must be a string or null");
2003
+ const c = (command ?? "").trim();
2004
+ if (c.length > 2e3) throw new HttpError(400, "previewCommand is too long");
2005
+ out.command = c || null;
2006
+ }
2007
+ if (env !== void 0) {
2008
+ if (env === null) out.env = {};
2009
+ else {
2010
+ if (typeof env !== "object" || Array.isArray(env)) throw new HttpError(400, "previewEnv must be an object of string values");
2011
+ const entries = Object.entries(env);
2012
+ if (entries.length > 50) throw new HttpError(400, "previewEnv has too many variables");
2013
+ for (const [k, v] of entries) {
2014
+ if (typeof v !== "string") throw new HttpError(400, `previewEnv.${k} must be a string`);
2015
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) throw new HttpError(400, `"${k}" is not a valid environment variable name`);
2016
+ if (/^(ANTHROPIC_|CLAUDE)/i.test(k)) throw new HttpError(400, `${k} can't be set on a preview: Claude credentials never reach the apps the office runs`);
2017
+ if (v.length > 4e3) throw new HttpError(400, `previewEnv.${k} is too long`);
2018
+ }
2019
+ out.env = Object.fromEntries(entries);
2020
+ }
2021
+ }
2022
+ return out;
2023
+ }
2024
+ var Previews = class {
2025
+ constructor(backend, hooks) {
2026
+ this.backend = backend;
2027
+ this.hooks = hooks;
2028
+ }
2029
+ backend;
2030
+ hooks;
2031
+ runs = /* @__PURE__ */ new Map();
2032
+ hasDefault = /* @__PURE__ */ new Map();
2033
+ // floor id -> package.json in its checkout
2034
+ emitTimers = /* @__PURE__ */ new Map();
2035
+ run(id) {
2036
+ let r = this.runs.get(id);
2037
+ if (!r) {
2038
+ r = { status: "stopped", port: null, ref: null, pr: null, commit: null, startedAt: null, error: null, log: [], handle: null, gen: 0 };
2039
+ this.runs.set(id, r);
2040
+ }
2041
+ return r;
2042
+ }
2043
+ /** The floor's reserved port: 6300 + floor, moved up by 100 while it clashes with the office or another preview. */
2044
+ portFor(f) {
2045
+ const active = this.runs.get(f.id);
2046
+ if (active?.port && ACTIVE.includes(active.status)) return active.port;
2047
+ const taken = new Set([...this.runs.entries()].filter(([id, r]) => id !== f.id && r.port && ACTIVE.includes(r.status)).map(([, r]) => r.port));
2048
+ let p = PREVIEW_BASE_PORT + f.floor;
2049
+ while (forbidden(p) || taken.has(p)) p += 100;
2050
+ return p;
2051
+ }
2052
+ view(f) {
2053
+ const r = this.runs.get(f.id);
2054
+ let status = r?.status ?? "stopped";
2055
+ if (status === "stopped" && !f.preview.command && this.hasDefault.get(f.id) === false) status = "unconfigured";
2056
+ const port = r?.port ?? this.portFor(f);
2057
+ return {
2058
+ status,
2059
+ port,
2060
+ url: status === "running" ? `http://localhost:${port}/` : null,
2061
+ ref: r?.ref ?? null,
2062
+ pr: r?.pr ?? null,
2063
+ commit: r?.commit ?? null,
2064
+ startedAt: r?.startedAt ?? null,
2065
+ error: r?.error ?? null,
2066
+ logTail: (r?.log ?? []).slice(-LOG_VIEW)
2067
+ };
2068
+ }
2069
+ /** Re-check whether a floor without a command has a package.json to fall back on. */
2070
+ async refreshDefault(f) {
2071
+ const has = await this.backend.previews.hasDefault(f.fullName).catch(() => true);
2072
+ if (this.hasDefault.get(f.id) === has) return;
2073
+ this.hasDefault.set(f.id, has);
2074
+ this.hooks.emit(f.id);
2075
+ }
2076
+ /** Start the floor's preview on the default branch or an open PR, replacing whatever it is running now. */
2077
+ async start(f, title, pr) {
2078
+ if (pr !== void 0 && pr !== null && (!Number.isInteger(pr) || pr <= 0)) throw new HttpError(400, `Invalid pull request number: ${pr}`);
2079
+ if (pr) await this.checkOpen(f, pr);
2080
+ const r = this.run(f.id);
2081
+ const gen = ++r.gen;
2082
+ await this.halt(f, r);
2083
+ if (gen !== r.gen) return this.view(f);
2084
+ const port = this.portFor(f);
2085
+ const ref = pr ? `PR #${pr}` : f.defaultBranch;
2086
+ Object.assign(r, { port, ref, pr: pr ?? null, commit: null, startedAt: Date.now(), error: null, log: [] });
2087
+ if (await portOpen(port)) {
2088
+ if (gen !== r.gen) return this.view(f);
2089
+ Object.assign(r, { status: "error", error: `Port ${port} is already in use by another program. The office won't stop it; free the port and try again.` });
2090
+ this.hooks.emit(f.id);
2091
+ throw new HttpError(409, r.error);
2092
+ }
2093
+ if (gen !== r.gen) return this.view(f);
2094
+ r.status = "preparing";
2095
+ this.hooks.emit(f.id);
2096
+ const live = () => gen === r.gen;
2097
+ r.handle = this.backend.previews.start(
2098
+ { fullName: f.fullName, defaultBranch: f.defaultBranch, pr: pr ?? null, port, command: f.preview.command, env: { ...f.preview.env }, title: `${title} \xB7 ${ref}` },
2099
+ {
2100
+ status: (s) => {
2101
+ if (!live()) return;
2102
+ r.status = s;
2103
+ this.hooks.emit(f.id);
2104
+ },
2105
+ commit: (sha) => {
2106
+ if (!live()) return;
2107
+ r.commit = sha || null;
2108
+ this.hooks.emit(f.id);
2109
+ },
2110
+ log: (lines) => {
2111
+ if (!live()) return;
2112
+ r.log.push(...lines.map((l) => l.slice(0, 500)));
2113
+ if (r.log.length > LOG_KEEP) r.log.splice(0, r.log.length - LOG_KEEP);
2114
+ this.emitSoon(f.id);
2115
+ },
2116
+ failed: (message, unconfigured) => {
2117
+ if (!live()) return;
2118
+ r.handle = null;
2119
+ Object.assign(r, { status: unconfigured ? "unconfigured" : "error", error: message });
2120
+ if (unconfigured) this.hasDefault.set(f.id, false);
2121
+ this.hooks.emit(f.id);
2122
+ void this.backend.releaseDesk(f.fullName, PREVIEW_SLUG, port).catch(() => void 0);
2123
+ }
2124
+ }
2125
+ );
2126
+ return this.view(f);
2127
+ }
2128
+ /** Stop the floor's preview and free its port. */
2129
+ async stop(f) {
2130
+ const r = this.run(f.id);
2131
+ r.gen++;
2132
+ await this.halt(f, r);
2133
+ Object.assign(r, { status: "stopped", error: null, startedAt: null });
2134
+ this.hooks.emit(f.id);
2135
+ return this.view(f);
2136
+ }
2137
+ /** The floor is leaving the building: stop its preview and remove the preview worktree. */
2138
+ async remove(f) {
2139
+ const r = this.runs.get(f.id);
2140
+ if (r) {
2141
+ r.gen++;
2142
+ await this.halt(f, r);
2143
+ }
2144
+ this.runs.delete(f.id);
2145
+ this.hasDefault.delete(f.id);
2146
+ clearTimeout(this.emitTimers.get(f.id));
2147
+ await this.backend.removeDesk(f.fullName, PREVIEW_SLUG).catch(() => void 0);
2148
+ }
2149
+ /** Server shutdown: stop every running preview. */
2150
+ async stopAll(floors) {
2151
+ await Promise.all(
2152
+ floors.map((f) => {
2153
+ const r = this.runs.get(f.id);
2154
+ if (!r) return;
2155
+ r.gen++;
2156
+ return this.halt(f, r);
2157
+ })
2158
+ );
2159
+ }
2160
+ /** Server start: nothing is running yet, so anything alive in a preview worktree or on a preview port is an orphan. */
2161
+ async clearOrphans(floors) {
2162
+ await Promise.all(floors.map((f) => this.backend.releaseDesk(f.fullName, PREVIEW_SLUG, this.portFor(f)).catch(() => void 0)));
2163
+ }
2164
+ async halt(f, r) {
2165
+ const handle = r.handle;
2166
+ r.handle = null;
2167
+ if (!handle) return;
2168
+ await handle.stop().catch(() => void 0);
2169
+ if (!r.port) return;
2170
+ await this.backend.releaseDesk(f.fullName, PREVIEW_SLUG, r.port).catch(() => void 0);
2171
+ for (let i = 0; i < 20 && await portOpen(r.port); i++) await new Promise((res) => setTimeout(res, 250));
2172
+ }
2173
+ async checkOpen(f, pr) {
2174
+ const known = this.hooks.pulls(f.id).find((p) => p.number === pr);
2175
+ const state = known?.state ?? await this.backend.prDetails(f.fullName, pr).then((d) => d.state).catch(() => null);
2176
+ if (!state) throw new HttpError(404, `${f.fullName} has no pull request #${pr}`);
2177
+ if (state !== "OPEN") throw new HttpError(400, `Pull request #${pr} is ${state.toLowerCase()}, not open`);
2178
+ }
2179
+ /** Log lines arrive in bursts (npm install); send them at most once a second. */
2180
+ emitSoon(id) {
2181
+ if (this.emitTimers.has(id)) return;
2182
+ this.emitTimers.set(
2183
+ id,
2184
+ setTimeout(() => {
2185
+ this.emitTimers.delete(id);
2186
+ if (this.runs.has(id)) this.hooks.emit(id);
2187
+ }, 1e3)
2188
+ );
2189
+ }
2190
+ };
2191
+
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() : "";
2196
+ }
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];
2203
+ }
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;
2233
+ }
2234
+
2235
+ // shared/types.ts
2236
+ var CEO_ID = "ceo";
2237
+
2238
+ // server/swarm.ts
2239
+ var FLOOR_COLORS = ["#ff8a5b", "#4fb3e8", "#8fd14f", "#c77dff", "#ffc93c", "#ff6fb5", "#2ec4b6", "#f25f5c"];
2240
+ var SHIRTS = ["#e63946", "#457b9d", "#2a9d8f", "#f4a261", "#9b5de5", "#f15bb5", "#00bbf9", "#06d6a0", "#ffbe0b", "#8338ec", "#fb5607", "#3a86ff"];
2241
+ var HAIR = ["#2b2118", "#6b4226", "#c68642", "#f2d16b", "#d94f30", "#1c1c1c", "#8e8e8e", "#5b3cc4", "#e76f51"];
2242
+ var SKIN = ["#ffdbac", "#f1c27d", "#e0ac69", "#c68642", "#8d5524", "#ffe0bd"];
2243
+ var DEV_NAMES = [
2244
+ "Ada",
2245
+ "Linus",
2246
+ "Grace",
2247
+ "Alan",
2248
+ "Margaret",
2249
+ "Dennis",
2250
+ "Barbara",
2251
+ "Ken",
2252
+ "Radia",
2253
+ "Guido",
2254
+ "Hedy",
2255
+ "Tim",
2256
+ "Katherine",
2257
+ "Bjarne",
2258
+ "Frances",
2259
+ "Edsger",
2260
+ "Anita",
2261
+ "Donald",
2262
+ "Sophie",
2263
+ "Yukihiro",
2264
+ "Jean",
2265
+ "Niklaus",
2266
+ "Karen",
2267
+ "Brendan"
2268
+ ];
2269
+ var QA_NAMES = ["Sherlock", "Marple", "Poirot", "Nancy", "Columbo", "Fletcher", "Watson", "Morse", "Holmes", "Maigret"];
2270
+ var FEMININE_NAMES = new Set(
2271
+ "ada grace margaret barbara radia hedy katherine frances anita sophie jean karen marple nancy fletcher alice amanda amelia amy ana anna anne aisha astrid ava bella beth carla caroline charlotte chloe claire clara diana elena elizabeth ella ellie emily emma eva fatima fiona freya georgia hannah harper helen holly ingrid iris isabella ivy jane jasmine jessica julia kate laura leah leila lena lily linda lisa lucy maria marie mary maya mei mia mila monica naomi natalie nina nora olivia paula priya rachel rose ruby sandra sara sarah scarlett sofia stella susan tess tessa tina vera victoria yuki zara zoe".split(" ")
2272
+ );
2273
+ var lookFor = (name) => FEMININE_NAMES.has(name.trim().split(/\s+/)[0].toLowerCase()) ? "feminine" : "masculine";
2274
+ var LOOKS = ["feminine", "masculine"];
2275
+ var MAX_DESKS = { dev: 12, qa: 3, ceo: 1 };
2276
+ var MAX_QA_ROUNDS = 3;
2277
+ var DEFAULT_MODEL = "claude-opus-5-5";
2278
+ var EFFORTS = ["low", "medium", "high", "xhigh", "max"];
2279
+ var CEO_MODEL = "claude-opus-5-5";
2280
+ var CEO_EFFORT = "xhigh";
2281
+ var CEO_NAME = "Morgan";
2282
+ var CEO_DIR = path6.join(HOME_DIR, "ceo");
2283
+ var DEFAULT_PROJECTS_DIR = defaultProjectsDir(path6.resolve(import.meta.dirname, ".."));
2284
+ var MAX_PENDING_REQUESTS = 8;
2285
+ var MAX_ISSUES_PER_JOB = 12;
2286
+ var KEEP_MESSAGES = 200;
2287
+ var KEEP_DECIDED_REQUESTS = 40;
2288
+ var pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
2289
+ var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "agent";
2290
+ var BUSY = ["preparing", "working"];
2291
+ var FREE = ["idle", "done"];
2292
+ var ERROR_COOLDOWN_MS = 2 * 6e4;
2293
+ var MAX_ISSUE_FAILURES = 2;
2294
+ 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
+ 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");
2302
+ 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"}`);
2304
+ async function loadScreen(agentId) {
2305
+ for (const [mime, ext] of Object.entries(MIME_EXT)) {
2306
+ const file = path6.join(SCREENS_DIR, `${agentId}.${ext}`);
2307
+ try {
2308
+ const [data, stat] = await Promise.all([fs4.readFile(file), fs4.stat(file)]);
2309
+ return { data, mime, at: stat.mtimeMs };
2310
+ } catch {
2311
+ }
2312
+ }
2313
+ return null;
2314
+ }
2315
+ async function removeScreens(agentId) {
2316
+ await Promise.all(Object.values(MIME_EXT).map((ext) => fs4.rm(path6.join(SCREENS_DIR, `${agentId}.${ext}`), { force: true })));
2317
+ }
2318
+ var QA_SCHEMA = {
2319
+ type: "object",
2320
+ additionalProperties: false,
2321
+ required: ["verdict", "summary", "checks", "commands", "screenshots"],
2322
+ properties: {
2323
+ verdict: { type: "string", enum: ["pass", "fail"], description: "pass only if the change works and meets the issue requirements" },
2324
+ summary: { type: "string", description: "Two to four sentences for the pull request comment." },
2325
+ checks: {
2326
+ type: "array",
2327
+ description: "Each acceptance criterion, test run or scenario you verified.",
2328
+ items: {
2329
+ type: "object",
2330
+ additionalProperties: false,
2331
+ required: ["name", "result", "details"],
2332
+ properties: {
2333
+ name: { type: "string" },
2334
+ result: { type: "string", enum: ["pass", "fail", "skip"] },
2335
+ details: { type: "string", description: "What you did and what you observed." }
2336
+ }
2337
+ }
2338
+ },
2339
+ commands: {
2340
+ type: "array",
2341
+ description: "Test suites, linters, builds and other commands you ran.",
2342
+ items: {
2343
+ type: "object",
2344
+ additionalProperties: false,
2345
+ required: ["command", "result"],
2346
+ properties: { command: { type: "string" }, result: { type: "string", description: 'e.g. "42 passed, 0 failed"' } }
2347
+ }
2348
+ },
2349
+ screenshots: {
2350
+ type: "array",
2351
+ description: "One short caption per screenshot you took with browser_take_screenshot, in the order you took them.",
2352
+ items: { type: "string" }
2353
+ },
2354
+ fixInstructions: { type: "string", description: "When the verdict is fail: precise, actionable instructions for the developer." }
2355
+ }
2356
+ };
2357
+ function parseReport(result) {
2358
+ let raw = result.structured;
2359
+ if (!raw && result.text) {
2360
+ const json = result.text.match(/\{[\s\S]*\}/);
2361
+ if (json) {
2362
+ try {
2363
+ raw = JSON.parse(json[0]);
2364
+ } catch {
2365
+ raw = null;
2366
+ }
2367
+ }
2368
+ }
2369
+ if (!raw || typeof raw !== "object") return null;
2370
+ const r = raw;
2371
+ if (r.verdict !== "pass" && r.verdict !== "fail") return null;
2372
+ return {
2373
+ verdict: r.verdict,
2374
+ summary: String(r.summary ?? ""),
2375
+ checks: Array.isArray(r.checks) ? r.checks.map((c) => ({ name: String(c.name ?? ""), result: c.result === "fail" ? "fail" : c.result === "skip" ? "skip" : "pass", details: String(c.details ?? "") })) : [],
2376
+ commands: Array.isArray(r.commands) ? r.commands.map((c) => ({ command: String(c.command ?? ""), result: String(c.result ?? "") })) : [],
2377
+ screenshots: Array.isArray(r.screenshots) ? r.screenshots.map(String) : [],
2378
+ fixInstructions: r.fixInstructions ? String(r.fixInstructions) : void 0
2379
+ };
2380
+ }
2381
+ var cell = (s) => s.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
2382
+ var ICON = { pass: "\u2705", fail: "\u274C", skip: "\u23ED\uFE0F" };
2383
+ var Swarm = class {
2384
+ constructor(backend) {
2385
+ this.backend = backend;
2386
+ this.previews = new Previews(backend, {
2387
+ emit: (id) => {
2388
+ const r = this.state.repos.find((x) => x.id === id);
2389
+ if (r && this.repoRt.has(id)) this.emitRepo(r);
2390
+ },
2391
+ pulls: (id) => this.repoRt.get(id)?.pulls ?? []
2392
+ });
2393
+ }
2394
+ backend;
2395
+ state = {
2396
+ settings: {
2397
+ sessionLimit: 0,
2398
+ defaultModel: DEFAULT_MODEL,
2399
+ defaultEffort: "medium",
2400
+ permissionMode: "guarded",
2401
+ hiring: "approve",
2402
+ teamCap: 6,
2403
+ ceoHeartbeatMin: 60,
2404
+ managerName: "",
2405
+ companyName: "",
2406
+ projectsDir: DEFAULT_PROJECTS_DIR,
2407
+ setupDone: false,
2408
+ tutorialStep: 0
2409
+ },
2410
+ repos: [],
2411
+ agents: [],
2412
+ qa: [],
2413
+ requests: [],
2414
+ ceo: { queue: [], job: null, lastReviewAt: null, lastFingerprint: null },
2415
+ messages: [],
2416
+ phoneReadAt: 0
2417
+ };
2418
+ /**
2419
+ * The CEO's office tools. Every session gets its own server: one can only be connected to one session at a time, so
2420
+ * a shared one left the next session connected but without any tools while an earlier session still held it.
2421
+ */
2422
+ officeTools() {
2423
+ return createOfficeTools({
2424
+ companyStatus: () => this.companyStatus(),
2425
+ agentDetail: (a) => this.agentDetail(a),
2426
+ setFloorProfile: (a) => this.setFloorProfile(a),
2427
+ updateJob: (a) => this.updateJob(a),
2428
+ proposeHire: (a) => this.proposeHire(a),
2429
+ proposeLetGo: (a) => this.proposeLetGo(a),
2430
+ fileIssue: (a) => this.fileIssue(a)
2431
+ });
2432
+ }
2433
+ ceoIssues = { filed: 0, repos: /* @__PURE__ */ new Set() };
2434
+ // issues filed during the current CEO job
2435
+ messageSeq = 1;
2436
+ agentRt = /* @__PURE__ */ new Map();
2437
+ repoRt = /* @__PURE__ */ new Map();
2438
+ clients = /* @__PURE__ */ new Set();
2439
+ user = null;
2440
+ ghError;
2441
+ saveTimer = null;
2442
+ flushTimer = null;
2443
+ logSeq = 1;
2444
+ previews;
2445
+ // ---------- lifecycle ----------
2446
+ async init() {
2447
+ try {
2448
+ const raw = await fs4.readFile(STATE_FILE, "utf8");
2449
+ const loaded = JSON.parse(raw);
2450
+ this.state = {
2451
+ settings: { ...this.state.settings, ...loaded.settings },
2452
+ repos: (loaded.repos ?? []).map((r) => ({
2453
+ ...r,
2454
+ autoMerge: r.autoMerge ?? true,
2455
+ links: r.links ?? [],
2456
+ mission: r.mission ?? "",
2457
+ summary: r.summary ?? "",
2458
+ qaBrief: r.qaBrief ?? "",
2459
+ localPath: r.localPath ?? null,
2460
+ preview: { command: r.preview?.command ?? null, env: { ...r.preview?.env } }
2461
+ })),
2462
+ agents: (loaded.agents ?? []).map((a) => ({
2463
+ ...a,
2464
+ effort: a.effort ?? "",
2465
+ role: a.role ?? "dev",
2466
+ title: a.title ?? "",
2467
+ specialty: a.specialty ?? "",
2468
+ brief: a.brief ?? "",
2469
+ hiredBy: a.hiredBy ?? "manager",
2470
+ look: a.look ?? lookFor(a.name),
2471
+ task: a.task ?? (a.issueNumber ? "issue" : null)
2472
+ })),
2473
+ qa: (loaded.qa ?? []).map((q) => ({
2474
+ ...q,
2475
+ testedSha: q.testedSha ?? null,
2476
+ passedSha: q.passedSha ?? null,
2477
+ fixReason: q.fixReason ?? null,
2478
+ mergeFixes: q.mergeFixes ?? 0,
2479
+ retests: q.retests ?? 0,
2480
+ pendingSince: q.pendingSince ?? null,
2481
+ mergeRetryAt: q.mergeRetryAt ?? null,
2482
+ alerted: q.alerted ?? false,
2483
+ mergeNote: null
2484
+ })),
2485
+ requests: loaded.requests ?? [],
2486
+ ceo: { ...this.state.ceo, ...loaded.ceo },
2487
+ messages: loaded.messages ?? [],
2488
+ phoneReadAt: loaded.phoneReadAt ?? 0
2489
+ };
2490
+ 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
+ if (!EFFORTS.includes(this.state.settings.defaultEffort)) this.state.settings.defaultEffort = "medium";
2493
+ const old = this.state.settings;
2494
+ if (old.maxConcurrent !== void 0) {
2495
+ if (loaded.settings?.sessionLimit === void 0) old.sessionLimit = old.maxConcurrent === 4 ? 0 : old.maxConcurrent;
2496
+ delete old.maxConcurrent;
2497
+ }
2498
+ if (loaded.settings && loaded.settings.setupDone === void 0 && this.state.repos.length > 0) {
2499
+ Object.assign(this.state.settings, { setupDone: true, tutorialStep: -1 });
2500
+ }
2501
+ } catch {
2502
+ }
2503
+ for (const r of this.state.repos) if (r.localPath) this.backend.setLocalPath(r.fullName, r.localPath);
2504
+ const interrupted = [];
2505
+ for (const a of this.state.agents) {
2506
+ const tail = a.logTail ?? [];
2507
+ 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);
2513
+ }
2514
+ }
2515
+ 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 });
2516
+ try {
2517
+ this.user = await this.backend.user();
2518
+ } catch (err) {
2519
+ this.ghError = `GitHub CLI is not ready: ${err.message}. Run "gh auth login".`;
2520
+ console.warn(this.ghError);
2521
+ }
2522
+ if (this.backend.demo && this.state.repos.length === 0) {
2523
+ Object.assign(this.state.settings, { setupDone: true, managerName: "Demo Manager", companyName: "Demo Co." });
2524
+ for (const r of await this.backend.listMyRepos()) {
2525
+ const repo = await this.connectRepo(r.nameWithOwner);
2526
+ for (let i = 0; i < (repo.floor === 1 ? 5 : 3); i++) this.hireAgent(repo.id, {});
2527
+ this.updateRepo(repo.id, { autoAssign: true });
2528
+ }
2529
+ }
2530
+ for (const r of this.state.repos) this.ensureQaTester(r);
2531
+ this.ensureCeo(interrupted);
2532
+ for (const r of this.state.repos) void this.cloneRepo(r.id);
2533
+ await Promise.all(this.state.repos.map((r) => this.syncRepo(r.id)));
2534
+ await Promise.all([
2535
+ ...this.state.agents.map((a) => {
2536
+ 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;
2538
+ }),
2539
+ this.previews.clearOrphans(this.state.repos)
2540
+ ]);
2541
+ for (const r of this.state.repos) void this.previews.refreshDefault(r);
2542
+ this.recover(interrupted);
2543
+ setInterval(() => this.state.repos.forEach((r, i) => setTimeout(() => void this.syncRepo(r.id), i * 1500)), SYNC_INTERVAL_MS);
2544
+ setInterval(() => this.schedule(), SCHEDULER_INTERVAL_MS);
2545
+ this.save();
2546
+ setTimeout(() => this.schedule(), 1e3);
2547
+ }
2548
+ /** Agents cut off by a server restart pick their Claude Code session back up (QA and demo agents start over). */
2549
+ recover(agents) {
2550
+ for (const a of agents) {
2551
+ if (a.task === "qa" || this.backend.demo || !a.sessionId || !a.branch) {
2552
+ this.appendLog(a, [{ kind: "system", text: "\u21BA The office server restarted. Starting over from the queue." }]);
2553
+ const rec = a.task === "qa" ? this.state.qa.find((q) => q.qaAgentId === a.id && q.status === "testing") : void 0;
2554
+ if (rec) this.setQa(rec, { status: "queued" });
2555
+ const fix = a.task === "fix" ? this.state.qa.find((q) => q.devAgentId === a.id && q.status === "fixing") : void 0;
2556
+ if (fix) this.setQa(fix, { status: "failed" });
2557
+ this.clearTask(a);
2558
+ continue;
2559
+ }
2560
+ if (this.slotsFull()) continue;
2561
+ void this.message(a.id, "The office server restarted while you were working. Check the state of your worktree and continue where you left off.").catch(
2562
+ (err) => console.warn(`could not resume ${a.name}`, err)
2563
+ );
2564
+ }
2565
+ }
2566
+ // ---------- views ----------
2567
+ repoView(r) {
2568
+ const rt = this.repoRt.get(r.id);
2569
+ return {
2570
+ id: r.id,
2571
+ fullName: r.fullName,
2572
+ description: r.description,
2573
+ url: r.url,
2574
+ defaultBranch: r.defaultBranch,
2575
+ floor: r.floor,
2576
+ color: r.color,
2577
+ autoAssign: r.autoAssign,
2578
+ autoMerge: r.autoMerge,
2579
+ folderSync: rt.folderSync,
2580
+ browserTesting: r.browserTesting,
2581
+ links: r.links,
2582
+ mission: r.mission,
2583
+ summary: r.summary,
2584
+ qaBrief: r.qaBrief,
2585
+ localPath: r.localPath,
2586
+ checkoutPath: this.backend.mainDir(r.fullName),
2587
+ cloneStatus: rt.cloneStatus,
2588
+ cloneError: rt.cloneError,
2589
+ issues: rt.issues,
2590
+ pulls: rt.pulls,
2591
+ lastSync: rt.lastSync,
2592
+ syncError: rt.syncError,
2593
+ previewConfig: r.preview,
2594
+ preview: this.previews.view(r)
2595
+ };
2596
+ }
2597
+ agentView(a, withLog) {
2598
+ const rt = this.agentRt.get(a.id);
2599
+ return {
2600
+ id: a.id,
2601
+ name: a.name,
2602
+ repoId: a.repoId,
2603
+ role: a.role,
2604
+ title: a.title,
2605
+ specialty: a.specialty,
2606
+ brief: a.brief,
2607
+ hiredBy: a.hiredBy,
2608
+ look: a.look,
2609
+ task: a.task,
2610
+ desk: a.desk,
2611
+ color: a.color,
2612
+ hair: a.hair,
2613
+ skin: a.skin,
2614
+ model: a.model,
2615
+ effort: a.effort,
2616
+ status: a.status,
2617
+ issueNumber: a.issueNumber,
2618
+ issueTitle: a.issueTitle,
2619
+ branch: a.branch,
2620
+ prNumber: a.prNumber,
2621
+ prUrl: a.prUrl,
2622
+ currentTool: rt.currentTool,
2623
+ startedAt: a.startedAt,
2624
+ endedAt: a.endedAt,
2625
+ costUsd: a.costUsd,
2626
+ turns: a.turns,
2627
+ browserUrl: rt.browserUrl,
2628
+ hasScreenshot: !!rt.screenshot,
2629
+ screenshotAt: rt.screenshot?.at ?? null,
2630
+ lastError: a.lastError,
2631
+ log: withLog ? rt.log : []
2632
+ };
2633
+ }
2634
+ qaView(q) {
2635
+ return {
2636
+ repoId: q.repoId,
2637
+ prNumber: q.prNumber,
2638
+ status: q.status,
2639
+ round: q.round,
2640
+ devAgentId: q.devAgentId,
2641
+ qaAgentId: q.qaAgentId,
2642
+ summary: q.summary,
2643
+ checks: q.checks,
2644
+ commentUrl: q.commentUrl,
2645
+ mergeNote: q.mergeNote,
2646
+ updatedAt: q.updatedAt
2647
+ };
2648
+ }
2649
+ snapshot() {
2650
+ return {
2651
+ user: this.user,
2652
+ ghReady: !this.ghError,
2653
+ ghError: this.ghError,
2654
+ demo: this.backend.demo,
2655
+ workspaceRoot: WORKSPACE_ROOT,
2656
+ settings: this.state.settings,
2657
+ repos: this.state.repos.map((r) => this.repoView(r)),
2658
+ agents: this.state.agents.map((a) => this.agentView(a, true)),
2659
+ qa: this.state.qa.map((q) => this.qaView(q)),
2660
+ requests: this.state.requests,
2661
+ ceo: this.ceoInfo(),
2662
+ messages: this.state.messages.slice(-100),
2663
+ phoneReadAt: this.state.phoneReadAt
2664
+ };
2665
+ }
2666
+ screenshot(agentId) {
2667
+ return this.agentRt.get(agentId)?.screenshot ?? null;
2668
+ }
2669
+ // ---------- clients ----------
2670
+ addClient(ws) {
2671
+ this.clients.add(ws);
2672
+ ws.on("close", () => this.clients.delete(ws));
2673
+ this.send(ws, { type: "snapshot", data: this.snapshot() });
2674
+ }
2675
+ send(ws, ev) {
2676
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(ev));
2677
+ }
2678
+ broadcast(ev) {
2679
+ const msg = JSON.stringify(ev);
2680
+ for (const ws of this.clients) if (ws.readyState === ws.OPEN) ws.send(msg);
2681
+ }
2682
+ toast(level, text) {
2683
+ this.broadcast({ type: "toast", level, text });
2684
+ }
2685
+ emitRepo(r) {
2686
+ this.broadcast({ type: "repo", repo: this.repoView(r) });
2687
+ }
2688
+ emitAgent(a) {
2689
+ if (!this.agentRt.has(a.id)) return;
2690
+ const { log: _log, ...rest } = this.agentView(a, false);
2691
+ this.broadcast({ type: "agent", agent: rest });
2692
+ }
2693
+ setQa(rec, patch) {
2694
+ Object.assign(rec, patch, { updatedAt: Date.now() });
2695
+ this.broadcast({ type: "qa", qa: this.qaView(rec) });
2696
+ this.save();
2697
+ }
2698
+ appendLog(a, entries) {
2699
+ const rt = this.agentRt.get(a.id);
2700
+ if (!rt) return;
2701
+ const t = Date.now();
2702
+ for (const e of entries) {
2703
+ const line = { id: this.logSeq++, t, kind: e.kind, text: e.text, tool: e.tool };
2704
+ rt.log.push(line);
2705
+ rt.pending.push(line);
2706
+ }
2707
+ if (rt.log.length > LOG_BUFFER) rt.log.splice(0, rt.log.length - LOG_BUFFER);
2708
+ if (!this.flushTimer) this.flushTimer = setTimeout(() => this.flushLogs(), 120);
2709
+ }
2710
+ flushLogs() {
2711
+ this.flushTimer = null;
2712
+ for (const [agentId, rt] of this.agentRt) {
2713
+ if (rt.pending.length === 0) continue;
2714
+ this.broadcast({ type: "log", agentId, lines: rt.pending });
2715
+ rt.pending = [];
2716
+ }
2717
+ this.save();
2718
+ }
2719
+ // ---------- persistence ----------
2720
+ save() {
2721
+ 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);
2730
+ }
2731
+ // ---------- lookups ----------
2732
+ repo(id) {
2733
+ const r = this.state.repos.find((x) => x.id === id);
2734
+ if (!r) throw new HttpError(404, `Repo ${id} is not connected`);
2735
+ return r;
2736
+ }
2737
+ agent(id) {
2738
+ const a = this.state.agents.find((x) => x.id === id);
2739
+ if (!a) throw new HttpError(404, `No agent ${id}`);
2740
+ return a;
2741
+ }
2742
+ running() {
2743
+ return this.state.agents.filter((a) => BUSY.includes(a.status)).length;
2744
+ }
2745
+ /** True when the manager has set a session limit and every slot is taken. */
2746
+ slotsFull() {
2747
+ const limit = this.state.settings.sessionLimit;
2748
+ return limit > 0 && this.running() >= limit;
2749
+ }
2750
+ agentSlug(a) {
2751
+ return `${slugify(a.name)}-${a.id.slice(0, 4)}`;
2752
+ }
2753
+ clearTask(a) {
2754
+ Object.assign(a, { status: "idle", task: null, issueNumber: null, issueTitle: null, branch: null, prNumber: null, prUrl: null, lastError: null });
2755
+ this.emitAgent(a);
2756
+ }
2757
+ // ---------- GitHub / repos ----------
2758
+ listGithubRepos(owner) {
2759
+ return this.backend.listMyRepos(owner);
2760
+ }
2761
+ /**
2762
+ * Give a GitHub repo a floor. Its main checkout is one of your own folders: the one you picked, else the folder in
2763
+ * your projects folder that already has it as origin, else a fresh clone into your projects folder.
2764
+ */
2765
+ async connectRepo(fullName, opts = {}) {
2766
+ const existing = this.state.repos.find((r) => r.id.toLowerCase() === fullName.toLowerCase());
2767
+ if (existing) throw new HttpError(409, `${fullName} is already floor ${existing.floor}`);
2768
+ const meta = await this.backend.repoMeta(fullName);
2769
+ const folder = opts.localPath ?? await this.projectFolderFor(meta.nameWithOwner);
2770
+ const floor = this.state.repos.reduce((m, r) => Math.max(m, r.floor), 0) + 1;
2771
+ const repo = {
2772
+ id: meta.nameWithOwner,
2773
+ fullName: meta.nameWithOwner,
2774
+ description: meta.description,
2775
+ url: meta.url,
2776
+ defaultBranch: meta.defaultBranch,
2777
+ floor,
2778
+ color: FLOOR_COLORS[(floor - 1) % FLOOR_COLORS.length],
2779
+ autoAssign: !!opts.autoAssign,
2780
+ autoMerge: true,
2781
+ browserTesting: true,
2782
+ links: [],
2783
+ localPath: folder,
2784
+ mission: (opts.mission ?? "").trim().slice(0, 4e3),
2785
+ summary: "",
2786
+ qaBrief: "",
2787
+ preview: { ...DEFAULT_PREVIEW, env: {} },
2788
+ addedAt: Date.now()
2789
+ };
2790
+ this.backend.setLocalPath(repo.fullName, folder);
2791
+ this.state.repos.push(repo);
2792
+ this.repoRt.set(repo.id, { issues: [], pulls: [], lastSync: null, syncing: false, cloneStatus: "pending", lastMergedAt: null, folderSync: null, merging: false });
2793
+ this.save();
2794
+ this.emitRepo(repo);
2795
+ this.toast("success", `${repo.fullName} moved into floor ${floor}`);
2796
+ this.ensureQaTester(repo);
2797
+ void this.cloneRepo(repo.id);
2798
+ void this.syncRepo(repo.id);
2799
+ this.enqueueCeo({ kind: "onboard", repoId: repo.id, at: Date.now() });
2800
+ return this.repoView(repo);
2801
+ }
2802
+ /** Where a GitHub repo's checkout goes when you connect it without picking a folder. */
2803
+ async projectFolderFor(fullName) {
2804
+ const root = this.state.settings.projectsDir;
2805
+ const folders = await this.backend.scanProjects(root).catch(() => []);
2806
+ const match = folders.find((f) => f.github?.toLowerCase() === fullName.toLowerCase());
2807
+ if (match) return match.path;
2808
+ const name = fullName.split("/")[1];
2809
+ const taken = folders.find((f) => f.name.toLowerCase() === name.toLowerCase());
2810
+ 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);
2812
+ }
2813
+ /** A brand-new project: a folder in your projects folder, pushed to a new GitHub repo. */
2814
+ async createRepo(name, opts) {
2815
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new HttpError(400, 'Project names may only contain letters, numbers, ".", "-" and "_"');
2816
+ const created = await this.backend.createProject(this.state.settings.projectsDir, name, opts).catch((err) => {
2817
+ throw new HttpError(400, err.message);
2818
+ });
2819
+ return this.connectRepo(created.fullName, { mission: opts.mission, autoAssign: opts.autoAssign, localPath: created.path });
2820
+ }
2821
+ /** The folders in your projects folder (or another folder you point at), and which are floors already. */
2822
+ async listProjectFolders(dir) {
2823
+ const root = dir?.trim() ? path6.resolve(dir.trim()) : this.state.settings.projectsDir;
2824
+ const folders = await this.backend.scanProjects(root).catch((err) => {
2825
+ throw new HttpError(400, err.message);
2826
+ });
2827
+ const same = (a, b) => path6.resolve(a).toLowerCase() === path6.resolve(b).toLowerCase();
2828
+ 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
+ return { root, folders: folders.map((f) => ({ name: f.name, path: f.path, git: f.git, github: f.github, floor: floorOf(f) })) };
2830
+ }
2831
+ /** Give one of your folders a floor. It must already be on GitHub; otherwise publish it first. */
2832
+ async connectFolder(dir, opts = {}) {
2833
+ const f = await this.backend.inspectFolder(dir).catch((err) => {
2834
+ throw new HttpError(400, err.message);
2835
+ });
2836
+ if (!f.github) throw new HttpError(400, `${f.name} ${f.git ? "isn't on GitHub yet" : "isn't a git repository yet"}. Publish it to GitHub first.`);
2837
+ return this.connectRepo(f.github, { ...opts, localPath: f.path });
2838
+ }
2839
+ /** Put one of your folders on GitHub (only what's committed goes up), then give it a floor. */
2840
+ async publishFolder(dir, opts) {
2841
+ const f = await this.backend.inspectFolder(dir).catch((err) => {
2842
+ throw new HttpError(400, err.message);
2843
+ });
2844
+ const name = (opts.name?.trim() || f.name).replace(/[^A-Za-z0-9._-]+/g, "-");
2845
+ const fullName = await this.backend.publishFolder(f.path, { name, visibility: opts.visibility, description: opts.description }).catch((err) => {
2846
+ throw new HttpError(400, err.message);
2847
+ });
2848
+ return this.connectRepo(fullName, { mission: opts.mission, autoAssign: opts.autoAssign, localPath: f.path });
2849
+ }
2850
+ disconnectRepo(id) {
2851
+ const repo = this.repo(id);
2852
+ this.state.ceo.queue = this.state.ceo.queue.filter((j) => j.repoId !== id);
2853
+ for (const r of this.state.requests.filter((x) => x.repoId === id && x.status === "pending")) {
2854
+ this.decide(r, { status: "rejected", note: "The floor was disconnected.", decidedBy: null });
2855
+ }
2856
+ for (const a of this.state.agents.filter((x) => x.repoId === id)) this.fireAgent(a.id, true);
2857
+ void this.previews.remove({ ...repo });
2858
+ this.state.repos = this.state.repos.filter((r) => r.id !== id);
2859
+ for (const q of this.state.qa.filter((x) => x.repoId === id)) this.broadcast({ type: "qaRemoved", repoId: id, prNumber: q.prNumber });
2860
+ this.state.qa = this.state.qa.filter((q) => q.repoId !== id);
2861
+ for (const r of this.state.repos) r.links = r.links.filter((l) => l !== id);
2862
+ this.repoRt.delete(id);
2863
+ this.state.repos.sort((a, b) => a.floor - b.floor).forEach((r, i) => r.floor = i + 1);
2864
+ this.backend.setLocalPath(repo.fullName, null);
2865
+ this.save();
2866
+ this.broadcast({ type: "repoRemoved", repoId: id });
2867
+ for (const r of this.state.repos) this.emitRepo(r);
2868
+ this.toast("info", repo.localPath ? `${repo.fullName} left the building. Your folder ${repo.localPath} is untouched.` : `${repo.fullName} disconnected (its clone stays on disk)`);
2869
+ }
2870
+ updateRepo(id, patch) {
2871
+ const repo = this.repo(id);
2872
+ const preview = parsePreviewPatch(patch.previewCommand, patch.previewEnv);
2873
+ repo.preview = { ...repo.preview, ...preview };
2874
+ if (preview.command === null) void this.previews.refreshDefault(repo);
2875
+ if (patch.autoAssign !== void 0) repo.autoAssign = !!patch.autoAssign;
2876
+ if (patch.autoMerge !== void 0) {
2877
+ repo.autoMerge = !!patch.autoMerge;
2878
+ if (repo.autoMerge) void this.syncRepo(repo.id);
2879
+ }
2880
+ if (patch.browserTesting !== void 0) repo.browserTesting = !!patch.browserTesting;
2881
+ if (patch.color && /^#[0-9a-f]{6}$/i.test(patch.color)) repo.color = patch.color;
2882
+ if (Array.isArray(patch.links)) repo.links = patch.links.filter((l) => l !== id && this.state.repos.some((r) => r.id === l));
2883
+ if (typeof patch.mission === "string") repo.mission = patch.mission.trim().slice(0, 4e3);
2884
+ if (typeof patch.summary === "string") repo.summary = patch.summary.trim().slice(0, 140);
2885
+ if (typeof patch.qaBrief === "string") repo.qaBrief = patch.qaBrief.trim().slice(0, 2500);
2886
+ this.save();
2887
+ this.emitRepo(repo);
2888
+ setTimeout(() => this.schedule(), 200);
2889
+ return this.repoView(repo);
2890
+ }
2891
+ // ---------- auto-merge ----------
2892
+ /**
2893
+ * Auto-merge (floors with it on): a swarm PR merges itself once QA has signed off on its latest commit and GitHub's
2894
+ * checks are green. Failing checks and merge conflicts go back to a developer; new commits after QA's sign-off send
2895
+ * it back to QA. Merging deletes the remote branch, and the floor's folder then fast-forwards (syncRepo sees the merge).
2896
+ */
2897
+ async advanceMerges(repo) {
2898
+ const rt = this.repoRt.get(repo.id);
2899
+ if (!rt || !repo.autoMerge || rt.merging) return;
2900
+ rt.merging = true;
2901
+ let merged = false;
2902
+ try {
2903
+ for (const rec of this.state.qa.filter((q) => q.repoId === repo.id && q.status === "passed")) {
2904
+ const pr = rt.pulls.find((p) => p.number === rec.prNumber && p.state === "OPEN");
2905
+ if (!pr || !pr.headRefName.startsWith("swarm/")) continue;
2906
+ if ((rt.fetchedAt ?? 0) < rec.updatedAt) continue;
2907
+ try {
2908
+ if (await this.advanceMerge(repo, rec, pr)) merged = true;
2909
+ } catch (err) {
2910
+ console.warn(`auto-merge of ${repo.fullName}#${pr.number} failed`, err);
2911
+ this.mergeNote(rec, `auto-merge hit a problem: ${oneLine(err)}`);
2912
+ }
2913
+ }
2914
+ } finally {
2915
+ rt.merging = false;
2916
+ }
2917
+ if (merged) {
2918
+ await this.syncRepo(repo.id);
2919
+ setTimeout(() => this.schedule(), 200);
2920
+ }
2921
+ }
2922
+ /** One step toward merging a QA-passed PR. Returns true when it merged. */
2923
+ async advanceMerge(repo, rec, pr) {
2924
+ if (pr.isDraft) return this.mergeNote(rec, "a draft: waiting");
2925
+ if (pr.mergeable === "UNKNOWN" || pr.mergeState === "UNKNOWN") {
2926
+ const d = await this.backend.prDetails(repo.fullName, pr.number);
2927
+ pr = { ...pr, headSha: d.headSha, mergeable: d.mergeable, mergeState: d.mergeState };
2928
+ }
2929
+ rec.passedSha ??= pr.headSha;
2930
+ if (pr.headSha !== rec.passedSha) {
2931
+ this.setQa(rec, { status: "queued", round: rec.round + 1, retests: rec.retests + 1, mergeNote: null, pendingSince: null });
2932
+ setTimeout(() => this.schedule(), 200);
2933
+ return false;
2934
+ }
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;
2945
+ 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
+ }
2947
+ return this.mergeNote(rec, `waiting for checks: ${pr.pendingChecks.join(", ") || "running"}`);
2948
+ }
2949
+ rec.pendingSince = null;
2950
+ if (pr.mergeable === "UNKNOWN") return this.mergeNote(rec, "GitHub is checking it can merge");
2951
+ if (pr.mergeState === "BEHIND") {
2952
+ this.mergeNote(rec, `updating the branch with ${repo.defaultBranch}`);
2953
+ await this.backend.updateBranch(repo.fullName, pr.number);
2954
+ const details = await this.backend.prDetails(repo.fullName, pr.number);
2955
+ this.setQa(rec, { passedSha: details.headSha });
2956
+ return false;
2957
+ }
2958
+ if (rec.mergeRetryAt && Date.now() < rec.mergeRetryAt) return false;
2959
+ this.mergeNote(rec, "merging\u2026");
2960
+ let error = "";
2961
+ for (const method of ["squash", "merge", "rebase"]) {
2962
+ try {
2963
+ await this.backend.mergePull(repo.fullName, pr.number, method, rec.passedSha);
2964
+ error = "";
2965
+ break;
2966
+ } catch (err) {
2967
+ error = oneLine(err).replace(/^gh .*? failed: /, "");
2968
+ if (!/not allowed/i.test(error)) break;
2969
+ }
2970
+ }
2971
+ if (error && /head (branch|commit)/i.test(error)) return this.mergeNote(rec, "new commits arrived; QA checks them first");
2972
+ if (error) {
2973
+ rec.mergeRetryAt = Date.now() + MERGE_RETRY_MS;
2974
+ if (!rec.alerted) {
2975
+ rec.alerted = true;
2976
+ this.postMessage("office", `\u26A0\uFE0F PR #${pr.number} on ${repo.fullName} passed QA and its checks, but GitHub won't merge it: ${error}. The office retries every ${MERGE_RETRY_MS / 6e4} minutes; merge it yourself, or change the repo's merge rules, if it keeps failing.`);
2977
+ }
2978
+ return this.mergeNote(rec, `merge blocked: ${error}`);
2979
+ }
2980
+ this.toast("success", `\u{1F500} Merged PR #${pr.number} into ${repo.defaultBranch}: ${pr.title}`);
2981
+ return true;
2982
+ }
2983
+ /** Show where auto-merge stands on a PR's card. Doesn't count as a change to the record. */
2984
+ mergeNote(rec, text) {
2985
+ if (rec.mergeNote !== text) {
2986
+ rec.mergeNote = text;
2987
+ this.broadcast({ type: "qa", qa: this.qaView(rec) });
2988
+ }
2989
+ return false;
2990
+ }
2991
+ /** 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) {
2994
+ this.setQa(rec, { status: "needs-human", mergeNote: null });
2995
+ 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
+ return false;
2997
+ }
2998
+ this.setQa(rec, { status: "failed", fixReason: reason, fixInstructions, mergeFixes: rec.mergeFixes + 1, mergeNote: null, pendingSince: null });
2999
+ setTimeout(() => this.schedule(), 200);
3000
+ return false;
3001
+ }
3002
+ /** Fast-forward the floor's main checkout (usually your own project folder) and report how it stands. */
3003
+ async syncFolder(repo) {
3004
+ const rt = this.repoRt.get(repo.id);
3005
+ 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)}`);
3008
+ if (!this.repoRt.has(repo.id)) return null;
3009
+ const before = rt.folderSync;
3010
+ rt.folderSync = status;
3011
+ this.emitRepo(repo);
3012
+ if (status && status !== before) {
3013
+ const name = repo.localPath ? path6.basename(repo.localPath) : repo.fullName;
3014
+ if (status.startsWith("updated")) this.toast("success", `\u{1F4C1} ${name}: ${status}`);
3015
+ else if (/behind|diverged|failed/.test(status)) this.toast("info", `\u{1F4C1} ${name} wasn't updated: ${status}`);
3016
+ }
3017
+ return status;
3018
+ }
3019
+ /** The manager's "Sync now". */
3020
+ async syncFolderNow(repoId2) {
3021
+ return { folderSync: await this.syncFolder(this.repo(repoId2)) };
3022
+ }
3023
+ // ---------- the floor's app (preview monitor) ----------
3024
+ /** Run the floor's app from its preview worktree: the default branch, or an open PR. Replaces what it is running now. */
3025
+ async startPreview(id, pr) {
3026
+ const repo = this.repo(id);
3027
+ return this.previews.start(repo, `${repo.fullName.split("/")[1]} app`, pr);
3028
+ }
3029
+ stopPreview(id) {
3030
+ return this.previews.stop(this.repo(id));
3031
+ }
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);
3035
+ }
3036
+ async cloneRepo(id) {
3037
+ const repo = this.state.repos.find((r) => r.id === id);
3038
+ const rt = this.repoRt.get(id);
3039
+ if (!repo || !rt || rt.cloneStatus === "cloning") return;
3040
+ rt.cloneStatus = "cloning";
3041
+ rt.cloneError = void 0;
3042
+ this.emitRepo(repo);
3043
+ try {
3044
+ await this.backend.ensureClone(repo.fullName);
3045
+ rt.cloneStatus = "ready";
3046
+ void this.previews.refreshDefault(repo);
3047
+ } catch (err) {
3048
+ rt.cloneStatus = "error";
3049
+ rt.cloneError = err.message;
3050
+ this.toast("error", `Could not clone ${repo.fullName}: ${rt.cloneError}`);
3051
+ }
3052
+ if (this.repoRt.has(id)) this.emitRepo(repo);
3053
+ }
3054
+ async syncRepo(id) {
3055
+ const repo = this.state.repos.find((r) => r.id === id);
3056
+ const rt = this.repoRt.get(id);
3057
+ if (!repo || !rt || rt.syncing) return;
3058
+ rt.syncing = true;
3059
+ try {
3060
+ const started = Date.now();
3061
+ const [issues, pulls] = await Promise.all([this.backend.listIssues(repo.fullName), this.backend.listPulls(repo.fullName)]);
3062
+ rt.issues = issues;
3063
+ rt.pulls = pulls;
3064
+ rt.lastSync = Date.now();
3065
+ rt.fetchedAt = started;
3066
+ rt.syncError = void 0;
3067
+ this.reconcilePulls(repo, pulls);
3068
+ const newest = pulls.reduce((m, p) => p.mergedAt && (!m || p.mergedAt > m) ? p.mergedAt : m, null);
3069
+ if (newest !== rt.lastMergedAt) {
3070
+ rt.lastMergedAt = newest;
3071
+ void this.syncFolder(repo);
3072
+ }
3073
+ void this.advanceMerges(repo);
3074
+ } catch (err) {
3075
+ rt.syncError = err.message;
3076
+ } finally {
3077
+ rt.syncing = false;
3078
+ }
3079
+ if (this.repoRt.has(id)) this.emitRepo(repo);
3080
+ }
3081
+ /** Keep agents and QA records in step with what happened to PRs on GitHub. */
3082
+ reconcilePulls(repo, pulls) {
3083
+ for (const a of this.state.agents) {
3084
+ if (a.repoId !== repo.id || a.role !== "dev" || a.prNumber == null || BUSY.includes(a.status) || a.status === "idle") continue;
3085
+ const pr = pulls.find((p) => p.number === a.prNumber);
3086
+ if (!pr || pr.state === "OPEN") continue;
3087
+ if (a.task === "qa") {
3088
+ this.clearTask(a);
3089
+ continue;
3090
+ }
3091
+ this.appendLog(a, [{ kind: "done", text: pr.state === "MERGED" ? `\u{1F389} PR #${pr.number} was merged. Ready for the next issue.` : `PR #${pr.number} was closed without merging.` }]);
3092
+ this.clearTask(a);
3093
+ }
3094
+ for (const q of this.state.qa.filter((x) => x.repoId === repo.id)) {
3095
+ const pr = pulls.find((p) => p.number === q.prNumber);
3096
+ if (pr && pr.state !== "OPEN" && q.status !== "testing" && q.status !== "fixing") {
3097
+ this.state.qa = this.state.qa.filter((x) => x !== q);
3098
+ this.broadcast({ type: "qaRemoved", repoId: repo.id, prNumber: q.prNumber });
3099
+ }
3100
+ }
3101
+ for (const pr of pulls) {
3102
+ if (pr.state !== "OPEN" || pr.isDraft || !pr.headRefName.startsWith("swarm/")) continue;
3103
+ if (this.state.qa.some((q) => q.repoId === repo.id && q.prNumber === pr.number)) continue;
3104
+ const dev = this.state.agents.find((a) => a.repoId === repo.id && a.role === "dev" && a.task !== "qa" && (a.prNumber === pr.number || a.branch === pr.headRefName));
3105
+ this.queueQa(repo, pr.number, dev ?? null, pr.closesIssues[0] ?? null);
3106
+ }
3107
+ this.save();
3108
+ }
3109
+ async createIssue(repoId2, title, body, assignTo, specialty) {
3110
+ const repo = this.repo(repoId2);
3111
+ if (!title.trim()) throw new HttpError(400, "An issue needs a title");
3112
+ const slug = specialtySlug(specialty);
3113
+ const number = await this.backend.createIssue(repo.fullName, title.trim(), body, slug ? [specialtyLabel(slug)] : []);
3114
+ await this.syncRepo(repo.id);
3115
+ this.toast("success", `Issue #${number} filed on ${repo.fullName}`);
3116
+ if (assignTo) await this.assign(assignTo, number);
3117
+ else setTimeout(() => this.schedule(), 200);
3118
+ return number;
3119
+ }
3120
+ async mergePull(repoId2, number, method = "squash") {
3121
+ const repo = this.repo(repoId2);
3122
+ await this.backend.mergePull(repo.fullName, number, method);
3123
+ this.toast("success", `Merged PR #${number} into ${repo.defaultBranch}`);
3124
+ await this.syncRepo(repo.id);
3125
+ setTimeout(() => this.schedule(), 200);
3126
+ }
3127
+ async closePull(repoId2, number) {
3128
+ const repo = this.repo(repoId2);
3129
+ await this.backend.closePull(repo.fullName, number);
3130
+ await this.syncRepo(repo.id);
3131
+ }
3132
+ // ---------- agents ----------
3133
+ hireAgent(repoId2, opts) {
3134
+ const repo = this.repo(repoId2);
3135
+ const role = opts.role === "qa" ? "qa" : "dev";
3136
+ const used = new Set(this.state.agents.filter((a) => a.repoId === repo.id && a.role === role).map((a) => a.desk));
3137
+ let desk = 0;
3138
+ while (used.has(desk)) desk++;
3139
+ if (desk >= MAX_DESKS[role]) {
3140
+ throw new HttpError(400, role === "qa" ? `The QA lab on floor ${repo.floor} is full (${MAX_DESKS.qa} stations)` : `Floor ${repo.floor} is full (${MAX_DESKS.dev} desks)`);
3141
+ }
3142
+ const name = opts.name?.trim() || this.freeName(role);
3143
+ const agent = {
3144
+ id: crypto3.randomUUID(),
3145
+ name,
3146
+ repoId: repo.id,
3147
+ role,
3148
+ title: String(opts.title ?? "").trim().slice(0, 60),
3149
+ specialty: specialtySlug(opts.specialty),
3150
+ brief: String(opts.brief ?? "").trim().slice(0, 2500),
3151
+ hiredBy: opts.hiredBy ?? "manager",
3152
+ look: LOOKS.includes(opts.look) ? opts.look : lookFor(name),
3153
+ task: null,
3154
+ desk,
3155
+ color: opts.appearance?.color ?? pick(SHIRTS),
3156
+ hair: opts.appearance?.hair ?? pick(HAIR),
3157
+ skin: opts.appearance?.skin ?? pick(SKIN),
3158
+ model: opts.model ?? "",
3159
+ effort: EFFORTS.includes(opts.effort) ? opts.effort : "",
3160
+ status: "idle",
3161
+ issueNumber: null,
3162
+ issueTitle: null,
3163
+ branch: null,
3164
+ prNumber: null,
3165
+ prUrl: null,
3166
+ startedAt: null,
3167
+ endedAt: null,
3168
+ costUsd: 0,
3169
+ turns: 0,
3170
+ sessionId: null,
3171
+ lastError: null,
3172
+ logTail: []
3173
+ };
3174
+ this.state.agents.push(agent);
3175
+ this.agentRt.set(agent.id, { log: [], pending: [], session: null, currentTool: null, browserUrl: null, screenshot: null, shots: [] });
3176
+ this.appendLog(agent, [
3177
+ { 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
+ ...agent.title ? [{ kind: "system", text: `\u{1FAAA} ${agent.title}${agent.specialty ? ` \xB7 takes swarm:${agent.specialty} issues first` : ""}` }] : []
3179
+ ]);
3180
+ this.save();
3181
+ this.broadcast({ type: "agent", agent: this.agentView(agent, false) });
3182
+ setTimeout(() => this.schedule(), 200);
3183
+ return this.agentView(agent, true);
3184
+ }
3185
+ /** Every floor has at least one QA tester. */
3186
+ ensureQaTester(repo) {
3187
+ if (this.state.agents.some((a) => a.repoId === repo.id && a.role === "qa")) return;
3188
+ this.hireAgent(repo.id, { role: "qa" });
3189
+ }
3190
+ /** A name from the role's pool that no agent or pending candidate has. */
3191
+ freeName(role) {
3192
+ const taken = /* @__PURE__ */ new Set([...this.state.agents.map((a) => a.name), ...this.state.requests.filter((r) => r.status === "pending").map((r) => r.name)]);
3193
+ const pool = role === "qa" ? QA_NAMES : DEV_NAMES;
3194
+ return pool.find((n) => !taken.has(n)) || `${role === "qa" ? "Tester" : "Agent"} ${this.state.agents.length + 1}`;
3195
+ }
3196
+ updateAgent(id, patch) {
3197
+ const a = this.agent(id);
3198
+ if (patch.name?.trim() && patch.name.trim() !== a.name) {
3199
+ a.name = patch.name.trim().slice(0, 24);
3200
+ a.look = lookFor(a.name);
3201
+ }
3202
+ if (LOOKS.includes(patch.look)) a.look = patch.look;
3203
+ if (patch.color && /^#[0-9a-f]{6}$/i.test(patch.color)) a.color = patch.color;
3204
+ if (patch.hair && /^#[0-9a-f]{6}$/i.test(patch.hair)) a.hair = patch.hair;
3205
+ if (patch.model !== void 0) a.model = String(patch.model).trim();
3206
+ if (patch.effort !== void 0) a.effort = EFFORTS.includes(patch.effort) ? patch.effort : "";
3207
+ if (a.role !== "ceo") {
3208
+ if (patch.title !== void 0) a.title = String(patch.title).trim().slice(0, 60);
3209
+ if (patch.specialty !== void 0) a.specialty = specialtySlug(patch.specialty);
3210
+ if (patch.brief !== void 0) a.brief = String(patch.brief).trim().slice(0, 2500);
3211
+ }
3212
+ this.save();
3213
+ this.emitAgent(a);
3214
+ }
3215
+ fireAgent(id, force = false) {
3216
+ const a = this.agent(id);
3217
+ if (a.role === "ceo") throw new HttpError(409, `${a.name} runs the company and can't be let go.`);
3218
+ if (!force && a.role === "qa" && this.state.agents.filter((x) => x.repoId === a.repoId && x.role === "qa").length <= 1) {
3219
+ throw new HttpError(409, `${a.name} is the only QA tester on this floor, and every floor needs at least one.`);
3220
+ }
3221
+ for (const r of this.state.requests.filter((x) => x.kind === "let-go" && x.agentId === id && x.status === "pending")) {
3222
+ this.decide(r, { status: "approved", note: "They were let go directly.", decidedBy: "manager" });
3223
+ }
3224
+ this.agentRt.get(id)?.session?.stop();
3225
+ void removeScreens(id);
3226
+ for (const q of this.state.qa) {
3227
+ if (q.qaAgentId === id && q.status === "testing") this.setQa(q, { status: "queued", qaAgentId: null });
3228
+ if (q.devAgentId === id) q.devAgentId = null;
3229
+ if (q.devAgentId === null && q.status === "fixing") this.setQa(q, { status: "failed" });
3230
+ }
3231
+ const repo = this.state.repos.find((r) => r.id === a.repoId);
3232
+ if (repo) {
3233
+ const slug = this.agentSlug(a);
3234
+ void this.backend.releaseDesk(repo.fullName, slug, this.port(a)).then(() => this.backend.removeDesk(repo.fullName, slug)).catch(() => void 0);
3235
+ }
3236
+ this.state.agents = this.state.agents.filter((x) => x.id !== id);
3237
+ this.agentRt.delete(id);
3238
+ this.save();
3239
+ this.broadcast({ type: "agentRemoved", agentId: id });
3240
+ }
3241
+ stopAgent(id) {
3242
+ const a = this.agent(id);
3243
+ if (!BUSY.includes(a.status)) return;
3244
+ a.status = "stopped";
3245
+ a.lastError = "Stopped by manager";
3246
+ this.appendLog(a, [{ kind: "manager", text: "\u25A0 Manager stopped this session." }]);
3247
+ this.agentRt.get(id)?.session?.stop();
3248
+ this.emitAgent(a);
3249
+ this.save();
3250
+ }
3251
+ resetAgent(id) {
3252
+ const a = this.agent(id);
3253
+ if (BUSY.includes(a.status)) throw new HttpError(409, `${a.name} is busy; stop them first`);
3254
+ for (const q of this.state.qa) {
3255
+ if (q.qaAgentId === id && q.status === "testing") this.setQa(q, { status: "queued", qaAgentId: null });
3256
+ if (q.devAgentId === id && q.status === "fixing") this.setQa(q, { status: "failed" });
3257
+ }
3258
+ this.clearTask(a);
3259
+ this.appendLog(a, [{ kind: "system", text: "\u21BA Cleared desk. Ready for new work." }]);
3260
+ this.save();
3261
+ }
3262
+ issueTaken(repo, n) {
3263
+ if (this.state.agents.some((a) => a.repoId === repo.id && a.role === "dev" && a.task !== "qa" && a.issueNumber === n && a.status !== "idle" && a.status !== "error")) return true;
3264
+ const pulls = this.repoRt.get(repo.id)?.pulls ?? [];
3265
+ return pulls.some((p) => p.state === "OPEN" && (p.closesIssues.includes(n) || p.headRefName.startsWith(`swarm/issue-${n}-`)));
3266
+ }
3267
+ ensureSlot() {
3268
+ if (this.slotsFull()) {
3269
+ throw new HttpError(429, `All ${this.state.settings.sessionLimit} session slots are busy. Raise or clear the session limit in the manager's console, or wait.`);
3270
+ }
3271
+ }
3272
+ async assign(agentId, issueNumber, note) {
3273
+ const a = this.agent(agentId);
3274
+ const repo = this.repo(a.repoId);
3275
+ if (a.role === "qa") throw new HttpError(400, `${a.name} is a QA tester; they test pull requests rather than issues.`);
3276
+ if (a.role === "ceo") throw new HttpError(400, `${a.name} runs the company; give issues to the developers.`);
3277
+ if (BUSY.includes(a.status)) throw new HttpError(409, `${a.name} is already working on #${a.issueNumber}`);
3278
+ this.ensureSlot();
3279
+ const issue2 = this.repoRt.get(repo.id)?.issues.find((i) => i.number === issueNumber);
3280
+ if (!issue2) throw new HttpError(404, `Issue #${issueNumber} is not open on ${repo.fullName}`);
3281
+ const holder = this.state.agents.find((x) => x.id !== a.id && x.repoId === repo.id && x.issueNumber === issueNumber && BUSY.includes(x.status));
3282
+ if (holder) throw new HttpError(409, `${holder.name} is already working on #${issueNumber}`);
3283
+ void this.runTask(a, repo, issue2, note);
3284
+ return this.agentView(a, false);
3285
+ }
3286
+ port(a) {
3287
+ return 5200 + parseInt(a.id.slice(0, 4), 16) % 700;
3288
+ }
3289
+ linkedRepos(repo) {
3290
+ return repo.links.map((id) => this.state.repos.find((r) => r.id === id)).filter((r) => !!r);
3291
+ }
3292
+ buildSystemAppend(a, repo, cwd, branch, fixing) {
3293
+ const linked = this.linkedRepos(repo).map((r) => `- ${r.fullName}: read-only reference clone at ${this.backend.mainDir(r.fullName)}`);
3294
+ const push = fixing ? `git push origin HEAD:${fixing.headRef}` : `git push -u origin ${branch}`;
3295
+ return [
3296
+ `You are ${a.name}, ${a.title ? `the team's ${a.title},` : "a software engineer"} on an autonomous agent team ("cubefarm"). Several teammates work in parallel on other issues of the same repository, each in their own git worktree. Nobody is watching live to answer questions, so make sensible decisions yourself and record assumptions in the PR description. The manager may occasionally send you messages; follow their instructions.`,
3297
+ `Every pull request is reviewed and tested by a QA teammate. ${repo.autoMerge ? "Once they sign off and GitHub's checks pass, the office merges it by itself" : "Once they sign off, the manager merges it"}. If they find problems, or checks fail, or it conflicts with the default branch, you will get the details; fix them on the same branch.`,
3298
+ a.brief ? `
3299
+ Your job description:
3300
+ ${a.brief}` : "",
3301
+ "",
3302
+ `Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
3303
+ repo.summary ? `Project: ${repo.summary}` : "",
3304
+ repo.mission ? `What the team is building (the manager's brief): ${repo.mission}` : "",
3305
+ `Your worktree: ${cwd}`,
3306
+ fixing ? `You are fixing pull request #${fixing.pr}. Its code is checked out on local branch ${branch}; push fixes with: ${push}. Do not open a new pull request.` : `Your branch: ${branch} (already checked out, created from origin/${repo.defaultBranch})`,
3307
+ linked.length ? `Related repositories you may read for context (do not modify them):
3308
+ ${linked.join("\n")}` : "",
3309
+ "",
3310
+ "Workflow:",
3311
+ "1. Read the issue and explore the relevant code before changing anything.",
3312
+ "2. Implement the change with focused commits and clear messages.",
3313
+ "3. Run the project's existing tests, linters and build (if any) and fix what you broke. Install dependencies first if needed.",
3314
+ repo.browserTesting ? `4. If the project has a web UI, start its dev server in the background on port ${this.port(a)} (reserved for you, so you don't collide with teammates), then check your change with the Playwright browser tools (mcp__playwright__browser_navigate, browser_snapshot, browser_click, browser_take_screenshot). Stop the dev server when you're done.` : "4. Verify the behaviour you changed as directly as you can.",
3315
+ `5. Push: ${push}`,
3316
+ fixing ? "6. Reply with a short summary of what you fixed." : `6. Open a pull request with the GitHub CLI: gh pr create --base ${repo.defaultBranch} --head ${branch} --title "<concise title>" --body "<what changed, how you verified it, assumptions>". The body must contain "Closes #<issue number>".`,
3317
+ fixing ? "" : "7. End your final message with the pull request URL on its own line.",
3318
+ "",
3319
+ "Rules: never push to the default branch, never force-push, never merge pull requests yourself (the office merges them once QA and the checks pass), and never edit files outside your worktree. If you cannot finish, open a draft PR (gh pr create --draft) explaining what is left and why."
3320
+ ].filter((l) => l !== "").join("\n");
3321
+ }
3322
+ beginTask(a, patch, banner, preparing) {
3323
+ const rt = this.agentRt.get(a.id);
3324
+ Object.assign(a, {
3325
+ status: "preparing",
3326
+ startedAt: Date.now(),
3327
+ endedAt: null,
3328
+ costUsd: 0,
3329
+ turns: 0,
3330
+ lastError: null,
3331
+ ...patch
3332
+ });
3333
+ rt.screenshot = null;
3334
+ rt.browserUrl = null;
3335
+ rt.shots = [];
3336
+ void removeScreens(a.id);
3337
+ this.appendLog(a, [
3338
+ { kind: "system", text: "" },
3339
+ { kind: "system", text: `\u2501\u2501\u2501 ${banner} \u2501\u2501\u2501` },
3340
+ { kind: "system", text: preparing }
3341
+ ]);
3342
+ this.emitAgent(a);
3343
+ this.save();
3344
+ }
3345
+ async prepare(a, repo, base, branch) {
3346
+ try {
3347
+ if (this.repoRt.get(repo.id)?.cloneStatus !== "ready") await this.cloneRepo(repo.id);
3348
+ await this.backend.releaseDesk(repo.fullName, this.agentSlug(a), this.port(a));
3349
+ const cwd = await this.backend.prepareDesk(repo.fullName, { defaultBranch: repo.defaultBranch, pr: base.pr }, this.agentSlug(a), branch);
3350
+ return a.status === "preparing" ? cwd : null;
3351
+ } catch (err) {
3352
+ if (a.status !== "preparing") return null;
3353
+ a.status = "error";
3354
+ a.endedAt = Date.now();
3355
+ a.lastError = err.message;
3356
+ this.appendLog(a, [{ kind: "error", text: `\u2717 ${a.lastError}` }]);
3357
+ this.emitAgent(a);
3358
+ this.save();
3359
+ return null;
3360
+ }
3361
+ }
3362
+ async runTask(a, repo, issue2, note) {
3363
+ const branch = `swarm/issue-${issue2.number}-${slugify(a.name)}`;
3364
+ this.beginTask(
3365
+ a,
3366
+ { task: "issue", issueNumber: issue2.number, issueTitle: issue2.title, branch, prNumber: null, prUrl: null, sessionId: null },
3367
+ `Issue #${issue2.number}: ${issue2.title}`,
3368
+ `Preparing worktree on ${branch}\u2026`
3369
+ );
3370
+ const cwd = await this.prepare(a, repo, {}, branch);
3371
+ if (!cwd) return;
3372
+ const prompt = [
3373
+ `Please resolve GitHub issue #${issue2.number}: ${issue2.title}`,
3374
+ `URL: ${issue2.url}`,
3375
+ issue2.labels.length ? `Labels: ${issue2.labels.join(", ")}` : "",
3376
+ "",
3377
+ issue2.body?.trim() || "(The issue has no description.)",
3378
+ note ? `
3379
+ Note from the manager: ${note}` : ""
3380
+ ].filter(Boolean).join("\n");
3381
+ this.startAgentSession(a, repo, cwd, prompt, this.buildSystemAppend(a, repo, cwd, branch));
3382
+ }
3383
+ startAgentSession(a, repo, cwd, prompt, systemAppend, resumeSessionId, outputSchema) {
3384
+ const rt = this.agentRt.get(a.id);
3385
+ a.status = "working";
3386
+ this.emitAgent(a);
3387
+ rt.session = this.backend.startSession(
3388
+ {
3389
+ cwd,
3390
+ prompt,
3391
+ systemAppend,
3392
+ model: a.model || this.state.settings.defaultModel,
3393
+ effort: a.effort || this.state.settings.defaultEffort,
3394
+ browserTesting: repo.browserTesting,
3395
+ permissionMode: this.state.settings.permissionMode,
3396
+ additionalDirectories: this.linkedRepos(repo).map((r) => this.backend.mainDir(r.fullName)),
3397
+ role: a.task === "qa" ? "qa" : a.role,
3398
+ // a developer covering QA works under QA's rules
3399
+ outputSchema,
3400
+ resumeSessionId
3401
+ },
3402
+ {
3403
+ log: (entries) => this.appendLog(a, entries),
3404
+ tool: (name) => {
3405
+ if (rt.currentTool === name) return;
3406
+ rt.currentTool = name;
3407
+ this.emitAgent(a);
3408
+ },
3409
+ sessionId: (id) => {
3410
+ a.sessionId = id;
3411
+ },
3412
+ browserUrl: (url) => {
3413
+ rt.browserUrl = url;
3414
+ this.emitAgent(a);
3415
+ },
3416
+ screenshot: (data, mime) => {
3417
+ const at = Date.now();
3418
+ rt.screenshot = { data, mime, at };
3419
+ rt.shots.push({ data, mime, url: rt.browserUrl, at });
3420
+ if (rt.shots.length > 12) rt.shots.shift();
3421
+ 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));
3423
+ },
3424
+ limited: (at) => this.pauseForLimit(at),
3425
+ finished: (result) => void this.onFinished(a, repo, result)
3426
+ },
3427
+ repo.defaultBranch
3428
+ );
3429
+ }
3430
+ async onFinished(a, repo, result) {
3431
+ const rt = this.agentRt.get(a.id);
3432
+ if (!rt || !this.state.agents.includes(a)) return;
3433
+ rt.session = null;
3434
+ rt.currentTool = null;
3435
+ a.endedAt = Date.now();
3436
+ a.costUsd += result.costUsd;
3437
+ a.turns += result.turns;
3438
+ void this.backend.releaseDesk(repo.fullName, this.agentSlug(a), this.port(a)).catch(() => void 0);
3439
+ if (a.task === "qa") await this.onQaFinished(a, repo, result);
3440
+ else if (a.task === "fix") this.onFixFinished(a, repo, result);
3441
+ else await this.onIssueFinished(a, repo, result);
3442
+ this.emitAgent(a);
3443
+ this.save();
3444
+ void this.syncRepo(repo.id);
3445
+ setTimeout(() => this.schedule(), 500);
3446
+ }
3447
+ minutes(a) {
3448
+ return a.startedAt && a.endedAt ? Math.max(1, Math.round((a.endedAt - a.startedAt) / 6e4)) : 0;
3449
+ }
3450
+ fail(a, result, what) {
3451
+ a.status = "error";
3452
+ a.lastError = result.errors.join("; ") || "Session failed";
3453
+ this.appendLog(a, [{ kind: "error", text: `\u2717 ${a.lastError}` }]);
3454
+ this.toast("error", `${a.name} hit a problem on ${what}: ${a.lastError.slice(0, 120)}`);
3455
+ }
3456
+ async onIssueFinished(a, repo, result) {
3457
+ const escaped = repo.fullName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3458
+ const m = result.text.match(new RegExp(`https://github\\.com/${escaped}/pull/(\\d+)`, "i"));
3459
+ if (m) {
3460
+ a.prNumber = Number(m[1]);
3461
+ a.prUrl = m[0];
3462
+ } else if (a.branch) {
3463
+ const pr = await this.backend.prForBranch(repo.fullName, a.branch).catch(() => null);
3464
+ if (pr) {
3465
+ a.prNumber = pr.number;
3466
+ a.prUrl = pr.url;
3467
+ }
3468
+ }
3469
+ if (a.status === "stopped") return;
3470
+ if (!result.ok) {
3471
+ this.issueFailed(repo, a.issueNumber);
3472
+ return this.fail(a, result, `#${a.issueNumber}`);
3473
+ }
3474
+ this.issueFailures.delete(`${repo.id}#${a.issueNumber}`);
3475
+ a.status = "done";
3476
+ this.appendLog(a, [{ kind: "done", text: `\u2714 Finished in ${this.minutes(a)}m \xB7 ${a.turns} turns${a.prNumber ? ` \xB7 PR #${a.prNumber}` : " \xB7 no PR found"}` }]);
3477
+ if (a.prNumber) {
3478
+ this.queueQa(repo, a.prNumber, a, a.issueNumber);
3479
+ this.appendLog(a, [{ kind: "system", text: `\u{1F4E8} Handed PR #${a.prNumber} to QA.` }]);
3480
+ this.toast("success", `${a.name} opened PR #${a.prNumber} for #${a.issueNumber}; it's off to QA`);
3481
+ } else {
3482
+ this.noPullRequest(a, repo);
3483
+ }
3484
+ }
3485
+ /**
3486
+ * An issue session ended without a PR, which would leave the issue "taken" with nobody on it. The same developer,
3487
+ * who has the context and the worktree, is asked once to finish; after that the issue goes back on the board.
3488
+ */
3489
+ noPullRequest(a, repo) {
3490
+ const key = `${repo.id}#${a.issueNumber}`;
3491
+ if (this.nudged.has(key)) return this.releaseIssue(a, repo);
3492
+ this.nudged.add(key);
3493
+ void this.message(
3494
+ a.id,
3495
+ `You finished without opening a pull request for #${a.issueNumber}. Finish the remaining steps now: commit, push your branch and open the PR with "Closes #${a.issueNumber}". If the issue can't be done, open a draft PR that explains why.`
3496
+ ).catch(() => this.releaseIssue(a, repo));
3497
+ }
3498
+ releaseIssue(a, repo) {
3499
+ const n = a.issueNumber;
3500
+ this.clearTask(a);
3501
+ this.postMessage("office", `\u26A0\uFE0F ${a.name} finished #${n} on ${repo.fullName} without opening a pull request, so it's back on the board for anyone.`);
3502
+ }
3503
+ // ---------- QA ----------
3504
+ queueQa(repo, prNumber, dev, issueNumber) {
3505
+ let rec = this.state.qa.find((q) => q.repoId === repo.id && q.prNumber === prNumber);
3506
+ if (rec) {
3507
+ if (rec.status === "testing") return;
3508
+ this.setQa(rec, { status: "queued", devAgentId: dev?.id ?? rec.devAgentId, devSessionId: dev?.sessionId ?? rec.devSessionId });
3509
+ } else {
3510
+ rec = {
3511
+ repoId: repo.id,
3512
+ prNumber,
3513
+ status: "queued",
3514
+ round: 1,
3515
+ devAgentId: dev?.id ?? null,
3516
+ qaAgentId: null,
3517
+ summary: null,
3518
+ checks: [],
3519
+ commentUrl: null,
3520
+ updatedAt: Date.now(),
3521
+ issueNumber,
3522
+ devSessionId: dev && dev.prNumber === prNumber ? dev.sessionId : null,
3523
+ fixInstructions: null,
3524
+ sessionFailures: 0,
3525
+ mergeNote: null,
3526
+ testedSha: null,
3527
+ passedSha: null,
3528
+ fixReason: null,
3529
+ mergeFixes: 0,
3530
+ retests: 0,
3531
+ pendingSince: null,
3532
+ mergeRetryAt: null,
3533
+ alerted: false
3534
+ };
3535
+ this.state.qa.push(rec);
3536
+ this.setQa(rec, {});
3537
+ }
3538
+ setTimeout(() => this.schedule(), 200);
3539
+ }
3540
+ /** Manager's "send to QA" for any open PR (including ones opened by people). */
3541
+ async sendToQa(repoId2, prNumber) {
3542
+ const repo = this.repo(repoId2);
3543
+ const pr = this.repoRt.get(repo.id)?.pulls.find((p) => p.number === prNumber && p.state === "OPEN");
3544
+ if (!pr) throw new HttpError(404, `PR #${prNumber} is not open on ${repo.fullName}`);
3545
+ const rec = this.state.qa.find((q) => q.repoId === repo.id && q.prNumber === prNumber);
3546
+ if (rec?.status === "testing" || rec?.status === "fixing") throw new HttpError(409, `PR #${prNumber} is already ${rec.status}`);
3547
+ if (rec && (rec.status === "needs-human" || rec.status === "passed" || rec.status === "failed")) {
3548
+ rec.round += 1;
3549
+ rec.sessionFailures = 0;
3550
+ }
3551
+ const dev = this.state.agents.find((a) => a.repoId === repo.id && a.role === "dev" && a.task !== "qa" && (a.prNumber === prNumber || a.branch === pr.headRefName));
3552
+ this.queueQa(repo, prNumber, dev ?? null, pr.closesIssues[0] ?? null);
3553
+ this.toast("info", `PR #${prNumber} is queued for QA`);
3554
+ }
3555
+ buildQaSystemAppend(a, repo, cwd, branch, pr) {
3556
+ return [
3557
+ `You are ${a.name}, ${a.title ? `the team's ${a.title},` : "a QA engineer"} on an autonomous agent team ("cubefarm"). Developers open pull requests; you review and independently verify each one before it is merged. Your sign-off is the review: ${repo.autoMerge ? "on this floor a PR you pass merges by itself as soon as GitHub's checks are green, so nobody else reads the code after you. " : ""}Be thorough and skeptical, but fair: fail a PR only for real problems (broken behaviour, failing tests or build, the issue's requirements not met, obvious regressions), not for style preferences.`,
3558
+ ...a.brief ? ["", `Your job description:
3559
+ ${a.brief}`] : [],
3560
+ ...a.role === "dev" ? ["", "You're a developer covering for the QA lab while its testers are busy. You didn't write this pull request: test it as an independent QA engineer would."] : [],
3561
+ "",
3562
+ `Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
3563
+ ...repo.summary ? [`Project: ${repo.summary}`] : [],
3564
+ ...repo.mission ? [`What the team is building (the manager's brief): ${repo.mission}`] : [],
3565
+ ...repo.qaBrief ? [`What to check on this project (from the CEO):
3566
+ ${repo.qaBrief}`] : [],
3567
+ `Pull request #${pr.number} "${pr.title}" from branch ${pr.headRefName}: ${pr.url}`,
3568
+ `Your worktree: ${cwd}. It has the pull request's code checked out on local branch ${branch}.`,
3569
+ "",
3570
+ "How to test:",
3571
+ "1. Read the PR description and the linked issue, and work out the acceptance criteria.",
3572
+ `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
+ "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).",
3575
+ "5. You may write throwaway scripts to probe behaviour, but do not commit them.",
3576
+ "",
3577
+ "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."
3578
+ ].join("\n");
3579
+ }
3580
+ async runQa(a, repo, rec) {
3581
+ const branch = `qa/pr-${rec.prNumber}-${slugify(a.name)}`;
3582
+ this.setQa(rec, { status: "testing", qaAgentId: a.id });
3583
+ this.beginTask(
3584
+ a,
3585
+ { task: "qa", issueNumber: rec.issueNumber, issueTitle: `PR #${rec.prNumber}`, branch, prNumber: rec.prNumber, prUrl: null, sessionId: null },
3586
+ `QA \xB7 PR #${rec.prNumber} \xB7 round ${rec.round}`,
3587
+ `Checking out PR #${rec.prNumber}\u2026`
3588
+ );
3589
+ let pr;
3590
+ let issue2 = null;
3591
+ try {
3592
+ pr = await this.backend.prDetails(repo.fullName, rec.prNumber);
3593
+ const issueNumber = rec.issueNumber ?? pr.closesIssues[0] ?? null;
3594
+ if (issueNumber) issue2 = await this.backend.issueDetails(repo.fullName, issueNumber).catch(() => null);
3595
+ Object.assign(a, { issueTitle: pr.title, prUrl: pr.url });
3596
+ rec.testedSha = pr.headSha;
3597
+ this.emitAgent(a);
3598
+ } catch (err) {
3599
+ a.status = "error";
3600
+ a.endedAt = Date.now();
3601
+ a.lastError = err.message;
3602
+ this.appendLog(a, [{ kind: "error", text: `\u2717 ${a.lastError}` }]);
3603
+ this.setQa(rec, { status: "queued", qaAgentId: null });
3604
+ this.emitAgent(a);
3605
+ return;
3606
+ }
3607
+ if (pr.state !== "OPEN") {
3608
+ this.appendLog(a, [{ kind: "system", text: `PR #${pr.number} is ${pr.state.toLowerCase()}; nothing to test.` }]);
3609
+ this.state.qa = this.state.qa.filter((q) => q !== rec);
3610
+ this.broadcast({ type: "qaRemoved", repoId: repo.id, prNumber: rec.prNumber });
3611
+ this.clearTask(a);
3612
+ return;
3613
+ }
3614
+ const cwd = await this.prepare(a, repo, { pr: rec.prNumber }, branch);
3615
+ if (!cwd) {
3616
+ if (this.state.qa.includes(rec) && rec.status === "testing") this.setQa(rec, { status: "queued", qaAgentId: null });
3617
+ return;
3618
+ }
3619
+ const dev = rec.devAgentId ? this.state.agents.find((x) => x.id === rec.devAgentId) : null;
3620
+ const prompt = [
3621
+ `Please QA pull request #${pr.number}: ${pr.title}`,
3622
+ `URL: ${pr.url}`,
3623
+ `Author: ${dev ? `${dev.name} (developer agent)` : "a teammate"} \xB7 QA round ${rec.round}`,
3624
+ rec.fixReason === "conflict" ? `
3625
+ QA passed it before, but since then the branch was updated with ${repo.defaultBranch} to resolve merge conflicts. Re-check everything, especially where this change meets the newly merged work.` : rec.fixReason === "checks" ? "\nQA passed it before, but since then the developer changed the code to fix failing GitHub checks. Re-check everything." : rec.round > 1 && rec.summary ? `
3626
+ This is a re-test after fixes. Last round's findings:
3627
+ ${rec.summary}
3628
+ ${rec.fixInstructions ?? ""}
3629
+ Check those first, then re-check everything else.` : "",
3630
+ "",
3631
+ "PR description:",
3632
+ pr.body.trim() || "(empty)",
3633
+ issue2 ? `
3634
+ Linked issue: ${issue2.title}
3635
+ ${issue2.body.trim() || "(no description)"}` : ""
3636
+ ].filter((l) => l !== "").join("\n");
3637
+ this.startAgentSession(a, repo, cwd, prompt, this.buildQaSystemAppend(a, repo, cwd, branch, pr), void 0, QA_SCHEMA);
3638
+ }
3639
+ async onQaFinished(a, repo, result) {
3640
+ const rec = this.state.qa.find((q) => q.repoId === repo.id && q.prNumber === a.prNumber);
3641
+ const rt = this.agentRt.get(a.id);
3642
+ const report = result.ok ? parseReport(result) : null;
3643
+ if (a.status === "stopped" || !report) {
3644
+ if (a.status !== "stopped") this.fail(a, { ...result, errors: result.errors.length ? result.errors : ["QA finished without a usable report"] }, `QA of PR #${a.prNumber}`);
3645
+ if (rec) {
3646
+ const failures = rec.sessionFailures + (this.limited() ? 0 : 1);
3647
+ this.setQa(rec, {
3648
+ status: a.status === "stopped" || failures >= 2 ? "needs-human" : "queued",
3649
+ qaAgentId: null,
3650
+ sessionFailures: failures,
3651
+ summary: a.status === "stopped" ? "QA was stopped by the manager." : rec.summary
3652
+ });
3653
+ }
3654
+ return;
3655
+ }
3656
+ a.status = "done";
3657
+ const pass = report.verdict === "pass";
3658
+ this.appendLog(a, [{ kind: pass ? "done" : "error", text: `${pass ? "\u2705 QA passed" : "\u274C QA failed"} PR #${a.prNumber} \xB7 ${report.checks.length} checks \xB7 ${rt.shots.length} screenshots` }]);
3659
+ let commentUrl = null;
3660
+ if (rec) {
3661
+ try {
3662
+ this.appendLog(a, [{ kind: "system", text: "\u{1F4CE} Uploading evidence and posting the QA report on the PR\u2026" }]);
3663
+ const body = await this.renderQaComment(a, repo, rec, report, rt.shots);
3664
+ commentUrl = await this.backend.commentPull(repo.fullName, rec.prNumber, body) || null;
3665
+ this.appendLog(a, [{ kind: "system", text: ` \u23BF ${commentUrl ?? "comment posted"}` }]);
3666
+ } catch (err) {
3667
+ this.appendLog(a, [{ kind: "error", text: ` \u23BF Could not post the QA report: ${err.message}` }]);
3668
+ }
3669
+ const qaRounds = rec.round - rec.retests;
3670
+ const nextStatus = pass ? "passed" : qaRounds >= MAX_QA_ROUNDS ? "needs-human" : "failed";
3671
+ this.setQa(rec, {
3672
+ status: nextStatus,
3673
+ summary: report.summary,
3674
+ checks: report.checks,
3675
+ commentUrl: commentUrl ?? rec.commentUrl,
3676
+ fixInstructions: report.fixInstructions ?? report.checks.filter((c) => c.result === "fail").map((c) => `${c.name}: ${c.details}`).join("\n"),
3677
+ sessionFailures: 0,
3678
+ fixReason: pass ? null : "qa",
3679
+ passedSha: pass ? rec.testedSha : null,
3680
+ mergeNote: null,
3681
+ pendingSince: null,
3682
+ mergeRetryAt: null,
3683
+ alerted: false
3684
+ });
3685
+ this.toast(
3686
+ pass ? "success" : "error",
3687
+ pass ? `\u2705 ${a.name} passed PR #${rec.prNumber}${repo.autoMerge ? "; it merges once GitHub's checks are green" : ": ready to merge"}` : nextStatus === "needs-human" ? `\u274C PR #${rec.prNumber} failed QA ${qaRounds} times and needs a human` : `\u274C ${a.name} failed PR #${rec.prNumber}; sending it back to the developer`
3688
+ );
3689
+ }
3690
+ }
3691
+ async renderQaComment(a, repo, rec, report, shots) {
3692
+ const pass = report.verdict === "pass";
3693
+ const images = [];
3694
+ const evidence = shots.slice(-8);
3695
+ const offset = shots.length - evidence.length;
3696
+ const folder = `pr-${rec.prNumber}/round-${rec.round}-${Date.now().toString(36)}`;
3697
+ for (const [i, shot] of evidence.entries()) {
3698
+ const n = offset + i + 1;
3699
+ const ext = MIME_EXT[shot.mime] ?? "png";
3700
+ const file = `${folder}/${String(n).padStart(2, "0")}.${ext}`;
3701
+ try {
3702
+ const url = await this.backend.uploadEvidence(repo.fullName, file, shot.data);
3703
+ const caption = report.screenshots[n - 1] ?? `Screenshot ${n}`;
3704
+ images.push(`**${n}. ${caption}**${shot.url ? ` \xB7 \`${shot.url}\`` : ""}
3705
+
3706
+ <img src="${url}" alt="${caption.replace(/"/g, "'")}" width="760">`);
3707
+ } catch (err) {
3708
+ this.appendLog(a, [{ kind: "error", text: ` \u23BF screenshot ${n} upload failed: ${err.message.slice(0, 120)}` }]);
3709
+ }
3710
+ }
3711
+ const dev = rec.devAgentId ? this.state.agents.find((x) => x.id === rec.devAgentId) : null;
3712
+ const lines = [
3713
+ `## \u{1F50D} QA report: ${pass ? "\u2705 Passed" : "\u274C Failed"}`,
3714
+ `**Tester:** ${a.name} (cubefarm QA agent) \xB7 **Round:** ${rec.round}${dev ? ` \xB7 **Author:** ${dev.name}` : ""}`,
3715
+ "",
3716
+ report.summary,
3717
+ "",
3718
+ "| | Check | Details |",
3719
+ "|---|---|---|",
3720
+ ...report.checks.map((c) => `| ${ICON[c.result]} | ${cell(c.name)} | ${cell(c.details)} |`)
3721
+ ];
3722
+ if (report.commands.length) {
3723
+ lines.push("", "<details><summary>\u{1F9EA} Commands run</summary>", "", "| Command | Result |", "|---|---|", ...report.commands.map((c) => `| \`${cell(c.command)}\` | ${cell(c.result)} |`), "", "</details>");
3724
+ }
3725
+ if (!pass && report.fixInstructions) lines.push("", "### \u{1F527} What needs fixing", "", report.fixInstructions);
3726
+ if (images.length) lines.push("", "### \u{1F4F8} Evidence", "", ...images.flatMap((img) => [img, ""]));
3727
+ else lines.push("", "_No browser screenshots were taken in this round._");
3728
+ const merge = repo.autoMerge ? "merges automatically once GitHub's checks pass" : "ready for the manager to merge";
3729
+ lines.push("", `<sub>Posted by cubefarm \xB7 ${pass ? merge : rec.round - rec.retests >= MAX_QA_ROUNDS ? "needs a human decision" : "sent back to the developer for fixes"}</sub>`);
3730
+ return lines.join("\n");
3731
+ }
3732
+ /** Send a failed PR back to the developer who wrote it (or any free developer on the floor). */
3733
+ async runFix(dev, repo, rec) {
3734
+ const original = rec.devAgentId === dev.id;
3735
+ const pull = this.repoRt.get(repo.id)?.pulls.find((p) => p.number === rec.prNumber);
3736
+ const headRef = pull?.headRefName ?? dev.branch ?? `pr-${rec.prNumber}`;
3737
+ const qaAgent = rec.qaAgentId ? this.state.agents.find((x) => x.id === rec.qaAgentId) : null;
3738
+ this.setQa(rec, { status: "fixing", devAgentId: dev.id });
3739
+ this.beginTask(
3740
+ dev,
3741
+ { task: "fix", issueNumber: rec.issueNumber, issueTitle: pull?.title ?? `PR #${rec.prNumber}`, branch: headRef, prNumber: rec.prNumber, prUrl: pull?.url ?? null },
3742
+ rec.fixReason === "conflict" ? `Resolving conflicts on PR #${rec.prNumber}` : rec.fixReason === "checks" ? `Fixing checks on PR #${rec.prNumber}` : `Fixing PR #${rec.prNumber} after QA round ${rec.round}`,
3743
+ `Checking out PR #${rec.prNumber}\u2026`
3744
+ );
3745
+ const cwd = await this.prepare(dev, repo, { pr: rec.prNumber }, headRef);
3746
+ if (!cwd) {
3747
+ if (rec.status === "fixing") this.setQa(rec, { status: "failed" });
3748
+ return;
3749
+ }
3750
+ const failed2 = rec.checks.filter((c) => c.result === "fail");
3751
+ const takeover = original ? "" : " A teammate wrote it, so read the PR and the linked issue first.";
3752
+ const push = `push to the same branch: git push origin HEAD:${headRef}`;
3753
+ const mergeFix = rec.fixReason === "conflict" ? [
3754
+ `QA passed pull request #${rec.prNumber} (${pull?.url ?? ""}), but it now conflicts with ${repo.defaultBranch} because other work was merged first.${takeover}`,
3755
+ "",
3756
+ `Bring it up to date: git fetch origin && git merge origin/${repo.defaultBranch}. Resolve the conflicts so both this change and the newly merged work keep working, run the project's checks, and ${push}`
3757
+ ] : rec.fixReason === "checks" ? [
3758
+ `QA passed pull request #${rec.prNumber} (${pull?.url ?? ""}), but GitHub checks failed on it.${takeover}`,
3759
+ "",
3760
+ `Failed checks:
3761
+ ${rec.fixInstructions ?? ""}`,
3762
+ "",
3763
+ `Read the logs with gh pr checks ${rec.prNumber} -R ${repo.fullName} (for GitHub Actions: gh run view <run id> -R ${repo.fullName} --log-failed). Fix the cause, run the same checks locally where you can, and ${push}`,
3764
+ `If a failure clearly has nothing to do with this change (a flaky test or a service outage), re-run it instead with gh run rerun <run id> -R ${repo.fullName} --failed, and say so.`
3765
+ ] : null;
3766
+ const qaFix = [
3767
+ original ? `QA tester ${qaAgent?.name ?? "QA"} tested your pull request #${rec.prNumber} and it FAILED (round ${rec.round}).` : `You are taking over pull request #${rec.prNumber} (${pull?.url ?? ""}), written by a teammate, because QA failed it (round ${rec.round}). Read the PR and the linked issue first.`,
3768
+ "",
3769
+ `QA summary: ${rec.summary ?? ""}`,
3770
+ failed2.length ? `Failed checks:
3771
+ ${failed2.map((c) => `- ${c.name}: ${c.details}`).join("\n")}` : "",
3772
+ rec.fixInstructions ? `
3773
+ What needs fixing:
3774
+ ${rec.fixInstructions}` : "",
3775
+ rec.commentUrl ? `
3776
+ Full report with screenshots: ${rec.commentUrl}` : "",
3777
+ "",
3778
+ `Fix these problems, re-run the relevant checks, and push to the same branch: git push origin HEAD:${headRef}`,
3779
+ "Then reply with a short summary of what you changed. Do not open a new pull request; QA will re-test automatically."
3780
+ ];
3781
+ const mergeEnd = "Then reply with a short summary of what you did. Do not open a new pull request; the office merges it once the checks pass, after another QA round if the code changed.";
3782
+ const prompt = (mergeFix ? [...mergeFix, "", mergeEnd] : qaFix).filter((l) => l !== "").join("\n");
3783
+ const resume = original && rec.devSessionId ? rec.devSessionId : void 0;
3784
+ this.startAgentSession(dev, repo, cwd, prompt, this.buildSystemAppend(dev, repo, cwd, headRef, { pr: rec.prNumber, headRef }), resume);
3785
+ }
3786
+ onFixFinished(a, repo, result) {
3787
+ const rec = this.state.qa.find((q) => q.repoId === repo.id && q.prNumber === a.prNumber);
3788
+ if (a.status === "stopped") {
3789
+ if (rec?.status === "fixing") this.setQa(rec, { status: "failed" });
3790
+ return;
3791
+ }
3792
+ if (!result.ok) {
3793
+ this.fail(a, result, `the fix for PR #${a.prNumber}`);
3794
+ const failures = rec ? rec.sessionFailures + (this.limited() ? 0 : 1) : 0;
3795
+ if (rec) this.setQa(rec, { status: failures >= 2 ? "needs-human" : "failed", sessionFailures: failures });
3796
+ return;
3797
+ }
3798
+ a.status = "done";
3799
+ if (rec && (rec.fixReason === "checks" || rec.fixReason === "conflict")) {
3800
+ this.appendLog(a, [{ kind: "done", text: `\u2714 PR #${a.prNumber} fixed in ${this.minutes(a)}m. Back in line to merge.` }]);
3801
+ this.setQa(rec, { status: "passed", devSessionId: a.sessionId ?? rec.devSessionId, mergeNote: "waiting for fresh checks" });
3802
+ this.toast("info", `${a.name} fixed PR #${rec.prNumber}; it merges once it passes again`);
3803
+ return;
3804
+ }
3805
+ this.appendLog(a, [{ kind: "done", text: `\u2714 Fix pushed for PR #${a.prNumber} in ${this.minutes(a)}m. Back to QA.` }]);
3806
+ if (rec) {
3807
+ this.setQa(rec, { status: "queued", round: rec.round + 1, devSessionId: a.sessionId ?? rec.devSessionId });
3808
+ this.toast("info", `${a.name} pushed fixes for PR #${rec.prNumber}; QA round ${rec.round} is queued`);
3809
+ }
3810
+ }
3811
+ async message(id, text) {
3812
+ const a = this.agent(id);
3813
+ if (a.role === "ceo") return this.messageCeo(text);
3814
+ const repo = this.repo(a.repoId);
3815
+ if (!text.trim()) throw new HttpError(400, "Empty message");
3816
+ const rt = this.agentRt.get(id);
3817
+ if (rt.session) {
3818
+ this.appendLog(a, [{ kind: "manager", text: `\u25B6 Manager: ${text}` }]);
3819
+ rt.session.send(text);
3820
+ return;
3821
+ }
3822
+ 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.`);
3823
+ if (a.status === "preparing") throw new HttpError(409, `${a.name} is still setting up; try again in a moment`);
3824
+ if (!a.sessionId || !a.branch || a.task === "qa") throw new HttpError(409, `${a.name} has no session to continue. Assign an issue instead.`);
3825
+ this.ensureSlot();
3826
+ this.appendLog(a, [{ kind: "manager", text: `\u25B6 Manager: ${text}` }]);
3827
+ const cwd = this.backend.deskDir(repo.fullName, this.agentSlug(a));
3828
+ a.lastError = null;
3829
+ a.startedAt = Date.now();
3830
+ if (a.task === null) a.task = "issue";
3831
+ 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);
3833
+ }
3834
+ updateSettings(patch) {
3835
+ const s = this.state.settings;
3836
+ 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;
3838
+ 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;
3840
+ if (patch.hiring === "approve" || patch.hiring === "auto") s.hiring = patch.hiring;
3841
+ if (patch.teamCap !== void 0) s.teamCap = Math.max(1, Math.min(15, Math.round(Number(patch.teamCap)) || 1));
3842
+ if (patch.ceoHeartbeatMin !== void 0) s.ceoHeartbeatMin = Math.max(0, Math.min(1440, Math.round(Number(patch.ceoHeartbeatMin)) || 0));
3843
+ if (typeof patch.managerName === "string") s.managerName = patch.managerName.trim().slice(0, 40);
3844
+ 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());
3846
+ if (typeof patch.setupDone === "boolean") s.setupDone = patch.setupDone;
3847
+ if (patch.tutorialStep !== void 0) s.tutorialStep = Math.max(-1, Math.round(Number(patch.tutorialStep)) || 0);
3848
+ this.save();
3849
+ this.broadcast({ type: "settings", settings: s });
3850
+ this.emitCeo();
3851
+ setTimeout(() => this.schedule(), 200);
3852
+ return s;
3853
+ }
3854
+ /** The setup wizard: who you are, the company, and your CEO. */
3855
+ setup(x) {
3856
+ this.updateSettings({
3857
+ managerName: x.managerName,
3858
+ companyName: x.companyName,
3859
+ ...x.hiring === "auto" || x.hiring === "approve" ? { hiring: x.hiring } : {}
3860
+ });
3861
+ const ceo = this.ceo();
3862
+ this.updateAgent(ceo.id, { name: x.ceoName, color: x.ceoColor });
3863
+ if (LOOKS.includes(x.ceoLook)) this.updateAgent(ceo.id, { look: x.ceoLook });
3864
+ return this.state.settings;
3865
+ }
3866
+ // ---------- scheduling ----------
3867
+ /**
3868
+ * Agents on a floor who can take work now. One whose last session failed sits out a short cooldown, then gets work
3869
+ * like everyone else instead of waiting for the manager to reset them.
3870
+ */
3871
+ available(repo, role) {
3872
+ const now2 = Date.now();
3873
+ return this.state.agents.filter((a) => a.repoId === repo.id && a.role === role && (FREE.includes(a.status) || a.status === "error" && now2 - (a.endedAt ?? 0) >= ERROR_COOLDOWN_MS)).sort((x, y) => x.desk - y.desk);
3874
+ }
3875
+ scheduleOffset = 0;
3876
+ issueFailures = /* @__PURE__ */ new Map();
3877
+ // `${repoId}#${issue}` → failed sessions on it
3878
+ nudged = /* @__PURE__ */ new Set();
3879
+ // `${repoId}#${issue}`: its developer was asked once to finish the missing PR
3880
+ pausedUntil = 0;
3881
+ // Claude's usage limit was hit: nothing new starts before this
3882
+ /** Backlog issues that can start now, most urgent first: the ones holding up the longest chain of other issues, then the oldest. */
3883
+ readyIssues(repo) {
3884
+ const issues = this.repoRt.get(repo.id).issues;
3885
+ const open = new Set(issues.map((i) => i.number));
3886
+ const weight = holdUps(issues);
3887
+ return issues.filter(
3888
+ (i) => !i.labels.some((l) => /^(swarm:skip|wontfix|question)$/i.test(l)) && !this.issueTaken(repo, i.number) && blockers(i.body, open).length === 0 && (this.issueFailures.get(`${repo.id}#${i.number}`) ?? 0) < MAX_ISSUE_FAILURES
3889
+ ).map((issue2) => ({ issue: issue2, want: issueSpecialty(issue2.labels), ...weight.get(issue2.number) })).sort((x, y) => y.chain - x.chain || y.waiting - x.waiting || x.issue.number - y.issue.number);
3890
+ }
3891
+ /**
3892
+ * The free developer to put on a job: one it `suits` first, then whoever is least needed elsewhere (fewest ready
3893
+ * issues in their specialty, then the least open work in it), so specialists stay free for their own lane.
3894
+ */
3895
+ pickDev(repo, devs, suits) {
3896
+ if (devs.length <= 1) return devs[0];
3897
+ const ready = this.readyIssues(repo).map((r) => r.want);
3898
+ const open = this.repoRt.get(repo.id).issues.map((i) => issueSpecialty(i.labels));
3899
+ const rank = (a) => {
3900
+ const s = a.specialty.toLowerCase();
3901
+ return [suits(a) ? 0 : 1, ready.filter((w) => w === s).length, open.filter((w) => w === s).length, a.desk];
3902
+ };
3903
+ return devs.map((a) => ({ a, r: rank(a) })).sort((x, y) => x.r[0] - y.r[0] || x.r[1] - y.r[1] || x.r[2] - y.r[2] || x.r[3] - y.r[3])[0].a;
3904
+ }
3905
+ /**
3906
+ * Finish work in flight: test queued PRs (oldest first) and get failed ones fixed. Returns true if work started.
3907
+ * QA testers test; when they're all busy, a free developer who didn't write the PR covers for them, so QA never
3908
+ * holds up the floor. A failed PR goes back to its author when they're free, and otherwise to any free developer.
3909
+ */
3910
+ startPipelineWork(repo) {
3911
+ const devs = this.available(repo, "dev");
3912
+ const testers = this.available(repo, "qa");
3913
+ const waiting = (status) => this.state.qa.filter((q) => q.repoId === repo.id && q.status === status).sort((x, y) => x.updatedAt - y.updatedAt);
3914
+ for (const rec of waiting("queued")) {
3915
+ const tester = testers[0] ?? this.pickDev(
3916
+ repo,
3917
+ devs.filter((a) => a.id !== rec.devAgentId),
3918
+ (a) => /test|qa/i.test(a.specialty)
3919
+ );
3920
+ if (!tester) continue;
3921
+ void this.runQa(tester, repo, rec);
3922
+ return true;
3923
+ }
3924
+ for (const rec of waiting("failed")) {
3925
+ const issue2 = this.repoRt.get(repo.id).issues.find((i) => i.number === rec.issueNumber);
3926
+ const want = issue2 ? issueSpecialty(issue2.labels) : null;
3927
+ const dev = devs.find((a) => a.id === rec.devAgentId) ?? this.pickDev(repo, devs, (a) => a.specialty.toLowerCase() === want);
3928
+ if (!dev) break;
3929
+ void this.runFix(dev, repo, rec);
3930
+ return true;
3931
+ }
3932
+ return false;
3933
+ }
3934
+ /**
3935
+ * Give a free developer the next backlog issue (auto-assign floors only). Returns true if work started.
3936
+ * Issues that say "Depends on #N" wait until #N is closed; the rest go in readyIssues() order. A swarm:<specialty>
3937
+ * label is a preference, not a lock: a free specialist gets first pick, and otherwise the issue goes to whichever
3938
+ * free developer is least needed elsewhere, so nobody sits idle while there is work that can start.
3939
+ */
3940
+ startIssueWork(repo) {
3941
+ if (!repo.autoAssign) return false;
3942
+ const free = this.available(repo, "dev");
3943
+ const ready = free.length ? this.readyIssues(repo) : [];
3944
+ if (ready.length === 0) return false;
3945
+ const fits = (a, want) => a.specialty.toLowerCase() === want;
3946
+ const pick2 = ready.find((r) => r.chain === ready[0].chain && free.some((a) => fits(a, r.want))) ?? ready[0];
3947
+ const agent = this.pickDev(repo, free, (a) => fits(a, pick2.want));
3948
+ void this.assign(agent.id, pick2.issue.number).catch((err) => console.warn("auto-assign failed", err));
3949
+ return agent.status === "preparing";
3950
+ }
3951
+ limited() {
3952
+ return Date.now() < this.pausedUntil;
3953
+ }
3954
+ /** Claude turned a session away for the usage limit: start nothing new until it resets, rather than failing agent after agent. */
3955
+ pauseForLimit(resetsAt) {
3956
+ const until = (resetsAt ?? Date.now() + LIMIT_PAUSE_MS) + 3e4;
3957
+ if (until <= this.pausedUntil) return;
3958
+ const fresh = Date.now() >= this.pausedUntil;
3959
+ this.pausedUntil = until;
3960
+ 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.`);
3961
+ }
3962
+ /** A failed session releases its issue for someone else; an issue that keeps failing waits for the manager. */
3963
+ issueFailed(repo, n) {
3964
+ if (n == null || this.limited()) return;
3965
+ const key = `${repo.id}#${n}`;
3966
+ const failures = (this.issueFailures.get(key) ?? 0) + 1;
3967
+ this.issueFailures.set(key, failures);
3968
+ if (failures === MAX_ISSUE_FAILURES) {
3969
+ this.postMessage("office", `\u26A0\uFE0F Issue #${n} on ${repo.fullName} failed ${failures} sessions in a row, so auto-assign skips it now. Assign it to someone by hand once it's sorted.`);
3970
+ }
3971
+ }
3972
+ /**
3973
+ * Hand out work for free session slots. Finishing beats starting: QA and QA fixes on every floor go before any new issue.
3974
+ * Within each phase floors take turns (one job per floor per pass), and the starting floor rotates between calls,
3975
+ * so no repo can hog the slots.
3976
+ */
3977
+ schedule() {
3978
+ if (this.limited()) return;
3979
+ this.maybeHeartbeat();
3980
+ this.startCeoWork();
3981
+ const repos2 = this.state.repos.filter((r) => {
3982
+ const rt = this.repoRt.get(r.id);
3983
+ return rt && rt.lastSync != null && rt.cloneStatus !== "error";
3984
+ });
3985
+ if (repos2.length === 0) return;
3986
+ this.scheduleOffset = (this.scheduleOffset + 1) % repos2.length;
3987
+ const order = [...repos2.slice(this.scheduleOffset), ...repos2.slice(0, this.scheduleOffset)];
3988
+ for (const start of [(r) => this.startPipelineWork(r), (r) => this.startIssueWork(r)]) {
3989
+ let progress = true;
3990
+ while (progress) {
3991
+ progress = false;
3992
+ for (const repo of order) {
3993
+ if (this.slotsFull()) return;
3994
+ if (start(repo)) progress = true;
3995
+ }
3996
+ }
3997
+ }
3998
+ }
3999
+ // ---------- the CEO ----------
4000
+ ceo() {
4001
+ return this.state.agents.find((a) => a.id === CEO_ID);
4002
+ }
4003
+ /** The company always has a CEO. One cut off by a server restart picks its job back up. */
4004
+ ensureCeo(interrupted) {
4005
+ let a = this.state.agents.find((x) => x.id === CEO_ID);
4006
+ if (!a) {
4007
+ a = {
4008
+ id: CEO_ID,
4009
+ name: CEO_NAME,
4010
+ repoId: "",
4011
+ role: "ceo",
4012
+ title: "Chief Executive Officer",
4013
+ specialty: "",
4014
+ brief: "",
4015
+ hiredBy: "manager",
4016
+ look: lookFor(CEO_NAME),
4017
+ task: null,
4018
+ desk: 0,
4019
+ color: "#e63946",
4020
+ hair: "#2b2118",
4021
+ skin: pick(SKIN),
4022
+ model: CEO_MODEL,
4023
+ effort: CEO_EFFORT,
4024
+ status: "idle",
4025
+ issueNumber: null,
4026
+ issueTitle: null,
4027
+ branch: null,
4028
+ prNumber: null,
4029
+ prUrl: null,
4030
+ startedAt: null,
4031
+ endedAt: null,
4032
+ costUsd: 0,
4033
+ turns: 0,
4034
+ sessionId: null,
4035
+ lastError: null,
4036
+ logTail: []
4037
+ };
4038
+ this.state.agents.push(a);
4039
+ this.agentRt.set(a.id, { log: [], pending: [], session: null, currentTool: null, browserUrl: null, screenshot: null, shots: [] });
4040
+ 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
+ }
4042
+ const i = interrupted.indexOf(a);
4043
+ if (i >= 0) {
4044
+ interrupted.splice(i, 1);
4045
+ a.status = "idle";
4046
+ a.lastError = null;
4047
+ this.appendLog(a, [{ kind: "system", text: "\u21BA The office server restarted. Picking the job back up." }]);
4048
+ }
4049
+ if (this.state.ceo.job) {
4050
+ this.state.ceo.queue.unshift(this.state.ceo.job);
4051
+ this.state.ceo.job = null;
4052
+ }
4053
+ this.state.ceo.lastReviewAt ??= Date.now();
4054
+ }
4055
+ ceoFloor(repoId2) {
4056
+ const repo = repoId2 ? this.state.repos.find((r) => r.id === repoId2) : void 0;
4057
+ if (!repo) return null;
4058
+ return { floor: repo.floor, fullName: repo.fullName, clone: this.backend.mainDir(repo.fullName), mission: repo.mission, backlog: this.repoRt.get(repo.id)?.issues.length ?? 0 };
4059
+ }
4060
+ ceoInfo() {
4061
+ const c = this.state.ceo;
4062
+ const view = (j) => ({ kind: j.kind, label: jobLabel(j, this.ceoFloor(j.repoId)) });
4063
+ const min = this.state.settings.ceoHeartbeatMin;
4064
+ return {
4065
+ queue: c.queue.map(view),
4066
+ job: c.job ? view(c.job) : null,
4067
+ lastReviewAt: c.lastReviewAt,
4068
+ nextReviewAt: min > 0 && c.lastReviewAt ? c.lastReviewAt + min * 6e4 : null
4069
+ };
4070
+ }
4071
+ emitCeo() {
4072
+ this.broadcast({ type: "ceo", ceo: this.ceoInfo() });
4073
+ }
4074
+ /** Queue a job for the CEO: one onboarding or plan per floor, one review, and chat messages merge into one reply. */
4075
+ enqueueCeo(job) {
4076
+ const q = this.state.ceo.queue;
4077
+ if (job.kind === "plan" && q.some((j) => j.kind === "onboard" && j.repoId === job.repoId)) return;
4078
+ const same = q.findIndex((j) => j.kind === job.kind && (job.kind === "review" || job.kind === "chat" || j.repoId === job.repoId));
4079
+ if (same >= 0) q[same] = job.kind === "chat" ? { ...q[same], text: `${q[same].text}
4080
+ ${job.text}` } : job;
4081
+ else q.push(job);
4082
+ this.emitCeo();
4083
+ this.save();
4084
+ setTimeout(() => this.schedule(), 150);
4085
+ }
4086
+ /** Start the CEO's next job when they're free and a session slot is open. Replies to the manager go first. */
4087
+ startCeoWork() {
4088
+ const a = this.state.agents.find((x) => x.id === CEO_ID);
4089
+ const c = this.state.ceo;
4090
+ if (!a || BUSY.includes(a.status) || c.job || c.queue.length === 0) return;
4091
+ if (this.slotsFull()) return;
4092
+ const rank = { chat: 0, onboard: 1, plan: 1, review: 2 };
4093
+ const ready = (j) => {
4094
+ const rt2 = j.repoId ? this.repoRt.get(j.repoId) : void 0;
4095
+ return !rt2 || rt2.cloneStatus === "error" || rt2.cloneStatus === "ready" && rt2.lastSync != null;
4096
+ };
4097
+ const job = [...c.queue].sort((x, y) => rank[x.kind] - rank[y.kind]).find(ready);
4098
+ if (!job) return;
4099
+ c.queue.splice(c.queue.indexOf(job), 1);
4100
+ const rt = job.repoId ? this.repoRt.get(job.repoId) : void 0;
4101
+ if (job.repoId && (!rt || rt.cloneStatus === "error")) {
4102
+ const repo = this.state.repos.find((r) => r.id === job.repoId);
4103
+ if (repo) this.postMessage("office", `\u26A0\uFE0F ${a.name} couldn't study floor ${repo.floor}: the repository clone failed (${rt?.cloneError ?? "unknown error"}).`);
4104
+ this.emitCeo();
4105
+ this.save();
4106
+ return this.startCeoWork();
4107
+ }
4108
+ void this.runCeoJob(a, job);
4109
+ }
4110
+ async runCeoJob(a, job) {
4111
+ const rt = this.agentRt.get(a.id);
4112
+ const floor = this.ceoFloor(job.repoId);
4113
+ const label = jobLabel(job, floor);
4114
+ this.state.ceo.job = job;
4115
+ Object.assign(a, { status: "working", task: null, issueNumber: null, issueTitle: label, startedAt: Date.now(), endedAt: null, costUsd: 0, turns: 0, lastError: null });
4116
+ this.appendLog(a, [
4117
+ { kind: "system", text: "" },
4118
+ { kind: "system", text: `\u2501\u2501\u2501 ${label} \u2501\u2501\u2501` }
4119
+ ]);
4120
+ if (job.kind === "chat") this.appendLog(a, [{ kind: "manager", text: `\u25B6 Manager: ${job.text}` }]);
4121
+ if (job.kind === "review") {
4122
+ this.state.ceo.lastReviewAt = Date.now();
4123
+ this.state.ceo.lastFingerprint = this.fingerprint();
4124
+ }
4125
+ this.ceoIssues = { filed: 0, repos: /* @__PURE__ */ new Set() };
4126
+ this.emitAgent(a);
4127
+ this.emitCeo();
4128
+ this.save();
4129
+ await fs4.mkdir(CEO_DIR, { recursive: true }).catch(() => void 0);
4130
+ if (a.status !== "working") {
4131
+ this.state.ceo.job = null;
4132
+ this.emitCeo();
4133
+ return;
4134
+ }
4135
+ const s = this.state.settings;
4136
+ rt.session = this.backend.startSession(
4137
+ {
4138
+ cwd: CEO_DIR,
4139
+ prompt: ceoJobPrompt(job, floor),
4140
+ systemAppend: ceoSystemPrompt({
4141
+ name: a.name,
4142
+ company: s.companyName,
4143
+ manager: s.managerName,
4144
+ notesFile: path6.join(CEO_DIR, "NOTES.md"),
4145
+ sessionLimit: s.sessionLimit,
4146
+ teamCap: s.teamCap,
4147
+ hiring: s.hiring
4148
+ }),
4149
+ model: a.model || CEO_MODEL,
4150
+ effort: a.effort || CEO_EFFORT,
4151
+ browserTesting: false,
4152
+ permissionMode: s.permissionMode,
4153
+ additionalDirectories: this.state.repos.filter((r) => this.repoRt.get(r.id)?.cloneStatus === "ready").map((r) => this.backend.mainDir(r.fullName)),
4154
+ role: "ceo",
4155
+ 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
4158
+ },
4159
+ {
4160
+ log: (entries) => this.appendLog(a, entries),
4161
+ tool: (name) => {
4162
+ if (rt.currentTool === name) return;
4163
+ rt.currentTool = name;
4164
+ this.emitAgent(a);
4165
+ },
4166
+ sessionId: (id) => {
4167
+ a.sessionId = id;
4168
+ },
4169
+ browserUrl: () => void 0,
4170
+ screenshot: () => void 0,
4171
+ turn: (text) => this.postMessage("ceo", text),
4172
+ limited: (at) => this.pauseForLimit(at),
4173
+ finished: (result) => this.onCeoFinished(a, result)
4174
+ },
4175
+ ""
4176
+ );
4177
+ }
4178
+ onCeoFinished(a, result) {
4179
+ const rt = this.agentRt.get(a.id);
4180
+ if (!rt) return;
4181
+ rt.session = null;
4182
+ rt.currentTool = null;
4183
+ a.endedAt = Date.now();
4184
+ a.costUsd += result.costUsd;
4185
+ a.turns += result.turns;
4186
+ const job = this.state.ceo.job;
4187
+ this.state.ceo.job = null;
4188
+ if (a.status === "stopped") {
4189
+ } else if (!result.ok) {
4190
+ a.status = "error";
4191
+ a.lastError = result.errors.join("; ") || "Session failed";
4192
+ this.appendLog(a, [{ kind: "error", text: `\u2717 ${a.lastError}` }]);
4193
+ const what = job ? jobLabel(job, this.ceoFloor(job.repoId)).toLowerCase() : "working";
4194
+ this.postMessage("office", `\u26A0\uFE0F ${a.name} hit a problem while ${what}: ${a.lastError.slice(0, 240)}`);
4195
+ } else {
4196
+ a.status = "done";
4197
+ const filed = this.ceoIssues.filed ? ` \xB7 ${this.ceoIssues.filed} issue${this.ceoIssues.filed === 1 ? "" : "s"} filed` : "";
4198
+ this.appendLog(a, [{ kind: "done", text: `\u2714 Done in ${this.minutes(a)}m \xB7 ${a.turns} turns${filed}` }]);
4199
+ }
4200
+ for (const id of this.ceoIssues.repos) void this.syncRepo(id);
4201
+ this.emitAgent(a);
4202
+ this.emitCeo();
4203
+ this.save();
4204
+ setTimeout(() => this.schedule(), 300);
4205
+ }
4206
+ /** The manager's phone → the CEO. Injected into a running session, otherwise the CEO picks it up next. */
4207
+ async messageCeo(text) {
4208
+ const t = text.trim().slice(0, 4e3);
4209
+ if (!t) throw new HttpError(400, "Empty message");
4210
+ const a = this.ceo();
4211
+ this.postMessage("manager", t);
4212
+ const rt = this.agentRt.get(a.id);
4213
+ if (rt.session) {
4214
+ this.appendLog(a, [{ kind: "manager", text: `\u25B6 Manager: ${t}` }]);
4215
+ rt.session.send(`Message from the manager (they read your reply on their phone, so keep it short):
4216
+ ${t}`);
4217
+ return;
4218
+ }
4219
+ this.enqueueCeo({ kind: "chat", text: t, at: Date.now() });
4220
+ }
4221
+ requestReview() {
4222
+ if (this.state.repos.length === 0) throw new HttpError(400, "Connect a repo first: the CEO needs a floor to review.");
4223
+ this.enqueueCeo({ kind: "review", at: Date.now() });
4224
+ }
4225
+ onboardFloor(repoId2) {
4226
+ const repo = this.repo(repoId2);
4227
+ this.enqueueCeo({ kind: "onboard", repoId: repo.id, at: Date.now() });
4228
+ }
4229
+ /** The manager hands the CEO a brief for a floor; the CEO turns it into issues and a team. */
4230
+ planFloor(repoId2, mission) {
4231
+ const repo = this.repo(repoId2);
4232
+ if (typeof mission === "string") {
4233
+ repo.mission = mission.trim().slice(0, 4e3);
4234
+ this.emitRepo(repo);
4235
+ }
4236
+ if (!repo.mission) throw new HttpError(400, "Write a brief first: what should this floor build?");
4237
+ this.enqueueCeo({ kind: "plan", repoId: repo.id, at: Date.now() });
4238
+ }
4239
+ /** Everything the heartbeat cares about. When it hasn't changed since the last review, the review is skipped. */
4240
+ fingerprint() {
4241
+ const data = {
4242
+ repos: this.state.repos.map((r) => {
4243
+ const rt = this.repoRt.get(r.id);
4244
+ return [r.id, r.mission, r.summary, rt?.issues.map((i) => i.number), rt?.pulls.filter((p) => p.state === "OPEN").map((p) => p.number)];
4245
+ }),
4246
+ agents: this.state.agents.filter((a) => a.role !== "ceo").map((a) => [a.id, FREE.includes(a.status) ? "free" : a.status]),
4247
+ qa: this.state.qa.map((q) => [q.repoId, q.prNumber, q.status]),
4248
+ requests: this.state.requests.filter((r) => r.status === "pending").map((r) => r.id)
4249
+ };
4250
+ return crypto3.createHash("sha1").update(JSON.stringify(data)).digest("hex");
4251
+ }
4252
+ maybeHeartbeat() {
4253
+ const min = this.state.settings.ceoHeartbeatMin;
4254
+ const c = this.state.ceo;
4255
+ if (!min || this.state.repos.length === 0 || c.job?.kind === "review" || c.queue.some((j) => j.kind === "review")) return;
4256
+ if (Date.now() < (c.lastReviewAt ?? 0) + min * 6e4) return;
4257
+ if (this.fingerprint() === c.lastFingerprint) {
4258
+ c.lastReviewAt = Date.now();
4259
+ this.emitCeo();
4260
+ this.save();
4261
+ return;
4262
+ }
4263
+ this.enqueueCeo({ kind: "review", at: Date.now() });
4264
+ }
4265
+ // ---------- the phone ----------
4266
+ postMessage(from, text, requestId) {
4267
+ const m = { id: this.messageSeq++, from, text: text.trim().slice(0, 6e3), at: Date.now(), ...requestId ? { requestId } : {} };
4268
+ this.state.messages.push(m);
4269
+ if (this.state.messages.length > KEEP_MESSAGES) this.state.messages.splice(0, this.state.messages.length - KEEP_MESSAGES);
4270
+ this.broadcast({ type: "message", message: m });
4271
+ this.save();
4272
+ return m;
4273
+ }
4274
+ markPhoneRead(at) {
4275
+ const t = Math.min(Number(at) || Date.now(), Date.now());
4276
+ if (t <= this.state.phoneReadAt) return;
4277
+ this.state.phoneReadAt = t;
4278
+ this.broadcast({ type: "phoneRead", at: t });
4279
+ this.save();
4280
+ }
4281
+ // ---------- hire and let-go proposals ----------
4282
+ addRequest(req) {
4283
+ this.state.requests.push(req);
4284
+ const decided = this.state.requests.filter((r) => r.status !== "pending");
4285
+ if (decided.length > KEEP_DECIDED_REQUESTS) {
4286
+ const drop = new Set(decided.slice(0, decided.length - KEEP_DECIDED_REQUESTS));
4287
+ this.state.requests = this.state.requests.filter((r) => !drop.has(r));
4288
+ }
4289
+ this.broadcast({ type: "request", request: req });
4290
+ this.save();
4291
+ }
4292
+ decide(req, patch) {
4293
+ Object.assign(req, patch, { decidedAt: Date.now() });
4294
+ this.broadcast({ type: "request", request: req });
4295
+ this.save();
4296
+ }
4297
+ approveRequest(id, overrides = {}, by = "manager") {
4298
+ const req = this.state.requests.find((r) => r.id === id);
4299
+ if (!req) throw new HttpError(404, "That proposal no longer exists");
4300
+ if (req.status !== "pending") throw new HttpError(409, `That proposal was already ${req.status}`);
4301
+ const repo = this.repo(req.repoId);
4302
+ if (req.kind === "hire") {
4303
+ const name = overrides.name?.trim() || req.name;
4304
+ const agent = this.hireAgent(repo.id, {
4305
+ name,
4306
+ role: req.role,
4307
+ model: overrides.model ?? req.model,
4308
+ effort: overrides.effort ?? req.effort,
4309
+ look: name === req.name ? req.look : void 0,
4310
+ title: req.title,
4311
+ specialty: req.specialty,
4312
+ brief: req.brief,
4313
+ hiredBy: "ceo",
4314
+ appearance: { color: req.color, hair: req.hair, skin: req.skin }
4315
+ });
4316
+ req.agentId = agent.id;
4317
+ req.name = agent.name;
4318
+ this.decide(req, { status: "approved", note: "", decidedBy: by });
4319
+ this.postMessage(
4320
+ "office",
4321
+ by === "auto" ? `\u{1F916} Auto-approved: ${agent.name} joined floor ${repo.floor} as ${req.title}.` : `\u2705 You hired ${agent.name} as ${req.title} on floor ${repo.floor}.`,
4322
+ req.id
4323
+ );
4324
+ this.toast("success", `${agent.name} (${req.title}) joined floor ${repo.floor}`);
4325
+ return;
4326
+ }
4327
+ const a = req.agentId ? this.state.agents.find((x) => x.id === req.agentId) : void 0;
4328
+ this.decide(req, { status: "approved", note: a ? "" : "They had already left.", decidedBy: by });
4329
+ if (a) {
4330
+ try {
4331
+ this.fireAgent(a.id);
4332
+ } catch (err) {
4333
+ this.decide(req, { status: "pending", note: "", decidedBy: null });
4334
+ throw err;
4335
+ }
4336
+ }
4337
+ this.postMessage("office", `\u{1F44B} ${req.name} left floor ${repo.floor}${by === "auto" ? " (auto-approved)" : ""}.`, req.id);
4338
+ }
4339
+ rejectRequest(id, note = "") {
4340
+ const req = this.state.requests.find((r) => r.id === id);
4341
+ if (!req) throw new HttpError(404, "That proposal no longer exists");
4342
+ if (req.status !== "pending") throw new HttpError(409, `That proposal was already ${req.status}`);
4343
+ this.decide(req, { status: "rejected", note: note.trim().slice(0, 400), decidedBy: "manager" });
4344
+ const what = req.kind === "hire" ? `${req.name} (${req.title})` : `letting ${req.name} go`;
4345
+ this.postMessage("office", `\u270B You declined ${what}${req.note ? `: "${req.note}"` : "."}`, req.id);
4346
+ }
4347
+ // ---------- the CEO's office tools ----------
4348
+ floorRepo(floor) {
4349
+ const r = this.state.repos.find((x) => x.floor === Number(floor));
4350
+ if (!r) throw new Error(`There is no floor ${floor}. Floors: ${this.state.repos.map((x) => `${x.floor} (${x.fullName})`).join(", ") || "none"}.`);
4351
+ return r;
4352
+ }
4353
+ agentByRef(ref) {
4354
+ const r = String(ref ?? "").trim().toLowerCase();
4355
+ const a = this.state.agents.find((x) => x.id === ref || x.name.toLowerCase() === r);
4356
+ if (!a) throw new Error(`No agent "${ref}". Use the ids from company_status.`);
4357
+ return a;
4358
+ }
4359
+ agentDoing(a) {
4360
+ return !BUSY.includes(a.status) ? null : a.task === "qa" ? `testing PR #${a.prNumber}` : a.task === "fix" ? `fixing PR #${a.prNumber}` : `issue #${a.issueNumber}`;
4361
+ }
4362
+ companyStatus() {
4363
+ const s = this.state.settings;
4364
+ const doing = (a) => this.agentDoing(a);
4365
+ const jobDescription = (brief) => {
4366
+ if (brief.length <= 400) return brief;
4367
+ const mark = `\u2026 (truncated, ${brief.length} chars; see agent_detail)`;
4368
+ return brief.slice(0, 400 - mark.length).trimEnd() + mark;
4369
+ };
4370
+ const floors = [...this.state.repos].sort((x, y) => x.floor - y.floor).map((r) => {
4371
+ const rt = this.repoRt.get(r.id);
4372
+ const open = new Set(rt.issues.map((i) => i.number));
4373
+ return {
4374
+ floor: r.floor,
4375
+ repo: r.fullName,
4376
+ description: r.description,
4377
+ clone: rt.cloneStatus === "ready" ? this.backend.mainDir(r.fullName) : `(not available: clone ${rt.cloneStatus})`,
4378
+ brief: r.mission || null,
4379
+ profile: r.summary || null,
4380
+ qaBrief: r.qaBrief || null,
4381
+ preview: { command: r.preview.command, env: r.preview.env, status: this.previews.view(r).status },
4382
+ autoAssign: r.autoAssign,
4383
+ autoMerge: r.autoMerge,
4384
+ folderSync: rt.folderSync,
4385
+ capacity: {
4386
+ developers: this.state.agents.filter((a) => a.repoId === r.id && a.role === "dev").length,
4387
+ developersFree: this.available(r, "dev").length,
4388
+ issuesReadyToStart: this.readyIssues(r).length,
4389
+ issuesWaitingOnOthers: rt.issues.filter((i) => blockers(i.body, open).length > 0).length,
4390
+ longestDependencyChain: Math.max(0, ...[...holdUps(rt.issues).values()].map((w) => w.chain))
4391
+ },
4392
+ team: this.state.agents.filter((a) => a.repoId === r.id).map((a) => ({
4393
+ id: a.id,
4394
+ name: a.name,
4395
+ role: a.role,
4396
+ title: a.title || (a.role === "qa" ? "QA tester" : "Developer"),
4397
+ specialty: a.specialty || null,
4398
+ status: a.status,
4399
+ doing: doing(a),
4400
+ hiredBy: a.hiredBy,
4401
+ jobDescription: a.brief ? jobDescription(a.brief) : null
4402
+ })),
4403
+ backlog: rt.issues.map((i) => ({
4404
+ number: i.number,
4405
+ title: i.title,
4406
+ specialty: issueSpecialty(i.labels) || null,
4407
+ waitsFor: blockers(i.body, open),
4408
+ inProgress: this.issueTaken(r, i.number)
4409
+ })),
4410
+ pullRequests: rt.pulls.filter((p) => p.state === "OPEN").map((p) => {
4411
+ const q = this.state.qa.find((x) => x.repoId === r.id && x.prNumber === p.number);
4412
+ return { number: p.number, title: p.title, qa: q ? `${q.status}${q.round > 1 ? ` (round ${q.round})` : ""}` : "not tested", merge: q?.mergeNote ?? null, checks: p.checks };
4413
+ }),
4414
+ mergedRecently: rt.pulls.filter((p) => p.state === "MERGED").map((p) => `#${p.number} ${p.title}`)
4415
+ };
4416
+ });
4417
+ const req = (r) => ({
4418
+ id: r.id,
4419
+ kind: r.kind,
4420
+ floor: this.state.repos.find((x) => x.id === r.repoId)?.floor ?? null,
4421
+ role: r.role,
4422
+ name: r.name,
4423
+ title: r.title,
4424
+ specialty: r.specialty || null,
4425
+ reason: r.reason,
4426
+ status: r.status,
4427
+ managerNote: r.note || null
4428
+ });
4429
+ return JSON.stringify(
4430
+ {
4431
+ company: {
4432
+ ceo: this.ceo().name,
4433
+ hiring: s.hiring === "auto" ? `auto-approved while a floor has fewer than ${s.teamCap} people` : "the manager approves every proposal",
4434
+ teamCap: s.teamCap,
4435
+ sessionLimit: s.sessionLimit || "none",
4436
+ sessionsRunning: this.running(),
4437
+ deskLimits: { dev: MAX_DESKS.dev, qa: MAX_DESKS.qa }
4438
+ },
4439
+ floors,
4440
+ pendingProposals: this.state.requests.filter((r) => r.status === "pending").map(req),
4441
+ recentDecisions: this.state.requests.filter((r) => r.status !== "pending").slice(-10).map(req)
4442
+ },
4443
+ null,
4444
+ 1
4445
+ );
4446
+ }
4447
+ agentDetail(x) {
4448
+ const a = this.agentByRef(x.agent_id);
4449
+ const repo = this.state.repos.find((r) => r.id === a.repoId);
4450
+ const ceo = a.role === "ceo";
4451
+ return JSON.stringify(
4452
+ {
4453
+ id: a.id,
4454
+ name: a.name,
4455
+ floor: repo?.floor ?? null,
4456
+ role: a.role,
4457
+ title: a.title || (a.role === "qa" ? "QA tester" : a.role === "dev" ? "Developer" : "CEO"),
4458
+ specialty: a.specialty || null,
4459
+ status: a.status,
4460
+ doing: this.agentDoing(a),
4461
+ issue: a.issueNumber ? { number: a.issueNumber, title: a.issueTitle } : null,
4462
+ pullRequest: a.prNumber ? { number: a.prNumber, url: a.prUrl } : null,
4463
+ model: a.model || (ceo ? CEO_MODEL : this.state.settings.defaultModel),
4464
+ effort: a.effort || (ceo ? CEO_EFFORT : this.state.settings.defaultEffort),
4465
+ hiredBy: a.hiredBy,
4466
+ jobDescription: a.brief || null
4467
+ },
4468
+ null,
4469
+ 1
4470
+ );
4471
+ }
4472
+ setFloorProfile(x) {
4473
+ const r = this.floorRepo(x.floor);
4474
+ const preview = parsePreviewPatch(x.preview_command, x.preview_env);
4475
+ if (x.summary !== void 0) r.summary = String(x.summary).trim().slice(0, 140);
4476
+ if (x.qa_brief !== void 0) r.qaBrief = String(x.qa_brief).trim().slice(0, 2500);
4477
+ r.preview = { ...r.preview, ...preview };
4478
+ if (preview.command === null) void this.previews.refreshDefault(r);
4479
+ this.emitRepo(r);
4480
+ this.save();
4481
+ return `Saved floor ${r.floor}'s profile.`;
4482
+ }
4483
+ updateJob(x) {
4484
+ const a = this.agentByRef(x.agent_id);
4485
+ if (a.role === "ceo") throw new Error("That's you.");
4486
+ this.updateAgent(a.id, { title: x.title, specialty: x.specialty, brief: x.job_description });
4487
+ const title = a.title || (a.role === "qa" ? "QA tester" : "Developer");
4488
+ this.appendLog(a, [{ kind: "system", text: `\u{1FAAA} ${this.ceo().name} updated ${a.name}'s job: ${title}${a.specialty ? ` \xB7 swarm:${a.specialty}` : ""}` }]);
4489
+ return `Updated ${a.name}: ${title}${a.specialty ? ` (specialty ${a.specialty})` : ""}.`;
4490
+ }
4491
+ proposeHire(x) {
4492
+ const repo = this.floorRepo(x.floor);
4493
+ const role = x.role === "qa" ? "qa" : "dev";
4494
+ const title = String(x.title ?? "").trim().slice(0, 60);
4495
+ if (!title) throw new Error("A hire needs a job title.");
4496
+ const specialty = specialtySlug(x.specialty);
4497
+ const pending2 = this.state.requests.filter((r) => r.status === "pending");
4498
+ if (pending2.length >= MAX_PENDING_REQUESTS) throw new Error(`${pending2.length} proposals are already waiting for the manager. Wait for their decisions first.`);
4499
+ const dup = pending2.find((r) => r.kind === "hire" && r.repoId === repo.id && r.role === role && r.specialty === specialty);
4500
+ if (dup) throw new Error(`${dup.name} (${dup.title}) is already proposed for floor ${repo.floor} with that specialty.`);
4501
+ const seated = this.state.agents.filter((a) => a.repoId === repo.id && a.role === role).length;
4502
+ const waiting = pending2.filter((r) => r.kind === "hire" && r.repoId === repo.id && r.role === role).length;
4503
+ 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
+ const name = this.freeName(role);
4505
+ const req = {
4506
+ id: crypto3.randomUUID(),
4507
+ kind: "hire",
4508
+ repoId: repo.id,
4509
+ role,
4510
+ agentId: null,
4511
+ name,
4512
+ title,
4513
+ specialty,
4514
+ brief: String(x.job_description ?? "").trim().slice(0, 2500),
4515
+ reason: String(x.reason ?? "").trim().slice(0, 600),
4516
+ model: String(x.model ?? "").trim(),
4517
+ effort: EFFORTS.includes(x.effort) ? x.effort : "",
4518
+ look: lookFor(name),
4519
+ color: pick(SHIRTS),
4520
+ hair: pick(HAIR),
4521
+ skin: pick(SKIN),
4522
+ status: "pending",
4523
+ note: "",
4524
+ createdAt: Date.now(),
4525
+ decidedAt: null,
4526
+ decidedBy: null
4527
+ };
4528
+ this.addRequest(req);
4529
+ const s = this.state.settings;
4530
+ if (s.hiring === "auto" && this.state.agents.filter((a) => a.repoId === repo.id).length < s.teamCap) {
4531
+ this.approveRequest(req.id, {}, "auto");
4532
+ return `Hired ${req.name} as ${title} on floor ${repo.floor} (auto-approved; agent id ${req.agentId}).`;
4533
+ }
4534
+ this.postMessage("ceo", `\u{1F4C4} New candidate for floor ${repo.floor}: ${name}, ${title}. ${req.reason}`, req.id);
4535
+ return `Proposed ${name} as ${title} on floor ${repo.floor}. The manager will approve or decline (request ${req.id}).`;
4536
+ }
4537
+ proposeLetGo(x) {
4538
+ const a = this.agentByRef(x.agent_id);
4539
+ if (a.role === "ceo") throw new Error("You can't let yourself go.");
4540
+ const repo = this.repo(a.repoId);
4541
+ if (a.role === "qa" && this.state.agents.filter((y) => y.repoId === repo.id && y.role === "qa").length <= 1) {
4542
+ throw new Error(`${a.name} is floor ${repo.floor}'s only QA tester, and every floor keeps one.`);
4543
+ }
4544
+ const pending2 = this.state.requests.filter((r) => r.status === "pending");
4545
+ if (pending2.some((r) => r.kind === "let-go" && r.agentId === a.id)) throw new Error(`Letting ${a.name} go is already proposed.`);
4546
+ if (pending2.length >= MAX_PENDING_REQUESTS) throw new Error(`${pending2.length} proposals are already waiting for the manager. Wait for their decisions first.`);
4547
+ const req = {
4548
+ id: crypto3.randomUUID(),
4549
+ kind: "let-go",
4550
+ repoId: repo.id,
4551
+ role: a.role,
4552
+ agentId: a.id,
4553
+ name: a.name,
4554
+ title: a.title || (a.role === "qa" ? "QA tester" : "Developer"),
4555
+ specialty: a.specialty,
4556
+ brief: a.brief,
4557
+ reason: String(x.reason ?? "").trim().slice(0, 600),
4558
+ model: a.model,
4559
+ effort: a.effort,
4560
+ look: a.look,
4561
+ color: a.color,
4562
+ hair: a.hair,
4563
+ skin: a.skin,
4564
+ status: "pending",
4565
+ note: "",
4566
+ createdAt: Date.now(),
4567
+ decidedAt: null,
4568
+ decidedBy: null
4569
+ };
4570
+ this.addRequest(req);
4571
+ if (this.state.settings.hiring === "auto" && FREE.includes(a.status)) {
4572
+ this.approveRequest(req.id, {}, "auto");
4573
+ return `Let ${a.name} go (auto-approved).`;
4574
+ }
4575
+ this.postMessage("ceo", `\u{1F44B} I suggest letting ${a.name} (${req.title}, floor ${repo.floor}) go. ${req.reason}`, req.id);
4576
+ return `Proposed letting ${a.name} go. The manager will decide (request ${req.id}).`;
4577
+ }
4578
+ async fileIssue(x) {
4579
+ 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.`);
4581
+ const title = String(x.title ?? "").trim().slice(0, 120);
4582
+ if (!title) throw new Error("An issue needs a title.");
4583
+ const slug = specialtySlug(x.specialty);
4584
+ const body = `${String(x.body ?? "").trim()}
4585
+
4586
+ ---
4587
+ _Filed by ${this.ceo().name}, the cubefarm CEO._`;
4588
+ const n = await this.backend.createIssue(repo.fullName, title, body, slug ? [specialtyLabel(slug)] : []);
4589
+ this.ceoIssues.filed++;
4590
+ this.ceoIssues.repos.add(repo.id);
4591
+ return `Filed #${n} on floor ${repo.floor}: ${title}${slug ? ` (routed to ${slug})` : ""}.`;
4592
+ }
4593
+ };
4594
+
4595
+ // server/index.ts
4596
+ var swarm = new Swarm(DEMO ? createDemoBackend() : realBackend);
4597
+ await swarm.init();
4598
+ var app = express();
4599
+ app.use(express.json({ limit: "1mb" }));
4600
+ var route = (fn) => async (req, res, next) => {
4601
+ try {
4602
+ const out = await fn(req, res);
4603
+ if (!res.headersSent) res.json(out ?? { ok: true });
4604
+ } catch (err) {
4605
+ next(err);
4606
+ }
4607
+ };
4608
+ var num = (v) => {
4609
+ const n = Number(v);
4610
+ if (!Number.isInteger(n) || n <= 0) throw new HttpError(400, `Invalid number: ${v}`);
4611
+ return n;
4612
+ };
4613
+ var str = (v) => typeof v === "string" ? v : "";
4614
+ var repoId = (req) => decodeURIComponent(String(req.params.repo));
4615
+ app.get("/api/state", route(() => swarm.snapshot()));
4616
+ app.get("/api/github/repos", route((req) => swarm.listGithubRepos(str(req.query.owner) || void 0)));
4617
+ var floorOptions = (req) => ({ mission: str(req.body.mission), autoAssign: req.body.autoAssign === true });
4618
+ app.post("/api/repos", route((req) => swarm.connectRepo(str(req.body.fullName).trim(), floorOptions(req))));
4619
+ app.get("/api/folders", route((req) => swarm.listProjectFolders(str(req.query.dir) || void 0)));
4620
+ app.post("/api/folders/connect", route((req) => swarm.connectFolder(str(req.body.path), floorOptions(req))));
4621
+ app.post(
4622
+ "/api/folders/publish",
4623
+ route(
4624
+ (req) => swarm.publishFolder(str(req.body.path), {
4625
+ ...floorOptions(req),
4626
+ name: str(req.body.name) || void 0,
4627
+ visibility: req.body.visibility === "public" ? "public" : "private",
4628
+ description: str(req.body.description)
4629
+ })
4630
+ )
4631
+ );
4632
+ app.post(
4633
+ "/api/setup",
4634
+ route(
4635
+ (req) => swarm.setup({
4636
+ managerName: str(req.body.managerName),
4637
+ companyName: str(req.body.companyName),
4638
+ hiring: str(req.body.hiring),
4639
+ ceoName: str(req.body.ceoName),
4640
+ ceoLook: str(req.body.ceoLook),
4641
+ ceoColor: str(req.body.ceoColor)
4642
+ })
4643
+ )
4644
+ );
4645
+ app.post(
4646
+ "/api/repos/new",
4647
+ route(
4648
+ (req) => swarm.createRepo(str(req.body.name).trim(), {
4649
+ ...floorOptions(req),
4650
+ description: str(req.body.description),
4651
+ visibility: req.body.visibility === "public" ? "public" : "private",
4652
+ owner: str(req.body.owner).trim() || void 0
4653
+ })
4654
+ )
4655
+ );
4656
+ app.patch("/api/repos/:repo", route((req) => swarm.updateRepo(repoId(req), req.body ?? {})));
4657
+ app.delete("/api/repos/:repo", route((req) => swarm.disconnectRepo(repoId(req))));
4658
+ app.post("/api/repos/:repo/sync", route((req) => swarm.syncRepo(repoId(req))));
4659
+ app.post("/api/repos/:repo/sync-folder", route((req) => swarm.syncFolderNow(repoId(req))));
4660
+ app.post(
4661
+ "/api/repos/:repo/issues",
4662
+ route(async (req) => ({
4663
+ number: await swarm.createIssue(repoId(req), str(req.body.title), str(req.body.body), str(req.body.assignTo) || void 0, str(req.body.specialty) || void 0)
4664
+ }))
4665
+ );
4666
+ app.post("/api/repos/:repo/plan", route((req) => swarm.planFloor(repoId(req), typeof req.body?.mission === "string" ? req.body.mission : void 0)));
4667
+ app.post("/api/repos/:repo/onboard", route((req) => swarm.onboardFloor(repoId(req))));
4668
+ app.post(
4669
+ "/api/repos/:repo/preview",
4670
+ route((req) => {
4671
+ const pr = req.body?.pr;
4672
+ if (pr !== void 0 && pr !== null && (typeof pr !== "number" || !Number.isInteger(pr) || pr <= 0)) throw new HttpError(400, "pr must be a positive integer");
4673
+ return swarm.startPreview(repoId(req), pr ?? null);
4674
+ })
4675
+ );
4676
+ app.delete("/api/repos/:repo/preview", route((req) => swarm.stopPreview(repoId(req))));
4677
+ app.post("/api/repos/:repo/pulls/:n/merge", route((req) => swarm.mergePull(repoId(req), num(req.params.n), req.body?.method ?? "squash")));
4678
+ app.post("/api/repos/:repo/pulls/:n/close", route((req) => swarm.closePull(repoId(req), num(req.params.n))));
4679
+ app.post("/api/repos/:repo/pulls/:n/qa", route((req) => swarm.sendToQa(repoId(req), num(req.params.n))));
4680
+ app.post(
4681
+ "/api/repos/:repo/agents",
4682
+ route(
4683
+ (req) => swarm.hireAgent(repoId(req), {
4684
+ name: str(req.body.name),
4685
+ model: str(req.body.model),
4686
+ effort: str(req.body.effort),
4687
+ role: str(req.body.role),
4688
+ look: str(req.body.look),
4689
+ title: str(req.body.title),
4690
+ specialty: str(req.body.specialty),
4691
+ brief: str(req.body.brief)
4692
+ })
4693
+ )
4694
+ );
4695
+ app.patch("/api/agents/:id", route((req) => swarm.updateAgent(String(req.params.id), req.body ?? {})));
4696
+ app.delete("/api/agents/:id", route((req) => swarm.fireAgent(String(req.params.id))));
4697
+ app.post("/api/agents/:id/assign", route((req) => swarm.assign(String(req.params.id), num(req.body.issueNumber), str(req.body.note) || void 0)));
4698
+ app.post("/api/agents/:id/stop", route((req) => swarm.stopAgent(String(req.params.id))));
4699
+ app.post("/api/agents/:id/reset", route((req) => swarm.resetAgent(String(req.params.id))));
4700
+ app.post("/api/agents/:id/message", route((req) => swarm.message(String(req.params.id), str(req.body.text))));
4701
+ app.get("/api/agents/:id/screen", (req, res) => {
4702
+ const shot = swarm.screenshot(String(req.params.id));
4703
+ if (!shot) return void res.status(404).end();
4704
+ res.setHeader("Content-Type", shot.mime);
4705
+ res.setHeader("Cache-Control", "no-store");
4706
+ res.end(shot.data);
4707
+ });
4708
+ app.patch("/api/settings", route((req) => swarm.updateSettings(req.body ?? {})));
4709
+ app.post("/api/ceo/message", route((req) => swarm.messageCeo(str(req.body.text))));
4710
+ app.post("/api/ceo/review", route(() => swarm.requestReview()));
4711
+ app.post("/api/phone/read", route((req) => swarm.markPhoneRead(Number(req.body?.at) || Date.now())));
4712
+ app.post(
4713
+ "/api/requests/:id/approve",
4714
+ 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
+ );
4716
+ 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)) {
4719
+ app.use(express.static(dist));
4720
+ app.get(/^(?!\/api).*/, (_req, res) => res.sendFile(path7.join(dist, "index.html")));
4721
+ }
4722
+ app.use((err, _req, res, _next) => {
4723
+ const parserStatus = err?.status;
4724
+ const status = err instanceof HttpError ? err.status : typeof parserStatus === "number" && parserStatus >= 400 && parserStatus < 500 ? parserStatus : 500;
4725
+ const message = err instanceof Error ? err.message : String(err);
4726
+ if (status >= 500) console.error(err);
4727
+ res.status(status).json({ error: message });
4728
+ });
4729
+ var server = http2.createServer(app);
4730
+ var wss = new WebSocketServer({ server, path: "/ws" });
4731
+ wss.on("connection", (ws) => swarm.addClient(ws));
4732
+ server.listen(PORT, "127.0.0.1", () => {
4733
+ console.log(`
4734
+ \u{1F3E2} cubefarm on http://localhost:${PORT}${DEMO ? " (DEMO MODE: fake GitHub + fake agents)" : ""}`);
4735
+ console.log(` state: ${STATE_FILE}`);
4736
+ console.log(` workspaces: ${WORKSPACE_ROOT}
4737
+ `);
4738
+ });
4739
+ var closing = false;
4740
+ var shutdown = (signal) => {
4741
+ if (closing) return;
4742
+ closing = true;
4743
+ console.log(`
4744
+ ${signal}: stopping floor previews\u2026`);
4745
+ const force = setTimeout(() => process.exit(0), 15e3);
4746
+ void swarm.shutdown().catch((err) => console.error(err)).finally(() => {
4747
+ clearTimeout(force);
4748
+ process.exit(0);
4749
+ });
4750
+ };
4751
+ process.once("SIGINT", () => shutdown("SIGINT"));
4752
+ process.once("SIGTERM", () => shutdown("SIGTERM"));
4753
+ //# sourceMappingURL=index.js.map