trantor 0.18.41 → 0.18.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/advise.mjs +10 -3
- package/bin/agent-settings.mjs +22 -0
- package/bin/autonomy.mjs +2 -2
- package/bin/cli.mjs +9 -5
- package/bin/crew/cmux.mjs +214 -0
- package/bin/crew/core.mjs +142 -0
- package/bin/crew/herdr.mjs +183 -0
- package/bin/crew/models.mjs +62 -0
- package/bin/crew/open.mjs +168 -0
- package/bin/crew/state.mjs +153 -0
- package/bin/crew/tmux.mjs +75 -0
- package/bin/crew/verify.mjs +19 -0
- package/bin/crew/worktrees.mjs +50 -0
- package/bin/crew-runner.mjs +23 -6
- package/bin/crew.mjs +116 -0
- package/bin/duty.mjs +3 -3
- package/bin/herdr-agent.mjs +1 -1
- package/bin/policy.mjs +1 -1
- package/bin/provider.mjs +13 -0
- package/bin/takeover.mjs +1 -1
- package/deploy/restart-hub.sh +25 -1
- package/hooks/githooks/pre-push +29 -0
- package/hooks/lib/resources.mjs +6 -6
- package/hooks/sessionstart.mjs +5 -0
- package/hub/auth.mjs +380 -0
- package/hub/duty.mjs +86 -0
- package/hub/events.mjs +170 -0
- package/hub/migrations.mjs +0 -0
- package/hub/overseer.mjs +206 -0
- package/hub/phases.mjs +80 -0
- package/hub/reaper.mjs +307 -0
- package/hub/routes/admin.mjs +416 -0
- package/hub/routes/cards.mjs +695 -0
- package/hub/routes/insights.mjs +205 -0
- package/hub/routes/messages.mjs +163 -0
- package/hub/store.mjs +245 -0
- package/hub.mjs +84 -2976
- package/lib/agent-preferences.mjs +100 -0
- package/lib/autonomy.mjs +1 -1
- package/lib/project.mjs +1 -1
- package/lib/providers.mjs +2 -2
- package/package.json +2 -1
- package/skills/crew/SKILL.md +7 -3
- package/skills/handoff/SKILL.md +6 -0
- package/skills/prd-review/SKILL.md +5 -1
- package/skills/research/SKILL.md +4 -0
- package/bin/crew.sh +0 -1294
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.43",
|
|
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
|
|
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.
|
|
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.
|
|
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":
|
|
26
|
-
case "open":
|
|
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":
|
|
32
|
-
case "swap":
|
|
33
|
-
case "prune":
|
|
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
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { call, gridColumns, parseJsonOutput, runnerCommand } from "./core.mjs";
|
|
2
|
+
import { dropState, readRows, recordState } from "./state.mjs";
|
|
3
|
+
|
|
4
|
+
export function herdrCall(ctx, args) {
|
|
5
|
+
return call("herdr", args, { env: ctx.env });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function workspaceList(ctx) {
|
|
9
|
+
if (!ctx.have.herdr) return { proven: false, ids: new Set(), names: new Set(), items: [] };
|
|
10
|
+
const result = herdrCall(ctx, ["workspace", "list"]);
|
|
11
|
+
const parsed = parseJsonOutput(result.stdout);
|
|
12
|
+
if (!result.ok || !parsed) return { proven: false, ids: new Set(), names: new Set(), items: [] };
|
|
13
|
+
const items = Array.isArray(parsed) ? parsed : parsed.workspaces || parsed.result?.workspaces || [];
|
|
14
|
+
return {
|
|
15
|
+
proven: items.length > 0,
|
|
16
|
+
ids: new Set(items.map(item => item.workspace_id || item.id).filter(Boolean)),
|
|
17
|
+
names: new Set(items.map(item => item.label || item.name || item.custom_title).filter(Boolean)),
|
|
18
|
+
items,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createWorkspace(ctx, cwd, label) {
|
|
23
|
+
const parsed = parseJsonOutput(herdrCall(ctx, ["workspace", "create", "--cwd", cwd, "--label", label, "--no-focus"]).stdout);
|
|
24
|
+
const result = parsed?.result || {};
|
|
25
|
+
return {
|
|
26
|
+
workspace: result.workspace?.workspace_id || "",
|
|
27
|
+
pane: result.root_pane?.pane_id || "",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function splitPane(ctx, pane, direction, cwd = "", execute = herdrCall) {
|
|
32
|
+
const args = ["pane", "split"];
|
|
33
|
+
if (pane) args.push(pane);
|
|
34
|
+
args.push("--direction", direction, "--no-focus");
|
|
35
|
+
if (cwd) args.push("--cwd", cwd);
|
|
36
|
+
const parsed = parseJsonOutput(execute(ctx, args).stdout);
|
|
37
|
+
return parsed?.result?.pane?.pane_id || "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function workspacePane(ctx, workspace, preferredCwd) {
|
|
41
|
+
const parsed = parseJsonOutput(herdrCall(ctx, ["pane", "list"]).stdout);
|
|
42
|
+
const panes = Array.isArray(parsed) ? parsed : parsed?.panes || parsed?.result?.panes || [];
|
|
43
|
+
const matches = panes.filter(pane => (pane.workspace_id || pane.workspace || "") === workspace);
|
|
44
|
+
return matches.find(pane => (pane.cwd || "") === preferredCwd)?.pane_id || matches[0]?.pane_id || matches[0]?.id || "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function closeWorkspace(ctx, id) {
|
|
48
|
+
if (ctx.dry) { console.log(`[dry] herdr workspace close ${id}`); return; }
|
|
49
|
+
herdrCall(ctx, ["workspace", "close", id]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function closePane(ctx, id) {
|
|
53
|
+
if (ctx.dry) { console.log(`[dry] herdr pane close ${id}`); return; }
|
|
54
|
+
herdrCall(ctx, ["pane", "close", id]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function reportAgent(ctx, pane, agent) {
|
|
58
|
+
if (!pane) return;
|
|
59
|
+
if (ctx.dry) {
|
|
60
|
+
console.log(`[dry] herdr pane report-agent ${pane} --source crew --agent ${agent} --state working`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
herdrCall(ctx, ["pane", "report-agent", pane, "--source", "crew", "--agent", agent, "--state", "working"]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function runSeat(ctx, pane, agent, command) {
|
|
67
|
+
if (!pane || ctx.dry) return;
|
|
68
|
+
herdrCall(ctx, ["pane", "rename", pane, `${agent} · ${ctx.project}`]);
|
|
69
|
+
herdrCall(ctx, ["pane", "run", pane, command]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function trackedWorkspace(ctx) {
|
|
73
|
+
const ids = readRows(ctx).filter(row => row.project === ctx.project && row.kind === "herdrws").map(row => row.handle);
|
|
74
|
+
return { reuse: ids.at(-1) || "", stale: ids.slice(0, -1) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function prepareWorkspace(ctx, prune) {
|
|
78
|
+
const tracked = trackedWorkspace(ctx);
|
|
79
|
+
for (const id of tracked.stale) {
|
|
80
|
+
console.log(` → closing stale stacked crew workspace for ${ctx.project} (${id})`);
|
|
81
|
+
closeWorkspace(ctx, id);
|
|
82
|
+
dropState(ctx, ctx.project, "herdrws", "", id);
|
|
83
|
+
}
|
|
84
|
+
if (tracked.stale.length) prune();
|
|
85
|
+
if (ctx.dry) return tracked.reuse;
|
|
86
|
+
let reuse = tracked.reuse;
|
|
87
|
+
const live = workspaceList(ctx).items.filter(item => [item.label, item.name, item.custom_title].includes(`trantor:${ctx.project}`));
|
|
88
|
+
for (const item of live) {
|
|
89
|
+
const id = item.workspace_id || item.id || "";
|
|
90
|
+
if (!id || id === reuse || readRows(ctx).some(row => row.handle === id)) continue;
|
|
91
|
+
if (!reuse) {
|
|
92
|
+
console.log(` → adopting existing untracked crew workspace for ${ctx.project} (${id})`);
|
|
93
|
+
reuse = id;
|
|
94
|
+
recordState(ctx, ctx.project, "herdrws", "__ws__", id);
|
|
95
|
+
} else {
|
|
96
|
+
console.log(` → closing stray crew workspace for ${ctx.project} (${id})`);
|
|
97
|
+
closeWorkspace(ctx, id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return reuse;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function replacementPane(ctx, workspace, spec, hostPane, resolve) {
|
|
104
|
+
const seat = resolve(spec);
|
|
105
|
+
if (!seat) return null;
|
|
106
|
+
const old = readRows(ctx).filter(row => row.project === ctx.project && row.kind === "herdr" && row.agent === seat.agent).at(-1)?.handle || "";
|
|
107
|
+
let pane;
|
|
108
|
+
if (ctx.dry) {
|
|
109
|
+
console.log(`[dry] herdr: reuse workspace ${workspace} — pane split for ${seat.agent}${old ? ` (replacing ${old})` : ""}`);
|
|
110
|
+
pane = `%DRYT${spec.index}`;
|
|
111
|
+
} else {
|
|
112
|
+
pane = splitPane(ctx, hostPane, "right", ctx.dir);
|
|
113
|
+
runSeat(ctx, pane, seat.agent, runnerCommand(ctx, seat.agent, seat.model));
|
|
114
|
+
}
|
|
115
|
+
if (old) {
|
|
116
|
+
closePane(ctx, old);
|
|
117
|
+
dropState(ctx, ctx.project, "herdr", seat.agent);
|
|
118
|
+
}
|
|
119
|
+
return { ...seat, pane };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function spawnHerdr(ctx, specs, resolve, prune) {
|
|
123
|
+
const reuse = prepareWorkspace(ctx, prune);
|
|
124
|
+
let workspace = reuse;
|
|
125
|
+
let hostPane = "";
|
|
126
|
+
if (reuse) {
|
|
127
|
+
hostPane = ctx.dry ? `%DRYHOST(${reuse})` : workspacePane(ctx, reuse, ctx.dir);
|
|
128
|
+
if (!hostPane) throw new Error(`trantor up: workspace ${reuse} has no live pane to host crew seats`);
|
|
129
|
+
}
|
|
130
|
+
const panes = [];
|
|
131
|
+
const columns = gridColumns(specs.length);
|
|
132
|
+
for (let index = 0; index < specs.length; index += 1) {
|
|
133
|
+
const spec = { value: specs[index], index };
|
|
134
|
+
let seat;
|
|
135
|
+
if (reuse) seat = replacementPane(ctx, workspace, spec, panes.at(-1) || hostPane, value => resolve(value.value));
|
|
136
|
+
else seat = freshPane(ctx, workspace, spec, panes, columns, resolve);
|
|
137
|
+
if (!seat) continue;
|
|
138
|
+
workspace = seat.workspace || workspace;
|
|
139
|
+
panes.push(seat.pane);
|
|
140
|
+
reportAgent(ctx, seat.pane, seat.agent);
|
|
141
|
+
recordState(ctx, ctx.project, "herdr", seat.agent, seat.pane);
|
|
142
|
+
console.log(` → ${seat.agent} seat in herdr workspace (${ctx.project})`);
|
|
143
|
+
}
|
|
144
|
+
console.log(`— crew grouped in herdr: ONE workspace for ${ctx.project}, seats as named panes in its server. Teardown (this project only): trantor down —`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function freshPane(ctx, workspace, spec, panes, columns, resolve) {
|
|
148
|
+
const seat = resolve(spec.value);
|
|
149
|
+
if (!seat) return null;
|
|
150
|
+
const command = runnerCommand(ctx, seat.agent, seat.model);
|
|
151
|
+
let pane = "";
|
|
152
|
+
if (spec.index === 0) {
|
|
153
|
+
if (ctx.dry) {
|
|
154
|
+
console.log(`[dry] herdr: workspace create (cwd ${ctx.dir}) --label 'trantor:${ctx.project}' → root pane + run '${command}'`);
|
|
155
|
+
workspace = "%DRYWS";
|
|
156
|
+
pane = "%DRYT0";
|
|
157
|
+
} else ({ workspace, pane } = createWorkspace(ctx, ctx.dir, `trantor:${ctx.project}`));
|
|
158
|
+
recordState(ctx, ctx.project, "herdrws", "__ws__", workspace);
|
|
159
|
+
} else {
|
|
160
|
+
const firstRow = Math.floor(spec.index / columns) === 0;
|
|
161
|
+
const direction = firstRow ? "right" : "down";
|
|
162
|
+
const target = panes[firstRow ? spec.index - 1 : spec.index - columns] || "";
|
|
163
|
+
if (ctx.dry) {
|
|
164
|
+
console.log(`[dry] herdr: pane split ${target || "<focused>"} --direction ${direction} + run '${command}'`);
|
|
165
|
+
pane = `%DRYT${spec.index}`;
|
|
166
|
+
} else pane = splitPane(ctx, target, direction, ctx.dir);
|
|
167
|
+
}
|
|
168
|
+
runSeat(ctx, pane, seat.agent, command);
|
|
169
|
+
return { ...seat, pane, workspace };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function paneHasAgent(ctx, pane) {
|
|
173
|
+
const result = herdrCall(ctx, ["agent", "list"]);
|
|
174
|
+
return result.ok && result.stdout.includes(`\"pane_id\":\"${pane}\"`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function createHerdrAdapter(ctx) {
|
|
178
|
+
return {
|
|
179
|
+
closeWorkspace: id => closeWorkspace(ctx, id),
|
|
180
|
+
closePane: id => closePane(ctx, id),
|
|
181
|
+
liveWorkspaces: () => workspaceList(ctx),
|
|
182
|
+
};
|
|
183
|
+
}
|