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