cubefarm 0.3.12 → 0.3.14

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.
@@ -661,6 +661,80 @@ function sweepDesks(fullName, keep) {
661
661
  return result;
662
662
  });
663
663
  }
664
+ var TRIM_DIRS = ["node_modules", "dist", "dist-server", "test-results", "playwright-report", ".swarm-home", ".preview-tmp", ".playwright-mcp"];
665
+ async function dirSize(dir, concurrency = 8) {
666
+ let total = 0;
667
+ const queue = [dir];
668
+ let active = 0;
669
+ const visit = async (d) => {
670
+ const entries = await fs2.readdir(d, { withFileTypes: true }).catch(() => []);
671
+ const sizes = await Promise.all(
672
+ entries.map((e) => {
673
+ const p = path2.join(d, e.name);
674
+ if (e.isDirectory()) queue.push(p);
675
+ return e.isFile() ? fs2.lstat(p).then((s2) => s2.size, () => 0) : 0;
676
+ })
677
+ );
678
+ for (const s2 of sizes) total += s2;
679
+ };
680
+ await new Promise((resolve) => {
681
+ const next = () => {
682
+ while (active < concurrency && queue.length) {
683
+ active++;
684
+ void visit(queue.pop()).finally(() => {
685
+ active--;
686
+ next();
687
+ });
688
+ }
689
+ if (active === 0 && queue.length === 0) resolve();
690
+ };
691
+ next();
692
+ });
693
+ return total;
694
+ }
695
+ async function trimTargets(wt) {
696
+ const ignored = (await git(["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--directory"], { cwd: wt })).split("\0").filter((p) => p.endsWith("/")).map((p) => p.slice(0, -1)).filter((p) => p.includes("/") ? p.endsWith("/node_modules") : TRIM_DIRS.includes(p));
697
+ const present = [];
698
+ for (const name of TRIM_DIRS) if ((await fs2.lstat(path2.join(wt, name)).catch(() => null))?.isDirectory()) present.push(name);
699
+ const candidates = [.../* @__PURE__ */ new Set([...ignored, ...present])];
700
+ if (candidates.length === 0) return [];
701
+ const tracked = (await git(["ls-files", "-z", "--", ...candidates], { cwd: wt })).split("\0").filter(Boolean);
702
+ return candidates.filter((c) => !tracked.some((f) => f.startsWith(`${c}/`)));
703
+ }
704
+ async function trimDesk(fullName, agentSlug, stillIdle = () => true) {
705
+ const wt = deskDir(fullName, agentSlug);
706
+ const trash = path2.join(repoDir(fullName), "trash");
707
+ for (const name of await fs2.readdir(trash).catch(() => [])) {
708
+ if (name.startsWith(`${agentSlug}-`)) await removeDir(path2.join(trash, name)).catch(() => void 0);
709
+ }
710
+ const bin = path2.join(trash, `${agentSlug}-${Date.now()}`);
711
+ const moved = await withRepoLock(fullName, async () => {
712
+ if (!stillIdle() || !await exists(path2.join(wt, ".git"))) return null;
713
+ const out = { removed: [], skipped: [] };
714
+ const targets = await trimTargets(wt);
715
+ for (const [i, rel2] of targets.entries()) {
716
+ if (!stillIdle()) break;
717
+ try {
718
+ await fs2.mkdir(bin, { recursive: true });
719
+ await fs2.rename(path2.join(wt, rel2), path2.join(bin, `${i}-${path2.basename(rel2)}`));
720
+ out.removed.push(rel2);
721
+ } catch {
722
+ out.skipped.push(rel2);
723
+ }
724
+ }
725
+ return out;
726
+ });
727
+ if (!moved) return null;
728
+ if (moved.removed.length === 0) return { freed: 0, ...moved };
729
+ const size = await dirSize(bin);
730
+ let left = 0;
731
+ try {
732
+ await removeDir(bin);
733
+ } catch {
734
+ left = await dirSize(bin);
735
+ }
736
+ return { freed: size - left, ...moved };
737
+ }
664
738
  var LAUNCHERS = ["node.exe", "cmd.exe", "bash.exe", "sh.exe", "conhost.exe", "python.exe", "npm.exe", "npx.exe", "bun.exe", "deno.exe"];
665
739
  var pathForms = (p) => [.../* @__PURE__ */ new Set([p, p.replaceAll("\\", "/"), p.replaceAll("\\", "\\\\")])];
666
740
  function leftoversInDesk(listing, desk, keep) {
@@ -725,8 +799,8 @@ Write-Output $kill.Count`;
725
799
  }
726
800
 
727
801
  // server/agentRunner.ts
728
- import fs4 from "node:fs";
729
- import path4 from "node:path";
802
+ import fs5 from "node:fs";
803
+ import path5 from "node:path";
730
804
  import { query } from "@anthropic-ai/claude-agent-sdk";
731
805
 
732
806
  // server/browser.ts
@@ -764,129 +838,403 @@ function browserProblem(mcp, chrome = hasChrome()) {
764
838
  return null;
765
839
  }
766
840
 
767
- // server/agentRunner.ts
768
- var InputQueue = class {
769
- items = [];
770
- waiters = [];
771
- closed = false;
772
- push(text) {
773
- if (this.closed) return;
774
- const msg = { type: "user", message: { role: "user", content: text }, parent_tool_use_id: null };
775
- const waiter = this.waiters.shift();
776
- if (waiter) waiter({ value: msg, done: false });
777
- else this.items.push(msg);
778
- }
779
- close() {
780
- this.closed = true;
781
- for (const w of this.waiters.splice(0)) w({ value: void 0, done: true });
782
- }
783
- [Symbol.asyncIterator]() {
784
- return {
785
- next: () => {
786
- const item = this.items.shift();
787
- if (item) return Promise.resolve({ value: item, done: false });
788
- if (this.closed) return Promise.resolve({ value: void 0, done: true });
789
- return new Promise((resolve) => this.waiters.push(resolve));
841
+ // server/clis.ts
842
+ import fs4 from "node:fs";
843
+ import { createRequire as createRequire2 } from "node:module";
844
+ import path4 from "node:path";
845
+ var CLIS = [
846
+ { id: "claude", label: "Claude Code", command: "claude", integrated: true },
847
+ { id: "codex", label: "Codex", command: "codex", integrated: false },
848
+ { id: "opencode", label: "OpenCode", command: "opencode", integrated: false }
849
+ ];
850
+ var isCli = (v) => CLIS.some((c) => c.id === v);
851
+ var cliLabel = (id) => CLIS.find((c) => c.id === id)?.label ?? id;
852
+ var WIN = process.platform === "win32";
853
+ function resolveCommand(name, env = process.env) {
854
+ const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH";
855
+ const dirs = (env[key] ?? "").split(path4.delimiter).filter(Boolean);
856
+ const exts = WIN ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((e) => /^\.(exe|cmd|bat|com)$/i.test(e)) : [""];
857
+ for (const dir of dirs) {
858
+ for (const ext of exts) {
859
+ const file = path4.join(dir, name + ext.toLowerCase());
860
+ try {
861
+ const st = fs4.statSync(file);
862
+ if (st.isFile() && (WIN || (st.mode & 73) !== 0)) return file;
863
+ } catch {
790
864
  }
791
- };
865
+ }
792
866
  }
793
- };
794
- var allowAll = async (_toolName, input) => ({ behavior: "allow", updatedInput: input });
795
- function screenshotFile(cwd, text) {
796
- const file = text.match(/\]\(([^)\s]+\.(png|jpe?g))\)/i);
797
- if (!file) return null;
867
+ return null;
868
+ }
869
+ function unwrapCmdShim(shimPath, text) {
870
+ const line = text.split(/\r?\n/).find((l) => /%\*\s*$/.test(l));
871
+ if (!line) return null;
872
+ const dir = path4.dirname(shimPath);
873
+ const targets = [...line.matchAll(/"%dp0%\\([^"]+)"/g)].map((m) => path4.join(dir, m[1]));
874
+ const target = targets[targets.length - 1];
875
+ if (!target) return null;
876
+ if (/\.exe$/i.test(target)) return { file: target, args: [] };
877
+ if (!/\.[cm]?js$/i.test(target)) return null;
878
+ const between = line.slice(line.indexOf('"%_prog%"') + '"%_prog%"'.length, line.lastIndexOf(`"%dp0%\\`));
879
+ const nodeFlags = line.includes('"%_prog%"') ? between.trim().split(/\s+/).filter((f) => f.startsWith("--")) : [];
880
+ const localNode = path4.join(dir, "node.exe");
881
+ return { file: fs4.existsSync(localNode) ? localNode : process.execPath, args: [...nodeFlags, target] };
882
+ }
883
+ var bundled;
884
+ function bundledClaude() {
885
+ if (bundled !== void 0) return bundled;
886
+ bundled = null;
887
+ let sdk;
798
888
  try {
799
- return { data: fs4.readFileSync(path4.resolve(cwd, file[1])), mime: /png$/i.test(file[2]) ? "image/png" : "image/jpeg" };
889
+ sdk = createRequire2(import.meta.url).resolve("@anthropic-ai/claude-agent-sdk");
800
890
  } catch {
801
891
  return null;
802
892
  }
803
- }
804
- var IMAGE_MIME = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg" };
805
- function newScreenshots(dir, seen, now2 = Date.now()) {
806
- let names;
807
- try {
808
- names = fs4.readdirSync(dir);
809
- } catch {
810
- return [];
893
+ const { platform, arch } = process;
894
+ const report = platform === "linux" ? process.report?.getReport() : void 0;
895
+ const musl = platform === "linux" && !report?.header?.glibcVersionRuntime;
896
+ const targets = platform === "linux" ? musl ? [`linux-${arch}-musl`, `linux-${arch}`] : [`linux-${arch}`, `linux-${arch}-musl`] : [`${platform}-${arch}`];
897
+ for (const target of targets) {
898
+ try {
899
+ bundled = createRequire2(sdk).resolve(`@anthropic-ai/claude-agent-sdk-${target}/claude${WIN ? ".exe" : ""}`);
900
+ return bundled;
901
+ } catch {
902
+ }
811
903
  }
812
- const found2 = [];
813
- for (const name of names.sort()) {
814
- const mime = IMAGE_MIME[path4.extname(name).toLowerCase()];
815
- const file = path4.join(dir, name);
816
- if (!mime || seen.has(name)) continue;
904
+ return null;
905
+ }
906
+ function commandFor(id) {
907
+ const bundled2 = id === "claude" ? bundledClaude() : null;
908
+ if (bundled2) return { file: bundled2, args: [] };
909
+ const def = CLIS.find((c) => c.id === id);
910
+ const found2 = def && resolveCommand(def.command);
911
+ if (!found2) return null;
912
+ if (WIN && /\.(cmd|bat)$/i.test(found2)) {
817
913
  try {
818
- if (now2 - fs4.statSync(file).mtimeMs < 1e3) continue;
914
+ return unwrapCmdShim(found2, fs4.readFileSync(found2, "utf8")) ?? { file: found2, args: [] };
819
915
  } catch {
820
- continue;
916
+ return { file: found2, args: [] };
821
917
  }
822
- found2.push({ name, file, mime });
823
918
  }
824
- return found2;
919
+ return { file: found2, args: [] };
825
920
  }
826
- var clip = (s2, n) => s2.length > n ? `${s2.slice(0, n - 1)}\u2026` : s2;
827
- function rel(cwd, p) {
828
- const s2 = String(p ?? "");
829
- if (!s2) return "";
830
- const r = path4.relative(cwd, path4.resolve(cwd, s2));
831
- return r && !r.startsWith("..") ? r.replaceAll("\\", "/") : s2;
921
+ async function detectClis() {
922
+ return Promise.all(
923
+ CLIS.map(async (c) => {
924
+ const cmd = commandFor(c.id);
925
+ const version = cmd ? await run(cmd.file, [...cmd.args, "--version"], { timeoutMs: 2e4 }).catch(() => null) : null;
926
+ return { id: c.id, label: c.label, installed: !!cmd, version: version?.split(/\r?\n/)[0].trim().slice(0, 60) || null, integrated: c.integrated };
927
+ })
928
+ );
832
929
  }
833
- var escapeRe = (s2) => s2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
834
- function tidyPaths(cwd, text) {
835
- let out = text;
836
- for (const v of /* @__PURE__ */ new Set([cwd, cwd.replaceAll("\\", "/")])) {
837
- out = out.replace(new RegExp(`^cd\\s+"?${escapeRe(v)}"?\\s*(&&|;)\\s*`, "i"), "");
838
- out = out.replace(new RegExp(`${escapeRe(v)}[\\\\/]?`, "gi"), "./");
839
- }
840
- return out;
930
+ var EFFORT_CODEX = { low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "xhigh" };
931
+ var CODEX_HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Interrupt"];
932
+ function codexHookCommand(node, script, win = WIN) {
933
+ return win ? `node "${script}"` : [node, script].map((s2) => `'${s2.replaceAll("'", `'"'"'`)}'`).join(" ");
841
934
  }
842
- function describeTool(cwd, name, input) {
843
- switch (name) {
844
- case "Bash":
845
- case "PowerShell":
846
- return `$ ${clip(tidyPaths(cwd, String(input.command ?? "")).split("\n")[0], 220)}`;
847
- case "Read":
848
- return `Read ${rel(cwd, input.file_path)}`;
849
- case "Write":
850
- return `Write ${rel(cwd, input.file_path)}`;
851
- case "Edit":
852
- case "MultiEdit":
853
- return `Edit ${rel(cwd, input.file_path)}`;
854
- case "Glob":
855
- return `Glob ${input.pattern}`;
856
- case "Grep":
857
- return `Grep "${clip(String(input.pattern ?? ""), 80)}"`;
858
- case "WebFetch":
859
- return `Fetch ${input.url}`;
860
- case "WebSearch":
861
- return `Search "${input.query}"`;
862
- case "Task":
863
- case "Agent":
864
- return `Subagent: ${input.description ?? ""}`;
865
- case "TodoWrite":
866
- return "Update todo list";
867
- case "ToolSearch":
868
- return `Load tools ${String(input.query ?? "").replace(/^select:/, "")}`;
869
- case "apply_patch": {
870
- const files = [...String(input.command ?? "").matchAll(/^\*\*\* (Add|Update|Delete) File: (.+)$/gm)].map((m) => `${m[1] === "Update" ? "" : `${m[1].toLowerCase()} `}${rel(cwd, m[2].trim())}`);
871
- return `Edit ${clip(files.join(", ") || "files", 200)}`;
935
+ var CROSS_TURN_TOOLS = ["Monitor", "ScheduleWakeup", "CronCreate", "CronDelete", "CronList", "RemoteTrigger"];
936
+ function launchArgs(id, ctx) {
937
+ switch (id) {
938
+ case "claude": {
939
+ const disallowed = ["AskUserQuestion", "EnterPlanMode", "ExitPlanMode", ...CROSS_TURN_TOOLS, ...ctx.role === "ceo" ? ["Bash", "PowerShell", "NotebookEdit"] : []];
940
+ const args = [
941
+ ...ctx.resumeId ? ["--resume", ctx.resumeId] : ["--session-id", ctx.sessionId],
942
+ ...ctx.model ? ["--model", ctx.model] : [],
943
+ ...ctx.effort ? ["--effort", ctx.effort] : [],
944
+ // The office's PreToolUse hook answers every permission question (allow, or deny with an office rule).
945
+ "--permission-mode",
946
+ "acceptEdits",
947
+ // On top of the manager's own setup (settings, skills, plugins, MCP servers): the office's hooks and servers.
948
+ "--settings",
949
+ ctx.files.settings,
950
+ ...ctx.files.mcp ? ["--mcp-config", ctx.files.mcp] : [],
951
+ "--append-system-prompt-file",
952
+ ctx.files.system,
953
+ "--name",
954
+ ctx.name,
955
+ ...ctx.additionalDirectories.flatMap((d) => ["--add-dir", d]),
956
+ "--disallowedTools",
957
+ ...disallowed,
958
+ "--",
959
+ ctx.prompt
960
+ ];
961
+ return { args, env: { DISABLE_AUTOUPDATER: "1" } };
962
+ }
963
+ case "codex": {
964
+ const toml = (s2) => JSON.stringify(s2);
965
+ const args = [
966
+ "--no-alt-screen",
967
+ "-c",
968
+ `notify=[${[process.execPath, ctx.notify.script, ctx.notify.url].map(toml).join(",")}]`,
969
+ "-c",
970
+ `developer_instructions=${toml(ctx.systemAppend)}`,
971
+ ...ctx.model ? ["-m", ctx.model] : [],
972
+ ...ctx.effort ? ["-c", `model_reasoning_effort=${toml(EFFORT_CODEX[ctx.effort])}`] : [],
973
+ ...ctx.browser ? ["-c", `mcp_servers.playwright={command=${toml(ctx.browser.command)},args=[${ctx.browser.args.map(toml).join(",")}]}`] : [],
974
+ ...CODEX_HOOK_EVENTS.flatMap((e) => ["-c", `hooks.${e}=[{hooks=[{type="command",command=${toml(codexHookCommand(process.execPath, ctx.codexHook))},timeout=10}]}]`]),
975
+ // Like the manager's own Codex, but it can't stop to ask: no approvals and no sandbox (its sandbox can't reach
976
+ // the credential store, so git and gh fail in it).
977
+ "--dangerously-bypass-approvals-and-sandbox"
978
+ ];
979
+ return { args: ctx.resumeId ? ["resume", ...args, ctx.resumeId, ctx.prompt] : [...args, "--", ctx.prompt], env: { CUBEFARM_HOOK_URL: ctx.notify.url } };
980
+ }
981
+ case "opencode": {
982
+ const config = {
983
+ plugin: [ctx.plugin],
984
+ instructions: [ctx.files.system],
985
+ autoupdate: false,
986
+ // several agents starting at once must not each reinstall it
987
+ permission: { edit: "allow", bash: "allow", webfetch: "allow" },
988
+ // it can't stop to ask either
989
+ ...ctx.browser ? { mcp: { playwright: { type: "local", command: [ctx.browser.command, ...ctx.browser.args], enabled: true } } } : {}
990
+ };
991
+ const args = [
992
+ "--auto",
993
+ ...ctx.model ? ["--model", ctx.model] : [],
994
+ ...ctx.resumeId ? ["--session", ctx.resumeId] : [],
995
+ "--prompt",
996
+ ctx.prompt
997
+ ];
998
+ return { args, env: { OPENCODE_CONFIG_CONTENT: JSON.stringify(config), CUBEFARM_NOTIFY_URL: ctx.notify.url } };
872
999
  }
873
1000
  }
874
- if (name.startsWith("mcp__playwright__")) {
875
- const action = name.replace("mcp__playwright__browser_", "").replace("mcp__playwright__", "");
876
- const detail = input.url ?? input.element ?? input.text ?? "";
877
- return `\u{1F310} ${action}${detail ? ` ${clip(String(detail), 120)}` : ""}`;
878
- }
879
- if (name.startsWith("mcp__office__")) return describeOfficeTool(name.slice("mcp__office__".length), input);
880
- return `${name}(${clip(JSON.stringify(input), 140)})`;
881
1001
  }
882
- function describeOfficeTool(action, input) {
883
- const floor = input.floor != null ? ` \u2192 floor ${input.floor}` : "";
884
- switch (action) {
885
- case "company_status":
886
- return "\u{1F3E2} company_status";
887
- case "agent_detail":
888
- return `\u{1F50E} agent_detail ${String(input.agent_id ?? "")}`;
889
- case "set_floor_profile":
1002
+ function oneAtATime() {
1003
+ const tails = /* @__PURE__ */ new Map();
1004
+ return (key, step) => {
1005
+ const next = (tails.get(key) ?? Promise.resolve()).then(step).then(
1006
+ () => void 0,
1007
+ () => void 0
1008
+ );
1009
+ tails.set(key, next);
1010
+ void next.then(() => tails.get(key) === next && tails.delete(key));
1011
+ return next;
1012
+ };
1013
+ }
1014
+ var threadSteps = oneAtATime();
1015
+ function codexThread(action, id, after) {
1016
+ return threadSteps(id, async () => {
1017
+ await after;
1018
+ const cmd = commandFor("codex");
1019
+ if (cmd) await run(cmd.file, [...cmd.args, action, id], { timeoutMs: 3e4 });
1020
+ });
1021
+ }
1022
+ 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;
1023
+ function trustKey(screen) {
1024
+ if (!TRUST_PROMPT.test(screen)) return null;
1025
+ const selected = screen.match(/^\s*[❯›]\s*(?:\d+\.\s*)?(.+)$/m)?.[1] ?? "";
1026
+ if (/^(yes|trust|continue|proceed)\b/i.test(selected.trim())) return "enter";
1027
+ if (/^(no|exit|quit|cancel)\b/i.test(selected.trim())) return "down";
1028
+ return null;
1029
+ }
1030
+ function hookReviewKey(screen) {
1031
+ if (!/Hooks need review/i.test(screen)) return null;
1032
+ const selected = screen.match(/^\s*[❯›]\s*(?:\d+\.\s*)?(.+)$/m)?.[1] ?? "";
1033
+ return /^continue without trusting/i.test(selected.trim()) ? "enter" : "down";
1034
+ }
1035
+ function interruptions(screen) {
1036
+ return screen.split("\n").filter((l) => /\binterrupted\b/i.test(l)).length;
1037
+ }
1038
+ 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.
1039
+ const url = process.argv[2];
1040
+ let body = '';
1041
+ process.stdin.on('data', (c) => (body += c));
1042
+ process.stdin.on('end', async () => {
1043
+ try {
1044
+ const status = JSON.parse(body);
1045
+ const res = await fetch(url, {
1046
+ method: 'POST',
1047
+ headers: { 'content-type': 'application/json' },
1048
+ body: JSON.stringify({ hook_event_name: 'StatusLine', session_id: status.session_id, cost: status.cost, rate_limits: status.rate_limits }),
1049
+ signal: AbortSignal.timeout(2000),
1050
+ });
1051
+ const out = await res.json();
1052
+ process.stdout.write(String(out.statusLine ?? ''));
1053
+ } catch {
1054
+ process.stdout.write('cubefarm');
1055
+ }
1056
+ });
1057
+ `;
1058
+ var NOTIFY_SOURCE = String.raw`// cubefarm: Codex's notify program. Tells the office a turn is complete.
1059
+ const [url, payload] = process.argv.slice(2);
1060
+ let event = {};
1061
+ try {
1062
+ event = JSON.parse(payload ?? '{}');
1063
+ } catch {}
1064
+ if (event.type === 'agent-turn-complete') {
1065
+ const input = String((event['input-messages'] ?? [])[0] ?? '').slice(0, 300);
1066
+ fetch(url, {
1067
+ method: 'POST',
1068
+ headers: { 'content-type': 'application/json' },
1069
+ body: JSON.stringify({ hook_event_name: 'TurnComplete', session_id: event['thread-id'] ?? null, last_assistant_message: event['last-assistant-message'] ?? '', input }),
1070
+ signal: AbortSignal.timeout(3000),
1071
+ }).catch(() => {});
1072
+ }
1073
+ `;
1074
+ var CODEX_HOOK_SOURCE = String.raw`// cubefarm: Codex's hooks. Tells the office what Codex is doing; never changes what it does.
1075
+ const url = process.env.CUBEFARM_HOOK_URL;
1076
+ const chunks = [];
1077
+ process.stdin.on('data', (c) => chunks.push(c));
1078
+ process.stdin.on('end', async () => {
1079
+ try {
1080
+ if (url) await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: Buffer.concat(chunks), signal: AbortSignal.timeout(5000) });
1081
+ } catch {}
1082
+ process.stdout.write('{}');
1083
+ });
1084
+ `;
1085
+ var OPENCODE_PLUGIN_SOURCE = String.raw`// cubefarm: tells the office when OpenCode's session goes idle.
1086
+ export default async function CubefarmPlugin({ client } = {}) {
1087
+ const url = process.env.CUBEFARM_NOTIFY_URL;
1088
+ if (!url) return {};
1089
+ let root;
1090
+ const post = (body) =>
1091
+ fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(3000) }).catch(() => {});
1092
+ const lastReply = async (id) => {
1093
+ try {
1094
+ const res = await client.session.messages({ path: { id } });
1095
+ const reply = (res?.data ?? []).filter((m) => m?.info?.role === 'assistant').pop();
1096
+ return (reply?.parts ?? []).filter((p) => p?.type === 'text').map((p) => p.text).join('\n').trim();
1097
+ } catch {
1098
+ return '';
1099
+ }
1100
+ };
1101
+ return {
1102
+ event: async ({ event }) => {
1103
+ const props = event?.properties ?? {};
1104
+ if (event?.type === 'session.created' && !props.info?.parentID && !root) root = props.info?.id;
1105
+ const sessionID = props.sessionID ?? props.info?.id;
1106
+ if (event?.type === 'session.idle' && (!root || sessionID === root)) {
1107
+ post({ hook_event_name: 'TurnComplete', session_id: sessionID ?? null, last_assistant_message: sessionID ? await lastReply(sessionID) : '' });
1108
+ }
1109
+ if (event?.type === 'session.error') post({ hook_event_name: 'TurnError', session_id: sessionID ?? null, error: String(props.error?.data?.message ?? props.error?.name ?? 'error') });
1110
+ },
1111
+ };
1112
+ }
1113
+ `;
1114
+
1115
+ // server/agentRunner.ts
1116
+ var InputQueue = class {
1117
+ items = [];
1118
+ waiters = [];
1119
+ closed = false;
1120
+ push(text) {
1121
+ if (this.closed) return;
1122
+ const msg = { type: "user", message: { role: "user", content: text }, parent_tool_use_id: null };
1123
+ const waiter = this.waiters.shift();
1124
+ if (waiter) waiter({ value: msg, done: false });
1125
+ else this.items.push(msg);
1126
+ }
1127
+ close() {
1128
+ this.closed = true;
1129
+ for (const w of this.waiters.splice(0)) w({ value: void 0, done: true });
1130
+ }
1131
+ [Symbol.asyncIterator]() {
1132
+ return {
1133
+ next: () => {
1134
+ const item = this.items.shift();
1135
+ if (item) return Promise.resolve({ value: item, done: false });
1136
+ if (this.closed) return Promise.resolve({ value: void 0, done: true });
1137
+ return new Promise((resolve) => this.waiters.push(resolve));
1138
+ }
1139
+ };
1140
+ }
1141
+ };
1142
+ var allowAll = async (_toolName, input) => ({ behavior: "allow", updatedInput: input });
1143
+ function screenshotFile(cwd, text) {
1144
+ const file = text.match(/\]\(([^)\s]+\.(png|jpe?g))\)/i);
1145
+ if (!file) return null;
1146
+ try {
1147
+ return { data: fs5.readFileSync(path5.resolve(cwd, file[1])), mime: /png$/i.test(file[2]) ? "image/png" : "image/jpeg" };
1148
+ } catch {
1149
+ return null;
1150
+ }
1151
+ }
1152
+ var IMAGE_MIME = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg" };
1153
+ function newScreenshots(dir, seen, now2 = Date.now()) {
1154
+ let names;
1155
+ try {
1156
+ names = fs5.readdirSync(dir);
1157
+ } catch {
1158
+ return [];
1159
+ }
1160
+ const found2 = [];
1161
+ for (const name of names.sort()) {
1162
+ const mime = IMAGE_MIME[path5.extname(name).toLowerCase()];
1163
+ const file = path5.join(dir, name);
1164
+ if (!mime || seen.has(name)) continue;
1165
+ try {
1166
+ if (now2 - fs5.statSync(file).mtimeMs < 1e3) continue;
1167
+ } catch {
1168
+ continue;
1169
+ }
1170
+ found2.push({ name, file, mime });
1171
+ }
1172
+ return found2;
1173
+ }
1174
+ var clip = (s2, n) => s2.length > n ? `${s2.slice(0, n - 1)}\u2026` : s2;
1175
+ function rel(cwd, p) {
1176
+ const s2 = String(p ?? "");
1177
+ if (!s2) return "";
1178
+ const r = path5.relative(cwd, path5.resolve(cwd, s2));
1179
+ return r && !r.startsWith("..") ? r.replaceAll("\\", "/") : s2;
1180
+ }
1181
+ var escapeRe = (s2) => s2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1182
+ function tidyPaths(cwd, text) {
1183
+ let out = text;
1184
+ for (const v of /* @__PURE__ */ new Set([cwd, cwd.replaceAll("\\", "/")])) {
1185
+ out = out.replace(new RegExp(`^cd\\s+"?${escapeRe(v)}"?\\s*(&&|;)\\s*`, "i"), "");
1186
+ out = out.replace(new RegExp(`${escapeRe(v)}[\\\\/]?`, "gi"), "./");
1187
+ }
1188
+ return out;
1189
+ }
1190
+ function describeTool(cwd, name, input) {
1191
+ switch (name) {
1192
+ case "Bash":
1193
+ case "PowerShell":
1194
+ return `$ ${clip(tidyPaths(cwd, String(input.command ?? "")).split("\n")[0], 220)}`;
1195
+ case "Read":
1196
+ return `Read ${rel(cwd, input.file_path)}`;
1197
+ case "Write":
1198
+ return `Write ${rel(cwd, input.file_path)}`;
1199
+ case "Edit":
1200
+ case "MultiEdit":
1201
+ return `Edit ${rel(cwd, input.file_path)}`;
1202
+ case "Glob":
1203
+ return `Glob ${input.pattern}`;
1204
+ case "Grep":
1205
+ return `Grep "${clip(String(input.pattern ?? ""), 80)}"`;
1206
+ case "WebFetch":
1207
+ return `Fetch ${input.url}`;
1208
+ case "WebSearch":
1209
+ return `Search "${input.query}"`;
1210
+ case "Task":
1211
+ case "Agent":
1212
+ return `Subagent: ${input.description ?? ""}`;
1213
+ case "TodoWrite":
1214
+ return "Update todo list";
1215
+ case "ToolSearch":
1216
+ return `Load tools ${String(input.query ?? "").replace(/^select:/, "")}`;
1217
+ case "apply_patch": {
1218
+ const files = [...String(input.command ?? "").matchAll(/^\*\*\* (Add|Update|Delete) File: (.+)$/gm)].map((m) => `${m[1] === "Update" ? "" : `${m[1].toLowerCase()} `}${rel(cwd, m[2].trim())}`);
1219
+ return `Edit ${clip(files.join(", ") || "files", 200)}`;
1220
+ }
1221
+ }
1222
+ if (name.startsWith("mcp__playwright__")) {
1223
+ const action = name.replace("mcp__playwright__browser_", "").replace("mcp__playwright__", "");
1224
+ const detail = input.url ?? input.element ?? input.text ?? "";
1225
+ return `\u{1F310} ${action}${detail ? ` ${clip(String(detail), 120)}` : ""}`;
1226
+ }
1227
+ if (name.startsWith("mcp__office__")) return describeOfficeTool(name.slice("mcp__office__".length), input);
1228
+ return `${name}(${clip(JSON.stringify(input), 140)})`;
1229
+ }
1230
+ function describeOfficeTool(action, input) {
1231
+ const floor = input.floor != null ? ` \u2192 floor ${input.floor}` : "";
1232
+ switch (action) {
1233
+ case "company_status":
1234
+ return "\u{1F3E2} company_status";
1235
+ case "agent_detail":
1236
+ return `\u{1F50E} agent_detail ${String(input.agent_id ?? "")}`;
1237
+ case "set_floor_profile":
890
1238
  return `\u{1F5C2}\uFE0F set_floor_profile${floor}${input.summary ? `: ${clip(String(input.summary), 90)}` : ""}`;
891
1239
  case "update_job":
892
1240
  return `\u{1FAAA} update_job ${input.title ? `"${clip(String(input.title), 60)}"` : String(input.agent_id ?? "")}`;
@@ -962,7 +1310,7 @@ function startSession(opts, callbacks) {
962
1310
  if (problem) cb.log([{ kind: "error", text: problem }]);
963
1311
  }
964
1312
  if (opts.office) mcpServers.office = opts.office.server;
965
- const disallowedTools = ["AskUserQuestion", "EnterPlanMode", "ExitPlanMode"];
1313
+ const disallowedTools = ["AskUserQuestion", "EnterPlanMode", "ExitPlanMode", ...CROSS_TURN_TOOLS];
966
1314
  if (opts.role === "ceo") disallowedTools.push("Bash", "PowerShell", "NotebookEdit");
967
1315
  const options = {
968
1316
  cwd: opts.cwd,
@@ -1082,333 +1430,60 @@ function startSession(opts, callbacks) {
1082
1430
  if (name.startsWith("mcp__playwright__")) {
1083
1431
  const url = text.match(/Page URL:\s*(\S+)/);
1084
1432
  if (url) cb.browserUrl(url[1]);
1085
- }
1086
- if (name === "TodoWrite") continue;
1087
- if (block.is_error) out.push({ kind: "error", text: ` \u23BF ${clip(tidyPaths(opts.cwd, text).split("\n")[0] || "Error", 200)}` });
1088
- else out.push(...summariseResult(opts.cwd, name, text));
1089
- }
1090
- cb.tool(null);
1091
- if (out.length) cb.log(out);
1092
- break;
1093
- }
1094
- case "result": {
1095
- lastCost = msg.total_cost_usd;
1096
- lastTurns = msg.num_turns;
1097
- if (msg.subtype === "success" && !msg.is_error && msg.num_turns === 0) {
1098
- emptyTurn = setTimeout(() => answered(msg), 3e4);
1099
- break;
1100
- }
1101
- if (msg.subtype === "success" && !msg.is_error && msg.result.trim()) cb.turn?.(msg.result.trim());
1102
- answered(msg);
1103
- break;
1104
- }
1105
- }
1106
- };
1107
- (async () => {
1108
- try {
1109
- for await (const msg of query({ prompt: input, options })) handle(msg);
1110
- if (pendingTurns > 0) {
1111
- cb.finished({ ok: false, text: "", costUsd: lastCost, turns: lastTurns, errors: [stopped ? "Stopped by manager" : "Session ended unexpectedly"] });
1112
- }
1113
- } catch (err) {
1114
- input.close();
1115
- const message = stopped ? "Stopped by manager" : err instanceof Error ? err.message : String(err);
1116
- cb.finished({ ok: false, text: "", costUsd: lastCost, turns: lastTurns, errors: [message] });
1117
- }
1118
- })();
1119
- return {
1120
- send(text) {
1121
- pendingTurns += 1;
1122
- input.push(text);
1123
- },
1124
- stop() {
1125
- stopped = true;
1126
- clearTimeout(emptyTurn);
1127
- input.close();
1128
- abort.abort();
1129
- }
1130
- };
1131
- }
1132
-
1133
- // server/cliRunner.ts
1134
- import crypto3 from "node:crypto";
1135
- import fs7 from "node:fs";
1136
- import path8 from "node:path";
1137
- import { pathToFileURL } from "node:url";
1138
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
1139
-
1140
- // server/clis.ts
1141
- import fs5 from "node:fs";
1142
- import { createRequire as createRequire2 } from "node:module";
1143
- import path5 from "node:path";
1144
- var CLIS = [
1145
- { id: "claude", label: "Claude Code", command: "claude", integrated: true },
1146
- { id: "codex", label: "Codex", command: "codex", integrated: false },
1147
- { id: "opencode", label: "OpenCode", command: "opencode", integrated: false }
1148
- ];
1149
- var isCli = (v) => CLIS.some((c) => c.id === v);
1150
- var cliLabel = (id) => CLIS.find((c) => c.id === id)?.label ?? id;
1151
- var WIN = process.platform === "win32";
1152
- function resolveCommand(name, env = process.env) {
1153
- const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH";
1154
- const dirs = (env[key] ?? "").split(path5.delimiter).filter(Boolean);
1155
- const exts = WIN ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((e) => /^\.(exe|cmd|bat|com)$/i.test(e)) : [""];
1156
- for (const dir of dirs) {
1157
- for (const ext of exts) {
1158
- const file = path5.join(dir, name + ext.toLowerCase());
1159
- try {
1160
- const st = fs5.statSync(file);
1161
- if (st.isFile() && (WIN || (st.mode & 73) !== 0)) return file;
1162
- } catch {
1163
- }
1164
- }
1165
- }
1166
- return null;
1167
- }
1168
- function unwrapCmdShim(shimPath, text) {
1169
- const line = text.split(/\r?\n/).find((l) => /%\*\s*$/.test(l));
1170
- if (!line) return null;
1171
- const dir = path5.dirname(shimPath);
1172
- const targets = [...line.matchAll(/"%dp0%\\([^"]+)"/g)].map((m) => path5.join(dir, m[1]));
1173
- const target = targets[targets.length - 1];
1174
- if (!target) return null;
1175
- if (/\.exe$/i.test(target)) return { file: target, args: [] };
1176
- if (!/\.[cm]?js$/i.test(target)) return null;
1177
- const between = line.slice(line.indexOf('"%_prog%"') + '"%_prog%"'.length, line.lastIndexOf(`"%dp0%\\`));
1178
- const nodeFlags = line.includes('"%_prog%"') ? between.trim().split(/\s+/).filter((f) => f.startsWith("--")) : [];
1179
- const localNode = path5.join(dir, "node.exe");
1180
- return { file: fs5.existsSync(localNode) ? localNode : process.execPath, args: [...nodeFlags, target] };
1181
- }
1182
- var bundled;
1183
- function bundledClaude() {
1184
- if (bundled !== void 0) return bundled;
1185
- bundled = null;
1186
- let sdk;
1187
- try {
1188
- sdk = createRequire2(import.meta.url).resolve("@anthropic-ai/claude-agent-sdk");
1189
- } catch {
1190
- return null;
1191
- }
1192
- const { platform, arch } = process;
1193
- const report = platform === "linux" ? process.report?.getReport() : void 0;
1194
- const musl = platform === "linux" && !report?.header?.glibcVersionRuntime;
1195
- const targets = platform === "linux" ? musl ? [`linux-${arch}-musl`, `linux-${arch}`] : [`linux-${arch}`, `linux-${arch}-musl`] : [`${platform}-${arch}`];
1196
- for (const target of targets) {
1197
- try {
1198
- bundled = createRequire2(sdk).resolve(`@anthropic-ai/claude-agent-sdk-${target}/claude${WIN ? ".exe" : ""}`);
1199
- return bundled;
1200
- } catch {
1201
- }
1202
- }
1203
- return null;
1204
- }
1205
- function commandFor(id) {
1206
- const bundled2 = id === "claude" ? bundledClaude() : null;
1207
- if (bundled2) return { file: bundled2, args: [] };
1208
- const def = CLIS.find((c) => c.id === id);
1209
- const found2 = def && resolveCommand(def.command);
1210
- if (!found2) return null;
1211
- if (WIN && /\.(cmd|bat)$/i.test(found2)) {
1212
- try {
1213
- return unwrapCmdShim(found2, fs5.readFileSync(found2, "utf8")) ?? { file: found2, args: [] };
1214
- } catch {
1215
- return { file: found2, args: [] };
1216
- }
1217
- }
1218
- return { file: found2, args: [] };
1219
- }
1220
- async function detectClis() {
1221
- return Promise.all(
1222
- CLIS.map(async (c) => {
1223
- const cmd = commandFor(c.id);
1224
- const version = cmd ? await run(cmd.file, [...cmd.args, "--version"], { timeoutMs: 2e4 }).catch(() => null) : null;
1225
- return { id: c.id, label: c.label, installed: !!cmd, version: version?.split(/\r?\n/)[0].trim().slice(0, 60) || null, integrated: c.integrated };
1226
- })
1227
- );
1228
- }
1229
- var EFFORT_CODEX = { low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "xhigh" };
1230
- var CODEX_HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Interrupt"];
1231
- function codexHookCommand(node, script, win = WIN) {
1232
- return win ? `node "${script}"` : [node, script].map((s2) => `'${s2.replaceAll("'", `'"'"'`)}'`).join(" ");
1233
- }
1234
- function launchArgs(id, ctx) {
1235
- switch (id) {
1236
- case "claude": {
1237
- const disallowed = ["AskUserQuestion", "EnterPlanMode", "ExitPlanMode", ...ctx.role === "ceo" ? ["Bash", "PowerShell", "NotebookEdit"] : []];
1238
- const args = [
1239
- ...ctx.resumeId ? ["--resume", ctx.resumeId] : ["--session-id", ctx.sessionId],
1240
- ...ctx.model ? ["--model", ctx.model] : [],
1241
- ...ctx.effort ? ["--effort", ctx.effort] : [],
1242
- // The office's PreToolUse hook answers every permission question (allow, or deny with an office rule).
1243
- "--permission-mode",
1244
- "acceptEdits",
1245
- // On top of the manager's own setup (settings, skills, plugins, MCP servers): the office's hooks and servers.
1246
- "--settings",
1247
- ctx.files.settings,
1248
- ...ctx.files.mcp ? ["--mcp-config", ctx.files.mcp] : [],
1249
- "--append-system-prompt-file",
1250
- ctx.files.system,
1251
- "--name",
1252
- ctx.name,
1253
- ...ctx.additionalDirectories.flatMap((d) => ["--add-dir", d]),
1254
- "--disallowedTools",
1255
- ...disallowed,
1256
- "--",
1257
- ctx.prompt
1258
- ];
1259
- return { args, env: { DISABLE_AUTOUPDATER: "1" } };
1260
- }
1261
- case "codex": {
1262
- const toml = (s2) => JSON.stringify(s2);
1263
- const args = [
1264
- "--no-alt-screen",
1265
- "-c",
1266
- `notify=[${[process.execPath, ctx.notify.script, ctx.notify.url].map(toml).join(",")}]`,
1267
- "-c",
1268
- `developer_instructions=${toml(ctx.systemAppend)}`,
1269
- ...ctx.model ? ["-m", ctx.model] : [],
1270
- ...ctx.effort ? ["-c", `model_reasoning_effort=${toml(EFFORT_CODEX[ctx.effort])}`] : [],
1271
- ...ctx.browser ? ["-c", `mcp_servers.playwright={command=${toml(ctx.browser.command)},args=[${ctx.browser.args.map(toml).join(",")}]}`] : [],
1272
- ...CODEX_HOOK_EVENTS.flatMap((e) => ["-c", `hooks.${e}=[{hooks=[{type="command",command=${toml(codexHookCommand(process.execPath, ctx.codexHook))},timeout=10}]}]`]),
1273
- // Like the manager's own Codex, but it can't stop to ask: no approvals and no sandbox (its sandbox can't reach
1274
- // the credential store, so git and gh fail in it).
1275
- "--dangerously-bypass-approvals-and-sandbox"
1276
- ];
1277
- return { args: ctx.resumeId ? ["resume", ...args, ctx.resumeId, ctx.prompt] : [...args, "--", ctx.prompt], env: { CUBEFARM_HOOK_URL: ctx.notify.url } };
1278
- }
1279
- case "opencode": {
1280
- const config = {
1281
- plugin: [ctx.plugin],
1282
- instructions: [ctx.files.system],
1283
- autoupdate: false,
1284
- // several agents starting at once must not each reinstall it
1285
- permission: { edit: "allow", bash: "allow", webfetch: "allow" },
1286
- // it can't stop to ask either
1287
- ...ctx.browser ? { mcp: { playwright: { type: "local", command: [ctx.browser.command, ...ctx.browser.args], enabled: true } } } : {}
1288
- };
1289
- const args = [
1290
- "--auto",
1291
- ...ctx.model ? ["--model", ctx.model] : [],
1292
- ...ctx.resumeId ? ["--session", ctx.resumeId] : [],
1293
- "--prompt",
1294
- ctx.prompt
1295
- ];
1296
- return { args, env: { OPENCODE_CONFIG_CONTENT: JSON.stringify(config), CUBEFARM_NOTIFY_URL: ctx.notify.url } };
1297
- }
1298
- }
1299
- }
1300
- function oneAtATime() {
1301
- const tails = /* @__PURE__ */ new Map();
1302
- return (key, step) => {
1303
- const next = (tails.get(key) ?? Promise.resolve()).then(step).then(
1304
- () => void 0,
1305
- () => void 0
1306
- );
1307
- tails.set(key, next);
1308
- void next.then(() => tails.get(key) === next && tails.delete(key));
1309
- return next;
1310
- };
1311
- }
1312
- var threadSteps = oneAtATime();
1313
- function codexThread(action, id, after) {
1314
- return threadSteps(id, async () => {
1315
- await after;
1316
- const cmd = commandFor("codex");
1317
- if (cmd) await run(cmd.file, [...cmd.args, action, id], { timeoutMs: 3e4 });
1318
- });
1319
- }
1320
- 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;
1321
- function trustKey(screen) {
1322
- if (!TRUST_PROMPT.test(screen)) return null;
1323
- const selected = screen.match(/^\s*[❯›]\s*(?:\d+\.\s*)?(.+)$/m)?.[1] ?? "";
1324
- if (/^(yes|trust|continue|proceed)\b/i.test(selected.trim())) return "enter";
1325
- if (/^(no|exit|quit|cancel)\b/i.test(selected.trim())) return "down";
1326
- return null;
1327
- }
1328
- function hookReviewKey(screen) {
1329
- if (!/Hooks need review/i.test(screen)) return null;
1330
- const selected = screen.match(/^\s*[❯›]\s*(?:\d+\.\s*)?(.+)$/m)?.[1] ?? "";
1331
- return /^continue without trusting/i.test(selected.trim()) ? "enter" : "down";
1332
- }
1333
- function interruptions(screen) {
1334
- return screen.split("\n").filter((l) => /\binterrupted\b/i.test(l)).length;
1335
- }
1336
- 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.
1337
- const url = process.argv[2];
1338
- let body = '';
1339
- process.stdin.on('data', (c) => (body += c));
1340
- process.stdin.on('end', async () => {
1341
- try {
1342
- const status = JSON.parse(body);
1343
- const res = await fetch(url, {
1344
- method: 'POST',
1345
- headers: { 'content-type': 'application/json' },
1346
- body: JSON.stringify({ hook_event_name: 'StatusLine', session_id: status.session_id, cost: status.cost, rate_limits: status.rate_limits }),
1347
- signal: AbortSignal.timeout(2000),
1348
- });
1349
- const out = await res.json();
1350
- process.stdout.write(String(out.statusLine ?? ''));
1351
- } catch {
1352
- process.stdout.write('cubefarm');
1353
- }
1354
- });
1355
- `;
1356
- var NOTIFY_SOURCE = String.raw`// cubefarm: Codex's notify program. Tells the office a turn is complete.
1357
- const [url, payload] = process.argv.slice(2);
1358
- let event = {};
1359
- try {
1360
- event = JSON.parse(payload ?? '{}');
1361
- } catch {}
1362
- if (event.type === 'agent-turn-complete') {
1363
- const input = String((event['input-messages'] ?? [])[0] ?? '').slice(0, 300);
1364
- fetch(url, {
1365
- method: 'POST',
1366
- headers: { 'content-type': 'application/json' },
1367
- body: JSON.stringify({ hook_event_name: 'TurnComplete', session_id: event['thread-id'] ?? null, last_assistant_message: event['last-assistant-message'] ?? '', input }),
1368
- signal: AbortSignal.timeout(3000),
1369
- }).catch(() => {});
1370
- }
1371
- `;
1372
- var CODEX_HOOK_SOURCE = String.raw`// cubefarm: Codex's hooks. Tells the office what Codex is doing; never changes what it does.
1373
- const url = process.env.CUBEFARM_HOOK_URL;
1374
- const chunks = [];
1375
- process.stdin.on('data', (c) => chunks.push(c));
1376
- process.stdin.on('end', async () => {
1377
- try {
1378
- if (url) await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: Buffer.concat(chunks), signal: AbortSignal.timeout(5000) });
1379
- } catch {}
1380
- process.stdout.write('{}');
1381
- });
1382
- `;
1383
- var OPENCODE_PLUGIN_SOURCE = String.raw`// cubefarm: tells the office when OpenCode's session goes idle.
1384
- export default async function CubefarmPlugin({ client } = {}) {
1385
- const url = process.env.CUBEFARM_NOTIFY_URL;
1386
- if (!url) return {};
1387
- let root;
1388
- const post = (body) =>
1389
- fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(3000) }).catch(() => {});
1390
- const lastReply = async (id) => {
1391
- try {
1392
- const res = await client.session.messages({ path: { id } });
1393
- const reply = (res?.data ?? []).filter((m) => m?.info?.role === 'assistant').pop();
1394
- return (reply?.parts ?? []).filter((p) => p?.type === 'text').map((p) => p.text).join('\n').trim();
1395
- } catch {
1396
- return '';
1433
+ }
1434
+ if (name === "TodoWrite") continue;
1435
+ if (block.is_error) out.push({ kind: "error", text: ` \u23BF ${clip(tidyPaths(opts.cwd, text).split("\n")[0] || "Error", 200)}` });
1436
+ else out.push(...summariseResult(opts.cwd, name, text));
1437
+ }
1438
+ cb.tool(null);
1439
+ if (out.length) cb.log(out);
1440
+ break;
1441
+ }
1442
+ case "result": {
1443
+ lastCost = msg.total_cost_usd;
1444
+ lastTurns = msg.num_turns;
1445
+ if (msg.subtype === "success" && !msg.is_error && msg.num_turns === 0) {
1446
+ emptyTurn = setTimeout(() => answered(msg), 3e4);
1447
+ break;
1448
+ }
1449
+ if (msg.subtype === "success" && !msg.is_error && msg.result.trim()) cb.turn?.(msg.result.trim());
1450
+ answered(msg);
1451
+ break;
1452
+ }
1397
1453
  }
1398
1454
  };
1399
- return {
1400
- event: async ({ event }) => {
1401
- const props = event?.properties ?? {};
1402
- if (event?.type === 'session.created' && !props.info?.parentID && !root) root = props.info?.id;
1403
- const sessionID = props.sessionID ?? props.info?.id;
1404
- if (event?.type === 'session.idle' && (!root || sessionID === root)) {
1405
- post({ hook_event_name: 'TurnComplete', session_id: sessionID ?? null, last_assistant_message: sessionID ? await lastReply(sessionID) : '' });
1455
+ (async () => {
1456
+ try {
1457
+ for await (const msg of query({ prompt: input, options })) handle(msg);
1458
+ if (pendingTurns > 0) {
1459
+ cb.finished({ ok: false, text: "", costUsd: lastCost, turns: lastTurns, errors: [stopped ? "Stopped by manager" : "Session ended unexpectedly"] });
1406
1460
  }
1407
- if (event?.type === 'session.error') post({ hook_event_name: 'TurnError', session_id: sessionID ?? null, error: String(props.error?.data?.message ?? props.error?.name ?? 'error') });
1461
+ } catch (err) {
1462
+ input.close();
1463
+ const message = stopped ? "Stopped by manager" : err instanceof Error ? err.message : String(err);
1464
+ cb.finished({ ok: false, text: "", costUsd: lastCost, turns: lastTurns, errors: [message] });
1465
+ }
1466
+ })();
1467
+ return {
1468
+ send(text) {
1469
+ pendingTurns += 1;
1470
+ input.push(text);
1408
1471
  },
1472
+ stop() {
1473
+ stopped = true;
1474
+ clearTimeout(emptyTurn);
1475
+ input.close();
1476
+ abort.abort();
1477
+ }
1409
1478
  };
1410
1479
  }
1411
- `;
1480
+
1481
+ // server/cliRunner.ts
1482
+ import crypto3 from "node:crypto";
1483
+ import fs7 from "node:fs";
1484
+ import path8 from "node:path";
1485
+ import { pathToFileURL } from "node:url";
1486
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
1412
1487
 
1413
1488
  // server/ptyClient.ts
1414
1489
  import crypto2 from "node:crypto";
@@ -2924,6 +2999,7 @@ var realBackend = {
2924
2999
  installDeps: (dir, cb) => installDesk(dir, cb),
2925
3000
  removeDesk,
2926
3001
  sweepDesks,
3002
+ trimDesk,
2927
3003
  releaseDesk: (fullName, agentSlug, port) => releaseDesk(fullName, agentSlug, port, officeProcesses()),
2928
3004
  startSession: (opts, cb) => opts.terminal ? startCliSession(opts, cb) : startSession(opts, cb),
2929
3005
  terminals: terminalsAvailable,
@@ -3422,6 +3498,12 @@ Closes #${pr.closesIssues[0] ?? "?"}`,
3422
3498
  },
3423
3499
  removeDesk: async () => void 0,
3424
3500
  sweepDesks: async () => ({ desks: 0, folders: 0, branches: 0, patches: [], skipped: [] }),
3501
+ // A made-up install and build of 300-900 MB, so the setting and the phone message can be seen.
3502
+ trimDesk: async (_fullName, _slug, stillIdle) => {
3503
+ await new Promise((r) => setTimeout(r, 300));
3504
+ if (!stillIdle()) return null;
3505
+ return { freed: Math.round((300 + Math.random() * 600) * 2 ** 20), removed: ["node_modules", "dist"], skipped: [] };
3506
+ },
3425
3507
  releaseDesk: async () => void 0,
3426
3508
  startSession: (opts, cb) => {
3427
3509
  if (++sessionsStarted === USAGE_WARNING_AT) fakeUsageWarning(cb);
@@ -4021,6 +4103,101 @@ function holdUps(issues) {
4021
4103
  return out;
4022
4104
  }
4023
4105
 
4106
+ // server/prompts.ts
4107
+ var PLACEHOLDERS = {
4108
+ worktree: "<your worktree>",
4109
+ issue: "<issue>",
4110
+ pr: "<PR>",
4111
+ prTitle: "<PR title>",
4112
+ prUrl: "<PR URL>",
4113
+ branch: "<branch>"
4114
+ };
4115
+ var ONE_TURN = "Your session ends when your turn ends: background tasks and notifications won't wake you. Never end a turn to wait. Wait in the foreground with a timeout, then finish.";
4116
+ var devBranch = (issue2, slug) => `swarm/issue-${issue2}-${slug}`;
4117
+ var qaBranch = (pr, slug) => `qa/pr-${pr}-${slug}`;
4118
+ function devSystemPrompt({ agent: a, repo, port, cwd, branch, linked, fixing, depsLine }) {
4119
+ const push = fixing ? `git push origin HEAD:${fixing.headRef}` : `git push -u origin ${branch}`;
4120
+ const links = linked.map((r) => `- ${r.fullName}: read-only reference clone at ${r.dir}`);
4121
+ return [
4122
+ `You are ${a.name}, ${a.title ? `the team's ${a.title},` : "a software engineer"} on an autonomous agent team ("cubefarm"). Several teammates work in parallel on other issues of the same repository, each in their own git worktree. Nobody is watching live to answer questions, so make sensible decisions yourself and record assumptions in the PR description. The manager may occasionally send you messages; follow their instructions.`,
4123
+ `Every pull request is reviewed and tested by a QA teammate. ${repo.autoMerge ? "Once they sign off and GitHub's checks pass, the office merges it by itself" : "Once they sign off, the manager merges it"}. If they find problems, or checks fail, or it conflicts with the default branch, you will get the details; fix them on the same branch.`,
4124
+ a.brief ? `
4125
+ Your job description:
4126
+ ${a.brief}` : "",
4127
+ "",
4128
+ `Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
4129
+ repo.summary ? `Project: ${repo.summary}` : "",
4130
+ repo.mission ? `What the team is building (the manager's brief): ${repo.mission}` : "",
4131
+ `Your worktree: ${cwd}`,
4132
+ depsLine ?? "",
4133
+ fixing ? `You are fixing pull request #${fixing.pr}. Its code is checked out on local branch ${branch}; push fixes with: ${push}. Do not open a new pull request.` : `Your branch: ${branch} (already checked out, created from origin/${repo.defaultBranch})`,
4134
+ links.length ? `Related repositories you may read for context (do not modify them):
4135
+ ${links.join("\n")}` : "",
4136
+ "",
4137
+ "Workflow:",
4138
+ "1. Read the issue and explore the relevant code before changing anything.",
4139
+ "2. Implement the change with focused commits and clear messages.",
4140
+ "3. Run the project's existing tests, linters and build (if any) and fix what you broke. Install dependencies first if needed (e.g. npm install when node_modules is missing).",
4141
+ repo.browserTesting ? `4. If the project has a web UI, start its dev server in the background on port ${port} (reserved for you, so you don't collide with teammates), then check your change with the Playwright browser tools (mcp__playwright__browser_navigate, browser_snapshot, browser_click, browser_take_screenshot). Stop the dev server when you're done.` : "4. Verify the behaviour you changed as directly as you can.",
4142
+ `5. Push: ${push}`,
4143
+ fixing ? "6. Reply with a short summary of what you fixed." : `6. Open a pull request with the GitHub CLI: gh pr create --base ${repo.defaultBranch} --head ${branch} --title "<concise title>" --body "<what changed, how you verified it, assumptions>". The body must contain "Closes #<issue number>".`,
4144
+ fixing ? "" : "7. End your final message with the pull request URL on its own line.",
4145
+ "",
4146
+ "Rules: never push to the default branch, never force-push, never merge pull requests yourself (the office merges them once QA and the checks pass), and never edit files outside your worktree. If you cannot finish, open a draft PR (gh pr create --draft) explaining what is left and why.",
4147
+ ONE_TURN
4148
+ ].filter((l) => l !== "").join("\n");
4149
+ }
4150
+ function qaSystemPrompt({ agent: a, repo, port, cwd, branch, pr, depsLine, testStep: testStep2 }) {
4151
+ return [
4152
+ `You are ${a.name}, ${a.title ? `the team's ${a.title},` : "a QA engineer"} on an autonomous agent team ("cubefarm"). Developers open pull requests; you review and independently verify each one before it is merged. Your sign-off is the review: ${repo.autoMerge ? "on this floor a PR you pass merges by itself as soon as GitHub's checks are green, so nobody else reads the code after you. " : ""}Be thorough and skeptical, but fair: fail a PR only for real problems (broken behaviour, failing tests or build, the issue's requirements not met, obvious regressions), not for style preferences.`,
4153
+ ...a.brief ? ["", `Your job description:
4154
+ ${a.brief}`] : [],
4155
+ ...a.role === "dev" ? ["", "You're a developer covering for the QA lab while its testers are busy. You didn't write this pull request: test it as an independent QA engineer would."] : [],
4156
+ "",
4157
+ `Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
4158
+ ...repo.summary ? [`Project: ${repo.summary}`] : [],
4159
+ ...repo.mission ? [`What the team is building (the manager's brief): ${repo.mission}`] : [],
4160
+ ...repo.qaBrief ? [`What to check on this project (from the CEO):
4161
+ ${repo.qaBrief}`] : [],
4162
+ `Pull request #${pr.number} "${pr.title}" from branch ${pr.headRefName}: ${pr.url}`,
4163
+ `Your worktree: ${cwd}. It has the pull request's code checked out on local branch ${branch}.`,
4164
+ ...depsLine ? [depsLine] : [],
4165
+ "",
4166
+ "How to test:",
4167
+ "1. Read the PR description and the linked issue, and work out the acceptance criteria.",
4168
+ `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.`,
4169
+ testStep2 ?? "3. Install dependencies if needed (e.g. npm install when node_modules is missing), then run the project's test suite, linters, type checks and build (whichever exist).",
4170
+ repo.browserTesting ? `4. If the project has a UI, start it in the background on port ${port} (reserved for you) and exercise the change in a real browser with the Playwright tools: navigate, click, type, resize to a phone size, try edge cases, and check the console for errors. Take a screenshot with browser_take_screenshot (no filename) of every important state: the screenshots are attached to the PR as evidence. Stop the server afterwards.` : "4. Exercise the changed behaviour directly (run the program, call the API, write a quick script).",
4171
+ "5. You may write throwaway scripts to probe behaviour, but do not commit them.",
4172
+ "A merge conflict with the default branch is not a fail, nor is a red check unrelated to this change (a flake or an outage: say why): judge the change itself (on a throwaway merge if you need newer work) and pass it if it's good. The office sends conflicting PRs back for a merge fix, then you re-test, and re-runs a failed check before anyone fixes it.",
4173
+ "",
4174
+ "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.",
4175
+ ONE_TURN
4176
+ ].join("\n");
4177
+ }
4178
+ var OFFICE = "Office instructions";
4179
+ var MARK = "\0job description\0";
4180
+ function promptParts(build, brief) {
4181
+ if (!brief) return [{ label: OFFICE, text: build(""), editable: false }];
4182
+ const [before, after] = build(MARK).split(MARK);
4183
+ return [
4184
+ { label: OFFICE, text: before, editable: false },
4185
+ { label: "Job description", text: brief, editable: true },
4186
+ { label: OFFICE, text: after, editable: false }
4187
+ ].filter((p) => p.text !== "");
4188
+ }
4189
+ var view = (kind, parts) => ({ kind, text: parts.map((p) => p.text).join(""), parts });
4190
+ function devPromptPreview({ slug, ...i }) {
4191
+ const branch = devBranch(PLACEHOLDERS.issue, slug);
4192
+ return view("dev", promptParts((brief) => devSystemPrompt({ ...i, agent: { ...i.agent, brief }, cwd: PLACEHOLDERS.worktree, branch }), i.agent.brief));
4193
+ }
4194
+ function qaPromptPreview({ slug, ...i }) {
4195
+ const pr = { number: PLACEHOLDERS.pr, title: PLACEHOLDERS.prTitle, headRefName: PLACEHOLDERS.branch, url: PLACEHOLDERS.prUrl };
4196
+ const branch = qaBranch(PLACEHOLDERS.pr, slug);
4197
+ return view("qa", promptParts((brief) => qaSystemPrompt({ ...i, agent: { ...i.agent, brief }, cwd: PLACEHOLDERS.worktree, branch, pr }), i.agent.brief));
4198
+ }
4199
+ var ceoPromptPreview = (text) => view("ceo", [{ label: OFFICE, text, editable: false }]);
4200
+
4024
4201
  // server/ceo.ts
4025
4202
  var EFFORT = z2.enum(["low", "medium", "high", "xhigh", "max"]);
4026
4203
  function createOfficeTools(h) {
@@ -4161,6 +4338,7 @@ function ceoSystemPrompt(o) {
4161
4338
  "- Read the repositories through their clone paths with Read, Glob and Grep. They are read-only to you. You cannot run shell commands.",
4162
4339
  `- Keep durable notes about the company in ${o.notesFile}: read it at the start, and update it at the end with decisions and anything worth remembering next time.`,
4163
4340
  "- Change things only through the mcp__office__ tools.",
4341
+ `- ${ONE_TURN}`,
4164
4342
  "- Before update_job rewrites someone's job description, read the full one with mcp__office__agent_detail and keep what still applies, especially its safety rules.",
4165
4343
  "",
4166
4344
  "Rules:",
@@ -4394,7 +4572,7 @@ function checksLine(i) {
4394
4572
  return `GitHub checks right now: ${state}`;
4395
4573
  }
4396
4574
  function testStep(hasChecks) {
4397
- return hasChecks ? "3. GitHub's checks run on this PR, and it merges only once they're green. Read .github/workflows to see what they cover and don't re-run that locally. Run only what you need to exercise the change (for example a build to start the app), anything the checks don't cover, or a failing check to reproduce it. A failing check is a finding: name it." : "3. Install dependencies if needed, then run the project's test suite, linters, type checks and build (whichever exist).";
4575
+ return hasChecks ? "3. GitHub's checks run on this PR, and it merges only once they're green. Read .github/workflows to see what they cover and don't re-run that locally. Run only what you need to exercise the change (for example a build to start the app), anything the checks don't cover, or a failing check to reproduce it. A failing check is a finding: name it." : "3. Install dependencies if needed (e.g. npm install when node_modules is missing), then run the project's test suite, linters, type checks and build (whichever exist).";
4398
4576
  }
4399
4577
  function retest(i) {
4400
4578
  const since = i.lastTestedSha && i.lastTestedSha !== i.headSha ? i.lastTestedSha : null;
@@ -4515,6 +4693,10 @@ var Previews = class {
4515
4693
  logTail: (r?.log ?? []).slice(-LOG_VIEW)
4516
4694
  };
4517
4695
  }
4696
+ /** The floor's preview is being set up or running: its worktree is in use. */
4697
+ active(f) {
4698
+ return ACTIVE.includes(this.runs.get(f.id)?.status ?? "stopped");
4699
+ }
4518
4700
  /** Re-check whether a floor without a command has a package.json to fall back on. */
4519
4701
  async refreshDefault(f) {
4520
4702
  const has = await this.backend.previews.hasDefault(f.fullName).catch(() => true);
@@ -4639,97 +4821,13 @@ var Previews = class {
4639
4821
  }
4640
4822
  };
4641
4823
 
4642
- // server/prompts.ts
4643
- var PLACEHOLDERS = {
4644
- worktree: "<your worktree>",
4645
- issue: "<issue>",
4646
- pr: "<PR>",
4647
- prTitle: "<PR title>",
4648
- prUrl: "<PR URL>",
4649
- branch: "<branch>"
4650
- };
4651
- var devBranch = (issue2, slug) => `swarm/issue-${issue2}-${slug}`;
4652
- var qaBranch = (pr, slug) => `qa/pr-${pr}-${slug}`;
4653
- function devSystemPrompt({ agent: a, repo, port, cwd, branch, linked, fixing, depsLine }) {
4654
- const push = fixing ? `git push origin HEAD:${fixing.headRef}` : `git push -u origin ${branch}`;
4655
- const links = linked.map((r) => `- ${r.fullName}: read-only reference clone at ${r.dir}`);
4656
- return [
4657
- `You are ${a.name}, ${a.title ? `the team's ${a.title},` : "a software engineer"} on an autonomous agent team ("cubefarm"). Several teammates work in parallel on other issues of the same repository, each in their own git worktree. Nobody is watching live to answer questions, so make sensible decisions yourself and record assumptions in the PR description. The manager may occasionally send you messages; follow their instructions.`,
4658
- `Every pull request is reviewed and tested by a QA teammate. ${repo.autoMerge ? "Once they sign off and GitHub's checks pass, the office merges it by itself" : "Once they sign off, the manager merges it"}. If they find problems, or checks fail, or it conflicts with the default branch, you will get the details; fix them on the same branch.`,
4659
- a.brief ? `
4660
- Your job description:
4661
- ${a.brief}` : "",
4662
- "",
4663
- `Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
4664
- repo.summary ? `Project: ${repo.summary}` : "",
4665
- repo.mission ? `What the team is building (the manager's brief): ${repo.mission}` : "",
4666
- `Your worktree: ${cwd}`,
4667
- depsLine ?? "",
4668
- fixing ? `You are fixing pull request #${fixing.pr}. Its code is checked out on local branch ${branch}; push fixes with: ${push}. Do not open a new pull request.` : `Your branch: ${branch} (already checked out, created from origin/${repo.defaultBranch})`,
4669
- links.length ? `Related repositories you may read for context (do not modify them):
4670
- ${links.join("\n")}` : "",
4671
- "",
4672
- "Workflow:",
4673
- "1. Read the issue and explore the relevant code before changing anything.",
4674
- "2. Implement the change with focused commits and clear messages.",
4675
- "3. Run the project's existing tests, linters and build (if any) and fix what you broke. Install dependencies first if needed.",
4676
- repo.browserTesting ? `4. If the project has a web UI, start its dev server in the background on port ${port} (reserved for you, so you don't collide with teammates), then check your change with the Playwright browser tools (mcp__playwright__browser_navigate, browser_snapshot, browser_click, browser_take_screenshot). Stop the dev server when you're done.` : "4. Verify the behaviour you changed as directly as you can.",
4677
- `5. Push: ${push}`,
4678
- fixing ? "6. Reply with a short summary of what you fixed." : `6. Open a pull request with the GitHub CLI: gh pr create --base ${repo.defaultBranch} --head ${branch} --title "<concise title>" --body "<what changed, how you verified it, assumptions>". The body must contain "Closes #<issue number>".`,
4679
- fixing ? "" : "7. End your final message with the pull request URL on its own line.",
4680
- "",
4681
- "Rules: never push to the default branch, never force-push, never merge pull requests yourself (the office merges them once QA and the checks pass), and never edit files outside your worktree. If you cannot finish, open a draft PR (gh pr create --draft) explaining what is left and why."
4682
- ].filter((l) => l !== "").join("\n");
4683
- }
4684
- function qaSystemPrompt({ agent: a, repo, port, cwd, branch, pr, depsLine, testStep: testStep2 }) {
4685
- return [
4686
- `You are ${a.name}, ${a.title ? `the team's ${a.title},` : "a QA engineer"} on an autonomous agent team ("cubefarm"). Developers open pull requests; you review and independently verify each one before it is merged. Your sign-off is the review: ${repo.autoMerge ? "on this floor a PR you pass merges by itself as soon as GitHub's checks are green, so nobody else reads the code after you. " : ""}Be thorough and skeptical, but fair: fail a PR only for real problems (broken behaviour, failing tests or build, the issue's requirements not met, obvious regressions), not for style preferences.`,
4687
- ...a.brief ? ["", `Your job description:
4688
- ${a.brief}`] : [],
4689
- ...a.role === "dev" ? ["", "You're a developer covering for the QA lab while its testers are busy. You didn't write this pull request: test it as an independent QA engineer would."] : [],
4690
- "",
4691
- `Repository: ${repo.fullName} (default branch: ${repo.defaultBranch})`,
4692
- ...repo.summary ? [`Project: ${repo.summary}`] : [],
4693
- ...repo.mission ? [`What the team is building (the manager's brief): ${repo.mission}`] : [],
4694
- ...repo.qaBrief ? [`What to check on this project (from the CEO):
4695
- ${repo.qaBrief}`] : [],
4696
- `Pull request #${pr.number} "${pr.title}" from branch ${pr.headRefName}: ${pr.url}`,
4697
- `Your worktree: ${cwd}. It has the pull request's code checked out on local branch ${branch}.`,
4698
- ...depsLine ? [depsLine] : [],
4699
- "",
4700
- "How to test:",
4701
- "1. Read the PR description and the linked issue, and work out the acceptance criteria.",
4702
- `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.`,
4703
- testStep2 ?? "3. Install dependencies if needed, then run the project's test suite, linters, type checks and build (whichever exist).",
4704
- repo.browserTesting ? `4. If the project has a UI, start it in the background on port ${port} (reserved for you) and exercise the change in a real browser with the Playwright tools: navigate, click, type, resize to a phone size, try edge cases, and check the console for errors. Take a screenshot with browser_take_screenshot (no filename) of every important state: the screenshots are attached to the PR as evidence. Stop the server afterwards.` : "4. Exercise the changed behaviour directly (run the program, call the API, write a quick script).",
4705
- "5. You may write throwaway scripts to probe behaviour, but do not commit them.",
4706
- "A merge conflict with the default branch is not a fail, nor is a red check unrelated to this change (a flake or an outage: say why): judge the change itself (on a throwaway merge if you need newer work) and pass it if it's good. The office sends conflicting PRs back for a merge fix, then you re-test, and re-runs a failed check before anyone fixes it.",
4707
- "",
4708
- "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."
4709
- ].join("\n");
4710
- }
4711
- var OFFICE = "Office instructions";
4712
- var MARK = "\0job description\0";
4713
- function promptParts(build, brief) {
4714
- if (!brief) return [{ label: OFFICE, text: build(""), editable: false }];
4715
- const [before, after] = build(MARK).split(MARK);
4716
- return [
4717
- { label: OFFICE, text: before, editable: false },
4718
- { label: "Job description", text: brief, editable: true },
4719
- { label: OFFICE, text: after, editable: false }
4720
- ].filter((p) => p.text !== "");
4721
- }
4722
- var view = (kind, parts) => ({ kind, text: parts.map((p) => p.text).join(""), parts });
4723
- function devPromptPreview({ slug, ...i }) {
4724
- const branch = devBranch(PLACEHOLDERS.issue, slug);
4725
- return view("dev", promptParts((brief) => devSystemPrompt({ ...i, agent: { ...i.agent, brief }, cwd: PLACEHOLDERS.worktree, branch }), i.agent.brief));
4726
- }
4727
- function qaPromptPreview({ slug, ...i }) {
4728
- const pr = { number: PLACEHOLDERS.pr, title: PLACEHOLDERS.prTitle, headRefName: PLACEHOLDERS.branch, url: PLACEHOLDERS.prUrl };
4729
- const branch = qaBranch(PLACEHOLDERS.pr, slug);
4730
- return view("qa", promptParts((brief) => qaSystemPrompt({ ...i, agent: { ...i.agent, brief }, cwd: PLACEHOLDERS.worktree, branch, pr }), i.agent.brief));
4824
+ // server/qaRetry.ts
4825
+ var QA_RESUME_PROMPT = "Your turn ended without your QA report. Finish what you were checking in the foreground, then end with the JSON report.";
4826
+ function qaRetry(e) {
4827
+ if (e.stopped) return "give-up";
4828
+ if (e.report) return "report";
4829
+ return e.ok && !e.limited && e.canResume ? "resume" : "give-up";
4731
4830
  }
4732
- var ceoPromptPreview = (text) => view("ceo", [{ label: OFFICE, text, editable: false }]);
4733
4831
 
4734
4832
  // server/pacing.ts
4735
4833
  var PACING_MS = 60 * 6e4;
@@ -4768,6 +4866,32 @@ function pacingMessage(w, until, sessions, now2) {
4768
4866
  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.`;
4769
4867
  }
4770
4868
 
4869
+ // server/deskTrim.ts
4870
+ var TRIM_SWEEP_MS = 15 * 6e4;
4871
+ var DEFAULT_TRIM_IDLE_MIN = 120;
4872
+ var MAX_TRIM_IDLE_MIN = 7 * 24 * 60;
4873
+ var idleSince = (endedAt, busyAt, bootAt) => Math.max(endedAt ?? bootAt, busyAt ?? 0);
4874
+ function desksToTrim(desks, thresholdMin, now2) {
4875
+ if (!(thresholdMin > 0)) return [];
4876
+ return desks.filter((d) => !d.busy && now2 - d.idleSince > thresholdMin * 6e4 && (d.trimmedAt === null || d.trimmedAt < d.idleSince)).map((d) => d.key);
4877
+ }
4878
+ function clampTrimIdleMin(v) {
4879
+ const n = Math.round(Number(v));
4880
+ return Number.isFinite(n) ? Math.max(0, Math.min(MAX_TRIM_IDLE_MIN, n)) : DEFAULT_TRIM_IDLE_MIN;
4881
+ }
4882
+ function formatBytes(n) {
4883
+ const units = ["bytes", "KB", "MB", "GB", "TB"];
4884
+ let v = n;
4885
+ let i = 0;
4886
+ while (v >= 1024 && i < units.length - 1) {
4887
+ v /= 1024;
4888
+ i++;
4889
+ }
4890
+ if (i === 0) return `${Math.round(n)} bytes`;
4891
+ return `${v < 9.95 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
4892
+ }
4893
+ var freedMessage = (bytes, desks) => `\u{1F9F9} Freed ${formatBytes(bytes)} from ${desks} idle desk${desks === 1 ? "" : "s"}`;
4894
+
4771
4895
  // server/terminal.ts
4772
4896
  import fs12 from "node:fs/promises";
4773
4897
  import path14 from "node:path";
@@ -5146,6 +5270,7 @@ var Swarm = class {
5146
5270
  tutorialStep: 0,
5147
5271
  autoUpdate: true,
5148
5272
  pacingSessions: DEFAULT_PACING_SESSIONS,
5273
+ trimIdleDesksMin: DEFAULT_TRIM_IDLE_MIN,
5149
5274
  voice: { ...DEFAULT_VOICE }
5150
5275
  },
5151
5276
  repos: [],
@@ -5260,6 +5385,7 @@ var Swarm = class {
5260
5385
  if (!EFFORTS.includes(this.state.settings.defaultEffort)) this.state.settings.defaultEffort = "medium";
5261
5386
  if (this.state.settings.runtime !== "sdk") this.state.settings.runtime = "terminal";
5262
5387
  if (!isCli(this.state.settings.defaultCli)) this.state.settings.defaultCli = "claude";
5388
+ this.state.settings.trimIdleDesksMin = clampTrimIdleMin(this.state.settings.trimIdleDesksMin);
5263
5389
  if (!this.state.settings.defaultModel && this.state.settings.defaultCli === "claude") this.state.settings.defaultModel = DEFAULT_MODEL;
5264
5390
  const old = this.state.settings;
5265
5391
  if (old.maxConcurrent !== void 0) {
@@ -5340,6 +5466,7 @@ var Swarm = class {
5340
5466
  setInterval(() => this.state.repos.forEach((r, i) => setTimeout(() => void this.syncRepo(r.id), i * 1500)), SYNC_INTERVAL_MS);
5341
5467
  setInterval(() => this.schedule(), SCHEDULER_INTERVAL_MS);
5342
5468
  setInterval(() => void this.saveTerminals(), TERMINAL_SAVE_MS);
5469
+ setInterval(() => void this.trimIdleDesks(), this.backend.demo ? 6e4 : TRIM_SWEEP_MS);
5343
5470
  setInterval(() => this.state.repos.forEach((r, i) => setTimeout(() => void this.sweepFloor(r.id), i * 1500)), DESK_SWEEP_INTERVAL_MS);
5344
5471
  void this.backend.detectClis().then((clis) => {
5345
5472
  this.clis = clis;
@@ -6612,13 +6739,24 @@ Note from the manager: ${note}` : ""
6612
6739
  Linked issue: ${issue2.title}
6613
6740
  ${issue2.body.trim() || "(no description)"}` : ""
6614
6741
  ].filter((l) => l !== "").join("\n");
6615
- this.startAgentSession(a, repo, cwd, prompt, this.buildQaSystemAppend(a, repo, cwd, branch, pr, qa.testStep, desk.deps), void 0, QA_SCHEMA);
6742
+ const systemAppend = this.buildQaSystemAppend(a, repo, cwd, branch, pr, qa.testStep, desk.deps);
6743
+ this.agentRt.get(a.id).qaResume = { cwd, systemAppend };
6744
+ this.startAgentSession(a, repo, cwd, prompt, systemAppend, void 0, QA_SCHEMA);
6616
6745
  }
6617
6746
  async onQaFinished(a, repo, result) {
6618
6747
  const rec = this.state.qa.find((q) => q.repoId === repo.id && q.prNumber === a.prNumber);
6619
6748
  const rt = this.agentRt.get(a.id);
6620
6749
  const report = result.ok ? parseReport(result) : null;
6621
- if (a.status === "stopped" || !report) {
6750
+ const resume = rt.qaResume;
6751
+ const next = qaRetry({ stopped: a.status === "stopped", limited: this.limited(), ok: result.ok, report: !!report, canResume: !!resume && !!a.sessionId });
6752
+ if (next === "resume" && resume && a.sessionId) {
6753
+ rt.qaResume = null;
6754
+ a.endedAt = null;
6755
+ this.appendLog(a, [{ kind: "system", text: "\u21BB The session ended without a QA report. Resuming it once to finish." }]);
6756
+ this.startAgentSession(a, repo, resume.cwd, QA_RESUME_PROMPT, resume.systemAppend, a.sessionId, QA_SCHEMA);
6757
+ return;
6758
+ }
6759
+ if (next === "give-up" || !report) {
6622
6760
  if (a.status !== "stopped") this.fail(a, { ...result, errors: result.errors.length ? result.errors : ["QA finished without a usable report"] }, `QA of PR #${a.prNumber}`);
6623
6761
  if (rec) {
6624
6762
  const failures = rec.sessionFailures + (this.limited() ? 0 : 1);
@@ -6637,17 +6775,17 @@ ${issue2.body.trim() || "(no description)"}` : ""
6637
6775
  let commentUrl = null;
6638
6776
  if (rec) {
6639
6777
  const pull = !pass ? await this.backend.prDetails(repo.fullName, rec.prNumber).catch(() => null) : null;
6640
- const next = qaOutcome(pass, rec, pull);
6778
+ const next2 = qaOutcome(pass, rec, pull);
6641
6779
  try {
6642
6780
  this.appendLog(a, [{ kind: "system", text: "\u{1F4CE} Uploading evidence and posting the QA report on the PR\u2026" }]);
6643
- const body2 = await this.renderQaComment(a, repo, rec, report, rt.shots, next);
6781
+ const body2 = await this.renderQaComment(a, repo, rec, report, rt.shots, next2);
6644
6782
  commentUrl = await this.backend.commentPull(repo.fullName, rec.prNumber, body2) || null;
6645
6783
  this.appendLog(a, [{ kind: "system", text: ` \u23BF ${commentUrl ?? "comment posted"}` }]);
6646
6784
  } catch (err) {
6647
6785
  this.appendLog(a, [{ kind: "error", text: ` \u23BF Could not post the QA report: ${err.message}` }]);
6648
6786
  }
6649
6787
  const qaRounds = rec.round - rec.retests;
6650
- const nextStatus = next === "conflict" ? "failed" : next;
6788
+ const nextStatus = next2 === "conflict" ? "failed" : next2;
6651
6789
  const fixInstructions = report.fixInstructions ?? report.checks.filter((c) => c.result === "fail").map((c) => `${c.name}: ${c.details}`).join("\n");
6652
6790
  this.setQa(rec, {
6653
6791
  status: nextStatus,
@@ -6664,10 +6802,10 @@ ${issue2.body.trim() || "(no description)"}` : ""
6664
6802
  alerted: false,
6665
6803
  qaChecks: pull?.checks ?? null
6666
6804
  });
6667
- if (next === "conflict") this.sendBack(repo, rec, "conflict", conflictFixInstructions(fixInstructions, repo.defaultBranch), false);
6805
+ if (next2 === "conflict") this.sendBack(repo, rec, "conflict", conflictFixInstructions(fixInstructions, repo.defaultBranch), false);
6668
6806
  this.toast(
6669
6807
  pass ? "success" : "error",
6670
- pass ? `\u2705 ${a.name} passed PR #${rec.prNumber}${repo.autoMerge ? "; it merges once GitHub's checks are green" : ": ready to merge"}` : nextStatus === "needs-human" ? `\u274C PR #${rec.prNumber} failed QA ${qaRounds} times and needs a human` : next === "conflict" ? `\u274C ${a.name} failed PR #${rec.prNumber}, and it conflicts with ${repo.defaultBranch}; sending it back to merge and fix` : `\u274C ${a.name} failed PR #${rec.prNumber}; sending it back to the developer`
6808
+ pass ? `\u2705 ${a.name} passed PR #${rec.prNumber}${repo.autoMerge ? "; it merges once GitHub's checks are green" : ": ready to merge"}` : nextStatus === "needs-human" ? `\u274C PR #${rec.prNumber} failed QA ${qaRounds} times and needs a human` : next2 === "conflict" ? `\u274C ${a.name} failed PR #${rec.prNumber}, and it conflicts with ${repo.defaultBranch}; sending it back to merge and fix` : `\u274C ${a.name} failed PR #${rec.prNumber}; sending it back to the developer`
6671
6809
  );
6672
6810
  }
6673
6811
  }
@@ -6864,6 +7002,10 @@ Full report with screenshots: ${rec.commentUrl}` : "",
6864
7002
  if (patch.tutorialStep !== void 0) s2.tutorialStep = Math.max(-1, Math.round(Number(patch.tutorialStep)) || 0);
6865
7003
  if (typeof patch.autoUpdate === "boolean") s2.autoUpdate = patch.autoUpdate;
6866
7004
  if (patch.pacingSessions !== void 0) s2.pacingSessions = clampPacingSessions(patch.pacingSessions);
7005
+ if (patch.trimIdleDesksMin !== void 0) {
7006
+ s2.trimIdleDesksMin = clampTrimIdleMin(patch.trimIdleDesksMin);
7007
+ setTimeout(() => void this.trimIdleDesks(), 1e3);
7008
+ }
6867
7009
  if (patch.voice !== void 0) s2.voice = voiceSettings(s2.voice, patch.voice);
6868
7010
  this.save();
6869
7011
  this.broadcast({ type: "settings", settings: s2 });
@@ -7440,6 +7582,63 @@ ${t}`);
7440
7582
  }
7441
7583
  this.enqueueCeo({ kind: "review", at: Date.now() });
7442
7584
  }
7585
+ // ---------- idle desks ----------
7586
+ bootAt = Date.now();
7587
+ deskWatch = /* @__PURE__ */ new Map();
7588
+ // desk → what the sweeps saw
7589
+ trimming = false;
7590
+ /** An agent's desk is in use: a task (preparing, working, testing or fixing) or a live CLI in their terminal. Hands off a fired agent's. */
7591
+ deskBusy(a) {
7592
+ const rt = this.agentRt.get(a.id);
7593
+ return !rt || !this.state.agents.includes(a) || BUSY.includes(a.status) || !!rt.session || !!rt.terminal?.live;
7594
+ }
7595
+ /**
7596
+ * Free disk space on desks idle longer than settings.trimIdleDesksMin: every agent's desk, and each floor's preview
7597
+ * worktree while no preview runs. One desk at a time, once per idle stretch; one phone message per sweep that freed anything.
7598
+ */
7599
+ async trimIdleDesks() {
7600
+ const min = this.state.settings.trimIdleDesksMin;
7601
+ if (!min || this.trimming) return;
7602
+ this.trimming = true;
7603
+ try {
7604
+ const now2 = Date.now();
7605
+ const desks = [];
7606
+ for (const a of this.state.agents) {
7607
+ const repo = this.state.repos.find((r) => r.id === a.repoId);
7608
+ if (repo) desks.push({ key: a.id, repo, slug: this.agentSlug(a), endedAt: a.endedAt, busy: () => this.deskBusy(a) });
7609
+ }
7610
+ for (const repo of this.state.repos) {
7611
+ desks.push({ key: `preview:${repo.id}`, repo, slug: PREVIEW_SLUG, endedAt: null, busy: () => !this.state.repos.includes(repo) || this.previews.active(repo) });
7612
+ }
7613
+ for (const key of this.deskWatch.keys()) if (!desks.some((d) => d.key === key)) this.deskWatch.delete(key);
7614
+ const seen = desks.map((d) => {
7615
+ const w = this.deskWatch.get(d.key) ?? { busyAt: null, trimmedAt: null };
7616
+ this.deskWatch.set(d.key, w);
7617
+ const busy = d.busy();
7618
+ if (busy) w.busyAt = now2;
7619
+ return { key: d.key, busy, idleSince: idleSince(d.endedAt, w.busyAt, this.bootAt), trimmedAt: w.trimmedAt };
7620
+ });
7621
+ const due = new Set(desksToTrim(seen, min, now2));
7622
+ let freed = 0;
7623
+ let count = 0;
7624
+ for (const d of desks.filter((x) => due.has(x.key))) {
7625
+ const result = await this.backend.trimDesk(d.repo.fullName, d.slug, () => !d.busy()).catch((err) => {
7626
+ console.warn(`could not trim desk ${d.slug} of ${d.repo.fullName}:`, oneLine(err));
7627
+ return void 0;
7628
+ });
7629
+ if (result === void 0) continue;
7630
+ if (!result?.skipped.length) this.deskWatch.get(d.key).trimmedAt = Date.now();
7631
+ if (!result?.freed) continue;
7632
+ freed += result.freed;
7633
+ count++;
7634
+ const locked = result.skipped.length ? `; still locked: ${result.skipped.join(", ")}` : "";
7635
+ console.log(`trimmed idle desk ${d.slug} of ${d.repo.fullName}: freed ${formatBytes(result.freed)} (${result.removed.join(", ")})${locked}`);
7636
+ }
7637
+ if (count) this.postMessage("office", freedMessage(freed, count));
7638
+ } finally {
7639
+ this.trimming = false;
7640
+ }
7641
+ }
7443
7642
  // ---------- the phone ----------
7444
7643
  postMessage(from, text, requestId) {
7445
7644
  const m = { id: this.messageSeq++, from, text: text.trim().slice(0, 6e3), at: Date.now(), ...requestId ? { requestId } : {} };