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.
Files changed (48) 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 +9 -5
  6. package/bin/crew/cmux.mjs +214 -0
  7. package/bin/crew/core.mjs +142 -0
  8. package/bin/crew/herdr.mjs +183 -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 +23 -6
  16. package/bin/crew.mjs +116 -0
  17. package/bin/duty.mjs +3 -3
  18. package/bin/herdr-agent.mjs +1 -1
  19. package/bin/policy.mjs +1 -1
  20. package/bin/provider.mjs +13 -0
  21. package/bin/takeover.mjs +1 -1
  22. package/deploy/restart-hub.sh +25 -1
  23. package/hooks/githooks/pre-push +29 -0
  24. package/hooks/lib/resources.mjs +6 -6
  25. package/hooks/sessionstart.mjs +5 -0
  26. package/hub/auth.mjs +380 -0
  27. package/hub/duty.mjs +86 -0
  28. package/hub/events.mjs +170 -0
  29. package/hub/migrations.mjs +0 -0
  30. package/hub/overseer.mjs +206 -0
  31. package/hub/phases.mjs +80 -0
  32. package/hub/reaper.mjs +307 -0
  33. package/hub/routes/admin.mjs +416 -0
  34. package/hub/routes/cards.mjs +695 -0
  35. package/hub/routes/insights.mjs +205 -0
  36. package/hub/routes/messages.mjs +163 -0
  37. package/hub/store.mjs +245 -0
  38. package/hub.mjs +84 -2976
  39. package/lib/agent-preferences.mjs +100 -0
  40. package/lib/autonomy.mjs +1 -1
  41. package/lib/project.mjs +1 -1
  42. package/lib/providers.mjs +2 -2
  43. package/package.json +2 -1
  44. package/skills/crew/SKILL.md +7 -3
  45. package/skills/handoff/SKILL.md +6 -0
  46. package/skills/prd-review/SKILL.md +5 -1
  47. package/skills/research/SKILL.md +4 -0
  48. package/bin/crew.sh +0 -1294
@@ -0,0 +1,62 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { call } from "./core.mjs";
4
+ import { reapSeat } from "./state.mjs";
5
+
6
+ function routeModel(ctx, provider, candidates, task, difficulty) {
7
+ const bundled = join(ctx.root, "engine/bin/scrooge");
8
+ const script = existsSync(bundled) ? bundled : "scrooge";
9
+ const args = candidates
10
+ ? [script, "route", "--candidates", candidates, "-t", task, "-d", difficulty, "--json"]
11
+ : [script, "route", "--provider", provider, "-t", task, "-d", difficulty, "--json"];
12
+ const result = call("python3", args, { env: ctx.env });
13
+ if (!result.ok || !result.stdout) return "";
14
+ try { return JSON.parse(result.stdout).qualified || ""; }
15
+ catch { return ""; }
16
+ }
17
+
18
+ export function resolveModel(ctx, agent, provider, task, difficulty) {
19
+ const result = call("opencode", ["models", provider], { env: ctx.env });
20
+ const candidates = result.ok ? result.stdout.split(/\s+/).filter(Boolean).join(" ") : "";
21
+ let model = routeModel(ctx, provider, candidates, task, difficulty);
22
+ if (!model && candidates) {
23
+ model = `${provider}/${candidates.split(" ")[0]}`;
24
+ console.error(`[crew] router unavailable for ${agent}:${provider} — using the provider's own catalog head (${model})`);
25
+ }
26
+ if (!model) throw new Error(`[crew] live model selection failed for ${agent}:${provider} — no router and no catalog; refusing opencode global default`);
27
+ if (model.split("/")[0] !== provider) throw new Error(`[crew] router selected ${model} outside ${provider} — refusing cross-provider fallback`);
28
+ return model;
29
+ }
30
+
31
+ export function resolveSpec(ctx, spec, task, difficulty, skipped) {
32
+ const [agent, ...rest] = spec.split(":");
33
+ reapSeat(ctx, agent);
34
+ let field = rest.join(":");
35
+ if (!field) {
36
+ if (agent === "glm") field = "zai-coding-plan";
37
+ else if (!["codex", "kimi", "claude", "gemini", "dsh", "opencode"].includes(agent)) field = agent;
38
+ }
39
+ if (!field) return { agent, model: "" };
40
+ if (field.includes("/")) return { agent, model: field };
41
+ if (["claude", "codex", "kimi", "gemini"].includes(agent)) {
42
+ console.log(` → ${agent}: model ${field} (native pin)`);
43
+ return { agent, model: field };
44
+ }
45
+ try {
46
+ const model = resolveModel(ctx, agent, field, task, difficulty);
47
+ console.log(` → ${agent}: live model ${model} (${field} · ${task}/${difficulty})`);
48
+ return { agent, model };
49
+ } catch (error) {
50
+ console.error(error.message);
51
+ console.error(`[crew] ✗ skipping seat '${agent}' — model resolution failed for ${field} (${task}/${difficulty}); remaining seats still launch`);
52
+ skipped.push(`${agent}: model resolution failed for ${field} (${task}/${difficulty})`);
53
+ return null;
54
+ }
55
+ }
56
+
57
+ export function reportSkipped(skipped) {
58
+ if (!skipped.length) return 0;
59
+ console.log(`\n✗✗ ${skipped.length} seat(s) skipped (model resolution failed) — the rest of the crew still launched:`);
60
+ for (const line of skipped) console.log(` - ${line}`);
61
+ return 1;
62
+ }
@@ -0,0 +1,168 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { call } from "./core.mjs";
5
+ import { createWorkspace, herdrCall, paneHasAgent, reportAgent, splitPane, workspaceList, workspacePane } from "./herdr.mjs";
6
+ import { dropState, readRows, recordState } from "./state.mjs";
7
+ import { resolveOrchestratorDir } from "./worktrees.mjs";
8
+
9
+ const STRIP = [
10
+ "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_CHILD_SESSION",
11
+ "CLAUDE_CODE_BRIDGE_SESSION_ID", "CLAUDE_CODE_MESSAGING_SOCKET", "CLAUDE_CODE_MESSAGING_TOKEN",
12
+ "CLAUDE_CODE_EXECPATH", "CLAUDE_PID",
13
+ ];
14
+
15
+ function usage() {
16
+ console.log("usage: trantor open [<project>]\n host THIS project's orchestrator session as a herdr pane in workspace trantor:<project>\n (creating the workspace if needed) and print its TARGET on stdout. A second open REATTACHES:\n it prints the existing target and exits 0 without spawning a second orchestrator.");
17
+ }
18
+
19
+ function parseArgs(args) {
20
+ let project = "";
21
+ for (const arg of args) {
22
+ if (arg === "--help" || arg === "-h") return { help: true };
23
+ if (arg.startsWith("--")) throw new Error(`trantor open: unknown flag '${arg}'`);
24
+ project = arg;
25
+ }
26
+ return { project };
27
+ }
28
+
29
+ function sessionFile(ctx) {
30
+ return join(ctx.home, ".agent-bus", "orch-sessions.txt");
31
+ }
32
+
33
+ function sessionId(ctx) {
34
+ const file = sessionFile(ctx);
35
+ if (existsSync(file)) {
36
+ const row = readFileSync(file, "utf8").split("\n").map(line => line.split("\t")).find(([project, id]) => project === ctx.project && id);
37
+ if (row) return row[1];
38
+ }
39
+ const id = randomUUID();
40
+ if (!ctx.dry) writeFileSync(file, `${existsSync(file) ? readFileSync(file, "utf8") : ""}${ctx.project}\t${id}\n`);
41
+ return id;
42
+ }
43
+
44
+ export function hasPendingHandoff(ctx) {
45
+ const dir = join(ctx.env.AGENT_BUS_DIR || ctx.env.RELAY_DATA_DIR || join(ctx.home, ".agent-bus"), "handoffs");
46
+ if (!existsSync(dir)) return false;
47
+ const prefix = `${ctx.project}-`;
48
+ for (const file of readdirSync(dir).filter(name => name.startsWith(prefix) && name.endsWith(".json")).sort().reverse()) {
49
+ try { if (!JSON.parse(readFileSync(join(dir, file), "utf8")).consumed) return true; }
50
+ catch {}
51
+ }
52
+ return false;
53
+ }
54
+
55
+ export function takeoverSessionId(ctx, id) {
56
+ return hasPendingHandoff(ctx) ? randomUUID() : id;
57
+ }
58
+
59
+ function harnessFlag(ctx) {
60
+ const result = call(process.execPath, [join(ctx.root, "bin/autonomy.mjs"), "get", "harness", "--project", ctx.project], { env: ctx.env });
61
+ return result.stdout === "bypass" ? " --dangerously-skip-permissions" : "";
62
+ }
63
+
64
+ function transcript(ctx, id) {
65
+ const slug = ctx.dir.replace(/[/.]/g, "-");
66
+ return join(ctx.home, ".claude", "projects", slug, `${id}.jsonl`);
67
+ }
68
+
69
+ function orchestratorCommand(ctx, id) {
70
+ const env = `env ${STRIP.map(name => `-u ${name}`).join(" ")} TRANTOR_ORCH=${ctx.project}`;
71
+ const action = existsSync(transcript(ctx, id)) ? `--resume ${id}` : `--session-id ${id}`;
72
+ return `${env} claude${harnessFlag(ctx)} ${action}`;
73
+ }
74
+
75
+ function tracked(ctx, live) {
76
+ let workspace = "";
77
+ let pane = "";
78
+ for (const row of readRows(ctx).filter(row => row.project === ctx.project)) {
79
+ if (row.kind === "herdrws") {
80
+ if (!ctx.dry && live.proven && !live.ids.has(row.handle)) dropState(ctx, ctx.project, "herdrws", "", row.handle);
81
+ else workspace = row.handle;
82
+ }
83
+ if (row.kind === "orch") pane = row.handle;
84
+ }
85
+ return { workspace, pane };
86
+ }
87
+
88
+ function reattach(ctx, workspace, pane, id) {
89
+ if (!pane) return false;
90
+ if (ctx.dry) { console.log(`herdr:${workspace || "%DRYWS"}/${pane}`); return true; }
91
+ const renamed = herdrCall(ctx, ["pane", "rename", pane, `orchestrator · ${ctx.project}`]);
92
+ if (!renamed.ok) { dropState(ctx, ctx.project, "orch"); return false; }
93
+ if (!paneHasAgent(ctx, pane)) {
94
+ herdrCall(ctx, ["pane", "run", pane, orchestratorCommand(ctx, id)]);
95
+ reportAgent(ctx, pane, "claude");
96
+ console.error(`— orchestrator pane was empty: resumed session ${id} in herdr:${workspace || "?"}/${pane} —`);
97
+ } else console.error(`— orchestrator already hosted: reattached to herdr:${workspace || "?"}/${pane} —`);
98
+ console.log(`herdr:${workspace || "?"}/${pane}`);
99
+ return true;
100
+ }
101
+
102
+ function chooseWorkspace(ctx, live, existing) {
103
+ if (existing) return { workspace: existing, pane: "", fresh: false };
104
+ if (ctx.dry) {
105
+ console.error(`[dry] herdr: workspace create (cwd ${ctx.dir}) --label 'trantor:${ctx.project}'`);
106
+ return { workspace: "%DRYWS", pane: "", fresh: false };
107
+ }
108
+ const found = live.items.find(item => [item.label, item.name, item.custom_title].includes(`trantor:${ctx.project}`));
109
+ if (found) {
110
+ const workspace = found.workspace_id || found.id || "";
111
+ console.error(`— adopting existing crew workspace for ${ctx.project} (${workspace}) —`);
112
+ return { workspace, pane: "", fresh: false };
113
+ }
114
+ const created = createWorkspace(ctx, ctx.dir, `trantor:${ctx.project}`);
115
+ if (!created.workspace) throw new Error("trantor open: herdr workspace create failed");
116
+ return { workspace: created.workspace, pane: created.pane, fresh: true };
117
+ }
118
+
119
+ function hostPane(ctx, chosen, id) {
120
+ let pane = chosen.pane;
121
+ if (ctx.dry) {
122
+ pane = "%DRYORCH";
123
+ if (!chosen.fresh) console.error(`[dry] herdr: pane split %DRYHOST(${chosen.workspace}) --direction right --cwd ${ctx.dir}`);
124
+ console.error(`[dry] herdr: pane rename ${pane} 'orchestrator · ${ctx.project}' + run '${orchestratorCommand(ctx, id)}'`);
125
+ return pane;
126
+ }
127
+ if (!chosen.fresh) {
128
+ const host = workspacePane(ctx, chosen.workspace, ctx.dir);
129
+ if (!host) throw new Error(`trantor open: workspace ${chosen.workspace} has no live pane to host the orchestrator`);
130
+ pane = splitPane(ctx, host, "right", ctx.dir);
131
+ }
132
+ if (!pane) throw new Error("trantor open: could not create the orchestrator pane");
133
+ herdrCall(ctx, ["pane", "rename", pane, `orchestrator · ${ctx.project}`]);
134
+ herdrCall(ctx, ["pane", "run", pane, orchestratorCommand(ctx, id)]);
135
+ reportAgent(ctx, pane, "claude");
136
+ return pane;
137
+ }
138
+
139
+ export function openOrchestrator(ctx, args) {
140
+ let parsed;
141
+ try { parsed = parseArgs(args); }
142
+ catch (error) { console.error(error.message); usage(); return 1; }
143
+ if (parsed.help) { usage(); return 0; }
144
+ try {
145
+ Object.assign(ctx, resolveOrchestratorDir(ctx, parsed.project));
146
+ if (!ctx.have.herdr) throw new Error("trantor open needs herdr (the pane host) — install: curl -fsSL https://herdr.dev/install.sh | sh");
147
+ let id = sessionId(ctx);
148
+ if (hasPendingHandoff(ctx)) {
149
+ const fresh = takeoverSessionId(ctx, id);
150
+ console.error(`— recorded session ${id} handed off: starting fresh as ${fresh} to claim it —`);
151
+ id = fresh;
152
+ }
153
+ const live = ctx.dry ? { proven: false, ids: new Set(), names: new Set(), items: [] } : workspaceList(ctx);
154
+ const prior = tracked(ctx, live);
155
+ if (reattach(ctx, prior.workspace, prior.pane, id)) return 0;
156
+ const chosen = chooseWorkspace(ctx, live, prior.workspace);
157
+ recordState(ctx, ctx.project, "herdrws", "__ws__", chosen.workspace);
158
+ const pane = hostPane(ctx, chosen, id);
159
+ dropState(ctx, ctx.project, "orch");
160
+ recordState(ctx, ctx.project, "orch", "__orch__", pane);
161
+ console.log(`herdr:${chosen.workspace}/${pane}`);
162
+ console.error(`— orchestrator pane hosted: herdr:${chosen.workspace}/${pane} (claude in ${ctx.dir}). 'trantor down' spares it. —`);
163
+ return 0;
164
+ } catch (error) {
165
+ console.error(error.message);
166
+ return 1;
167
+ }
168
+ }
@@ -0,0 +1,153 @@
1
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { call, listPids, run } from "./core.mjs";
3
+
4
+ export function parseRow(line) {
5
+ const fields = line.split("\t");
6
+ if (fields.length >= 4) return { project: fields[0], kind: fields[1], agent: fields[2], handle: fields[3] };
7
+ if (fields.length === 2) return { project: "", kind: "win", agent: fields[0], handle: fields[1] };
8
+ return { project: "", kind: "win", agent: fields[0] || "", handle: fields[1] || "" };
9
+ }
10
+
11
+ export function readRows(ctx) {
12
+ if (!existsSync(ctx.statePath)) return [];
13
+ return readFileSync(ctx.statePath, "utf8").split("\n").filter(Boolean).map(parseRow);
14
+ }
15
+
16
+ export function writeRows(ctx, rows) {
17
+ if (ctx.dry) return;
18
+ if (!rows.length) {
19
+ try { unlinkSync(ctx.statePath); } catch {}
20
+ return;
21
+ }
22
+ const tmp = `${ctx.statePath}.tmp`;
23
+ writeFileSync(tmp, `${rows.map(row => [row.project, row.kind, row.agent, row.handle].join("\t")).join("\n")}\n`);
24
+ renameSync(tmp, ctx.statePath);
25
+ }
26
+
27
+ export function recordState(ctx, project, kind, agent, handle) {
28
+ if (ctx.dry) return;
29
+ const rows = readRows(ctx);
30
+ rows.push({ project, kind, agent, handle });
31
+ writeRows(ctx, rows);
32
+ }
33
+
34
+ export function dropState(ctx, project, kind, agent = "", handle = "") {
35
+ const rows = readRows(ctx).filter(row => !(
36
+ row.project === project && row.kind === kind &&
37
+ (!agent || row.agent === agent) && (!handle || row.handle === handle)
38
+ ));
39
+ writeRows(ctx, rows);
40
+ }
41
+
42
+ function killSeatProcesses(ctx, project, agent) {
43
+ if (ctx.env.CREW_NO_PROC_KILL === "1" || !project || !agent) return;
44
+ const patterns = [`seats/${project}-${agent}\\.sh`, `crew-runner\\.mjs ${agent} .*/${project}$`];
45
+ for (const pattern of patterns) {
46
+ for (const pid of listPids(pattern)) run(ctx, "kill", ["-9", pid], { rendered: `kill -9 ${pid} 2>/dev/null` });
47
+ }
48
+ }
49
+
50
+ function killTerminal(ctx, handle) {
51
+ if (!handle) return;
52
+ const tty = call("osascript", ["-e", `tell application \"Terminal\" to get tty of (first window whose id is ${handle})`]).stdout;
53
+ if (tty) {
54
+ const processes = call("ps", ["-t", tty.replace("/dev/", ""), "-o", "pid="]).stdout.split(/\s+/).filter(Boolean);
55
+ for (const pid of processes) run(ctx, "kill", ["-9", pid], { rendered: `kill -9 ${pid} 2>/dev/null` });
56
+ }
57
+ run(ctx, "osascript", ["-e", `tell application \"Terminal\" to close (first window whose id is ${handle})`]);
58
+ }
59
+
60
+ function usage(project) {
61
+ console.log(`usage: trantor down [<agent>...] [--all] [--yes]\n trantor down tear down THIS project's crew (${project})\n trantor down codex glm tear down only those seats in this project\n trantor down --all --yes tear down EVERY project's crew on this machine (--yes required)`);
62
+ }
63
+
64
+ function parseDownArgs(ctx, args) {
65
+ const parsed = { all: false, yes: false, agents: [] };
66
+ for (const arg of args) {
67
+ if (arg === "--all") parsed.all = true;
68
+ else if (arg === "--yes" || arg === "-y") parsed.yes = true;
69
+ else if (arg === "--help" || arg === "-h") { usage(ctx.project); return { help: true }; }
70
+ else if (arg.startsWith("--")) {
71
+ console.log(`trantor down: unknown flag '${arg}'`);
72
+ usage(ctx.project);
73
+ return { error: true };
74
+ } else parsed.agents.push(arg);
75
+ }
76
+ return parsed;
77
+ }
78
+
79
+ function closeRow(ctx, row, selected, orchProjects, adapters, tmuxClosed) {
80
+ const perSeat = selected.length > 0;
81
+ const hasOrch = orchProjects.has(row.project);
82
+ if (row.kind === "cmuxws" && !perSeat && !hasOrch) adapters.cmux.closeWorkspace(row.handle);
83
+ if (row.kind === "cmux" && (perSeat || hasOrch)) adapters.cmux.closePane(row.handle);
84
+ if (row.kind === "herdrws" && !perSeat && !hasOrch) adapters.herdr.closeWorkspace(row.handle);
85
+ if (row.kind === "herdr" && (perSeat || hasOrch)) adapters.herdr.closePane(row.handle);
86
+ if (row.kind === "tmux") closeTmux(ctx, row, perSeat, tmuxClosed);
87
+ if ((row.kind === "win" || row.kind === "attach") && !(perSeat && row.kind === "attach")) killTerminal(ctx, row.handle);
88
+ if (!["cmuxws", "attach", "herdrws", "orch"].includes(row.kind)) killSeatProcesses(ctx, row.project, row.agent);
89
+ }
90
+
91
+ function closeTmux(ctx, row, perSeat, seen) {
92
+ if (perSeat) {
93
+ run(ctx, "tmux", ["kill-pane", "-t", row.handle], { rendered: `tmux kill-pane -t '${row.handle}' 2>/dev/null` });
94
+ return;
95
+ }
96
+ const session = `trantor:${row.project}`;
97
+ if (seen.has(session)) return;
98
+ seen.add(session);
99
+ run(ctx, "tmux", ["kill-session", "-t", session], { rendered: `tmux kill-session -t '${session}' 2>/dev/null` });
100
+ }
101
+
102
+ export function down(ctx, args, adapters) {
103
+ const options = parseDownArgs(ctx, args);
104
+ if (options.help) return 0;
105
+ if (options.error) return 1;
106
+ const rows = readRows(ctx);
107
+ if (!rows.length) { console.log("no tracked crew windows"); return 0; }
108
+ const scoped = rows.filter(row => (options.all || row.project === ctx.project) && (!options.agents.length || options.agents.includes(row.agent)));
109
+ const scope = options.all ? "all projects" : `project \"${ctx.project}\"`;
110
+ if (!scoped.length) { console.log(`nothing to tear down for ${scope}`); return 0; }
111
+ console.log(`— tearing down (${scope}):`);
112
+ for (const row of scoped) if (!["attach", "cmuxws", "herdrws", "orch"].includes(row.kind)) console.log(` • ${row.project || "<legacy>"} · ${row.agent} (${row.kind})`);
113
+ if (options.all && !options.yes) {
114
+ console.log("— this is EVERY project's crew. Re-run with --yes to confirm: trantor down --all --yes");
115
+ return 0;
116
+ }
117
+ const orchProjects = new Set(rows.filter(row => row.kind === "orch").map(row => row.project));
118
+ const tmuxClosed = new Set();
119
+ for (const row of scoped) closeRow(ctx, row, options.agents, orchProjects, adapters, tmuxClosed);
120
+ const removed = new Set(scoped.map(row => `${row.project}|${row.kind}|${row.agent}|${row.handle}`));
121
+ const kept = rows.filter(row => !removed.has(`${row.project}|${row.kind}|${row.agent}|${row.handle}`) || (orchProjects.has(row.project) && ["herdrws", "cmuxws", "orch"].includes(row.kind)));
122
+ writeRows(ctx, kept);
123
+ console.log(`— crew torn down (${scope})`);
124
+ return 0;
125
+ }
126
+
127
+ function terminalAlive(row) {
128
+ return Boolean(call("osascript", ["-e", `tell application \"Terminal\" to get id of (first window whose id is ${row.handle})`]).stdout);
129
+ }
130
+
131
+ export function prune(ctx, adapters) {
132
+ const rows = readRows(ctx);
133
+ if (!rows.length || ctx.dry) return;
134
+ const cmuxLive = adapters.cmux.liveWorkspaces();
135
+ const herdrLive = adapters.herdr.liveWorkspaces();
136
+ const kept = rows.filter(row => {
137
+ if (["win", "attach"].includes(row.kind)) return terminalAlive(row);
138
+ if (row.kind === "tmux") return call("tmux", ["has-session", "-t", `trantor:${row.project}`]).ok;
139
+ if (row.kind === "cmuxws") return !cmuxLive.proven || cmuxLive.ids.has(row.handle);
140
+ if (row.kind === "cmux") return !cmuxLive.proven || cmuxLive.names.has(`trantor:${row.project}`);
141
+ if (row.kind === "herdrws") return !herdrLive.proven || herdrLive.ids.has(row.handle);
142
+ if (["herdr", "orch"].includes(row.kind)) return !herdrLive.proven || herdrLive.names.has(`trantor:${row.project}`);
143
+ return true;
144
+ });
145
+ writeRows(ctx, kept);
146
+ }
147
+
148
+ export function reapSeat(ctx, agent) {
149
+ const escapedDir = ctx.dir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
150
+ for (const pid of listPids(`crew-runner\\.mjs ${agent} ${escapedDir}$`)) {
151
+ run(ctx, "kill", ["-9", pid], { rendered: `kill -9 ${pid} 2>/dev/null` });
152
+ }
153
+ }
@@ -0,0 +1,75 @@
1
+ import { appleScript, appleScriptString, call, runnerCommand, run, shellQuote } from "./core.mjs";
2
+ import { recordState } from "./state.mjs";
3
+
4
+ export function spawnTmux(ctx, specs, resolve) {
5
+ const session = `trantor:${ctx.project}`;
6
+ let first = !call("tmux", ["has-session", "-t", session], { env: ctx.env }).ok;
7
+ for (const spec of specs) {
8
+ const seat = resolve(spec);
9
+ if (!seat) continue;
10
+ const command = runnerCommand(ctx, seat.agent, seat.model);
11
+ let pane = ctx.dry ? "%DRY" : "";
12
+ if (first) {
13
+ run(ctx, "tmux", ["new-session", "-d", "-s", session, "-n", "crew", "-x", "260", "-y", "60"], { rendered: `tmux new-session -d -s '${session}' -n crew -x 260 -y 60` });
14
+ run(ctx, "tmux", ["send-keys", "-t", session, command, "Enter"], { rendered: `tmux send-keys -t '${session}' ${shellQuote(command)} Enter` });
15
+ first = false;
16
+ } else {
17
+ run(ctx, "tmux", ["split-window", "-t", session, command], { rendered: `tmux split-window -t '${session}' ${shellQuote(command)}` });
18
+ run(ctx, "tmux", ["select-layout", "-t", session, "tiled"], { rendered: `tmux select-layout -t '${session}' tiled >/dev/null 2>&1` });
19
+ }
20
+ if (!ctx.dry) pane = call("tmux", ["display-message", "-p", "-t", session, "#{pane_id}"], { env: ctx.env }).stdout;
21
+ run(ctx, "tmux", ["select-pane", "-t", pane || session, "-T", seat.agent.toUpperCase()], { rendered: `tmux select-pane -t '${pane || session}' -T ${seat.agent.toUpperCase()}` });
22
+ recordState(ctx, ctx.project, "tmux", seat.agent, pane || "%?");
23
+ console.log(` → ${seat.agent} pane in ${session}`);
24
+ }
25
+ run(ctx, "tmux", ["set-option", "-t", session, "pane-border-status", "top"], { rendered: `tmux set-option -t '${session}' pane-border-status top >/dev/null 2>&1` });
26
+ run(ctx, "tmux", ["set-option", "-t", session, "pane-border-format", " #{pane_title} "], { rendered: `tmux set-option -t '${session}' pane-border-format ' #{pane_title} ' >/dev/null 2>&1` });
27
+ if (ctx.dry) {
28
+ console.log(`[dry] would attach a Terminal window to ${session}`);
29
+ recordState(ctx, ctx.project, "attach", "__win__", "%DRYWIN");
30
+ return;
31
+ }
32
+ const attached = appleScript(`tell application "Terminal"\nset w to do script "tmux attach -t ${appleScriptString(session)}"\nset custom title of w to "${appleScriptString(`${ctx.project} — trantor crew`)}"\nset theWin to first window whose tabs contains w\nreturn id of theWin\nend tell\n`);
33
+ if (attached.stdout) recordState(ctx, ctx.project, "attach", "__win__", attached.stdout);
34
+ console.log(`— crew grouped in tmux session ${session} (one window: ${ctx.project} — trantor crew). Detach with Ctrl-b d; it keeps running. —`);
35
+ }
36
+
37
+ export function spawnTerminal(ctx, specs, resolve) {
38
+ console.log("— no cmux/tmux → per-agent Terminal windows. For ONE grouped, named window per crew (and");
39
+ console.log(" bulletproof scoped teardown), install cmux, or run: brew install tmux —");
40
+ const rect = terminalRect(ctx);
41
+ const columns = specs.length <= 2 ? 1 : 2;
42
+ const rows = Math.ceil(specs.length / columns);
43
+ const width = Math.floor(rect.width / columns);
44
+ const height = Math.floor(rect.height / rows);
45
+ for (let index = 0; index < specs.length; index += 1) {
46
+ const seat = resolve(specs[index]);
47
+ if (!seat) continue;
48
+ const x = rect.x + (index % columns) * width;
49
+ const y = rect.y + Math.floor(index / columns) * height;
50
+ if (ctx.dry) {
51
+ console.log(`[dry] Terminal window for ${seat.agent} at ${x},${y}`);
52
+ recordState(ctx, ctx.project, "win", seat.agent, `%DRYWIN${index}`);
53
+ continue;
54
+ }
55
+ const command = `clear && ${runnerCommand(ctx, seat.agent, seat.model)}`;
56
+ const title = `${ctx.project} · ${seat.agent.toUpperCase()}`;
57
+ const result = appleScript(`tell application "Terminal"\nset w to do script "${appleScriptString(command)}"\nset custom title of w to "${appleScriptString(title)}"\nset theWin to first window whose tabs contains w\nset bounds of theWin to {${x}, ${y}, ${x + width}, ${y + height}}\nreturn id of theWin\nend tell\n`);
58
+ if (result.stdout) {
59
+ recordState(ctx, ctx.project, "win", seat.agent, result.stdout);
60
+ console.log(` → ${seat.agent} window spawned (${ctx.project} · ${seat.agent})`);
61
+ } else console.log(` ✗ ${seat.agent} osascript spawn ERROR`);
62
+ call("sleep", ["1.2"]);
63
+ }
64
+ }
65
+
66
+ function terminalRect(ctx) {
67
+ if (ctx.env.CREW_RECT) {
68
+ const [x, y, width, height] = ctx.env.CREW_RECT.split(",").map(Number);
69
+ return { x, y, width, height };
70
+ }
71
+ const result = call("osascript", ["-e", 'tell application "Finder" to get bounds of window of desktop']);
72
+ const [left = 0, top = 25, right = 1440, bottom = 900] = result.stdout.split(/,\s*/).map(Number);
73
+ const screenWidth = right - left;
74
+ return { x: left + Math.floor(screenWidth * 0.42), y: top || 25, width: Math.floor(screenWidth * 0.58), height: bottom - (top || 25) };
75
+ }
@@ -0,0 +1,19 @@
1
+ import { call } from "./core.mjs";
2
+
3
+ export function epochMs() {
4
+ return Date.now();
5
+ }
6
+
7
+ export function verifyCrew(ctx, agents, since) {
8
+ const result = call(process.execPath, [
9
+ `${ctx.root}/bin/crew-verify.mjs`, ctx.project, ...agents,
10
+ "--since", String(since), "--timeout", "30",
11
+ ], { env: ctx.env });
12
+ if (result.stdout) console.log(result.stdout);
13
+ return result.stdout;
14
+ }
15
+
16
+ export function failedAgents(output) {
17
+ const line = String(output).split("\n").find(value => value.startsWith("FAILED:"));
18
+ return line ? line.slice("FAILED:".length).split(",").map(value => value.trim()).filter(Boolean) : [];
19
+ }
@@ -0,0 +1,50 @@
1
+ import { basename, join } from "node:path";
2
+ import { existsSync } from "node:fs";
3
+ import { call } from "./core.mjs";
4
+
5
+ function gitRoot(dir) {
6
+ const result = call("git", ["-C", dir, "rev-parse", "--show-toplevel"]);
7
+ return result.ok ? result.stdout : "";
8
+ }
9
+
10
+ export function resolveOrchestratorDir(ctx, projectArg) {
11
+ const target = projectArg || ctx.project;
12
+ const badge = ctx.env.TRANTOR_ORCH || ctx.env.TRANTOR_SEAT || "";
13
+ const root = gitRoot(ctx.dir);
14
+ const here = root ? basename(root) : "";
15
+ if (badge && badge !== target) {
16
+ throw new Error(`trantor open: refused — this shell is badged for '${badge}', not '${target}'; open it from the target project's shell`);
17
+ }
18
+ if (here && here !== target) {
19
+ throw new Error(`trantor open: refused — cwd belongs to project '${here}', not '${target}'; cd to the target checkout first`);
20
+ }
21
+ if (!projectArg || target === here) return { dir: ctx.dir, project: target };
22
+ const devRoot = ctx.env.TRANTOR_DEV_ROOT || join(ctx.home, "development");
23
+ const targetDir = join(devRoot, target);
24
+ if (existsSync(targetDir)) {
25
+ console.error(`— opening ${target} in its checkout: ${targetDir} —`);
26
+ return { dir: targetDir, project: target };
27
+ }
28
+ throw new Error(`trantor open: '${target}' has no checkout at ${targetDir} — cd into the project first`);
29
+ }
30
+
31
+ function linkedByTest(env, badge, project) {
32
+ if (!("CREW_TEST_POLICY_LINKS" in env)) return null;
33
+ const links = String(env.CREW_TEST_POLICY_LINKS).split(",");
34
+ return links.includes(`${badge}:${project}`) || links.includes(`${project}:${badge}`);
35
+ }
36
+
37
+ export function guardCrossProjectUp(ctx) {
38
+ const badge = ctx.env.TRANTOR_ORCH || ctx.env.TRANTOR_SEAT || "";
39
+ if (!badge || badge === ctx.project) return true;
40
+ let linked = linkedByTest(ctx.env, badge, ctx.project);
41
+ if (linked === null) linked = call(process.execPath, [join(ctx.root, "bin/policy.mjs"), "check", badge, ctx.project], { env: ctx.env }).ok;
42
+ if (!linked) {
43
+ console.error(`trantor: refused — this shell is badged for '${badge}', not '${ctx.project}'.`);
44
+ console.error(" Cross-project action is a breach unless the operator linked the projects.");
45
+ console.error(` Run: trantor policy link ${badge} ${ctx.project} --reason \"<why>\"`);
46
+ return false;
47
+ }
48
+ console.error(`trantor: '${badge}' ↔ '${ctx.project}' is policy-linked — proceeding.`);
49
+ return true;
50
+ }
@@ -31,7 +31,7 @@ import {
31
31
  const AGENT = process.argv[2];
32
32
  const DIR = process.argv[3] || process.cwd();
33
33
  // Crew agents MUST share the orchestrator's project key (one repo = one lane).
34
- // RELAY_PROJECT is inherited from crew.sh (the host's resolved key); else fall
34
+ // RELAY_PROJECT is inherited from crew.mjs (the host's resolved key); else fall
35
35
  // back to the git-repo-root basename — never a loose dir basename that could
36
36
  // fork the host's "builtbetter.ai" into a separate "builtbetter" lane.
37
37
  const PROJ = process.env.RELAY_PROJECT || resolveProject(DIR);
@@ -620,6 +620,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
620
620
  // the turn was CUT rather than that the CLI failed on its own. Cleared before every turn.
621
621
  const CUTF = join(homedir(), ".agent-bus", `turncut-${AGENT}-${PROJ}`);
622
622
  try { unlinkSync(CUTF); } catch {}
623
+ // Touched by the stderr scrubber as its LAST act (the shell below); node waits for it after
624
+ // spawnSync before reading ERRF — see the drain note at the spawnSync call.
625
+ const DRAINF = join(homedir(), ".agent-bus", `turndrain-${AGENT}-${PROJ}`);
626
+ try { unlinkSync(DRAINF); } catch {}
623
627
  try {
624
628
  writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now(), runner: RUNNER_ID }));
625
629
  const wd = spawn(process.execPath, [join(import.meta.dirname, "turn-watchdog.mjs"), STAMPF, ERRF, String(WD_MS), SESSION, PROJ, HUB, TRANSCRIPT_DIR, TURN_DIR],
@@ -652,7 +656,7 @@ ${sweep}
652
656
  sweep $job
653
657
  ) & boxpid=$!` : "boxpid=";
654
658
  const shell = `set -o pipefail
655
- { ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF}) &
659
+ { ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF}; : >> "${DRAINF}") &
656
660
  job=$!${box}
657
661
  wait $job; turn_exit=$?
658
662
  [ -n "$boxpid" ] && kill $boxpid 2>/dev/null
@@ -669,9 +673,9 @@ exit $turn_exit`;
669
673
  cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : ["ignore", "inherit", "inherit"],
670
674
  env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
671
675
  // #6228: badges this seat's env as belonging to PROJ, distinctly from a one-off RELAY_PROJECT
672
- // override (bin/crew.sh's own tests, and any deliberate `RELAY_PROJECT=x trantor up` from a
676
+ // override (bin/crew.mjs's own tests, and any deliberate `RELAY_PROJECT=x trantor up` from a
673
677
  // plain shell, set RELAY_PROJECT alone and must keep working — only THIS marker means "the
674
- // env I'm running in already has a project home"). crew.sh's `up` guard refuses to bring up a
678
+ // env I'm running in already has a project home"). crew.mjs's `up` guard refuses to bring up a
675
679
  // DIFFERENT project's crew from a shell carrying this badge, same as it refuses TRANTOR_ORCH.
676
680
  TRANTOR_SEAT: PROJ,
677
681
  // A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
@@ -695,12 +699,25 @@ exit $turn_exit`;
695
699
  // is the point: the shell kills while the tree is still walkable, node cannot.
696
700
  if (TURN_MAX_MS) { spawnOpts.timeout = TURN_MAX_MS + 30000; spawnOpts.killSignal = "SIGKILL"; }
697
701
  const r = spawnSync("/bin/bash", ["-c", shell], spawnOpts);
698
- killWatchdog(); // #6206: turn over — the watchdog dies NOW, it does not sleep on
699
- try { unlinkSync(STAMPF); } catch {} // disarm any survivor: the stamp is gone
700
702
  // The shell's box leaves the marker; the backstop leaves an ETIMEDOUT. Either way the turn was
701
703
  // cut, not merely failed.
702
704
  const boxed = existsSync(CUTF);
703
705
  const cut = !!TURN_MAX_MS && (boxed || r.error?.code === "ETIMEDOUT");
706
+ // DRAIN before classifying — but never on a CUT turn: the box's sweep killed the scrubber
707
+ // mid-flight, so its marker can never appear and waiting is pure stall. bash 3.2 (macOS's
708
+ // /bin/bash) `wait` does NOT wait for process substitutions — verified 2026-09-05 — so when
709
+ // spawnSync returns on a LIVE turn, the stderr scrubber can still be draining, and an auth
710
+ // line still in the pipe reads as an EMPTY ERRF: the turn is then mislabelled "empty-output",
711
+ // which breaks the seat-down contract (wrong DOWN label, retry ladder instead of a park) and
712
+ // cost run 33940247163 three CI-only drill-6 failures. The scrubber touches DRAINF as its
713
+ // last act; wait for it, bounded.
714
+ if (!cut) {
715
+ const drainStart = Date.now();
716
+ while (!existsSync(DRAINF) && Date.now() - drainStart < 3000) await new Promise(s => setTimeout(s, 50));
717
+ }
718
+ try { unlinkSync(DRAINF); } catch {}
719
+ killWatchdog(); // #6206: turn over — the watchdog dies NOW, it does not sleep on
720
+ try { unlinkSync(STAMPF); } catch {} // disarm any survivor: the stamp is gone
704
721
  if (cut) {
705
722
  // Belt and braces after the shell's descendant sweep: anything still sharing the turn's group.
706
723
  if (r.pid) { try { process.kill(-r.pid, "SIGKILL"); } catch {} }