trantor 0.18.40 → 0.18.42

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.
Files changed (58) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/advise.mjs +10 -3
  3. package/bin/agent-settings.mjs +22 -0
  4. package/bin/autonomy.mjs +2 -2
  5. package/bin/cli.mjs +13 -6
  6. package/bin/crew/cmux.mjs +214 -0
  7. package/bin/crew/core.mjs +142 -0
  8. package/bin/crew/herdr.mjs +179 -0
  9. package/bin/crew/models.mjs +62 -0
  10. package/bin/crew/open.mjs +168 -0
  11. package/bin/crew/state.mjs +153 -0
  12. package/bin/crew/tmux.mjs +75 -0
  13. package/bin/crew/verify.mjs +19 -0
  14. package/bin/crew/worktrees.mjs +50 -0
  15. package/bin/crew-runner.mjs +30 -9
  16. package/bin/crew.mjs +116 -0
  17. package/bin/doctor.mjs +21 -0
  18. package/bin/duty.mjs +2 -2
  19. package/bin/herdr-agent.mjs +1 -1
  20. package/bin/policy.mjs +1 -1
  21. package/bin/provider.mjs +129 -8
  22. package/bin/takeover.mjs +1 -1
  23. package/deploy/restart-hub.sh +18 -1
  24. package/hooks/file-claim.mjs +3 -1
  25. package/hooks/githooks/pre-push +29 -0
  26. package/hooks/lib/api.mjs +3 -1
  27. package/hooks/lib/balance-check.mjs +3 -1
  28. package/hooks/lib/handoff.mjs +4 -0
  29. package/hooks/lib/resources.mjs +6 -6
  30. package/hooks/lib/subagent-cost-lib.mjs +3 -1
  31. package/hooks/overseer-warn.mjs +4 -1
  32. package/hooks/sessionstart.mjs +5 -0
  33. package/hooks/statusline.mjs +4 -0
  34. package/hooks/subagent-cost.mjs +4 -0
  35. package/hub/auth.mjs +360 -0
  36. package/hub/duty.mjs +86 -0
  37. package/hub/events.mjs +170 -0
  38. package/hub/migrations.mjs +0 -0
  39. package/hub/overseer.mjs +206 -0
  40. package/hub/phases.mjs +80 -0
  41. package/hub/reaper.mjs +307 -0
  42. package/hub/routes/admin.mjs +416 -0
  43. package/hub/routes/cards.mjs +695 -0
  44. package/hub/routes/insights.mjs +205 -0
  45. package/hub/routes/messages.mjs +163 -0
  46. package/hub/store.mjs +245 -0
  47. package/hub.mjs +79 -2959
  48. package/lib/agent-preferences.mjs +100 -0
  49. package/lib/autonomy.mjs +1 -1
  50. package/lib/balances.mjs +3 -1
  51. package/lib/project.mjs +1 -1
  52. package/lib/providers.mjs +332 -0
  53. package/package.json +2 -1
  54. package/skills/crew/SKILL.md +7 -3
  55. package/skills/handoff/SKILL.md +6 -0
  56. package/skills/prd-review/SKILL.md +5 -1
  57. package/skills/research/SKILL.md +4 -0
  58. package/bin/crew.sh +0 -1286
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.40",
3
+ "version": "0.18.42",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/bin/advise.mjs CHANGED
@@ -15,6 +15,7 @@ import { join } from "node:path";
15
15
  import { homedir } from "node:os";
16
16
  import { execSync } from "node:child_process";
17
17
  import { pathToFileURL } from "node:url";
18
+ import { busDir, readConfig } from "../lib/project.mjs";
18
19
 
19
20
  const H = homedir();
20
21
  const read = (p, fb) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return fb; } };
@@ -64,8 +65,14 @@ export function buildRoster(profile, ocConfig) {
64
65
  return { ...BUILTIN_ROSTER, ...discoverSeats(profile, ocConfig) };
65
66
  }
66
67
 
68
+ export function filterEnabledAgents(agentIds, config = {}) {
69
+ const disabled = new Set(Array.isArray(config?.agents?.disabled) ? config.agents.disabled : []);
70
+ return agentIds.filter(agent => !disabled.has(agent));
71
+ }
72
+
67
73
  export function loadWorld() {
68
- const profile = read(join(H, ".agent-bus", "profile.json"), { providers: {} });
74
+ const config = readConfig();
75
+ const profile = read(join(busDir(), "profile.json"), { providers: {} });
69
76
  const registry = read(join(H, ".token-scrooge", "registry.json"), { models: {}, tasks: {} });
70
77
  const caps = read(join(H, ".token-scrooge", "capabilities.json"), {});
71
78
  const ocConfig = read(join(H, ".config", "opencode", "opencode.json"), {});
@@ -84,8 +91,8 @@ export function loadWorld() {
84
91
  const envKey = `${String(s.providerOc).toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
85
92
  return opencodeKey(s.providerOc) || envHasKey(envKey) || !!profile?.providers?.[s.provider];
86
93
  };
87
- const agents = Object.keys(roster).filter(hasSeat);
88
- return { profile, registry, caps, roster, agents, scrooge: has("scrooge") };
94
+ const agents = filterEnabledAgents(Object.keys(roster).filter(hasSeat), config);
95
+ return { profile, registry, caps, ocConfig, roster, agents, scrooge: has("scrooge") };
89
96
  }
90
97
 
91
98
  const tierOf = (profile, prov) => profile?.providers?.[prov]?.tier || "api";
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ import { buildAgentSettingsStatus, setAgentEnabled, setDefaultAgent } from "../lib/agent-preferences.mjs";
3
+
4
+ const [command = "status", id, value] = process.argv.slice(2).filter(argument => argument !== "--json");
5
+
6
+ try {
7
+ const result = command === "status"
8
+ ? buildAgentSettingsStatus()
9
+ : command === "set-enabled"
10
+ ? setAgentEnabled(id, value === "true")
11
+ : command === "set-default"
12
+ ? setDefaultAgent(id === "auto" ? null : id)
13
+ : null;
14
+ if (!result) {
15
+ console.error("usage: agent-settings.mjs status | set-enabled <agent> <true|false> | set-default <agent|auto>");
16
+ process.exit(1);
17
+ }
18
+ console.log(JSON.stringify(result));
19
+ } catch (error) {
20
+ console.error(error instanceof Error ? error.message : String(error));
21
+ process.exit(1);
22
+ }
package/bin/autonomy.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // `trantor autonomy` — read and set the three dials from the CLI.
3
3
  //
4
- // The app will grow a settings pane for this, but the CLI has to work on its own: crew.sh asks
4
+ // The app will grow a settings pane for this, but the CLI has to work on its own: crew.mjs asks
5
5
  // this for the harness dial every time it starts your session, and a headless machine has no app.
6
6
  import { resolveAutonomy, setAutonomy, loadAutonomy, AUTONOMY_PATH } from "../lib/autonomy.mjs";
7
7
  import { resolveProject } from "../lib/project.mjs";
@@ -21,7 +21,7 @@ const BOOLS = ["commit", "push", "deploy", "swapDeadSeat", "retryFailedTurn"];
21
21
  const ENUMS = { harness: ["prompt", "bypass"], baton: ["ask", "auto"] };
22
22
 
23
23
  if (cmd === "get") {
24
- // Machine-readable, one value, no decoration — crew.sh reads this.
24
+ // Machine-readable, one value, no decoration — crew.mjs reads this.
25
25
  const key = args[1];
26
26
  const a = resolveAutonomy(projectFlag() || "");
27
27
  if (!(key in a)) { console.error(`unknown dial '${key}'`); process.exit(1); }
package/bin/cli.mjs CHANGED
@@ -12,6 +12,10 @@ const run = (file, runner = process.execPath) => {
12
12
  const child = spawn(runner, [join(ROOT, file), ...args], { stdio: "inherit", cwd: process.cwd() });
13
13
  child.on("exit", (c) => process.exit(c ?? 0));
14
14
  };
15
+ const runCrew = () => {
16
+ spawn(process.execPath, [join(ROOT, "bin/crew.mjs"), cmd, ...args], { stdio: "inherit", cwd: process.cwd() })
17
+ .on("exit", c => process.exit(c ?? 0));
18
+ };
15
19
 
16
20
  switch (cmd) {
17
21
  case "setup": run("deploy/setup.sh", "/bin/bash"); break;
@@ -22,15 +26,15 @@ switch (cmd) {
22
26
  case "models": run("bin/models.mjs"); break;
23
27
  case "advise": run("bin/advise.mjs"); break;
24
28
  case "verify": run("bin/crew-verify.mjs"); break;
25
- case "up": process.argv.splice(2, 1); spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "up", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
26
- case "open": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "open", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
29
+ case "up": runCrew(); break;
30
+ case "open": runCrew(); break;
27
31
  case "herdr": spawn(process.execPath, [join(ROOT, "bin/herdr-agent.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
28
32
  case "autonomy": spawn(process.execPath, [join(ROOT, "bin/autonomy.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
29
33
  case "adopt": spawn(process.execPath, [join(ROOT, "bin/adopt.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
30
34
  case "integrate": spawn(process.execPath, [join(ROOT, "bin/integrate.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
31
- case "down": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "down", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
32
- case "swap": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "swap", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
33
- case "prune": spawn("/bin/bash", [join(ROOT, "bin/crew.sh"), "prune", ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
35
+ case "down": runCrew(); break;
36
+ case "swap": runCrew(); break;
37
+ case "prune": runCrew(); break;
34
38
  case "hub": {
35
39
  const sub = args[0];
36
40
  // Per-project hub routing (TDD §12.1): a project lives on exactly ONE hub; codependent
@@ -41,6 +45,9 @@ switch (cmd) {
41
45
  const cfg = readConfig();
42
46
  const here = resolveProject();
43
47
  console.log(`global default: ${cfg.url || DEFAULT_HUB_URL}${cfg.url ? "" : " (built-in)"}`);
48
+ // SAFETY: cfg.hubs is the config.json hubs map decoded by JSON.parse; the check separates
49
+ // "a pin map" (any object, even field-less) from primitives/null (no pins to list).
50
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof
44
51
  const hubs = cfg.hubs && typeof cfg.hubs === "object" ? Object.entries(cfg.hubs) : [];
45
52
  if (!hubs.length) console.log("no per-project pins — every project uses the global default");
46
53
  for (const [p, u] of hubs) console.log(`${p === here ? "*" : " "} ${p} → ${u}`);
@@ -192,7 +199,7 @@ switch (cmd) {
192
199
  trantor doctor where do I stand? hub/plugin/CLIs/auth/keys/profile, with copy-paste fixes
193
200
  trantor connect (re)wire every installed AI CLI to the bus
194
201
  trantor profile declare your plans: trantor profile set claude=max codex=plus deepseek=api
195
- trantor provider bring ANY model (BYOM): list seats · add <name> --key … · remove <name>
202
+ trantor provider bring ANY model (BYOM): list · status [--json] · verify <name> --key … · add <name> --key … · remove <name>
196
203
  trantor models browse live models behind each seat + the router's pick per difficulty
197
204
  trantor up … spawn a crew here: trantor up codex kimi deepseek:deepseek glm:zai-coding-plan
198
205
  trantor open host THIS session as the project's orchestrator pane (trantor down spares it)
@@ -0,0 +1,214 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { appleScript, appleScriptString, call, commandExists, gridColumns, parseJsonOutput, runnerCommand } from "./core.mjs";
4
+ import { dropState, readRows, recordState } from "./state.mjs";
5
+
6
+ function binary(ctx) {
7
+ return commandExists("cmux", ctx.env) ? "cmux" : "/Applications/cmux.app/Contents/Resources/bin/cmux";
8
+ }
9
+
10
+ function cmuxCall(ctx, args, options = {}) {
11
+ return call(binary(ctx), args, { ...options, env: { ...ctx.env, CMUX_QUIET: "1" } });
12
+ }
13
+
14
+ function socketWorks(ctx) {
15
+ return ctx.have.cmux && cmuxCall(ctx, ["ping"]).ok;
16
+ }
17
+
18
+ export function liveWorkspaces(ctx) {
19
+ if (!socketWorks(ctx)) return { proven: false, ids: new Set(), names: new Set(), items: [] };
20
+ const result = cmuxCall(ctx, ["workspace", "list", "--id-format", "both", "--json"]);
21
+ const parsed = parseJsonOutput(result.stdout);
22
+ if (!parsed) return { proven: false, ids: new Set(), names: new Set(), items: [] };
23
+ const items = Array.isArray(parsed) ? parsed : parsed.workspaces || [];
24
+ return {
25
+ proven: true,
26
+ ids: new Set(items.map(item => item.id).filter(Boolean)),
27
+ names: new Set(items.map(item => item.custom_title || item.name).filter(Boolean)),
28
+ items,
29
+ };
30
+ }
31
+
32
+ export function closeWorkspace(ctx, id) {
33
+ if (ctx.dry) { console.log(`[dry] cmux close-workspace ${id}`); return; }
34
+ if (socketWorks(ctx) && cmuxCall(ctx, ["close-workspace", "--workspace", id]).ok) return;
35
+ call("osascript", [], { input: `tell application "cmux"\nrepeat with w in windows\nrepeat with tt in tabs of w\nif (id of tt) is "${id}" then close tab tt\nend repeat\nend repeat\nend tell\n` });
36
+ }
37
+
38
+ export function closePane(ctx, id) {
39
+ if (ctx.dry) { console.log(`[dry] cmux close-surface ${id}`); return; }
40
+ if (socketWorks(ctx) && cmuxCall(ctx, ["close-surface", "--surface", id]).ok) return;
41
+ call("osascript", [], { input: `tell application "cmux"\nrepeat with tr in terminals\nif (id of tr) is "${id}" then close tr\nend repeat\nend tell\n` });
42
+ }
43
+
44
+ function seatLauncher(ctx, agent, command) {
45
+ mkdirSync(ctx.seatDir, { recursive: true });
46
+ const file = join(ctx.seatDir, `${ctx.project}-${agent}.sh`);
47
+ if (!ctx.dry) writeFileSync(file, `#!/bin/bash\nprintf "\\033]0;%s\\007" ${JSON.stringify(`${agent} · ${ctx.project}`)}\n${command}\n`);
48
+ return file;
49
+ }
50
+
51
+ function tracked(ctx) {
52
+ const ids = readRows(ctx).filter(row => row.project === ctx.project && row.kind === "cmuxws").map(row => row.handle);
53
+ return { reuse: ids.at(-1) || "", stale: ids.slice(0, -1) };
54
+ }
55
+
56
+ function prepare(ctx, prune) {
57
+ const result = tracked(ctx);
58
+ for (const id of result.stale) {
59
+ console.log(` → closing stale stacked crew workspace for ${ctx.project} (${id})`);
60
+ closeWorkspace(ctx, id);
61
+ dropState(ctx, ctx.project, "cmuxws", "", id);
62
+ }
63
+ if (result.stale.length) prune();
64
+ if (ctx.dry || result.reuse) return result.reuse;
65
+ const candidate = liveWorkspaces(ctx).items.find(item => [item.custom_title, item.name].includes(`trantor:${ctx.project}`));
66
+ const id = candidate?.id || "";
67
+ if (id) {
68
+ console.log(` → adopting existing untracked crew workspace for ${ctx.project} (${id})`);
69
+ recordState(ctx, ctx.project, "cmuxws", "__ws__", id);
70
+ }
71
+ return id;
72
+ }
73
+
74
+ function surfaceFrom(result) {
75
+ const parsed = parseJsonOutput(result.stdout);
76
+ return parsed?.surface_id || parsed?.surface_ref || parsed?.id || "";
77
+ }
78
+
79
+ function newWorkspace(ctx, launcher) {
80
+ const created = cmuxCall(ctx, ["new-workspace", "--cwd", ctx.dir, "--command", `bash ${launcher}`]);
81
+ const ref = created.stdout.split(/\s+/)[1] || "";
82
+ const list = liveWorkspaces(ctx).items;
83
+ const workspace = list.find(item => item.ref === ref)?.id || list.at(-1)?.id || "";
84
+ if (workspace) cmuxCall(ctx, ["rename-workspace", "--workspace", workspace, `trantor:${ctx.project}`]);
85
+ const panes = cmuxCall(ctx, ["list-pane-surfaces", "--workspace", workspace, "--id-format", "uuids", "--json"]);
86
+ const parsed = parseJsonOutput(panes.stdout);
87
+ const first = (parsed?.surfaces || parsed?.panes || (Array.isArray(parsed) ? parsed : []))[0];
88
+ return { workspace, pane: first?.id || first?.surface_id || "" };
89
+ }
90
+
91
+ function split(ctx, workspace, direction, target, launcher) {
92
+ const args = ["new-split", direction, "--workspace", workspace];
93
+ if (target) args.push("--surface", target);
94
+ args.push("--id-format", "uuids", "--json");
95
+ const pane = surfaceFrom(cmuxCall(ctx, args));
96
+ if (pane) {
97
+ cmuxCall(ctx, ["send", "--surface", pane, `bash ${launcher}`]);
98
+ cmuxCall(ctx, ["send-key", "--surface", pane, "enter"]);
99
+ }
100
+ return pane;
101
+ }
102
+
103
+ export function spawnCmux(ctx, specs, resolve, prune) {
104
+ if (!socketWorks(ctx)) return spawnAppleScript(ctx, specs, resolve);
105
+ let workspace = prepare(ctx, prune);
106
+ const reuse = Boolean(workspace);
107
+ const panes = [];
108
+ const columns = gridColumns(specs.length);
109
+ for (let index = 0; index < specs.length; index += 1) {
110
+ const seat = resolve(specs[index]);
111
+ if (!seat) continue;
112
+ const launcher = seatLauncher(ctx, seat.agent, runnerCommand(ctx, seat.agent, seat.model));
113
+ const old = reuse ? readRows(ctx).filter(row => row.project === ctx.project && row.kind === "cmux" && row.agent === seat.agent).at(-1)?.handle || "" : "";
114
+ let pane = "";
115
+ if (reuse) {
116
+ console.log(ctx.dry ? `[dry] cmux: reuse workspace ${workspace} — new-split for ${seat.agent}${old ? ` (replacing ${old})` : ""}` : ` → reusing workspace ${workspace}`);
117
+ pane = ctx.dry ? `%DRYT${index}` : split(ctx, workspace, "right", old || panes.at(-1) || "", launcher);
118
+ if (old) { closePane(ctx, old); dropState(ctx, ctx.project, "cmux", seat.agent); }
119
+ } else if (index === 0) {
120
+ if (ctx.dry) {
121
+ console.log(`[dry] cmux: new-workspace (cwd ${ctx.dir}) --command 'bash ${launcher}' → rename 'trantor:${ctx.project}'`);
122
+ workspace = "%DRYWS"; pane = "%DRYT0";
123
+ } else ({ workspace, pane } = newWorkspace(ctx, launcher));
124
+ recordState(ctx, ctx.project, "cmuxws", "__ws__", workspace);
125
+ } else {
126
+ const firstRow = Math.floor(index / columns) === 0;
127
+ const direction = firstRow ? "right" : "down";
128
+ const target = panes[firstRow ? index - 1 : index - columns] || "";
129
+ if (ctx.dry) {
130
+ console.log(`[dry] cmux: new-split ${direction} --surface ${target || "<focused>"} + send 'bash ${launcher}'`);
131
+ pane = `%DRYT${index}`;
132
+ } else pane = split(ctx, workspace, direction, target, launcher);
133
+ }
134
+ panes.push(pane);
135
+ recordState(ctx, ctx.project, "cmux", seat.agent, pane);
136
+ console.log(` → ${seat.agent} seat in cmux workspace (${ctx.project})`);
137
+ }
138
+ console.log(`— crew grouped in cmux: ONE workspace tab for ${ctx.project}, seats tiled + sidebar status. Teardown (this project only): trantor down —`);
139
+ }
140
+
141
+ function spawnAppleScript(ctx, specs, resolve) {
142
+ console.log("— cmux control socket is OFF (mode cmuxOnly) → using AppleScript (works; no native sidebar status).");
143
+ console.log(" Enable the full integration: add \"automation\": { \"socketControlMode\": \"allowAll\" } to");
144
+ console.log(" ~/.config/cmux/cmux.json (cmux auto-reloads). —");
145
+ let tab = prepareAppleScriptWorkspace(ctx);
146
+ const reuse = Boolean(tab);
147
+ const panes = [];
148
+ const columns = gridColumns(specs.length);
149
+ for (let index = 0; index < specs.length; index += 1) {
150
+ const seat = resolve(specs[index]);
151
+ if (!seat) continue;
152
+ const launcher = seatLauncher(ctx, seat.agent, runnerCommand(ctx, seat.agent, seat.model));
153
+ const old = reuse ? readRows(ctx).filter(row => row.project === ctx.project && row.kind === "cmux" && row.agent === seat.agent).at(-1)?.handle || "" : "";
154
+ let pane;
155
+ if (!tab && index === 0) {
156
+ if (ctx.dry) {
157
+ console.log(`[dry] cmux(AppleScript): new tab (trantor:${ctx.project}) + run 'bash ${launcher}'`);
158
+ tab = "%DRYTAB"; pane = "%DRYT0";
159
+ } else ({ tab, pane } = createAppleScriptTab(ctx, launcher));
160
+ recordState(ctx, ctx.project, "cmuxws", "__ws__", tab);
161
+ } else {
162
+ const firstRow = Math.floor(index / columns) === 0;
163
+ const direction = old || firstRow ? "right" : "down";
164
+ const target = old || panes[firstRow ? index - 1 : index - columns] || "";
165
+ if (ctx.dry) {
166
+ console.log(`[dry] cmux(AppleScript): split ${direction} from ${target || "<focused>"} + run 'bash ${launcher}'`);
167
+ pane = `%DRYT${index}`;
168
+ } else pane = splitAppleScriptPane(ctx, tab, target, direction, launcher);
169
+ if (old && pane && pane !== "ERR") { closePane(ctx, old); dropState(ctx, ctx.project, "cmux", seat.agent); }
170
+ }
171
+ panes.push(pane);
172
+ recordState(ctx, ctx.project, "cmux", seat.agent, pane);
173
+ console.log(` → ${seat.agent} seat in cmux workspace (${ctx.project})`);
174
+ }
175
+ console.log(`— crew grouped in cmux (AppleScript): ONE workspace tab for ${ctx.project}, seats tiled. Teardown: trantor down —`);
176
+ }
177
+
178
+ function prepareAppleScriptWorkspace(ctx) {
179
+ const ids = readRows(ctx).filter(row => row.project === ctx.project && row.kind === "cmuxws").map(row => row.handle);
180
+ const reuse = ids.at(-1) || "";
181
+ for (const id of ids.slice(0, -1)) {
182
+ console.log(` → closing stale stacked crew workspace for ${ctx.project} (${id})`);
183
+ closeWorkspace(ctx, id);
184
+ dropState(ctx, ctx.project, "cmuxws", "", id);
185
+ }
186
+ if (!reuse || ctx.dry) return reuse;
187
+ const script = `tell application "cmux"\nrepeat with w in windows\nrepeat with tt in tabs of w\nif (id of tt) is "${appleScriptString(reuse)}" then return "OK"\nend repeat\nend repeat\nreturn "ERR"\nend tell\n`;
188
+ if (appleScript(script).stdout === "OK") {
189
+ console.log(` → reusing existing crew workspace for ${ctx.project} (${reuse})`);
190
+ return reuse;
191
+ }
192
+ dropState(ctx, ctx.project, "cmuxws", "", reuse);
193
+ dropState(ctx, ctx.project, "cmux");
194
+ return "";
195
+ }
196
+
197
+ function createAppleScriptTab(ctx, launcher) {
198
+ const script = `tell application "cmux"\nactivate\nif (count of windows) is 0 then\nnew window\ndelay 0.5\nend if\nset t to (new tab)\ndelay 0.4\nset term1 to (focused terminal of t)\ninput text ("bash ${appleScriptString(launcher)}" & return) to term1\nreturn (id of t) & "|" & (id of term1)\nend tell\n`;
199
+ const [tab = "", pane = ""] = appleScript(script).stdout.split("|");
200
+ return { tab, pane };
201
+ }
202
+
203
+ function splitAppleScriptPane(ctx, tab, target, direction, launcher) {
204
+ const script = `tell application "cmux"\nset theTab to missing value\nrepeat with w in windows\nrepeat with tt in tabs of w\nif (id of tt) is "${appleScriptString(tab)}" then set theTab to tt\nend repeat\nend repeat\nif theTab is missing value then return "ERR"\nset srcTerm to missing value\nrepeat with tm in terminals of theTab\nif (id of tm) is "${appleScriptString(target)}" then set srcTerm to tm\nend repeat\nif srcTerm is missing value then set srcTerm to (focused terminal of theTab)\nset newterm to (split srcTerm direction ${direction})\ndelay 0.25\ninput text ("bash ${appleScriptString(launcher)}" & return) to newterm\nreturn (id of newterm)\nend tell\n`;
205
+ return appleScript(script).stdout;
206
+ }
207
+
208
+ export function createCmuxAdapter(ctx) {
209
+ return {
210
+ closeWorkspace: id => closeWorkspace(ctx, id),
211
+ closePane: id => closePane(ctx, id),
212
+ liveWorkspaces: () => liveWorkspaces(ctx),
213
+ };
214
+ }
@@ -0,0 +1,142 @@
1
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ export const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
7
+
8
+ export function commandExists(name, env = process.env) {
9
+ for (const dir of String(env.PATH || "").split(":")) {
10
+ try { accessSync(join(dir, name), constants.X_OK); return true; }
11
+ catch {}
12
+ }
13
+ return false;
14
+ }
15
+
16
+ export function call(command, args = [], options = {}) {
17
+ const spawnOptions = {
18
+ cwd: options.cwd || process.cwd(),
19
+ encoding: "utf8",
20
+ env: options.env || process.env,
21
+ input: options.input,
22
+ stdio: options.stdio || ["ignore", "pipe", "pipe"],
23
+ };
24
+ let result = spawnSync(command, args, spawnOptions);
25
+ if (result.error?.code === "ENOEXEC") result = spawnSync("/bin/sh", [command, ...args], spawnOptions);
26
+ return {
27
+ ok: !result.error && result.status === 0,
28
+ status: result.status ?? 1,
29
+ stdout: String(result.stdout || "").trim(),
30
+ stderr: String(result.stderr || "").trim(),
31
+ };
32
+ }
33
+
34
+ export function shellQuote(value) {
35
+ const text = String(value);
36
+ if (/^[A-Za-z0-9_./:@%+-]+$/.test(text)) return text;
37
+ return `'${text.replaceAll("'", `'\\''`)}'`;
38
+ }
39
+
40
+ export function formatCommand(command, args = []) {
41
+ return [command, ...args].map(shellQuote).join(" ");
42
+ }
43
+
44
+ export function run(ctx, command, args = [], options = {}) {
45
+ const rendered = options.rendered || formatCommand(command, args);
46
+ if (ctx.dry) {
47
+ console.log(`[dry] ${rendered}`);
48
+ return { ok: true, status: 0, stdout: "", stderr: "" };
49
+ }
50
+ const result = spawnSync(command, args, {
51
+ cwd: options.cwd || ctx.dir,
52
+ encoding: "utf8",
53
+ env: options.env || process.env,
54
+ stdio: options.stdio || "ignore",
55
+ });
56
+ return { ok: !result.error && result.status === 0, status: result.status ?? 1 };
57
+ }
58
+
59
+ function projectFromGit(dir) {
60
+ const result = call("git", ["-C", dir, "rev-parse", "--show-toplevel"]);
61
+ return result.ok && result.stdout ? basename(result.stdout) : basename(dir);
62
+ }
63
+
64
+ function readConfig(home, env) {
65
+ const base = env.AGENT_BUS_DIR || join(home, ".agent-bus");
66
+ try { return JSON.parse(readFileSync(join(base, "config.json"), "utf8")); }
67
+ catch { return {}; }
68
+ }
69
+
70
+ function resolveHub(home, project, env) {
71
+ if (env.CREW_HUB) return env.CREW_HUB;
72
+ const config = readConfig(home, env);
73
+ return config.hubs?.[project] || env.RELAY_URL || config.url || "http://127.0.0.1:4477";
74
+ }
75
+
76
+ function selectMux(env, have) {
77
+ if (env.CREW_MUX === "herdr" && !have.herdr) {
78
+ throw new Error("CREW_MUX=herdr but herdr is not installed — user-local (no sudo): curl -fsSL https://herdr.dev/install.sh | sh — see https://herdr.dev");
79
+ }
80
+ if (env.CREW_MUX) return env.CREW_MUX;
81
+ if (have.herdr) return "herdr";
82
+ if (have.cmux) return "cmux";
83
+ if (have.tmux) return "tmux";
84
+ return "terminal";
85
+ }
86
+
87
+ export function createContext(command, env = process.env) {
88
+ const dir = env.PWD || process.cwd();
89
+ const home = env.HOME || "";
90
+ const project = env.RELAY_PROJECT || projectFromGit(dir);
91
+ const have = {
92
+ herdr: commandExists("herdr", env),
93
+ tmux: commandExists("tmux", env),
94
+ cmux: existsSync("/Applications/cmux.app") || commandExists("cmux", env),
95
+ };
96
+ const ctx = {
97
+ command,
98
+ dir,
99
+ home,
100
+ project,
101
+ root: ROOT,
102
+ hub: resolveHub(home, project, env),
103
+ statePath: join(home, ".agent-bus", "crew-windows.txt"),
104
+ seatDir: join(home, ".agent-bus", "seats"),
105
+ dry: env.CREW_DRY_RUN === "1",
106
+ have,
107
+ env,
108
+ };
109
+ mkdirSync(dirname(ctx.statePath), { recursive: true });
110
+ ctx.mux = selectMux(env, have);
111
+ return ctx;
112
+ }
113
+
114
+ export function parseJsonOutput(text) {
115
+ const start = String(text).search(/[\[{]/);
116
+ if (start < 0) return null;
117
+ try { return JSON.parse(String(text).slice(start)); }
118
+ catch { return null; }
119
+ }
120
+
121
+ export function appleScript(script) {
122
+ return call("osascript", [], { input: script });
123
+ }
124
+
125
+ export function appleScriptString(value) {
126
+ return String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"');
127
+ }
128
+
129
+ export function gridColumns(size) {
130
+ let columns = 1;
131
+ while (columns * columns < size) columns += 1;
132
+ return columns;
133
+ }
134
+
135
+ export function runnerCommand(ctx, agent, model = "") {
136
+ return `cd ${shellQuote(ctx.dir)} && CREW_MODEL=${shellQuote(model)} RELAY_PROJECT=${shellQuote(ctx.project)} RELAY_URL=${shellQuote(ctx.hub)} node ${shellQuote(join(ROOT, "bin/crew-runner.mjs"))} ${shellQuote(agent)} ${shellQuote(ctx.dir)}`;
137
+ }
138
+
139
+ export function listPids(pattern) {
140
+ const result = call("pgrep", ["-f", pattern]);
141
+ return result.ok ? result.stdout.split(/\s+/).filter(Boolean) : [];
142
+ }